Skip to main content

ext_php_rs/zend/
exception_observer.rs

1//! Zend Exception Hook bindings for PHP exception observation.
2//!
3//! Enables creation of exception loggers, APM integrations, and monitoring tools.
4//!
5//! # Example
6//!
7//! ```ignore
8//! use ext_php_rs::prelude::*;
9//!
10//! struct MyExceptionLogger;
11//!
12//! impl ExceptionObserver for MyExceptionLogger {
13//!     fn on_exception(&self, exception: &ExceptionInfo) {
14//!         eprintln!("[EXCEPTION] {}: {}",
15//!             exception.class_name,
16//!             exception.message.as_deref().unwrap_or("<no message>")
17//!         );
18//!     }
19//! }
20//! ```
21
22use std::sync::OnceLock;
23
24use crate::ffi;
25
26/// Information about a PHP exception passed to observers.
27#[derive(Debug)]
28pub struct ExceptionInfo {
29    /// The exception class name (e.g., "Exception", "`RuntimeException`").
30    pub class_name: String,
31    /// The exception message.
32    pub message: Option<String>,
33    /// The exception code.
34    pub code: i64,
35    /// Source filename where the exception was thrown.
36    pub file: Option<String>,
37    /// Line number where the exception was thrown.
38    pub line: u32,
39}
40
41impl ExceptionInfo {
42    /// # Safety
43    ///
44    /// `exception` must be a valid pointer to a `zend_object` representing a Throwable.
45    unsafe fn from_zend_object(exception: *mut ffi::zend_object) -> Option<Self> {
46        if exception.is_null() {
47            return None;
48        }
49
50        let ex = unsafe { &*exception };
51
52        let class_name = if ex.ce.is_null() {
53            "Unknown".to_string()
54        } else {
55            let ce = unsafe { &*ex.ce };
56            if ce.name.is_null() {
57                "Unknown".to_string()
58            } else {
59                unsafe { zend_string_to_string(ce.name) }.unwrap_or_else(|| "Unknown".to_string())
60            }
61        };
62
63        let message = unsafe { read_exception_property(exception, "message") };
64        let code = unsafe { read_exception_property_long(exception, "code") };
65        let file = unsafe { read_exception_property(exception, "file") };
66        let line = unsafe { read_exception_property_long(exception, "line") }
67            .try_into()
68            .unwrap_or(0);
69
70        Some(Self {
71            class_name,
72            message,
73            code,
74            file,
75            line,
76        })
77    }
78
79    /// Captures the current PHP call stack at exception throw time.
80    ///
81    /// This is lazy - only captures when called, zero cost if unused.
82    /// Returns `None` if no execution context is available.
83    ///
84    /// # Example
85    ///
86    /// ```ignore
87    /// fn on_exception(&self, exception: &ExceptionInfo) {
88    ///     if let Some(trace) = exception.backtrace() {
89    ///         for frame in trace {
90    ///             eprintln!("  at {}:{} in {}",
91    ///                 frame.file.as_deref().unwrap_or("<internal>"),
92    ///                 frame.line,
93    ///                 frame.function.as_deref().unwrap_or("<main>")
94    ///             );
95    ///         }
96    ///     }
97    /// }
98    /// ```
99    #[must_use]
100    pub fn backtrace(&self) -> Option<Vec<super::BacktraceFrame>> {
101        let eg = unsafe { crate::ffi::ext_php_rs_executor_globals().as_ref()? };
102        let mut execute_data = eg.current_execute_data;
103
104        let mut frames = Vec::new();
105        while !execute_data.is_null() {
106            if let Some(frame) = unsafe { super::BacktraceFrame::from_execute_data(execute_data) } {
107                frames.push(frame);
108            }
109            execute_data = unsafe { (*execute_data).prev_execute_data };
110        }
111
112        if frames.is_empty() {
113            None
114        } else {
115            Some(frames)
116        }
117    }
118}
119
120/// # Safety
121///
122/// `exception` must be a valid pointer to a `zend_object`.
123unsafe fn read_exception_property(exception: *mut ffi::zend_object, name: &str) -> Option<String> {
124    let ce = unsafe { (*exception).ce };
125    if ce.is_null() {
126        return None;
127    }
128
129    let name_cstr = std::ffi::CString::new(name).ok()?;
130    let mut rv = std::mem::MaybeUninit::<ffi::zval>::uninit();
131
132    let prop = unsafe {
133        ffi::zend_read_property(
134            ce,
135            exception,
136            name_cstr.as_ptr(),
137            name.len(),
138            true,
139            rv.as_mut_ptr(),
140        )
141    };
142
143    if prop.is_null() {
144        return None;
145    }
146
147    let prop_ref = unsafe { &*prop };
148    let type_info = unsafe { prop_ref.u1.type_info };
149
150    if (type_info & 0xFF) == ffi::IS_STRING {
151        let zs = unsafe { prop_ref.value.str_ };
152        unsafe { zend_string_to_string(zs) }
153    } else {
154        None
155    }
156}
157
158/// # Safety
159///
160/// `exception` must be a valid pointer to a `zend_object`.
161unsafe fn read_exception_property_long(exception: *mut ffi::zend_object, name: &str) -> i64 {
162    let ce = unsafe { (*exception).ce };
163    if ce.is_null() {
164        return 0;
165    }
166
167    let Ok(name_cstr) = std::ffi::CString::new(name) else {
168        return 0;
169    };
170    let mut rv = std::mem::MaybeUninit::<ffi::zval>::uninit();
171
172    let prop = unsafe {
173        ffi::zend_read_property(
174            ce,
175            exception,
176            name_cstr.as_ptr(),
177            name.len(),
178            true,
179            rv.as_mut_ptr(),
180        )
181    };
182
183    if prop.is_null() {
184        return 0;
185    }
186
187    let prop_ref = unsafe { &*prop };
188    let type_info = unsafe { prop_ref.u1.type_info };
189
190    if (type_info & 0xFF) == ffi::IS_LONG {
191        unsafe { prop_ref.value.lval }
192    } else {
193        0
194    }
195}
196
197/// # Safety
198///
199/// Pointer must be valid and string must be valid UTF-8.
200unsafe fn zend_string_to_string(zs: *mut ffi::zend_string) -> Option<String> {
201    if zs.is_null() {
202        return None;
203    }
204    let len = unsafe { (*zs).len };
205    let ptr = unsafe { (*zs).val.as_ptr() };
206    let slice = unsafe { std::slice::from_raw_parts(ptr.cast::<u8>(), len) };
207    std::str::from_utf8(slice).ok().map(String::from)
208}
209
210/// Trait for observing PHP exceptions.
211///
212/// # Lifecycle
213///
214/// `on_exception` is called whenever an exception is thrown in PHP,
215/// before any catch blocks are evaluated.
216///
217/// # Thread Safety
218///
219/// Observer must be `Send + Sync`. Use thread-safe primitives for mutable state.
220///
221/// # Example
222///
223/// ```ignore
224/// use ext_php_rs::prelude::*;
225/// use std::sync::atomic::{AtomicU64, Ordering};
226///
227/// struct ExceptionCounter {
228///     count: AtomicU64,
229/// }
230///
231/// impl ExceptionObserver for ExceptionCounter {
232///     fn on_exception(&self, exception: &ExceptionInfo) {
233///         self.count.fetch_add(1, Ordering::Relaxed);
234///         eprintln!("Exception #{}: {}",
235///             self.count.load(Ordering::Relaxed),
236///             exception.class_name
237///         );
238///     }
239/// }
240/// ```
241pub trait ExceptionObserver: 'static {
242    /// Called when an exception is thrown.
243    ///
244    /// This is called at throw time, before any catch blocks are evaluated.
245    /// The exception may or may not be caught.
246    fn on_exception(&self, exception: &ExceptionInfo);
247}
248
249type ExceptionObserverFactory =
250    Box<dyn Fn() -> Box<dyn ExceptionObserver + Send + Sync> + Send + Sync>;
251
252static EXCEPTION_OBSERVER_FACTORY: OnceLock<ExceptionObserverFactory> = OnceLock::new();
253static EXCEPTION_OBSERVER_INSTANCE: OnceLock<Box<dyn ExceptionObserver + Send + Sync>> =
254    OnceLock::new();
255
256static PREVIOUS_HOOK: OnceLock<Option<unsafe extern "C" fn(*mut ffi::zend_object)>> =
257    OnceLock::new();
258
259fn get_exception_observer() -> Option<&'static (dyn ExceptionObserver + Send + Sync)> {
260    EXCEPTION_OBSERVER_INSTANCE
261        .get()
262        .map(std::convert::AsRef::as_ref)
263}
264
265/// # Safety
266///
267/// Called from PHP's C code when an exception is thrown.
268unsafe extern "C" fn exception_observer_callback(exception: *mut ffi::zend_object) {
269    if let Some(observer) = get_exception_observer()
270        && let Some(info) = (unsafe { ExceptionInfo::from_zend_object(exception) })
271    {
272        observer.on_exception(&info);
273    }
274
275    if let Some(Some(prev)) = PREVIOUS_HOOK.get() {
276        unsafe { prev(exception) };
277    }
278}
279
280/// # Panics
281///
282/// Panics if called more than once.
283pub(crate) fn register_exception_observer_factory(factory: ExceptionObserverFactory) {
284    assert!(
285        EXCEPTION_OBSERVER_FACTORY.set(factory).is_ok(),
286        "exception_observer can only be registered once per extension"
287    );
288}
289
290/// # Safety
291///
292/// Must be called during MINIT phase only.
293pub(crate) unsafe fn exception_observer_startup() {
294    if let Some(factory) = EXCEPTION_OBSERVER_FACTORY.get() {
295        if EXCEPTION_OBSERVER_INSTANCE.set(factory()).is_err() {
296            return;
297        }
298
299        let prev = unsafe { ffi::zend_throw_exception_hook };
300        let _ = PREVIOUS_HOOK.set(prev);
301
302        unsafe {
303            ffi::zend_throw_exception_hook = Some(exception_observer_callback);
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    struct TestExceptionObserver;
313
314    unsafe impl Send for TestExceptionObserver {}
315    unsafe impl Sync for TestExceptionObserver {}
316
317    impl ExceptionObserver for TestExceptionObserver {
318        fn on_exception(&self, _exception: &ExceptionInfo) {}
319    }
320
321    #[test]
322    fn test_exception_info_fields() {
323        let info = ExceptionInfo {
324            class_name: "RuntimeException".to_string(),
325            message: Some("Test error".to_string()),
326            code: 42,
327            file: Some("/path/to/file.php".to_string()),
328            line: 100,
329        };
330
331        assert_eq!(info.class_name, "RuntimeException");
332        assert_eq!(info.message, Some("Test error".to_string()));
333        assert_eq!(info.code, 42);
334        assert_eq!(info.file, Some("/path/to/file.php".to_string()));
335        assert_eq!(info.line, 100);
336    }
337
338    #[test]
339    fn test_observer_trait_impl() {
340        let observer = TestExceptionObserver;
341        let info = ExceptionInfo {
342            class_name: "Exception".to_string(),
343            message: None,
344            code: 0,
345            file: None,
346            line: 0,
347        };
348        observer.on_exception(&info);
349    }
350}