Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ name = "observer"
crate-type = ["cdylib"]
required-features = ["observer"]

[[example]]
name = "zend_extension"
crate-type = ["cdylib"]
required-features = ["observer"]

[[test]]
name = "guide_tests"
path = "tests/guide.rs"
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ RUN rustup component add rustfmt
RUN --mount=type=bind,target=/src,rw <<EOF
set -e
cargo clean
cargo build
cargo build --features observer
cp target/debug/build/ext-php-rs-*/out/bindings.rs /docsrs_bindings.rs
rustfmt /docsrs_bindings.rs
EOF
Expand Down
8 changes: 7 additions & 1 deletion allowed_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,5 +387,11 @@ bind! {
zend_execute,
zend_get_executed_scope,
zend_destroy_static_vars,
destroy_op_array
destroy_op_array,
zend_extension,
zend_extension_version_info,
zend_register_extension,
zend_get_resource_handle,
zend_get_op_array_extension_handle,
zend_get_op_array_extension_handles
}
3 changes: 3 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ fn main() -> Result<()> {
let mut defines = provider.get_defines()?;
add_php_version_defines(&mut defines, &info)?;

#[cfg(feature = "observer")]
defines.push(("EXT_PHP_RS_OBSERVER", "1"));

check_php_version(&info)?;
build_wrapper(&defines, &includes)?;

Expand Down
91 changes: 91 additions & 0 deletions docsrs_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3889,3 +3889,94 @@ pub type zend_observer_error_cb = ::std::option::Option<
unsafe extern "C" {
pub fn zend_observer_error_register(callback: zend_observer_error_cb);
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct _zend_extension_version_info {
pub zend_extension_api_no: ::std::os::raw::c_int,
pub build_id: *const ::std::os::raw::c_char,
}
pub type zend_extension_version_info = _zend_extension_version_info;
pub type zend_extension = _zend_extension;
pub type startup_func_t = ::std::option::Option<
unsafe extern "C" fn(extension: *mut zend_extension) -> ::std::os::raw::c_int,
>;
pub type shutdown_func_t =
::std::option::Option<unsafe extern "C" fn(extension: *mut zend_extension)>;
pub type activate_func_t = ::std::option::Option<unsafe extern "C" fn()>;
pub type deactivate_func_t = ::std::option::Option<unsafe extern "C" fn()>;
pub type message_handler_func_t = ::std::option::Option<
unsafe extern "C" fn(message: ::std::os::raw::c_int, arg: *mut ::std::os::raw::c_void),
>;
pub type op_array_handler_func_t =
::std::option::Option<unsafe extern "C" fn(op_array: *mut zend_op_array)>;
pub type statement_handler_func_t =
::std::option::Option<unsafe extern "C" fn(frame: *mut zend_execute_data)>;
pub type fcall_begin_handler_func_t =
::std::option::Option<unsafe extern "C" fn(frame: *mut zend_execute_data)>;
pub type fcall_end_handler_func_t =
::std::option::Option<unsafe extern "C" fn(frame: *mut zend_execute_data)>;
pub type op_array_ctor_func_t =
::std::option::Option<unsafe extern "C" fn(op_array: *mut zend_op_array)>;
pub type op_array_dtor_func_t =
::std::option::Option<unsafe extern "C" fn(op_array: *mut zend_op_array)>;
pub type op_array_persist_calc_func_t =
::std::option::Option<unsafe extern "C" fn(op_array: *mut zend_op_array) -> usize>;
pub type op_array_persist_func_t = ::std::option::Option<
unsafe extern "C" fn(op_array: *mut zend_op_array, mem: *mut ::std::os::raw::c_void) -> usize,
>;
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct _zend_extension {
pub name: *const ::std::os::raw::c_char,
pub version: *const ::std::os::raw::c_char,
pub author: *const ::std::os::raw::c_char,
pub URL: *const ::std::os::raw::c_char,
pub copyright: *const ::std::os::raw::c_char,
pub startup: startup_func_t,
pub shutdown: shutdown_func_t,
pub activate: activate_func_t,
pub deactivate: deactivate_func_t,
pub message_handler: message_handler_func_t,
pub op_array_handler: op_array_handler_func_t,
pub statement_handler: statement_handler_func_t,
pub fcall_begin_handler: fcall_begin_handler_func_t,
pub fcall_end_handler: fcall_end_handler_func_t,
pub op_array_ctor: op_array_ctor_func_t,
pub op_array_dtor: op_array_dtor_func_t,
pub api_no_check: ::std::option::Option<
unsafe extern "C" fn(api_no: ::std::os::raw::c_int) -> ::std::os::raw::c_int,
>,
pub build_id_check: ::std::option::Option<
unsafe extern "C" fn(build_id: *const ::std::os::raw::c_char) -> ::std::os::raw::c_int,
>,
pub op_array_persist_calc: op_array_persist_calc_func_t,
pub op_array_persist: op_array_persist_func_t,
pub reserved5: *mut ::std::os::raw::c_void,
pub reserved6: *mut ::std::os::raw::c_void,
pub reserved7: *mut ::std::os::raw::c_void,
pub reserved8: *mut ::std::os::raw::c_void,
pub handle: *mut ::std::os::raw::c_void,
pub resource_number: ::std::os::raw::c_int,
}
unsafe extern "C" {
pub fn zend_get_resource_handle(
module_name: *const ::std::os::raw::c_char,
) -> ::std::os::raw::c_int;
}
unsafe extern "C" {
pub fn zend_get_op_array_extension_handle(
module_name: *const ::std::os::raw::c_char,
) -> ::std::os::raw::c_int;
}
unsafe extern "C" {
pub fn zend_get_op_array_extension_handles(
module_name: *const ::std::os::raw::c_char,
handles: ::std::os::raw::c_int,
) -> ::std::os::raw::c_int;
}
unsafe extern "C" {
pub fn zend_register_extension(
new_extension: *mut zend_extension,
handle: *mut ::std::os::raw::c_void,
);
}
54 changes: 54 additions & 0 deletions examples/zend_extension.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! Example: Zend Extension hooks for low-level profiling.
//!
//! Build: `cargo build --example zend_extension --features observer`

#![allow(missing_docs, clippy::must_use_candidate)]
#![cfg_attr(windows, feature(abi_vectorcall))]

use ext_php_rs::ffi::zend_op_array;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::ExecuteData;
use std::sync::atomic::{AtomicU64, Ordering};

pub struct StatementProfiler {
compiled_functions: AtomicU64,
executed_statements: AtomicU64,
}

impl StatementProfiler {
fn new() -> Self {
Self {
compiled_functions: AtomicU64::new(0),
executed_statements: AtomicU64::new(0),
}
}
}

impl ZendExtensionHandler for StatementProfiler {
fn on_op_array_compiled(&self, _op_array: &mut zend_op_array) {
self.compiled_functions.fetch_add(1, Ordering::Relaxed);
}

fn on_statement(&self, _execute_data: &ExecuteData) {
self.executed_statements.fetch_add(1, Ordering::Relaxed);
}

fn on_activate(&self) {
self.executed_statements.store(0, Ordering::Relaxed);
}
}

#[php_function]
pub fn zend_ext_compiled_count() -> u64 {
0
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
module
.zend_extension(StatementProfiler::new)
.hook_statements()
.hook_op_array_compile()
.finish()
.function(wrap_function!(zend_ext_compiled_count))
}
84 changes: 83 additions & 1 deletion guide/src/advanced/observer.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,84 @@ The backtrace is lazy - only captured when called, so there's zero cost if unuse
| `file` | `Option<String>` | Source file |
| `line` | `u32` | Line number |

## Zend Extension Handler

For low-level engine hooks beyond the Observer API -- per-statement profiling,
bytecode instrumentation, or `op_array` lifecycle tracking -- register a
`ZendExtensionHandler`. This registers your extension as a `zend_extension`
alongside the regular PHP extension, the same mechanism used by OPcache,
Xdebug, and dd-trace-php.

```rust,ignore
use ext_php_rs::prelude::*;
use ext_php_rs::ffi::zend_op_array;
use ext_php_rs::zend::ExecuteData;
use std::sync::atomic::{AtomicU64, Ordering};

struct StatementProfiler { count: AtomicU64 }

impl ZendExtensionHandler for StatementProfiler {
fn on_statement(&self, _execute_data: &ExecuteData) {
self.count.fetch_add(1, Ordering::Relaxed);
}
fn on_activate(&self) {
self.count.store(0, Ordering::Relaxed);
}
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
module
.zend_extension(|| StatementProfiler { count: AtomicU64::new(0) })
.hook_statements()
.finish()
}
```

### Opt-in hooks

| Method | Enables | Cost when enabled |
|--------|---------|-------------------|
| `hook_op_array_compile()` | `on_op_array_compiled` | One callback per compiled function |
| `hook_statements()` | `on_statement` | Extra `ZEND_EXT_STMT` opcode on every statement of every compiled script |
| `hook_fcalls()` | `on_fcall_begin` / `on_fcall_end` | Extra `ZEND_EXT_FCALL_BEGIN`/`END` opcodes around every call site |

### Why opt in?

`hook_statements()` and `hook_fcalls()` tell the PHP engine to emit extra
opcodes in every compiled script. Paying that tax by default would slow every
PHP script, even when your profiler doesn't need the data. The builder makes
the trade-off explicit.

`hook_op_array_compile()` has no compile-time cost: PHP's default
`CG(compiler_options)` already includes `ZEND_COMPILE_HANDLE_OP_ARRAY`. Opting
in only registers the dispatcher, so enabling it just adds one callback per
compiled function.

The other hooks -- `on_activate`, `on_deactivate`, `on_message`,
`on_op_array_ctor`, `on_op_array_dtor` -- are always wired when you register
an extension; they don't need opt-in because they're cold-path.

### ZTS note

Flags are re-asserted in `on_activate` so worker threads created after MINIT
get them on their first request. Scripts pre-compiled by opcache before a
thread's first activation may miss hooks -- for full coverage in ZTS with
opcache, load the extension via `zend_extension=...` in `php.ini`.

### Zend Extension vs Observer API

| Feature | Observer API (`FcallObserver`) | Zend Extension (`ZendExtensionHandler`) |
|---------|------|------|
| Function call hooks | `begin` / `end` with return value | `on_fcall_begin` / `on_fcall_end` (legacy) |
| Filtering | `should_observe` (cached per function) | No built-in filtering |
| Statement-level hooks | Not available | `on_statement` |
| Bytecode access | Not available | `on_op_array_compiled`, `on_op_array_ctor`, `on_op_array_dtor` |
| Request lifecycle | Not available | `on_activate` / `on_deactivate` |
| Best for | Function-level profiling, tracing | Statement-level profiling, code coverage, bytecode instrumentation |

Both can be registered on the same module simultaneously.

## Using All Observers

You can register all observers on the same module:
Expand All @@ -290,6 +368,9 @@ pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
.fcall_observer(MyProfiler::new)
.error_observer(MyErrorTracker::new)
.exception_observer(MyExceptionTracker::new)
.zend_extension(MyStatementProfiler::new)
.hook_statements()
.finish()
}
```

Expand Down Expand Up @@ -324,4 +405,5 @@ Use thread-safe primitives like `AtomicU64`, `Mutex`, or `RwLock` for mutable st
- Only one fcall observer can be registered per extension
- Only one error observer can be registered per extension
- Only one exception observer can be registered per extension
- Observers are registered globally for the entire PHP process
- Only one zend extension handler can be registered per extension
- Observers and handlers are registered globally for the entire PHP process
51 changes: 51 additions & 0 deletions src/builders/module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,9 +575,50 @@ impl ModuleBuilder<'_> {
}
}

#[cfg(feature = "observer")]
impl<'a> ModuleBuilder<'a> {
/// Register a [`ZendExtensionHandler`] and configure which hooks PHP should
/// call.
///
/// Returns a [`ZendExtensionBuilder`] that exposes three opt-in methods
/// (`hook_statements`, `hook_fcalls`, `hook_op_array_compile`). Call
/// [`ZendExtensionBuilder::finish`] to return to `ModuleBuilder`.
///
/// # Example
///
/// ```ignore
/// module
/// .zend_extension(|| MyProfiler::new())
/// .hook_statements()
/// .finish()
/// ```
///
/// # Panics
///
/// Panics if called more than once on the same module.
///
/// [`ZendExtensionHandler`]: crate::zend::ZendExtensionHandler
/// [`ZendExtensionBuilder`]: crate::zend::ZendExtensionBuilder
/// [`ZendExtensionBuilder::finish`]: crate::zend::ZendExtensionBuilder::finish
pub fn zend_extension<F, H>(
self,
factory: F,
) -> crate::zend::zend_extension::ZendExtensionBuilder<'a>
where
F: Fn() -> H + Send + Sync + 'static,
H: crate::zend::ZendExtensionHandler,
{
crate::zend::zend_extension::ZendExtensionBuilder::new(self, factory)
}
}

/// Artifacts from the [`ModuleBuilder`] that should be revisited inside the
/// extension startup function.
pub struct ModuleStartup {
#[cfg(feature = "observer")]
name: String,
#[cfg(feature = "observer")]
version: String,
constants: Vec<(String, Box<dyn IntoConst + Send>)>,
classes: Vec<fn() -> ClassBuilder>,
interfaces: Vec<fn() -> ClassBuilder>,
Expand Down Expand Up @@ -625,6 +666,7 @@ impl ModuleStartup {
crate::zend::observer::observer_startup();
crate::zend::error_observer::error_observer_startup();
crate::zend::exception_observer::exception_observer_startup();
crate::zend::zend_extension::zend_extension_startup(&self.name, &self.version);
}

Ok(())
Expand All @@ -651,10 +693,19 @@ impl TryFrom<ModuleBuilder<'_>> for (ModuleEntry, ModuleStartup) {
functions.push(FunctionEntry::end());
let functions = Box::into_raw(functions.into_boxed_slice()) as *const FunctionEntry;

#[cfg(feature = "observer")]
let ext_name = builder.name.clone();
#[cfg(feature = "observer")]
let ext_version = builder.version.clone();

let name = CString::new(builder.name)?.into_raw();
let version = CString::new(builder.version)?.into_raw();

let startup = ModuleStartup {
#[cfg(feature = "observer")]
name: ext_name,
#[cfg(feature = "observer")]
version: ext_version,
constants: builder
.constants
.into_iter()
Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub mod observer {
pub use crate::zend::error_observer::{BacktraceFrame, ErrorInfo, ErrorObserver, ErrorType};
pub use crate::zend::exception_observer::{ExceptionInfo, ExceptionObserver};
pub use crate::zend::observer::{FcallInfo, FcallObserver};
pub use crate::zend::zend_extension::{ZendExtensionBuilder, ZendExtensionHandler};
}
#[doc(hidden)]
pub mod internal;
Expand Down Expand Up @@ -71,7 +72,7 @@ pub mod prelude {
#[cfg(feature = "observer")]
pub use crate::zend::{
BacktraceFrame, ErrorInfo, ErrorObserver, ErrorType, ExceptionInfo, ExceptionObserver,
FcallInfo, FcallObserver,
FcallInfo, FcallObserver, ZendExtensionHandler,
};
pub use crate::zend::{BailoutGuard, ModuleGlobal, ModuleGlobals};
pub use crate::{
Expand Down
Loading
Loading