Skip to main content

posthog_rs/
error_tracking.rs

1use std::any::{type_name, type_name_of_val};
2use std::error::Error as StdError;
3use std::io::Write;
4use std::panic::{self, AssertUnwindSafe};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7
8use derive_builder::Builder;
9use serde::Serialize;
10use serde_json::Value;
11
12use crate::{Client, Error, Event};
13
14/// Hard cap on stack frames per exception; frames beyond it are trimmed from
15/// the outermost end.
16const MAX_FRAMES: usize = 64;
17/// Hard cap on the `source()` chain walk, bounding pathological or cyclic
18/// error chains.
19const MAX_ERROR_SOURCES: usize = 50;
20
21/// Latches the single process-wide panic hook so a second install is rejected.
22static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
23
24/// An atomic trace/span identifier pair used to correlate an exception with
25/// distributed traces and logs.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct TraceContext {
28    trace_id: String,
29    span_id: String,
30}
31
32impl TraceContext {
33    /// Create a trace context from its trace and span identifiers.
34    pub fn new<T: Into<String>, S: Into<String>>(trace_id: T, span_id: S) -> Self {
35        Self {
36            trace_id: trace_id.into(),
37            span_id: span_id.into(),
38        }
39    }
40}
41
42#[derive(Clone)]
43struct TraceContextProvider(Arc<dyn Fn() -> Option<TraceContext> + Send + Sync>);
44
45impl TraceContextProvider {
46    fn new<F>(provider: F) -> Self
47    where
48        F: Fn() -> Option<TraceContext> + Send + Sync + 'static,
49    {
50        Self(Arc::new(provider))
51    }
52
53    fn provide(&self) -> Option<TraceContext> {
54        match panic::catch_unwind(AssertUnwindSafe(|| (self.0)())) {
55            Ok(context) => context,
56            Err(_) => {
57                tracing::warn!(
58                    "posthog-rs trace context provider panicked; omitting trace context"
59                );
60                None
61            }
62        }
63    }
64}
65
66impl Default for TraceContextProvider {
67    fn default() -> Self {
68        Self::new(|| None)
69    }
70}
71
72impl std::fmt::Debug for TraceContextProvider {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str("TraceContextProvider(..)")
75    }
76}
77
78/// How long the panic hook blocks the panicking thread waiting for the
79/// `$exception` to flush before letting the panic proceed. Deliberately short:
80/// the hook runs on the dying thread, so a long wait would freeze the crash (and
81/// delay the panic message, which prints only after the flush) when PostHog is
82/// slow or unreachable. Fixed rather than configurable for now — easy to expose
83/// later if a need arises.
84#[cfg(not(test))]
85const PANIC_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
86/// Shortened under test: the bounded-flush tests deliberately deadlock
87/// `before_send`, so they wait the full budget — only its boundedness matters
88/// there, not the production duration.
89#[cfg(test)]
90const PANIC_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(200);
91
92/// Client-level Error Tracking configuration, applied to every exception the
93/// client captures. Set it via [`ErrorTrackingOptionsBuilder`] on
94/// `ClientOptions::error_tracking`.
95///
96/// # Examples
97///
98/// ```
99/// use posthog_rs::{ErrorTrackingOptionsBuilder, TraceContext};
100///
101/// let options = ErrorTrackingOptionsBuilder::default()
102///     .capture_stacktrace(true)
103///     // Substring patterns match file paths and function symbols, so a
104///     // crate prefix marks that crate's frames as not in-app.
105///     .in_app_exclude_paths(vec!["other_crate::".to_string()])
106///     // Read IDs from whichever tracing system the application uses.
107///     .trace_context_provider(|| {
108///         Some(TraceContext::new("trace-id", "span-id"))
109///     })
110///     .build()
111///     .unwrap();
112/// ```
113#[derive(Builder, Clone, Debug)]
114#[builder(default)]
115pub struct ErrorTrackingOptions {
116    /// Capture a stack trace at the `capture_exception` call site and attach
117    /// it to the first entry of `$exception_list` (default: `true`).
118    ///
119    /// The trace shows where the error was *captured*, not where it was
120    /// created — a bubbled-up `Err` value carries no stack of its own. The
121    /// error type/message chain in `$exception_list` is always sent regardless
122    /// of this setting. Disabling it skips the stack walk and per-frame symbol
123    /// resolution entirely, which can matter when capturing handled errors in
124    /// high-volume paths.
125    capture_stacktrace: bool,
126    /// Treat only frames matching one of these patterns as in-app. Patterns
127    /// are substring matches against a frame's file path *and* function
128    /// symbol, so both path fragments (`"/service/"`) and crate prefixes
129    /// (`"my_service::"`) work. When empty, built-in defaults apply: frames
130    /// from the cargo registry, the standard library, and vendored/target
131    /// paths are library frames, everything else is in-app.
132    in_app_include_paths: Vec<String>,
133    /// Always mark matching frames as not in-app, taking precedence over
134    /// includes and defaults. Same matching rules as `in_app_include_paths`
135    /// — e.g. `"other_crate::"` excludes every frame of that crate.
136    in_app_exclude_paths: Vec<String>,
137    /// When `true`, [`crate::init_global`] installs a process-wide panic hook
138    /// that captures panics as `$exception` events through the global client.
139    /// Defaults to `false` — panic autocapture is opt-in.
140    ///
141    /// Only the global client installs a hook: a panic hook is process-global
142    /// (`std::panic::set_hook`), so it pairs with the process-global client, and
143    /// there is intentionally no per-`Client` panic API for now.
144    capture_panics: bool,
145    /// Callback used to read the current distributed trace context at the
146    /// `capture_exception` call site. Keep it fast, non-blocking, and free of
147    /// calls back into the PostHog SDK. Panics are caught and the context is
148    /// omitted.
149    #[builder(setter(custom))]
150    trace_context_provider: TraceContextProvider,
151}
152
153impl Default for ErrorTrackingOptions {
154    fn default() -> Self {
155        Self {
156            capture_stacktrace: true,
157            in_app_include_paths: Vec::new(),
158            in_app_exclude_paths: Vec::new(),
159            capture_panics: false,
160            trace_context_provider: TraceContextProvider::default(),
161        }
162    }
163}
164
165impl ErrorTrackingOptions {
166    fn capture_stacktrace(&self) -> bool {
167        self.capture_stacktrace
168    }
169
170    fn capture_panics(&self) -> bool {
171        self.capture_panics
172    }
173
174    fn trace_context(&self) -> Option<TraceContext> {
175        self.trace_context_provider.provide()
176    }
177
178    fn is_in_app_path(&self, filename: &str) -> bool {
179        if self
180            .in_app_exclude_paths
181            .iter()
182            .any(|path| filename.contains(path))
183        {
184            return false;
185        }
186
187        if !self.in_app_include_paths.is_empty() {
188            return self
189                .in_app_include_paths
190                .iter()
191                .any(|path| filename.contains(path));
192        }
193
194        default_in_app_path(filename)
195    }
196
197    fn is_in_app_frame(&self, filename: Option<&str>, function: Option<&str>) -> bool {
198        if self.in_app_exclude_paths.iter().any(|path| {
199            filename.is_some_and(|filename| filename.contains(path))
200                || function.is_some_and(|function| function.contains(path))
201        }) {
202            return false;
203        }
204
205        if !self.in_app_include_paths.is_empty() {
206            return self.in_app_include_paths.iter().any(|path| {
207                filename.is_some_and(|filename| filename.contains(path))
208                    || function.is_some_and(|function| function.contains(path))
209            });
210        }
211
212        if filename.is_some_and(|filename| !self.is_in_app_path(filename)) {
213            return false;
214        }
215
216        if let Some(function) = function {
217            if !default_in_app_function(function) {
218                return false;
219            }
220            // Only a known-symbol list for fileless bootstrap glue: a broader
221            // "no `::` path and no file" rule would also hide legitimate app
222            // symbols (`#[no_mangle]`/`#[export_name]` functions, C code
223            // linked into the binary) that resolve the same way.
224            if filename.is_none() && is_bootstrap_symbol(function) {
225                return false;
226            }
227            return true;
228        }
229
230        filename.is_some()
231    }
232}
233
234impl ErrorTrackingOptionsBuilder {
235    /// Set a callback that returns the distributed trace context active at an
236    /// exception's capture site.
237    ///
238    /// The callback runs synchronously during `capture_exception`. It must be
239    /// fast, non-blocking, and must not call back into the PostHog SDK. Panics
240    /// are caught and cause trace context to be omitted from that exception.
241    pub fn trace_context_provider<F>(&mut self, provider: F) -> &mut Self
242    where
243        F: Fn() -> Option<TraceContext> + Send + Sync + 'static,
244    {
245        self.trace_context_provider = Some(TraceContextProvider::new(provider));
246        self
247    }
248}
249
250/// Install the panic hook against a specific `client`. Internal/test-only: the
251/// public entry point is the global client's `capture_panics` option (via
252/// [`crate::init_global`]), since a panic hook is process-global. Kept to
253/// exercise the shared hook path against a standalone client in tests. A
254/// disabled client installs nothing and returns `Ok(())`.
255#[cfg(test)]
256fn install_panic_hook(client: Arc<Client>) -> Result<(), Error> {
257    if client.is_disabled() {
258        return Ok(());
259    }
260    install_hook(move |panic_info| capture_panic(&client, panic_info))
261}
262
263/// If the global client has `capture_panics` enabled (opt-in) and can actually
264/// send, install the panic hook against it. Best-effort and idempotent: a hook
265/// installed earlier is left in place. Called by `init_global` once the global
266/// client is set.
267pub(crate) fn maybe_install_global_panic_hook() {
268    let Some(client) = crate::global::global_client() else {
269        return;
270    };
271    if !should_capture_global_panics(client) {
272        return;
273    }
274    // The hook reads the global client at panic time — it lives in a process
275    // `static`, so the hook needs no owned handle. `AlreadyInstalled` is benign.
276    let _ = install_hook(|panic_info| match crate::global::global_client() {
277        Some(client) => capture_panic(client, panic_info),
278        None => Ok(()),
279    });
280}
281
282/// Whether `init_global` should auto-install the panic hook for this client.
283/// A disabled client can't send, so it must not latch the single process-wide
284/// hook.
285fn should_capture_global_panics(client: &Client) -> bool {
286    !client.is_disabled() && client.error_tracking_options().capture_panics()
287}
288
289/// Latch the single process-wide panic hook, then install one that runs
290/// `capture` (kept panic-free) and chains the previously installed hook. Returns
291/// [`Error::PanicHookAlreadyInstalled`] if a hook is already installed.
292#[allow(deprecated)]
293fn install_hook<F>(capture: F) -> Result<(), Error>
294where
295    F: Fn(&panic::PanicInfo<'_>) -> Result<(), Error> + Send + Sync + 'static,
296{
297    if PANIC_HOOK_INSTALLED
298        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
299        .is_err()
300    {
301        return Err(Error::PanicHookAlreadyInstalled);
302    }
303
304    let previous_hook = panic::take_hook();
305    panic::set_hook(Box::new(move |panic_info| {
306        // Report capture failures straight to stderr: dispatching through
307        // tracing would run arbitrary subscriber code on the panicking thread.
308        // catch_unwind cannot prevent a nested-panic abort here (the panic count
309        // is already non-zero), so `capture` is kept panic-free — it only
310        // enqueues and flushes; the send happens on the worker thread.
311        if let Ok(Err(error)) = panic::catch_unwind(AssertUnwindSafe(|| capture(panic_info))) {
312            let _ = writeln!(
313                std::io::stderr(),
314                "posthog-rs: failed to capture panic: {error}"
315            );
316        }
317
318        previous_hook(panic_info);
319    }));
320
321    Ok(())
322}
323
324/// Build the panic `$exception` event and route it through `client`'s transport:
325/// a non-blocking enqueue followed by a time-bounded synchronous flush, so the
326/// event is attempted before the process potentially exits without ever hanging
327/// the dying process. `before_send` and the HTTP send run on the worker thread.
328#[allow(deprecated)]
329fn capture_panic(client: &Client, panic_info: &panic::PanicInfo<'_>) -> Result<(), Error> {
330    // A panic on this client's own transport worker thread is almost always a
331    // panicking `before_send` (which the worker already catches and logs).
332    // Capturing it there would deadlock — a synchronous flush can't be serviced
333    // by the worker that's busy running this hook — and would recurse: the
334    // captured `$exception` re-enters `before_send` on the worker and panics
335    // again. Skip it.
336    if client.is_disabled() || client.on_transport_worker() {
337        return Ok(());
338    }
339    let et_options = client.error_tracking_options();
340    let event = build_panic_event(panic_info, et_options)?;
341    // Enqueue through the tracing-free path: `capture` is `#[instrument]` and
342    // warns on a full queue, both of which run subscriber code that's unsafe on
343    // the panicking thread (it could panic again -> abort, or wait on a lock the
344    // panic site holds -> hang before the previous hook runs).
345    client.enqueue_panic_event(event);
346    // Time-bounded flush on the panicking thread (before unwinding frees locks).
347    // The fixed bound (`PANIC_FLUSH_TIMEOUT`) keeps the dying process from
348    // hanging — and the panic message, which prints only after this returns,
349    // from being delayed — when PostHog is slow/unreachable or a `before_send`
350    // hook needs a lock the panic site still holds.
351    client.flush_blocking_timeout(PANIC_FLUSH_TIMEOUT);
352    Ok(())
353}
354
355/// Build a personless `$exception` event from a panic. The panic-site location
356/// is stamped before the reserved `$exception_*` properties so it can't override
357/// them.
358#[allow(deprecated)]
359fn build_panic_event(
360    panic_info: &panic::PanicInfo<'_>,
361    et_options: &ErrorTrackingOptions,
362) -> Result<Event, Error> {
363    let exception = Exception::from_panic_info(panic_info, et_options.capture_stacktrace());
364
365    let mut event = Event::new_anon("$exception");
366    if let Some(location) = panic_info.location() {
367        event.insert_prop("$exception_panic_file", location.file())?;
368        event.insert_prop("$exception_panic_line", location.line())?;
369        event.insert_prop("$exception_panic_column", location.column())?;
370    }
371    exception.write_into(&mut event, et_options)?;
372    Ok(event)
373}
374
375/// Optional context for `capture_exception_with`: person identity, custom
376/// properties, groups, trace context, and exception fingerprint/level.
377///
378/// All fields are optional. An empty options set (`new()` / `Default`)
379/// captures the exception personlessly with no extra context. With the
380/// trace-context provider configured on [`ErrorTrackingOptions`], its trace
381/// and span IDs are attached automatically; values set here take precedence.
382///
383/// # Examples
384///
385/// ```
386/// use posthog_rs::CaptureExceptionOptions;
387///
388/// let options = CaptureExceptionOptions::new()
389///     .distinct_id("user-123")
390///     .property("route", "/checkout")?
391///     .group("company", "acme")
392///     .trace_context(
393///         "00000000000000000000000000000123",
394///         "0000000000000456",
395///     )
396///     .fingerprint("checkout-error")
397///     .level("warning");
398/// # Ok::<(), posthog_rs::Error>(())
399/// ```
400#[derive(Clone, Debug, Default)]
401pub struct CaptureExceptionOptions {
402    distinct_id: Option<String>,
403    properties: Vec<(String, Value)>,
404    groups: Vec<(String, String)>,
405    trace_context: Option<TraceContext>,
406    fingerprint: Option<String>,
407    level: Option<String>,
408}
409
410impl CaptureExceptionOptions {
411    /// Create an empty options set: personless, no extra context.
412    pub fn new() -> Self {
413        Self::default()
414    }
415
416    /// Associate the exception with a person.
417    pub fn distinct_id<S: Into<String>>(mut self, distinct_id: S) -> Self {
418        self.distinct_id = Some(distinct_id.into());
419        self
420    }
421
422    /// Add a custom property to the exception event.
423    pub fn property<K: Into<String>, V: Serialize>(
424        mut self,
425        key: K,
426        value: V,
427    ) -> Result<Self, Error> {
428        let value = serde_json::to_value(value).map_err(|e| Error::Serialization(e.to_string()))?;
429        self.properties.push((key.into(), value));
430        Ok(self)
431    }
432
433    /// Capture the exception as a group event.
434    pub fn group<N: Into<String>, I: Into<String>>(mut self, group_name: N, group_id: I) -> Self {
435        self.groups.push((group_name.into(), group_id.into()));
436        self
437    }
438
439    /// Associate the exception with a span in a distributed trace.
440    ///
441    /// The IDs are accepted together so an exception can never combine a trace
442    /// ID and span ID from different contexts. This pair overrides any context
443    /// returned by the client's trace-context provider.
444    pub fn trace_context<T: Into<String>, S: Into<String>>(
445        mut self,
446        trace_id: T,
447        span_id: S,
448    ) -> Self {
449        self.trace_context = Some(TraceContext::new(trace_id, span_id));
450        self
451    }
452
453    /// Set a custom exception fingerprint.
454    pub fn fingerprint<S: Into<String>>(mut self, fingerprint: S) -> Self {
455        self.fingerprint = Some(fingerprint.into());
456        self
457    }
458
459    /// Set the exception severity level. Defaults to `"error"`.
460    pub fn level<S: Into<String>>(mut self, level: S) -> Self {
461        self.level = Some(level.into());
462        self
463    }
464}
465
466/// Build a finalized `$exception` [`Event`] from a Rust error, capture
467/// options, and the capturing client's Error Tracking configuration.
468///
469/// All client policy is applied here, eagerly: the stack walk only runs when
470/// `capture_stacktrace` is enabled, and in-app classification, frame and
471/// source-chain limits, and the reserved `$exception_*` properties are written
472/// before the event is returned. The returned event is an ordinary [`Event`].
473pub(crate) fn build_exception_event<E>(
474    error: &E,
475    options: CaptureExceptionOptions,
476    et_options: &ErrorTrackingOptions,
477) -> Result<Event, Error>
478where
479    E: StdError + ?Sized,
480{
481    let CaptureExceptionOptions {
482        distinct_id,
483        properties,
484        groups,
485        trace_context,
486        fingerprint,
487        level,
488    } = options;
489
490    let trace_context = trace_context.or_else(|| et_options.trace_context());
491
492    let mut exception = Exception::from_error(error, et_options.capture_stacktrace());
493    if let Some(fingerprint) = fingerprint {
494        exception.set_fingerprint(fingerprint);
495    }
496    if let Some(level) = level {
497        exception.set_level(level);
498    }
499
500    let mut event = match distinct_id {
501        Some(distinct_id) => Event::new("$exception".to_string(), distinct_id),
502        None => Event::new_anon("$exception"),
503    };
504    for (key, value) in properties {
505        event.insert_prop(key, value)?;
506    }
507    for (group_name, group_id) in groups {
508        event.add_group(&group_name, &group_id);
509    }
510
511    // Reserved properties are written after user-set properties so they can't
512    // be overridden.
513    if let Some(trace_context) = trace_context {
514        event.insert_prop("$trace_id", trace_context.trace_id)?;
515        event.insert_prop("$span_id", trace_context.span_id)?;
516    }
517    exception.write_into(&mut event, et_options)?;
518    Ok(event)
519}
520
521/// A PostHog Error Tracking exception payload.
522///
523/// Internal staging type: every construction site lives in this module and is
524/// reached through a client method that holds the client's
525/// [`ErrorTrackingOptions`], so client policy is applied eagerly when the
526/// `$exception` event is built ([`build_exception_event`]). Constructors take
527/// only a `capture_stacktrace` cost hint — the stack walk must happen at the
528/// capture site or not at all, and disabling it skips the walk entirely.
529#[derive(Clone, Debug, PartialEq, Eq)]
530pub(crate) struct Exception {
531    items: Vec<ExceptionItem>,
532    // SDK-captured raw frames pending client policy (in-app classification
533    // and trimming), applied in write_into and attached to items[0]. None when
534    // stacktrace capture is disabled.
535    captured_frames: Option<Vec<StackFrame>>,
536    // Loaded modules referenced by captured_frames; becomes the event-level
537    // $debug_images property after trimming. Empty when stacktrace capture is
538    // disabled or no frame points into a module with an uploadable debug id.
539    captured_images: Vec<DebugImage>,
540    fingerprint: Option<String>,
541    level: String,
542}
543
544impl Exception {
545    /// Build an exception from a Rust error, walking the `source()` chain and
546    /// capturing the current stacktrace when `capture_stacktrace` is set.
547    pub(crate) fn from_error<E>(error: &E, capture_stacktrace: bool) -> Self
548    where
549        E: StdError + ?Sized,
550    {
551        let mut items = vec![ExceptionItem {
552            exception_type: simple_type_name(type_name::<E>()),
553            value: error_value(error),
554            mechanism: ExceptionMechanism::default(),
555            stacktrace: None,
556        }];
557
558        let mut source = error.source();
559        while let Some(err) = source {
560            if items.len() >= MAX_ERROR_SOURCES {
561                break;
562            }
563            items.push(ExceptionItem {
564                exception_type: source_type_name(err),
565                value: error_value(err),
566                mechanism: ExceptionMechanism::default(),
567                stacktrace: None,
568            });
569            source = err.source();
570        }
571
572        link_exception_chain(&mut items);
573
574        let (captured_frames, captured_images) = if capture_stacktrace {
575            let (frames, images) = capture_raw_application_frames();
576            (Some(frames), images)
577        } else {
578            (None, Vec::new())
579        };
580
581        Self {
582            items,
583            captured_frames,
584            captured_images,
585            fingerprint: None,
586            level: "error".to_string(),
587        }
588    }
589
590    /// Build an exception from an arbitrary type/message pair, capturing the
591    /// current stacktrace when `capture_stacktrace` is set.
592    // Only exercised by tests today; kept as the message-capture seam.
593    #[allow(dead_code)]
594    pub(crate) fn from_message<T: Into<String>, V: Into<String>>(
595        exception_type: T,
596        value: V,
597        capture_stacktrace: bool,
598    ) -> Self {
599        let (captured_frames, captured_images) = if capture_stacktrace {
600            let (frames, images) = capture_raw_application_frames();
601            (Some(frames), images)
602        } else {
603            (None, Vec::new())
604        };
605
606        Self {
607            items: vec![ExceptionItem {
608                exception_type: exception_type.into(),
609                value: value.into(),
610                mechanism: ExceptionMechanism::default(),
611                stacktrace: None,
612            }],
613            captured_frames,
614            captured_images,
615            fingerprint: None,
616            level: "error".to_string(),
617        }
618    }
619
620    /// Build an exception from a panic, capturing the current stacktrace when
621    /// `capture_stacktrace` is set.
622    #[allow(deprecated)]
623    fn from_panic_info(panic_info: &panic::PanicInfo<'_>, capture_stacktrace: bool) -> Self {
624        let (captured_frames, captured_images) = if capture_stacktrace {
625            let (frames, images) = capture_raw_panic_frames();
626            (Some(frames), images)
627        } else {
628            (None, Vec::new())
629        };
630
631        Self {
632            items: vec![ExceptionItem {
633                exception_type: "Panic".to_string(),
634                value: panic_message(panic_info),
635                mechanism: ExceptionMechanism {
636                    mechanism_type: "panic".to_string(),
637                    handled: false,
638                    synthetic: false,
639                    exception_id: None,
640                    parent_id: None,
641                },
642                stacktrace: None,
643            }],
644            captured_frames,
645            captured_images,
646            fingerprint: None,
647            // Panics are unrecoverable (the process is unwinding/aborting), so
648            // they are reported at `fatal`, not `error`.
649            level: "fatal".to_string(),
650        }
651    }
652
653    /// Set a custom exception fingerprint.
654    pub(crate) fn set_fingerprint<S: Into<String>>(&mut self, fingerprint: S) {
655        self.fingerprint = Some(fingerprint.into());
656    }
657
658    /// Set the exception severity level. Defaults to `"error"`.
659    pub(crate) fn set_level<S: Into<String>>(&mut self, level: S) {
660        self.level = level.into();
661    }
662
663    /// Apply client-level Error Tracking options (in-app classification, frame
664    /// and source-chain limits) and write the reserved `$exception_*`
665    /// properties onto `event`.
666    fn write_into(self, event: &mut Event, options: &ErrorTrackingOptions) -> Result<(), Error> {
667        let Exception {
668            mut items,
669            captured_frames,
670            captured_images,
671            fingerprint,
672            level,
673        } = self;
674        if items.is_empty() {
675            return Ok(());
676        }
677
678        let mut debug_images = Vec::new();
679        if let Some(mut frames) = captured_frames {
680            for frame in frames.iter_mut() {
681                let function = (!frame.function.is_empty()).then_some(frame.function.as_str());
682                // Frames without any symbol information keep their capture-time
683                // image-based classification; the path/function rules have
684                // nothing to act on.
685                if function.is_some() || frame.filename.is_some() {
686                    frame.in_app = options.is_in_app_frame(frame.filename.as_deref(), function);
687                }
688            }
689            trim_to_max_frames(&mut frames, MAX_FRAMES);
690            // Only report modules still referenced after trimming.
691            debug_images = captured_images
692                .into_iter()
693                .filter(|image| {
694                    frames
695                        .iter()
696                        .any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
697                })
698                .collect();
699            items[0].stacktrace = Some(ExceptionStacktrace::raw(frames));
700        }
701
702        event.insert_prop("$exception_level", level)?;
703        if let Some(fingerprint) = fingerprint {
704            event.insert_prop("$exception_fingerprint", fingerprint)?;
705        }
706        if !debug_images.is_empty() {
707            event.insert_prop("$debug_images", debug_images)?;
708        }
709        event.insert_prop("$exception_list", items)?;
710        Ok(())
711    }
712}
713
714/// A normalized exception entry in `$exception_list`.
715#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
716pub(crate) struct ExceptionItem {
717    #[serde(rename = "type")]
718    pub exception_type: String,
719    pub value: String,
720    pub mechanism: ExceptionMechanism,
721    #[serde(skip_serializing_if = "Option::is_none")]
722    pub stacktrace: Option<ExceptionStacktrace>,
723}
724
725/// How an exception was captured.
726#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
727pub(crate) struct ExceptionMechanism {
728    #[serde(rename = "type")]
729    pub mechanism_type: String,
730    pub handled: bool,
731    pub synthetic: bool,
732    /// Position in the cause chain, `0` being the outermost error. Only set when
733    /// the exception is part of a multi-error chain.
734    #[serde(skip_serializing_if = "Option::is_none")]
735    pub exception_id: Option<usize>,
736    /// `exception_id` of the error this one was a source of.
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub parent_id: Option<usize>,
739}
740
741impl Default for ExceptionMechanism {
742    fn default() -> Self {
743        Self {
744            mechanism_type: "generic".to_string(),
745            handled: true,
746            synthetic: false,
747            exception_id: None,
748            parent_id: None,
749        }
750    }
751}
752
753/// A normalized stacktrace.
754#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
755pub(crate) struct ExceptionStacktrace {
756    #[serde(rename = "type")]
757    pub stacktrace_type: String,
758    pub frames: Vec<StackFrame>,
759}
760
761impl ExceptionStacktrace {
762    fn raw(frames: Vec<StackFrame>) -> Self {
763        Self {
764            stacktrace_type: "raw".to_string(),
765            frames,
766        }
767    }
768}
769
770/// A normalized stack frame.
771///
772/// Frames carry the raw `instruction_addr` for server-side symbolication
773/// against uploaded debug symbols (`posthog-cli debug-symbols upload`), plus
774/// best-effort client-side enrichment (`function`/`filename`/`lineno`) used
775/// for display when no debug symbols are available.
776#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
777pub(crate) struct StackFrame {
778    #[serde(skip_serializing_if = "Option::is_none")]
779    pub filename: Option<String>,
780    #[serde(rename = "lineno")]
781    #[serde(skip_serializing_if = "Option::is_none")]
782    pub line_no: Option<u32>,
783    #[serde(skip_serializing_if = "String::is_empty")]
784    pub function: String,
785    pub lang: String,
786    pub in_app: bool,
787    pub synthetic: bool,
788    pub platform: String,
789    /// Absolute address of the instruction, as a hex string.
790    #[serde(skip_serializing_if = "Option::is_none")]
791    pub instruction_addr: Option<String>,
792    /// Start address of the enclosing symbol, when known.
793    #[serde(skip_serializing_if = "Option::is_none")]
794    pub symbol_addr: Option<String>,
795    /// Load address of the module containing the instruction, when known.
796    #[serde(skip_serializing_if = "Option::is_none")]
797    pub image_addr: Option<String>,
798    /// Whether the SDK resolved this frame's `function`/`filename`/`lineno`
799    /// from debug info in the running process. Address-less client-resolved
800    /// frames pass through the server untouched; addressed ones still get
801    /// symbolicated server-side — group-wise when `inline` markers are
802    /// present — with the client fields kept as the fallback.
803    pub client_resolved: bool,
804    /// Marks a frame the SDK synthesized by expanding the inline chain of the
805    /// group's physical frame, which leads the group and carries the same
806    /// `instruction_addr`. The server resolves the group's address once and
807    /// either replaces the whole group with its own expansion or keeps the
808    /// client's frames verbatim — so inline data survives without symbols
809    /// uploaded, and doesn't duplicate with them. Serialized only when true.
810    #[serde(skip_serializing_if = "std::ops::Not::not")]
811    pub inline: bool,
812}
813
814/// A loaded module (binary image) referenced by captured stack frames. Sent as
815/// the event-level `$debug_images` property so the server can map instruction
816/// addresses onto uploaded debug symbols.
817#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
818pub(crate) struct DebugImage {
819    #[serde(rename = "type")]
820    pub image_type: String,
821    /// The debug identifier matching the uploaded symbol set (derived from
822    /// the GNU build id on ELF, `LC_UUID` on Mach-O).
823    pub debug_id: String,
824    /// The full code identifier (e.g. complete GNU build id), when available.
825    #[serde(skip_serializing_if = "Option::is_none")]
826    pub code_id: Option<String>,
827    pub image_addr: String,
828    #[serde(skip_serializing_if = "Option::is_none")]
829    pub image_size: Option<u64>,
830    #[serde(skip_serializing_if = "Option::is_none")]
831    pub image_vmaddr: Option<String>,
832    #[serde(skip_serializing_if = "Option::is_none")]
833    pub code_file: Option<String>,
834    pub arch: String,
835}
836
837/// A module mapped into the process, used to attach load addresses to frames
838/// and build the `$debug_images` list.
839struct LoadedModule {
840    base: u64,
841    end: u64,
842    image: DebugImage,
843}
844
845const fn native_image_type() -> &'static str {
846    if cfg!(any(target_os = "macos", target_os = "ios")) {
847        "macho"
848    } else if cfg!(target_os = "windows") {
849        "pe"
850    } else {
851        "elf"
852    }
853}
854
855/// Normalize a CPU architecture name to the shared native vocabulary used by
856/// the other PostHog SDKs (informational; image matching is by debug_id and
857/// address). `std::env::consts::ARCH` already uses most of these names.
858fn normalize_arch(arch: &str) -> String {
859    match arch {
860        "aarch64" => "arm64".to_string(),
861        other => other.to_string(),
862    }
863}
864
865/// Render 16 bytes laid out as a little-endian GUID (Microsoft convention:
866/// the first three fields are stored byte-swapped) as a canonical UUID string.
867///
868/// Used for PDB signatures, whose GUID is always stored little-endian on disk;
869/// the swap is therefore unconditional, matching `symbolic`'s PE/PDB path at
870/// upload time. (The ELF GNU-build-id path is endianness-aware instead — see
871/// `debug_id_from_gnu_build_id`.)
872fn guid_le_to_uuid(mut data: [u8; 16]) -> String {
873    data[0..4].reverse();
874    data[4..6].reverse();
875    data[6..8].reverse();
876    uuid::Uuid::from_bytes(data).to_string()
877}
878
879/// Derive a debug id from a GNU build id: the first 16 bytes interpreted as a
880/// GUID, zero-padded when the build id is shorter.
881///
882/// This must match `symbolic`'s `ElfObject::compute_debug_id` (used by the
883/// server and `posthog-cli` at upload time), which byte-swaps the first three
884/// GUID fields *only for little-endian ELF objects*. The SDK enumerates its own
885/// process, so the object endianness is this target's endianness — hence the
886/// swap is gated on `target_endian` rather than applied unconditionally (a
887/// big-endian ELF binary on s390x/powerpc64 must not swap, or its debug id
888/// won't match the uploaded symbol set).
889fn debug_id_from_gnu_build_id(build_id: &[u8]) -> Option<String> {
890    if build_id.is_empty() {
891        return None;
892    }
893    let mut data = [0u8; 16];
894    let len = build_id.len().min(16);
895    data[..len].copy_from_slice(&build_id[..len]);
896    if cfg!(target_endian = "little") {
897        data[0..4].reverse();
898        data[4..6].reverse();
899        data[6..8].reverse();
900    }
901    Some(uuid::Uuid::from_bytes(data).to_string())
902}
903
904fn debug_id_for(id: &findshlibs::SharedLibraryId) -> Option<String> {
905    use findshlibs::SharedLibraryId;
906
907    match id {
908        SharedLibraryId::GnuBuildId(bytes) => debug_id_from_gnu_build_id(bytes),
909        // Uppercase to match the chunk_ids stored by `posthog-cli dsym
910        // upload`, which takes them verbatim from dwarfdump output.
911        SharedLibraryId::Uuid(bytes) => {
912            Some(uuid::Uuid::from_bytes(*bytes).to_string().to_uppercase())
913        }
914        SharedLibraryId::PdbSignature(guid, age) => {
915            let uuid = guid_le_to_uuid(*guid);
916            Some(if *age > 0 {
917                format!("{uuid}-{age:x}")
918            } else {
919                uuid
920            })
921        }
922        // PE timestamp/size signatures carry no debug id we can match symbols to.
923        _ => None,
924    }
925}
926
927/// Enumerate the modules currently mapped into the process, sorted by load
928/// address. Modules without a usable debug id are kept for address matching
929/// (frames still get an `image_addr`) but marked so they're never reported
930/// in `$debug_images`.
931fn collect_loaded_modules() -> Vec<LoadedModule> {
932    use findshlibs::{IterationControl, SharedLibrary, TargetSharedLibrary};
933
934    let mut modules = Vec::new();
935
936    TargetSharedLibrary::each(|shlib| {
937        let base = shlib.actual_load_addr().0 as u64;
938        let size = shlib.len() as u64;
939
940        // The main executable's name can be empty on Linux; the code_file
941        // fallback below covers that.
942        let name = shlib.name().to_string_lossy().into_owned();
943        let code_file = if name.is_empty() {
944            std::env::current_exe()
945                .ok()
946                .map(|p| p.to_string_lossy().into_owned())
947        } else {
948            Some(name)
949        };
950
951        // debug_id() (PDB GUID+age on Windows, same as id() elsewhere) is the
952        // identifier that matches uploaded symbols; id() supplies the full
953        // code identifier (e.g. complete GNU build id).
954        let debug_id = shlib
955            .debug_id()
956            .as_ref()
957            .and_then(debug_id_for)
958            .unwrap_or_default();
959        let code_id = match shlib.id() {
960            Some(findshlibs::SharedLibraryId::GnuBuildId(bytes)) => {
961                Some(bytes.iter().map(|b| format!("{b:02x}")).collect::<String>())
962            }
963            _ => None,
964        };
965
966        modules.push(LoadedModule {
967            base,
968            end: base.saturating_add(size),
969            image: DebugImage {
970                image_type: native_image_type().to_string(),
971                debug_id,
972                code_id,
973                image_addr: format!("0x{base:x}"),
974                image_size: Some(size),
975                image_vmaddr: Some(format!("0x{:x}", shlib.stated_load_addr().0 as u64)),
976                code_file,
977                arch: normalize_arch(std::env::consts::ARCH),
978            },
979        });
980
981        IterationControl::Continue
982    });
983
984    modules.sort_by_key(|m| m.base);
985    modules
986}
987
988fn find_module(modules: &[LoadedModule], addr: u64) -> Option<&LoadedModule> {
989    let idx = modules.partition_point(|m| m.base <= addr);
990    let module = modules[..idx].last()?;
991    (addr < module.end).then_some(module)
992}
993
994// Captures raw Rust stack traces for Error Tracking. Frames are unclassified
995// at this point: in-app classification and trimming are client policy, applied
996// when the exception event is built. Every frame carries its instruction
997// address; function/file/line enrichment is best-effort and missing entirely
998// in stripped release builds.
999//
1000// inline(never): the entry address of this function identifies the SDK's own
1001// frames for address-based stripping, which must survive symbol-less builds.
1002#[inline(never)]
1003fn capture_frames_current_first(skip: usize, modules: &[LoadedModule]) -> Vec<StackFrame> {
1004    let mut frames = Vec::new();
1005    let mut skipped = 0usize;
1006
1007    backtrace::trace(|frame| {
1008        if skipped < skip {
1009            skipped += 1;
1010            return true;
1011        }
1012
1013        let instruction_addr = frame.ip() as u64;
1014        let frame_symbol_addr = frame.symbol_address() as u64;
1015        let module = find_module(modules, instruction_addr);
1016        // Only send addresses the server can actually resolve: without a
1017        // module carrying a debug id there is no `$debug_images` entry to
1018        // match, and the frame should pass through as purely client-resolved.
1019        let resolvable = module.is_some_and(|m| !m.image.debug_id.is_empty());
1020
1021        // One physical frame resolves to multiple symbols when the compiler
1022        // inlined functions into it; `resolve_frame` yields those layers
1023        // innermost-first. Collect them so we can choose how to emit based on
1024        // whether the server can symbolicate this address.
1025        let mut layers: Vec<(Option<String>, Option<u32>, String)> = Vec::new();
1026        backtrace::resolve_frame(frame, |symbol| {
1027            let filename = symbol.filename().map(path_to_string);
1028            let function = symbol
1029                .name()
1030                .map(|name| normalize_function_name(&name.to_string()));
1031
1032            if filename.is_none() && function.is_none() {
1033                return;
1034            }
1035
1036            layers.push((filename, symbol.lineno(), function.unwrap_or_default()));
1037        });
1038
1039        if resolvable {
1040            let wire_instruction_addr = Some(format!("0x{instruction_addr:x}"));
1041            // frame.symbol_address() is the physical entry the pinned-frame
1042            // stripping matches against; group members carry it too, so the
1043            // rposition drain sweeps whole SDK groups.
1044            let wire_symbol_addr =
1045                (frame_symbol_addr != 0).then(|| format!("0x{frame_symbol_addr:x}"));
1046            let wire_image_addr = module.map(|m| m.image.image_addr.clone());
1047
1048            if layers.is_empty() {
1049                // Stripped build: no local symbols to offer, so send the bare
1050                // address for the server to symbolicate and expand.
1051                frames.push(StackFrame {
1052                    filename: None,
1053                    line_no: None,
1054                    function: String::new(),
1055                    lang: "rust".to_string(),
1056                    in_app: false,
1057                    synthetic: false,
1058                    platform: "native".to_string(),
1059                    instruction_addr: wire_instruction_addr,
1060                    symbol_addr: wire_symbol_addr,
1061                    image_addr: wire_image_addr,
1062                    client_resolved: false,
1063                    inline: false,
1064                });
1065            } else {
1066                // Client-expanded inline group: one frame per logical layer,
1067                // every layer carrying the physical address. Layers are pushed
1068                // innermost-first here with the physical (outermost) layer
1069                // last; the reverse into canonical wire order in
1070                // `capture_raw_frames` / `capture_raw_panic_frames` then puts
1071                // the physical frame first as the group's non-inline lead,
1072                // with its inline members after it. The resolver symbolicates
1073                // the lead's address once and atomically replaces the whole
1074                // group with its own expansion — or keeps these frames
1075                // verbatim when no debug symbols are uploaded. That preserves
1076                // inline data in every case without duplicating it.
1077                let physical_index = layers.len() - 1;
1078                for (index, (filename, line_no, function)) in layers.into_iter().enumerate() {
1079                    frames.push(StackFrame {
1080                        filename,
1081                        line_no,
1082                        function,
1083                        lang: "rust".to_string(),
1084                        in_app: false,
1085                        synthetic: false,
1086                        platform: "native".to_string(),
1087                        instruction_addr: wire_instruction_addr.clone(),
1088                        symbol_addr: wire_symbol_addr.clone(),
1089                        image_addr: wire_image_addr.clone(),
1090                        client_resolved: true,
1091                        inline: index != physical_index,
1092                    });
1093                }
1094            }
1095        } else if !layers.is_empty() {
1096            // We resolved symbols locally but there's no uploadable debug image,
1097            // so the server can't symbolicate this address. Keep the client-side
1098            // inline expansion (one frame per layer, no native addresses) — it's
1099            // the only way these inlined calls survive. Layers are pushed
1100            // innermost-first here; the reverse in `capture_raw_frames` /
1101            // `capture_raw_panic_frames` later flips them so the outermost
1102            // logical layer leads and the inlined leaf is last, matching the
1103            // canonical bottom-up wire order.
1104            for (filename, line_no, function) in layers {
1105                frames.push(StackFrame {
1106                    filename,
1107                    line_no,
1108                    function,
1109                    lang: "rust".to_string(),
1110                    // Placeholder: these frames carry a name, so `write_into`
1111                    // reclassifies in_app from the path/function before sending.
1112                    in_app: false,
1113                    synthetic: false,
1114                    platform: "native".to_string(),
1115                    instruction_addr: None,
1116                    symbol_addr: None,
1117                    image_addr: None,
1118                    // Resolved client-side (no debug image for the server to
1119                    // use); address-less frames pass through untouched.
1120                    client_resolved: true,
1121                    inline: false,
1122                });
1123            }
1124        }
1125        // A non-resolvable frame with no local symbols is dropped: with no name
1126        // and no address, neither the client nor the server can resolve it, so a
1127        // bare entry would be pure noise.
1128
1129        true
1130    });
1131
1132    frames
1133}
1134
1135// Frames are in canonical wire order (outermost first, crash-site frame last),
1136// so trimming drops the outermost frames from the front and keeps the ones
1137// nearest the crash site.
1138fn trim_to_max_frames(frames: &mut Vec<StackFrame>, max_frames: usize) {
1139    if frames.len() <= max_frames {
1140        return;
1141    }
1142    let mut cut = frames.len() - max_frames;
1143    // Never cut into the middle of a client-expanded inline group: members
1144    // without their physical frame can't be grouped server-side. Drop the
1145    // orphaned members along with their lead.
1146    while frames.get(cut).is_some_and(|frame| frame.inline) {
1147        cut += 1;
1148    }
1149    frames.drain(..cut);
1150}
1151
1152/// Drop the innermost (front, current-first) SDK prefix by matching each frame's
1153/// symbol entry address against `pinned_entries` (the SDK capture functions).
1154/// This works even in stripped builds where there are no names to match. The SDK
1155/// frames sit at the front, so dropping through the last match removes the whole
1156/// prefix, including the unwinder frames before our innermost one.
1157///
1158/// Platform caveat: this only works where the frame's symbol address is the
1159/// runtime function entry (Linux/glibc via `_Unwind_FindEnclosingFunction`). On
1160/// macOS the symbolization backend reports the queried address rather than the
1161/// function entry, so the address pass never matches there and the caller's
1162/// name-based pass does the stripping instead; fully stripped Apple/Windows
1163/// builds keep the SDK prefix as address-only frames, which regain names through
1164/// server-side symbolication.
1165fn strip_pinned_prefix(frames: &mut Vec<StackFrame>, pinned_entries: &[u64]) {
1166    // The window bounds the pinned scan to the SDK prefix; inline-group
1167    // expansion can multiply those prefix entries, so it's sized generously.
1168    let scan = frames.len().min(32);
1169    let matches_pinned = |frame: &StackFrame| {
1170        frame
1171            .symbol_addr
1172            .as_deref()
1173            .and_then(|addr| u64::from_str_radix(addr.trim_start_matches("0x"), 16).ok())
1174            .is_some_and(|addr| pinned_entries.contains(&addr))
1175    };
1176    if let Some(last_sdk) = frames[..scan].iter().rposition(matches_pinned) {
1177        frames.drain(..=last_sdk);
1178    }
1179}
1180
1181/// Capture the current raw stacktrace, dropping the leading SDK frames, and
1182/// return the loaded modules those frames point into for the `$debug_images`
1183/// property. The result is in canonical wire order — outermost frame first,
1184/// crash/capture-site frame last — matching the other PostHog SDKs.
1185///
1186/// `capture_frames_current_first` yields innermost-first, so the SDK's own
1187/// frames lead. Two passes drop them: an address-based pass that matches each
1188/// frame's symbol entry against `pinned_entries` (the SDK capture functions),
1189/// which works even in stripped builds where there are no names; then the
1190/// name-based `is_internal` pass for everything the resolver could name. Only
1191/// after stripping do we reverse into wire order (see the trailing `reverse`),
1192/// so both passes keep operating on the front of the innermost-first vec.
1193///
1194/// inline(never): this generic function sits between the pinned non-generic
1195/// wrapper and `capture_frames_current_first`; keeping it a physical frame lets
1196/// the name-based pass match it (its monomorphized address can't be pinned),
1197/// and draining through the outermost pinned wrapper sweeps it out in stripped
1198/// builds.
1199#[inline(never)]
1200fn capture_raw_frames(
1201    is_internal: impl Fn(&str) -> bool,
1202    pinned_entries: &[u64],
1203) -> (Vec<StackFrame>, Vec<DebugImage>) {
1204    let modules = collect_loaded_modules();
1205    let mut frames = capture_frames_current_first(0, &modules);
1206
1207    // Address-based stripping first (see `strip_pinned_prefix`): works even in
1208    // stripped builds where the name-based pass below has nothing to match.
1209    strip_pinned_prefix(&mut frames, pinned_entries);
1210
1211    while frames
1212        .first()
1213        .map(|frame| is_internal(&frame.function))
1214        .unwrap_or(false)
1215    {
1216        frames.remove(0);
1217    }
1218
1219    // Flip innermost-first into canonical wire order: outermost frame first,
1220    // crash-site frame last. A single reverse of the flattened vec is correct
1221    // because `capture_frames_current_first` pushes both the physical frames
1222    // and each frame's inline layers innermost-first, so one reverse flips both
1223    // levels at once — the outermost physical frame leads, and within a
1224    // client-expanded frame the outermost logical layer leads with the inlined
1225    // leaf last, exactly the bottom-up input the server-side native contract
1226    // expects.
1227    frames.reverse();
1228
1229    let images = referenced_images(modules, &frames);
1230    (frames, images)
1231}
1232
1233/// Only report modules that frames actually point into, and only those with a
1234/// usable debug id; the final filtering against the trimmed frame list happens
1235/// in `write_into`.
1236fn referenced_images(modules: Vec<LoadedModule>, frames: &[StackFrame]) -> Vec<DebugImage> {
1237    modules
1238        .into_iter()
1239        .filter(|m| !m.image.debug_id.is_empty())
1240        .map(|m| m.image)
1241        .filter(|image| {
1242            frames
1243                .iter()
1244                .any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
1245        })
1246        .collect()
1247}
1248
1249// inline(never): this non-generic wrapper sits on the stack directly below the
1250// constructor and its entry address anchors the address-based stripping in
1251// stripped builds (the generic `capture_raw_frames` between it and
1252// `capture_frames_current_first` is matched by name instead — its monomorphized
1253// address isn't nameable as a single fn pointer).
1254#[inline(never)]
1255fn capture_raw_application_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1256    let pinned = [
1257        capture_frames_current_first as *const () as u64,
1258        capture_raw_application_frames as *const () as u64,
1259    ];
1260    capture_raw_frames(is_internal_capture_frame, &pinned)
1261}
1262
1263// inline(never): anchors the address-based capture-helper strip below, exactly
1264// like `capture_raw_application_frames` does for the manual path.
1265#[inline(never)]
1266fn capture_raw_panic_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1267    // We deliberately keep the panic and unwind *runtime* machinery
1268    // (`panic_with_hook`, `begin_panic_handler`, `rust_begin_unwind`, ...) — it is
1269    // classified out-of-app and the UI collapses it, which is more robust than
1270    // dropping runtime internals by an ever-drifting name list. Everything
1271    // *innermost of* the panic dispatcher is the SDK's own hook plumbing: the
1272    // dispatcher (`rust_panic_with_hook`) synchronously invoked our hook, so our
1273    // capture helpers, the `install_hook` closures, and the `catch_unwind` guard
1274    // they run under all sit below it. Under the canonical crash-last wire order
1275    // the innermost frame becomes the tail, which must be the crash-side runtime
1276    // frame — not our hook plumbing — so we strip that inner prefix in three
1277    // layers of decreasing robustness:
1278    //
1279    //   1. Address-based: drop our own `backtrace`/capture-helper frames by
1280    //      pinned symbol entry. Works even in stripped builds with no names,
1281    //      matching the manual path's first pass.
1282    //   2. Dispatcher anchor: if the panic dispatcher is visible by name, drop
1283    //      everything up to (but not including) it — a single stable anchor that
1284    //      sweeps the whole hook-wrapper chain (closures + `catch_unwind` guard)
1285    //      without enumerating its drifting frames.
1286    //   3. Name fallback: if the dispatcher isn't nameable, drop the SDK capture
1287    //      helpers by name.
1288    //
1289    // In a fully stripped build only (1) runs; the nameless hook-wrapper frames
1290    // then survive as address-only frames that regain names (and normalization)
1291    // through server-side symbolication, the same documented limitation the
1292    // manual path carries.
1293    let modules = collect_loaded_modules();
1294    let mut frames = capture_frames_current_first(0, &modules);
1295
1296    let pinned = [
1297        capture_frames_current_first as *const () as u64,
1298        capture_raw_panic_frames as *const () as u64,
1299    ];
1300    strip_pinned_prefix(&mut frames, &pinned);
1301
1302    let scan = frames.len().min(24);
1303    let dispatcher = frames[..scan]
1304        .iter()
1305        .position(|frame| is_panic_dispatcher_frame(&frame.function));
1306    match dispatcher {
1307        Some(index) => {
1308            frames.drain(..index);
1309        }
1310        None => {
1311            while frames
1312                .first()
1313                .map(|frame| is_internal_capture_frame(&frame.function))
1314                .unwrap_or(false)
1315            {
1316                frames.remove(0);
1317            }
1318        }
1319    }
1320
1321    // Flip innermost-first into canonical wire order: outermost frame first,
1322    // panic site last (see `capture_raw_frames` for why one reverse suffices).
1323    frames.reverse();
1324    let images = referenced_images(modules, &frames);
1325    (frames, images)
1326}
1327
1328// The std panic dispatcher that synchronously invokes the installed hook. Its
1329// name has been stable across recent toolchains; everything innermost of it on a
1330// panicking thread is our own hook plumbing.
1331fn is_panic_dispatcher_frame(function: &str) -> bool {
1332    function.contains("rust_panic_with_hook") || function.contains("panicking::panic_with_hook")
1333}
1334
1335// Matches the SDK's own capture-plumbing frames — the ones our capture helpers
1336// push onto the innermost end simply by calling `backtrace` from inside
1337// themselves. These are always noise and are stripped from the innermost prefix
1338// so the canonical tail after the wire-order reverse is the crash site rather
1339// than an SDK helper. Only SDK-owned names appear here (stable, we control
1340// them); panic/unwind *runtime* frames (`begin_panic_handler`,
1341// `rust_begin_unwind`, `panic_with_hook`, ...) are deliberately NOT matched —
1342// the strip loop stops at them and they survive, classified out-of-app for the
1343// UI to collapse.
1344fn is_internal_capture_frame(function: &str) -> bool {
1345    // Demanglers differ on qualified-path rendering across toolchain versions:
1346    // older output is `Exception::from_error`, newer output wraps the type as
1347    // `<posthog_rs::error_tracking::Exception>::from_error::<T>`. Strip the
1348    // angle brackets before matching so both forms hit.
1349    let function: String = function.replace(['<', '>'], "");
1350    function.starts_with("backtrace::")
1351        || function.contains("capture_frames_current_first")
1352        || function.contains("capture_raw_frames")
1353        || function.contains("capture_raw_application_frames")
1354        || function.contains("Exception::from_error")
1355        || function.contains("Exception::from_message")
1356        || function.contains("build_exception_event")
1357        || function.contains("Client::capture_exception")
1358        || function.contains("global::capture_exception")
1359}
1360
1361/// The panic payload as a string, falling back to a generic message.
1362#[allow(deprecated)]
1363fn panic_message(panic_info: &panic::PanicInfo<'_>) -> String {
1364    let value = panic_info
1365        .payload()
1366        .downcast_ref::<&str>()
1367        .map(|value| (*value).to_string())
1368        .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
1369        .unwrap_or_else(|| "panic occurred".to_string());
1370
1371    if value.is_empty() {
1372        "panic occurred".to_string()
1373    } else {
1374        value
1375    }
1376}
1377
1378fn path_to_string(path: &std::path::Path) -> String {
1379    path.to_string_lossy().into_owned()
1380}
1381
1382/// Best-effort, human-readable exception type from a Rust type name.
1383///
1384/// Keeps the full module path (minus generic arguments and `&`/`dyn` markers) so
1385/// types whose leaf name is the idiomatic `Error` — `std::io::Error`,
1386/// `serde_json::Error`, `mycrate::Error` — stay distinguishable rather than all
1387/// collapsing to a single `"Error"`.
1388fn simple_type_name(type_name: &str) -> String {
1389    let trimmed = type_name.trim().trim_start_matches('&').trim();
1390    let trimmed = trimmed.strip_prefix("dyn ").unwrap_or(trimmed).trim();
1391    let trimmed = trimmed
1392        .split_once('<')
1393        .map_or(trimmed, |(outer_type, _)| outer_type)
1394        .trim_end();
1395    // A type-erased `dyn Error` only reports the trait itself, which carries no
1396    // concrete type information, so collapse it to a bare "Error".
1397    if trimmed.is_empty() || trimmed == "core::error::Error" || trimmed == "std::error::Error" {
1398        return "Error".to_string();
1399    }
1400    trimmed.to_string()
1401}
1402
1403/// Type name for a chained source.
1404///
1405/// Sources are exposed as `&dyn Error`, which is type-erased: `type_name_of_val`
1406/// can only report the trait, not the original type. Chained sources therefore
1407/// carry the value/message but report a generic `"Error"` type — the concrete
1408/// type of a `dyn Error` cannot be recovered on stable Rust.
1409fn source_type_name(error: &(dyn StdError + 'static)) -> String {
1410    simple_type_name(type_name_of_val(error))
1411}
1412
1413/// Link a multi-error chain so each source points at the error it came from,
1414/// mirroring the `$exception_list` chaining other PostHog SDKs emit. Single
1415/// exceptions are left unlinked.
1416fn link_exception_chain(exception_list: &mut [ExceptionItem]) {
1417    if exception_list.len() < 2 {
1418        return;
1419    }
1420    for (index, item) in exception_list.iter_mut().enumerate() {
1421        item.mechanism.exception_id = Some(index);
1422        if index > 0 {
1423            item.mechanism.parent_id = Some(index - 1);
1424            item.mechanism.mechanism_type = "chained".to_string();
1425        }
1426    }
1427}
1428
1429fn error_value<E>(error: &E) -> String
1430where
1431    E: StdError + ?Sized,
1432{
1433    let value = error.to_string();
1434    if value.is_empty() {
1435        "Error".to_string()
1436    } else {
1437        value
1438    }
1439}
1440
1441/// Demangled symbols carry compiler-internal hashes that vary per platform and
1442/// rustc release: legacy mangling appends a trailing `::h<16 hex>`, and v0
1443/// mangling tags crate names with `[<hex>]` disambiguators (std ships v0-mangled
1444/// on Linux, so std frames demangle as `std[b887e3750a86e3a0]::panicking::…`).
1445/// Strip both so internal-frame matching and server-side grouping see stable,
1446/// readable names.
1447fn normalize_function_name(function: &str) -> String {
1448    let function = strip_crate_disambiguators(function);
1449    match function.rsplit_once("::") {
1450        Some((prefix, suffix)) if is_rust_symbol_hash(suffix) => prefix.to_string(),
1451        _ => function,
1452    }
1453}
1454
1455fn strip_crate_disambiguators(function: &str) -> String {
1456    let mut out = String::with_capacity(function.len());
1457    let mut rest = function;
1458    while let Some(open) = rest.find('[') {
1459        out.push_str(&rest[..open]);
1460        let bracketed = &rest[open..];
1461        match bracketed.find(']') {
1462            Some(close) => {
1463                let content = &bracketed[1..close];
1464                if !is_crate_disambiguator(content) {
1465                    out.push_str(&bracketed[..=close]);
1466                }
1467                rest = &bracketed[close + 1..];
1468            }
1469            None => {
1470                out.push_str(bracketed);
1471                rest = "";
1472            }
1473        }
1474    }
1475    out.push_str(rest);
1476    out
1477}
1478
1479/// Lowercase-hex bracket contents of disambiguator length; array/slice type
1480/// brackets (`[u8; 32]`) never qualify.
1481fn is_crate_disambiguator(content: &str) -> bool {
1482    content.len() >= 8
1483        && content
1484            .chars()
1485            .all(|ch| ch.is_ascii_digit() || ('a'..='f').contains(&ch))
1486}
1487
1488fn is_rust_symbol_hash(segment: &str) -> bool {
1489    segment.len() >= 9
1490        && segment.starts_with('h')
1491        && segment[1..].chars().all(|ch| ch.is_ascii_hexdigit())
1492}
1493
1494/// Thread/process entry symbols from libc/libpthread (`__clone`,
1495/// `start_thread`) and the C `main` shim. They resolve from the symbol table
1496/// with no source file and no `crate::` path, so the crate denylist can't see
1497/// them; matched exactly so app symbols of the same bare shape stay in-app.
1498fn is_bootstrap_symbol(function: &str) -> bool {
1499    matches!(
1500        function,
1501        "main"
1502            | "_start"
1503            | "__libc_start_main"
1504            | "clone"
1505            | "clone3"
1506            | "__clone"
1507            | "__clone3"
1508            | "start_thread"
1509            | "_pthread_start"
1510            | "thread_start"
1511    )
1512}
1513
1514/// A trailing `-<hex hash>` on a cargo registry/checkout directory name, e.g.
1515/// `index.crates.io-6f17d22bba15001f` or `somecrate-9a8b7c6d5e4f3a2b`. Cargo's
1516/// ident hash is a hex-encoded u64, so exactly 16 chars — requiring that
1517/// keeps deploy dirs suffixed with short git SHAs (7–12 chars) from matching.
1518fn has_cargo_hash_suffix(dir: &str) -> bool {
1519    dir.rsplit_once('-')
1520        .is_some_and(|(_, hash)| hash.len() == 16 && hash.chars().all(|ch| ch.is_ascii_hexdigit()))
1521}
1522
1523/// Matches cargo's registry source layout,
1524/// `$CARGO_HOME/registry/src/<registry>-<hex hash>/<crate>-<version>/...`,
1525/// regardless of where the cargo home lives. The hash suffix and a crate
1526/// directory beneath are required so app paths that merely contain
1527/// registry-like names don't match.
1528fn is_cargo_registry_src(normalized: &str) -> bool {
1529    let Some(idx) = normalized.find("/registry/src/") else {
1530        return false;
1531    };
1532    let rest = &normalized[idx + "/registry/src/".len()..];
1533    let Some((registry_dir, rest)) = rest.split_once('/') else {
1534        return false;
1535    };
1536    has_cargo_hash_suffix(registry_dir) && rest.contains('/')
1537}
1538
1539/// Matches cargo's git dependency layout,
1540/// `$CARGO_HOME/git/checkouts/<repo>-<hex ident hash>/<short rev>/...`,
1541/// regardless of where the cargo home lives. Both the ident-hash suffix and
1542/// the short-rev component are required so an app that merely lives under a
1543/// `git/checkouts/` directory (even one named `*-deadbeef`) doesn't match.
1544fn is_cargo_git_checkout(normalized: &str) -> bool {
1545    let Some(idx) = normalized.find("/git/checkouts/") else {
1546        return false;
1547    };
1548    let rest = &normalized[idx + "/git/checkouts/".len()..];
1549    let Some((repo_dir, rest)) = rest.split_once('/') else {
1550        return false;
1551    };
1552    let ident_ok = has_cargo_hash_suffix(repo_dir);
1553    let Some((rev_dir, rest)) = rest.split_once('/') else {
1554        return false;
1555    };
1556    let rev_ok = (7..=40).contains(&rev_dir.len())
1557        && rev_dir.chars().all(|ch| ch.is_ascii_hexdigit())
1558        && !rest.is_empty();
1559    ident_ok && rev_ok
1560}
1561
1562fn default_in_app_path(filename: &str) -> bool {
1563    let normalized = filename.replace('\\', "/");
1564    // CARGO_HOME isn't always `~/.cargo` — the official Rust Docker images use
1565    // `/usr/local/cargo`. Rather than guessing at cargo-home names, the
1566    // registry-src and git-checkout checks match cargo's own on-disk layouts
1567    // under any home; the `/.cargo/` rules stay as the original home-based
1568    // fallback.
1569    if normalized.contains("/.cargo/registry/")
1570        || normalized.contains("/.cargo/git/")
1571        || is_cargo_registry_src(&normalized)
1572        || is_cargo_git_checkout(&normalized)
1573        || normalized.contains("/rustc/")
1574        || normalized.contains("/rustc-")
1575        || normalized.contains("/library/alloc/src/")
1576        || normalized.contains("/library/core/src/")
1577        || normalized.contains("/library/proc_macro/src/")
1578        || normalized.contains("/library/std/src/")
1579        || normalized.contains("/library/test/src/")
1580        || normalized.contains("/toolchains/")
1581        || normalized.contains("/target/")
1582        || normalized.contains("/vendor/")
1583    {
1584        return false;
1585    }
1586
1587    true
1588}
1589
1590/// Strip trailing generic arguments from a function name so the crate-segment
1591/// checks see the path, not the instantiation. DWARF-derived names carry the
1592/// bare method name with its generic arguments
1593/// (`poll_future<tokio::runtime::blocking::task::BlockingTask<...>>`), where a
1594/// naive `::` split would yield a garbage first segment (`poll_future<tokio`).
1595/// A *leading* `<` is a qualified-path rendering
1596/// (`<alloc::boxed::Box<F> as core::ops::function::FnOnce<Args>>::call_once`),
1597/// not generic arguments, so the name is kept whole for the existing
1598/// `trim_start_matches('<')` handling.
1599fn strip_generic_args(function: &str) -> &str {
1600    match function.find('<') {
1601        Some(idx) if idx > 0 => &function[..idx],
1602        _ => function,
1603    }
1604}
1605
1606fn default_in_app_function(function: &str) -> bool {
1607    // Bare runtime/unwind symbols that carry no `crate::` prefix (so the segment
1608    // match below can't catch them). `rust_begin_unwind` is the panic entry; the
1609    // `__rust`/`___rust` shims are the unwind glue (the extra underscore is
1610    // Mach-O's).
1611    if function.is_empty()
1612        || function == "_main"
1613        || function == "rust_begin_unwind"
1614        || function.starts_with("__rust")
1615        || function.starts_with("___rust")
1616    {
1617        return false;
1618    }
1619
1620    !matches!(
1621        strip_generic_args(function)
1622            .trim_start_matches('<')
1623            .split("::")
1624            .next()
1625            .unwrap_or_default(),
1626        "alloc"
1627            | "anyhow"
1628            | "backtrace"
1629            | "color_eyre"
1630            | "core"
1631            | "eyre"
1632            | "futures_core"
1633            | "futures_util"
1634            | "log"
1635            | "posthog_rs"
1636            | "reqwest"
1637            | "std"
1638            | "stable_eyre"
1639            | "tokio"
1640            | "tracing"
1641            | "tracing_core"
1642    )
1643}
1644
1645#[cfg(test)]
1646mod tests {
1647    use std::error::Error as StdError;
1648    use std::fmt;
1649    use std::sync::atomic::{AtomicBool, Ordering};
1650    use std::sync::{Arc, Mutex, OnceLock};
1651
1652    use httpmock::prelude::*;
1653    use serde_json::{json, Value};
1654
1655    use super::*;
1656    use crate::client::ClientOptionsBuilder;
1657    use crate::event::InnerEvent;
1658
1659    #[derive(Debug)]
1660    struct OuterError {
1661        source: InnerError,
1662    }
1663
1664    impl fmt::Display for OuterError {
1665        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1666            write!(f, "checkout failed")
1667        }
1668    }
1669
1670    impl StdError for OuterError {
1671        fn source(&self) -> Option<&(dyn StdError + 'static)> {
1672            Some(&self.source)
1673        }
1674    }
1675
1676    #[derive(Debug)]
1677    struct InnerError;
1678
1679    impl fmt::Display for InnerError {
1680        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1681            write!(f, "database unavailable")
1682        }
1683    }
1684
1685    impl StdError for InnerError {}
1686
1687    #[derive(Debug)]
1688    struct BorrowedError<'a>(&'a str);
1689
1690    impl fmt::Display for BorrowedError<'_> {
1691        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1692            f.write_str(self.0)
1693        }
1694    }
1695
1696    impl StdError for BorrowedError<'_> {}
1697
1698    fn built_event_json(mut event: Event) -> Value {
1699        event.prepare_for_v0();
1700        serde_json::to_value(InnerEvent::new(event, "api-key".to_string())).unwrap()
1701    }
1702
1703    fn event_json_with(exception: Exception, options: &ErrorTrackingOptions) -> Value {
1704        let mut event = Event::new_anon("$exception");
1705        exception.write_into(&mut event, options).unwrap();
1706        built_event_json(event)
1707    }
1708
1709    fn event_json(exception: Exception) -> Value {
1710        event_json_with(exception, &ErrorTrackingOptions::default())
1711    }
1712
1713    #[allow(deprecated)]
1714    type PanicHook = Box<dyn Fn(&panic::PanicInfo<'_>) + Sync + Send + 'static>;
1715
1716    /// Restores the previous panic hook and clears the install latch so the
1717    /// panic tests don't leak global state into one another.
1718    struct PanicHookReset {
1719        previous: Option<PanicHook>,
1720    }
1721
1722    impl PanicHookReset {
1723        fn new(previous: PanicHook) -> Self {
1724            Self {
1725                previous: Some(previous),
1726            }
1727        }
1728
1729        fn restore(&mut self) {
1730            if let Some(previous) = self.previous.take() {
1731                panic::set_hook(previous);
1732            }
1733            PANIC_HOOK_INSTALLED.store(false, Ordering::Release);
1734        }
1735    }
1736
1737    impl Drop for PanicHookReset {
1738        fn drop(&mut self) {
1739            if !std::thread::panicking() {
1740                self.restore();
1741            }
1742        }
1743    }
1744
1745    /// Serializes the panic tests: they share the process-wide panic hook and
1746    /// the install latch.
1747    fn panic_hook_test_lock() -> &'static Mutex<()> {
1748        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1749        LOCK.get_or_init(|| Mutex::new(()))
1750    }
1751
1752    // The async constructor only awaits when local evaluation is enabled, so a
1753    // plain client builds synchronously under a minimal executor — no Tokio
1754    // runtime needed to set up the panic-hook tests.
1755    #[cfg(feature = "async-client")]
1756    fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1757        Arc::new(futures::executor::block_on(crate::client::client(options)))
1758    }
1759
1760    #[cfg(not(feature = "async-client"))]
1761    fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1762        Arc::new(crate::client::client(options))
1763    }
1764
1765    #[inline(never)]
1766    fn panic_hook_test_panic_site() {
1767        panic!("panic hook boom");
1768    }
1769
1770    #[inline(never)]
1771    fn panic_hook_disabled_test_panic_site() {
1772        panic!("disabled panic hook boom");
1773    }
1774
1775    /// Match the panic `$exception` event inside the transport's batch envelope
1776    /// (`batch[0]`) — the same event shape for the V0 and V1 wire formats.
1777    fn request_has_panic_payload(req: &HttpMockRequest) -> bool {
1778        let Ok(body) = serde_json::from_slice::<Value>(req.body_ref()) else {
1779            return false;
1780        };
1781        let event = &body["batch"][0];
1782        let exception = &event["properties"]["$exception_list"][0];
1783        let frames = exception["stacktrace"]["frames"].as_array();
1784
1785        // The user's panic site is captured (no longer forced to frame 0 — we
1786        // keep the machinery frames now instead of stripping them).
1787        let has_panic_site = frames.is_some_and(|frames| {
1788            frames.iter().any(|frame| {
1789                frame["function"]
1790                    .as_str()
1791                    .is_some_and(|name| name.contains("panic_hook_test_panic_site"))
1792            })
1793        });
1794        // Panic/unwind machinery is kept and marked out of app rather than
1795        // dropped by name.
1796        let has_machinery_not_in_app = frames.is_some_and(|frames| {
1797            frames.iter().any(|frame| {
1798                frame["in_app"] == false
1799                    && frame["function"].as_str().is_some_and(|name| {
1800                        name.contains("panicking") || name == "rust_begin_unwind"
1801                    })
1802            })
1803        });
1804
1805        event["event"] == "$exception"
1806            // V0 injects `$process_person_profile` into properties; V1 keeps it
1807            // in the typed `options` object.
1808            && (event["properties"]["$process_person_profile"] == false
1809                || event["options"]["process_person_profile"] == false)
1810            && event["properties"]["$exception_level"] == "fatal"
1811            && exception["type"] == "Panic"
1812            && exception["value"] == "panic hook boom"
1813            && exception["mechanism"]["type"] == "panic"
1814            && exception["mechanism"]["handled"] == false
1815            && event["properties"]["$exception_panic_file"]
1816                .as_str()
1817                .is_some_and(|file| file.contains("error_tracking.rs"))
1818            && event["properties"]["$exception_panic_line"]
1819                .as_u64()
1820                .is_some_and(|line| line > 0)
1821            && event["properties"]["$exception_panic_column"]
1822                .as_u64()
1823                .is_some_and(|column| column > 0)
1824            && has_panic_site
1825            && has_machinery_not_in_app
1826    }
1827
1828    #[test]
1829    fn panic_hook_sends_personless_exception_and_calls_previous_hook() {
1830        let _guard = panic_hook_test_lock()
1831            .lock()
1832            .unwrap_or_else(|e| e.into_inner());
1833        let original_hook = panic::take_hook();
1834        let mut reset = PanicHookReset::new(original_hook);
1835        let previous_called = Arc::new(AtomicBool::new(false));
1836        let previous_called_for_hook = Arc::clone(&previous_called);
1837        panic::set_hook(Box::new(move |_| {
1838            previous_called_for_hook.store(true, Ordering::Release);
1839        }));
1840
1841        let server = MockServer::start();
1842        let capture_mock = server.mock(|when, then| {
1843            when.method(POST).matches(request_has_panic_payload);
1844            then.status(200);
1845        });
1846        let options = ClientOptionsBuilder::default()
1847            .api_key("test_api_key".to_string())
1848            .host(server.base_url())
1849            .build()
1850            .unwrap();
1851        let client = build_test_client(options);
1852
1853        install_panic_hook(Arc::clone(&client)).unwrap();
1854        assert!(matches!(
1855            install_panic_hook(Arc::clone(&client)),
1856            Err(Error::PanicHookAlreadyInstalled)
1857        ));
1858
1859        let result = panic::catch_unwind(panic_hook_test_panic_site);
1860        reset.restore();
1861
1862        assert!(result.is_err());
1863        assert!(previous_called.load(Ordering::Acquire));
1864        capture_mock.assert_hits(1);
1865    }
1866
1867    #[test]
1868    fn disabled_panic_hook_does_not_send() {
1869        let _guard = panic_hook_test_lock()
1870            .lock()
1871            .unwrap_or_else(|e| e.into_inner());
1872        let original_hook = panic::take_hook();
1873        let mut reset = PanicHookReset::new(original_hook);
1874        panic::set_hook(Box::new(|_| {}));
1875
1876        let server = MockServer::start();
1877        let capture_mock = server.mock(|when, then| {
1878            when.method(POST);
1879            then.status(200);
1880        });
1881        let options = ClientOptionsBuilder::default()
1882            .api_key("test_api_key".to_string())
1883            .host(server.base_url())
1884            .disabled(true)
1885            .build()
1886            .unwrap();
1887        let client = build_test_client(options);
1888
1889        install_panic_hook(client).unwrap();
1890        let result = panic::catch_unwind(panic_hook_disabled_test_panic_site);
1891        reset.restore();
1892
1893        assert!(result.is_err());
1894        capture_mock.assert_hits(0);
1895    }
1896
1897    /// Panics inside Tokio tasks run the hook on a runtime worker thread; the
1898    /// transport's own worker is a separate std::thread, so the enqueue + flush
1899    /// still deliver the event rather than re-panicking on a runtime thread.
1900    #[cfg(feature = "async-client")]
1901    #[test]
1902    fn panic_hook_captures_panics_on_tokio_runtime_threads() {
1903        let _guard = panic_hook_test_lock()
1904            .lock()
1905            .unwrap_or_else(|e| e.into_inner());
1906        let original_hook = panic::take_hook();
1907        let mut reset = PanicHookReset::new(original_hook);
1908        panic::set_hook(Box::new(|_| {}));
1909
1910        let server = MockServer::start();
1911        let capture_mock = server.mock(|when, then| {
1912            when.method(POST)
1913                .body_includes(r#""value":"tokio task boom""#);
1914            then.status(200);
1915        });
1916        let options = ClientOptionsBuilder::default()
1917            .api_key("test_api_key".to_string())
1918            .host(server.base_url())
1919            .build()
1920            .unwrap();
1921        install_panic_hook(build_test_client(options)).unwrap();
1922
1923        let runtime = tokio::runtime::Builder::new_multi_thread()
1924            .worker_threads(1)
1925            .enable_all()
1926            .build()
1927            .unwrap();
1928        let result = runtime.block_on(async {
1929            tokio::spawn(async {
1930                panic!("tokio task boom");
1931            })
1932            .await
1933        });
1934        drop(runtime);
1935
1936        // Strictest flavor: the hook fires on the very thread driving block_on
1937        // of a current-thread runtime.
1938        let current_thread = tokio::runtime::Builder::new_current_thread()
1939            .enable_all()
1940            .build()
1941            .unwrap();
1942        let current_result = current_thread.block_on(async {
1943            panic::catch_unwind(AssertUnwindSafe(|| panic!("tokio task boom")))
1944        });
1945        drop(current_thread);
1946        reset.restore();
1947
1948        assert!(result.is_err());
1949        assert!(current_result.is_err());
1950        capture_mock.assert_hits(2);
1951    }
1952
1953    #[test]
1954    fn panic_in_before_send_on_worker_neither_deadlocks_nor_recurses() {
1955        // A `before_send` hook that panics unconditionally fires the panic hook
1956        // ON the transport worker thread. Capturing there must be skipped: a
1957        // synchronous self-flush would deadlock the worker, and routing the
1958        // `$exception` back through `before_send` (which panics again) would
1959        // recurse forever. A watchdog turns either regression into a failure.
1960        let _guard = panic_hook_test_lock()
1961            .lock()
1962            .unwrap_or_else(|e| e.into_inner());
1963        let original_hook = panic::take_hook();
1964        let mut reset = PanicHookReset::new(original_hook);
1965        panic::set_hook(Box::new(|_| {}));
1966
1967        let server = MockServer::start();
1968        let _capture_mock = server.mock(|when, then| {
1969            when.method(POST);
1970            then.status(200);
1971        });
1972        let options = ClientOptionsBuilder::default()
1973            .api_key("test_api_key".to_string())
1974            .host(server.base_url())
1975            .before_send(|_event| panic!("before_send boom"))
1976            .build()
1977            .unwrap();
1978        let client = build_test_client(options);
1979        install_panic_hook(Arc::clone(&client)).unwrap();
1980
1981        let finished = Arc::new(AtomicBool::new(false));
1982        let finished_for_worker = Arc::clone(&finished);
1983        let work_client = Arc::clone(&client);
1984        let _worker = std::thread::spawn(move || {
1985            work_client.capture(Event::new("boom", "user-1"));
1986            // From this (non-worker) thread this is a real blocking flush; it
1987            // returns only if the worker neither deadlocked nor spun on recursion.
1988            work_client.flush_blocking();
1989            finished_for_worker.store(true, Ordering::Release);
1990        });
1991
1992        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1993        while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
1994            std::thread::sleep(std::time::Duration::from_millis(20));
1995        }
1996        reset.restore();
1997
1998        assert!(
1999            finished.load(Ordering::Acquire),
2000            "panic in before_send on the worker thread deadlocked or recursed"
2001        );
2002        // The spawned thread is intentionally not joined: on a regression it is
2003        // stuck in flush_blocking and a join would hang too; on success it has
2004        // already finished. Dropping the handle detaches it.
2005    }
2006
2007    #[test]
2008    fn panic_hook_flush_is_bounded_when_before_send_needs_a_panic_held_lock() {
2009        // The panic hook flushes on the *panicking* thread, before unwinding
2010        // releases locks held at the panic site. If a `before_send` hook needs
2011        // such a lock, the worker blocks on it and the hook would block on the
2012        // worker forever — the process hangs instead of crashing. The flush is
2013        // time-bounded (`PANIC_FLUSH_TIMEOUT`), so the hook returns and the panic
2014        // proceeds; the watchdog turns a regression (an unbounded wait) into a
2015        // failure instead of a hang.
2016        let _guard = panic_hook_test_lock()
2017            .lock()
2018            .unwrap_or_else(|e| e.into_inner());
2019        let original_hook = panic::take_hook();
2020        let mut reset = PanicHookReset::new(original_hook);
2021        panic::set_hook(Box::new(|_| {}));
2022
2023        // A lock the application holds across its panic and that `before_send`
2024        // also wants — the classic shape that would deadlock an unbounded flush.
2025        static SHARED: Mutex<()> = Mutex::new(());
2026
2027        let server = MockServer::start();
2028        let _capture_mock = server.mock(|when, then| {
2029            when.method(POST);
2030            then.status(200);
2031        });
2032        let options = ClientOptionsBuilder::default()
2033            .api_key("test_api_key".to_string())
2034            .host(server.base_url())
2035            .before_send(|event| {
2036                let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
2037                Some(event)
2038            })
2039            .build()
2040            .unwrap();
2041        let client = build_test_client(options);
2042        install_panic_hook(Arc::clone(&client)).unwrap();
2043
2044        let finished = Arc::new(AtomicBool::new(false));
2045        let finished_for_panicker = Arc::clone(&finished);
2046        let _panicker = std::thread::spawn(move || {
2047            {
2048                // Hold SHARED across the panic so the hook fires while it is
2049                // locked. Release it (end of scope) *before* signalling, so test
2050                // teardown can drain the worker without blocking on the lock.
2051                let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
2052                let _ = panic::catch_unwind(AssertUnwindSafe(|| {
2053                    panic!("boom while holding a before_send lock")
2054                }));
2055            }
2056            finished_for_panicker.store(true, Ordering::Release);
2057        });
2058
2059        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2060        while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
2061            std::thread::sleep(std::time::Duration::from_millis(20));
2062        }
2063        reset.restore();
2064
2065        assert!(
2066            finished.load(Ordering::Acquire),
2067            "panic hook flush hung on a before_send that needed a panic-held lock"
2068        );
2069        // Not joined: on a regression the thread is stuck in the hook's flush and
2070        // a join would hang too; on success it has already finished.
2071    }
2072
2073    #[test]
2074    fn global_capture_panics_defaults_off_and_is_configurable() {
2075        assert!(
2076            !ErrorTrackingOptions::default().capture_panics(),
2077            "panic autocapture is opt-in (off by default)"
2078        );
2079        let enabled = ErrorTrackingOptionsBuilder::default()
2080            .capture_panics(true)
2081            .build()
2082            .unwrap();
2083        assert!(enabled.capture_panics(), "capture_panics is configurable");
2084    }
2085
2086    #[test]
2087    fn should_capture_global_panics_gates_on_enabled_and_flag() {
2088        let enabled = build_test_client(
2089            ClientOptionsBuilder::default()
2090                .api_key("test_api_key".to_string())
2091                .error_tracking(
2092                    ErrorTrackingOptionsBuilder::default()
2093                        .capture_panics(true)
2094                        .build()
2095                        .unwrap(),
2096                )
2097                .build()
2098                .unwrap(),
2099        );
2100        assert!(should_capture_global_panics(&enabled));
2101
2102        // Disabled gates it even with capture_panics on.
2103        let disabled = build_test_client(
2104            ClientOptionsBuilder::default()
2105                .api_key("test_api_key".to_string())
2106                .disabled(true)
2107                .error_tracking(
2108                    ErrorTrackingOptionsBuilder::default()
2109                        .capture_panics(true)
2110                        .build()
2111                        .unwrap(),
2112                )
2113                .build()
2114                .unwrap(),
2115        );
2116        assert!(
2117            !should_capture_global_panics(&disabled),
2118            "a disabled client must not latch the process-wide hook"
2119        );
2120
2121        // Default options leave capture_panics off, so nothing installs.
2122        let default_off = build_test_client(
2123            ClientOptionsBuilder::default()
2124                .api_key("test_api_key".to_string())
2125                .build()
2126                .unwrap(),
2127        );
2128        assert!(!should_capture_global_panics(&default_off));
2129    }
2130
2131    #[test]
2132    fn install_panic_hook_on_disabled_client_does_not_latch() {
2133        let _guard = panic_hook_test_lock()
2134            .lock()
2135            .unwrap_or_else(|e| e.into_inner());
2136        let original_hook = panic::take_hook();
2137        let mut reset = PanicHookReset::new(original_hook);
2138
2139        let disabled = build_test_client(
2140            ClientOptionsBuilder::default()
2141                .api_key("test_api_key".to_string())
2142                .disabled(true)
2143                .build()
2144                .unwrap(),
2145        );
2146        let result = install_panic_hook(disabled);
2147        let latched = PANIC_HOOK_INSTALLED.load(Ordering::Acquire);
2148
2149        // Restore before asserting so a regression (which would install) can't
2150        // leave a hook dangling for other tests.
2151        reset.restore();
2152        assert!(result.is_ok(), "installing on a disabled client returns Ok");
2153        assert!(
2154            !latched,
2155            "a disabled client must not latch the process-wide hook"
2156        );
2157    }
2158
2159    #[test]
2160    fn panic_machinery_frames_classify_out_of_app() {
2161        // Panic/unwind/SDK machinery is kept in the stacktrace now (not stripped
2162        // by name) and classified out of app by the default in-app rules, so the
2163        // UI can collapse it while the user's frames stay in-app.
2164        let options = ErrorTrackingOptions::default();
2165        for not_in_app in [
2166            "std::panicking::begin_panic_handler",
2167            "core::panicking::panic_fmt",
2168            "std::panic::catch_unwind",
2169            "std::sys::backtrace::__rust_begin_short_backtrace",
2170            "rust_begin_unwind",
2171            "__rust_try",
2172            "backtrace::backtrace::trace",
2173            "posthog_rs::error_tracking::capture_panic",
2174            "posthog_rs::error_tracking::install_hook::{{closure}}",
2175            "core::ops::function::FnOnce::call_once",
2176            "tokio::runtime::task::raw::poll",
2177            "futures_util::future::FutureExt::poll",
2178            "anyhow::error::Error::msg",
2179            "eyre::Report::msg",
2180            "color_eyre::config::EyreHook::into_eyre_hook::{{closure}}",
2181            "tracing::span::Span::record",
2182            "tracing_core::dispatcher::get_default",
2183            "log::__private_api::log",
2184        ] {
2185            assert!(
2186                !options.is_in_app_frame(None, Some(not_in_app)),
2187                "{} should classify as not in-app",
2188                not_in_app
2189            );
2190        }
2191
2192        for in_app in [
2193            "my_app::checkout::process_payment",
2194            "checkout_service::submit",
2195        ] {
2196            assert!(
2197                options.is_in_app_frame(None, Some(in_app)),
2198                "{} should classify as in-app",
2199                in_app
2200            );
2201        }
2202    }
2203
2204    #[test]
2205    fn function_names_strip_v0_crate_disambiguators() {
2206        // std ships v0-mangled on Linux; crate names demangle with `[hex]`.
2207        assert_eq!(
2208            normalize_function_name("std[b887e3750a86e3a0]::panicking::panic_with_hook"),
2209            "std::panicking::panic_with_hook"
2210        );
2211        assert_eq!(
2212            normalize_function_name(
2213                "<alloc[8a71accd1b3711a1]::boxed::Box<dyn core[e000b89356eb4406]::ops::function::Fn<(&std[b887e3750a86e3a0]::panic::PanicHookInfo,)>> as core[e000b89356eb4406]::ops::function::Fn<(&std[b887e3750a86e3a0]::panic::PanicHookInfo,)>>::call"
2214            ),
2215            "<alloc::boxed::Box<dyn core::ops::function::Fn<(&std::panic::PanicHookInfo,)>> as core::ops::function::Fn<(&std::panic::PanicHookInfo,)>>::call"
2216        );
2217        // Array and slice type brackets are not disambiguators.
2218        assert_eq!(
2219            normalize_function_name("core::array::<impl [u8; 32]>::map"),
2220            "core::array::<impl [u8; 32]>::map"
2221        );
2222        assert_eq!(
2223            normalize_function_name("<[u8] as checkout_service::Digest>::digest"),
2224            "<[u8] as checkout_service::Digest>::digest"
2225        );
2226    }
2227
2228    #[test]
2229    fn internal_capture_frames_match_both_demangler_renderings() {
2230        // Older toolchains demangle as `Type::method`, newer ones as
2231        // `<path::Type>::method::<T>` — the strip must catch both, or an SDK
2232        // frame survives at the crash-site end of the canonical order.
2233        for name in [
2234            "posthog_rs::error_tracking::Exception::from_error",
2235            "<posthog_rs::error_tracking::Exception>::from_error::<posthog_rs::error_tracking::tests::OuterError>",
2236            "<posthog_rs::error_tracking::Exception>::from_message",
2237            "<posthog_rs::client::Client>::capture_exception::<E>",
2238        ] {
2239            assert!(is_internal_capture_frame(name), "should strip {name:?}");
2240        }
2241        assert!(!is_internal_capture_frame(
2242            "my_app::checkout::Exception_from_error_report"
2243        ));
2244    }
2245
2246    #[test]
2247    fn from_error_builds_exception_list_with_stacktrace() {
2248        let error = OuterError { source: InnerError };
2249        let event = build_exception_event(
2250            &error,
2251            CaptureExceptionOptions::new().distinct_id("user-1"),
2252            &ErrorTrackingOptions::default(),
2253        )
2254        .unwrap();
2255        let json = built_event_json(event);
2256
2257        assert_eq!(json["event"], "$exception");
2258        assert_eq!(json["distinct_id"], "user-1");
2259        assert_eq!(json["properties"]["$exception_level"], "error");
2260
2261        let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
2262        assert!(exception_list[0]["type"]
2263            .as_str()
2264            .unwrap()
2265            .ends_with("OuterError"));
2266        assert_eq!(exception_list[0]["value"], "checkout failed");
2267        assert_eq!(exception_list[0]["mechanism"]["type"], "generic");
2268        assert_eq!(exception_list[0]["mechanism"]["handled"], true);
2269        assert_eq!(exception_list[0]["mechanism"]["synthetic"], false);
2270        assert_eq!(exception_list[0]["mechanism"]["exception_id"], 0);
2271        assert_eq!(exception_list[0]["stacktrace"]["type"], "raw");
2272        assert_eq!(exception_list[1]["value"], "database unavailable");
2273        assert_eq!(exception_list[1]["mechanism"]["type"], "chained");
2274        assert_eq!(exception_list[1]["mechanism"]["exception_id"], 1);
2275        assert_eq!(exception_list[1]["mechanism"]["parent_id"], 0);
2276
2277        let frames = exception_list[0]["stacktrace"]["frames"]
2278            .as_array()
2279            .expect("expected stack frames");
2280        // Canonical wire order is outermost first, so the crash/capture-site
2281        // user frame is the last frame.
2282        let crash_frame = frames.last().expect("expected crash frame");
2283        assert_eq!(crash_frame["platform"], "native");
2284        assert_eq!(crash_frame["lang"], "rust");
2285        let instruction_addr = crash_frame["instruction_addr"].as_str().unwrap_or_default();
2286        assert!(
2287            instruction_addr.starts_with("0x"),
2288            "expected hex instruction_addr, got {:?}",
2289            instruction_addr
2290        );
2291        let crash_function = crash_frame["function"].as_str().unwrap_or_default();
2292        assert!(
2293            crash_function.contains("from_error_builds_exception_list_with_stacktrace"),
2294            "expected user frame last, got {:?}",
2295            crash_function
2296        );
2297        assert!(
2298            !crash_function.contains("Exception::"),
2299            "expected SDK frames to be skipped, got {:?}",
2300            crash_function
2301        );
2302    }
2303
2304    #[test]
2305    fn gnu_build_ids_convert_to_debug_ids_like_the_server() {
2306        // Vector verified against symbolic's ElfObject::debug_id (which the
2307        // server and CLI use): the first 16 bytes as a little-endian GUID.
2308        let build_id: Vec<u8> = (0..20)
2309            .map(|i| {
2310                u8::from_str_radix(
2311                    &"555398ebd01c90285a3d85138a19cbf9bbcec352"[i * 2..i * 2 + 2],
2312                    16,
2313                )
2314                .unwrap()
2315            })
2316            .collect();
2317        // symbolic swaps the first three GUID fields on little-endian ELF and
2318        // leaves them as-is on big-endian; debug_id_from_gnu_build_id mirrors
2319        // that, so the expected ids differ by host endianness.
2320        let (full, short) = if cfg!(target_endian = "little") {
2321            (
2322                "eb985355-1cd0-2890-5a3d-85138a19cbf9",
2323                "0000cdab-0000-0000-0000-000000000000",
2324            )
2325        } else {
2326            (
2327                "555398eb-d01c-9028-5a3d-85138a19cbf9",
2328                "abcd0000-0000-0000-0000-000000000000",
2329            )
2330        };
2331        assert_eq!(debug_id_from_gnu_build_id(&build_id).as_deref(), Some(full));
2332
2333        // Short build ids are zero-padded to 16 bytes
2334        assert_eq!(
2335            debug_id_from_gnu_build_id(&[0xab, 0xcd]).as_deref(),
2336            Some(short)
2337        );
2338        assert_eq!(debug_id_from_gnu_build_id(&[]), None);
2339    }
2340
2341    #[test]
2342    fn arch_normalizes_to_the_shared_native_vocabulary() {
2343        // aarch64 is reported as arm64 to match the other native SDKs; every
2344        // other name passes through unchanged.
2345        assert_eq!(normalize_arch("aarch64"), "arm64");
2346        assert_eq!(normalize_arch("x86_64"), "x86_64");
2347        assert_eq!(normalize_arch("arm"), "arm");
2348    }
2349
2350    #[test]
2351    fn find_module_matches_address_ranges() {
2352        let module_at = |base: u64, size: u64| LoadedModule {
2353            base,
2354            end: base + size,
2355            image: DebugImage {
2356                image_type: "elf".to_string(),
2357                debug_id: "test".to_string(),
2358                code_id: None,
2359                image_addr: format!("0x{base:x}"),
2360                image_size: Some(size),
2361                image_vmaddr: None,
2362                code_file: None,
2363                arch: "x86_64".to_string(),
2364            },
2365        };
2366
2367        let modules = vec![module_at(0x1000, 0x1000), module_at(0x4000, 0x1000)];
2368
2369        assert_eq!(find_module(&modules, 0x1500).map(|m| m.base), Some(0x1000));
2370        assert_eq!(find_module(&modules, 0x4000).map(|m| m.base), Some(0x4000));
2371        assert!(find_module(&modules, 0x2000).is_none()); // gap between modules
2372        assert!(find_module(&modules, 0x500).is_none()); // before first module
2373        assert!(find_module(&modules, 0x5000).is_none()); // past the last module
2374    }
2375
2376    #[test]
2377    fn captured_stacks_reference_loaded_debug_images() {
2378        let json = event_json(Exception::from_message(
2379            "AddrCheck",
2380            "captures addresses",
2381            true,
2382        ));
2383
2384        let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2385            .as_array()
2386            .expect("expected stack frames");
2387
2388        // instruction_addr is set only for frames whose module has a debug id;
2389        // it's omitted otherwise (e.g. system libraries without a GNU build id,
2390        // common on Linux). Assert the format wherever present, and that at
2391        // least one frame carries it.
2392        let mut saw_instruction_addr = false;
2393        for frame in frames {
2394            let Some(addr) = frame["instruction_addr"].as_str() else {
2395                continue;
2396            };
2397            saw_instruction_addr = true;
2398            assert!(
2399                addr.starts_with("0x") && u64::from_str_radix(&addr[2..], 16).is_ok(),
2400                "expected hex instruction_addr, got {:?}",
2401                frame["instruction_addr"]
2402            );
2403        }
2404        assert!(
2405            saw_instruction_addr,
2406            "expected at least one frame to carry an instruction_addr"
2407        );
2408
2409        // The test binary itself is a loaded module with a debug id on the
2410        // platforms we capture modules on, so $debug_images must be present
2411        // and every entry must be referenced by at least one frame.
2412        let images = json["properties"]["$debug_images"]
2413            .as_array()
2414            .expect("expected $debug_images");
2415        assert!(!images.is_empty());
2416        let expected_type = super::native_image_type();
2417        let expected_arch = super::normalize_arch(std::env::consts::ARCH);
2418        for image in images {
2419            assert_eq!(image["type"].as_str(), Some(expected_type));
2420            assert_eq!(
2421                image["arch"].as_str(),
2422                Some(expected_arch.as_str()),
2423                "arch should match the running process"
2424            );
2425            let debug_id = image["debug_id"].as_str().unwrap_or_default();
2426            assert!(
2427                debug_id.len() >= 36,
2428                "expected uuid-shaped debug_id, got {:?}",
2429                debug_id
2430            );
2431            let image_addr = image["image_addr"].as_str().unwrap_or_default();
2432            assert!(
2433                frames
2434                    .iter()
2435                    .any(|f| f["image_addr"].as_str() == Some(image_addr)),
2436                "image {} not referenced by any frame",
2437                image_addr
2438            );
2439        }
2440    }
2441
2442    #[test]
2443    fn from_error_accepts_borrowed_error_types() {
2444        let message = String::from("borrowed parse failure");
2445        let error = BorrowedError(&message);
2446        let json = event_json(Exception::from_error(&error, true));
2447
2448        assert_eq!(
2449            json["properties"]["$exception_list"][0]["value"],
2450            "borrowed parse failure"
2451        );
2452    }
2453
2454    #[test]
2455    fn personless_capture_disables_person_profile() {
2456        let json = event_json(Exception::from_message("Error", "no user context", true));
2457
2458        assert_eq!(json["event"], "$exception");
2459        assert_eq!(json["properties"]["$process_person_profile"], false);
2460    }
2461
2462    #[test]
2463    fn custom_properties_cannot_override_reserved_exception_payload() {
2464        let error = OuterError { source: InnerError };
2465        let event = build_exception_event(
2466            &error,
2467            CaptureExceptionOptions::new()
2468                .property("$exception_list", json!([{"value": "fake"}]))
2469                .unwrap(),
2470            &ErrorTrackingOptions::default(),
2471        )
2472        .unwrap();
2473
2474        let json = built_event_json(event);
2475        assert_eq!(
2476            json["properties"]["$exception_list"][0]["value"],
2477            "checkout failed"
2478        );
2479    }
2480
2481    #[test]
2482    fn options_can_disable_stacktrace() {
2483        let options = ErrorTrackingOptionsBuilder::default()
2484            .capture_stacktrace(false)
2485            .build()
2486            .unwrap();
2487        let error = OuterError { source: InnerError };
2488        let event =
2489            build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2490        let json = built_event_json(event);
2491
2492        let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
2493        assert_eq!(exception_list.len(), 2);
2494        assert!(exception_list[0].get("stacktrace").is_none());
2495    }
2496
2497    #[test]
2498    fn in_app_path_defaults_and_overrides_are_applied() {
2499        let options = ErrorTrackingOptions::default();
2500        assert!(options.is_in_app_path("/app/src/main.rs"));
2501        assert!(!options.is_in_app_path("/home/user/.cargo/registry/src/lib.rs"));
2502        assert!(!options.is_in_app_path(
2503            "/private/tmp/nix-build-rustc-1.91.1/rustc-1.91.1-src/library/core/src/ops/function.rs"
2504        ));
2505        assert!(options.is_in_app_frame(None, Some("checkout_service::submit")));
2506        assert!(!options.is_in_app_frame(None, Some("std::rt::lang_start")));
2507        assert!(!options.is_in_app_frame(None, Some("core::ops::function::FnOnce::call_once")));
2508        assert!(
2509            !options.is_in_app_frame(None, Some("posthog_rs::client::Client::capture_exception"))
2510        );
2511        assert!(!options.is_in_app_frame(None, Some("_main")));
2512
2513        let options = ErrorTrackingOptionsBuilder::default()
2514            .in_app_include_paths(vec!["/service/".to_string(), "my_service::".to_string()])
2515            .in_app_exclude_paths(vec!["/service/vendor/".to_string()])
2516            .build()
2517            .unwrap();
2518
2519        assert!(options.is_in_app_path("/service/src/main.rs"));
2520        assert!(!options.is_in_app_path("/other/src/main.rs"));
2521        assert!(!options.is_in_app_path("/service/vendor/lib.rs"));
2522        assert!(options.is_in_app_frame(None, Some("my_service::checkout")));
2523        assert!(!options.is_in_app_frame(None, Some("other_service::checkout")));
2524    }
2525
2526    #[test]
2527    fn in_app_defaults_cover_docker_cargo_home_and_dwarf_names() {
2528        let options = ErrorTrackingOptions::default();
2529
2530        // CARGO_HOME isn't always `~/.cargo`: the official Rust Docker images
2531        // put the registry under /usr/local/cargo.
2532        assert!(!options.is_in_app_path(
2533            "/usr/local/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.1/src/runtime/task/harness.rs"
2534        ));
2535        assert!(!options.is_in_app_path(
2536            "/usr/local/cargo/git/checkouts/somecrate-9a8b7c6d5e4f3a2b/0f1e2d3/src/lib.rs"
2537        ));
2538        // A renamed CARGO_HOME is still caught by cargo's own layouts.
2539        assert!(!options.is_in_app_path(
2540            "/cache/rust-deps/registry/src/index.crates.io-1949cf8c6b5b557f/serde-1.0.219/src/de/mod.rs"
2541        ));
2542        assert!(!options.is_in_app_path(
2543            "/cache/rust-deps/git/checkouts/somecrate-9a8b7c6d5e4f3a2b/0f1e2d3/src/lib.rs"
2544        ));
2545        // ...but only cargo's `<repo>-<ident hash>/<short rev>/` checkout
2546        // layout: an app that happens to live under a `git/checkouts/`
2547        // directory stays in-app, even with a hex-looking repo-dir suffix.
2548        assert!(options.is_in_app_path("/srv/git/checkouts/my-service/src/main.rs"));
2549        assert!(options.is_in_app_path("/srv/git/checkouts/my-service-deadbeef/src/main.rs"));
2550        // Only cargo's real layouts match: apps under directories that merely
2551        // look cargo-ish stay in-app.
2552        assert!(options.is_in_app_path("/srv/mycargo/registry/src/model.rs"));
2553        assert!(options.is_in_app_path("/srv/cargo/registry/my-service/src/main.rs"));
2554        assert!(options
2555            .is_in_app_path("/srv/registry/src/index.crates.io-local/my-service/src/main.rs"));
2556        // Deploy/checkout dirs suffixed with short git SHAs (< 16 hex chars)
2557        // don't pass for cargo's u64 ident hash.
2558        assert!(options.is_in_app_path("/opt/app/registry/src/service-a1b2c3d4/modules/api.rs"));
2559        assert!(options.is_in_app_path("/srv/git/checkouts/service-deadbeef/0f1e2d3/src/main.rs"));
2560
2561        // DWARF-derived names hide the crate behind generic arguments
2562        // (`poll_future<tokio::...>`); the frame is still classified out of
2563        // app by its registry path, and the garbage `poll_future<tokio`
2564        // segment must not make the name override that.
2565        assert!(!options.is_in_app_frame(
2566            Some("/usr/local/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.1/src/runtime/task/harness.rs"),
2567            Some("poll_future<tokio::runtime::blocking::task::BlockingTask<tokio::runtime::scheduler::multi_thread::worker::{impl#0}::launch::{closure_env#0}>, tokio::runtime::blocking::schedule::BlockingSchedule>"),
2568        ));
2569        // An app function generic over a vendor type stays in-app: only the
2570        // base name (path before `<`) feeds the crate check.
2571        assert!(
2572            options.is_in_app_frame(Some("/app/src/worker.rs"), Some("run<tokio::time::Sleep>"),)
2573        );
2574        // A *fileless* DWARF-style name deliberately fails open to in-app: the
2575        // generic arguments are instantiation types, not the defining crate,
2576        // so guessing the owner from them would mislabel app functions generic
2577        // over vendor types. In practice DWARF-derived names ship with file
2578        // info, which classifies them (as above).
2579        assert!(options.is_in_app_frame(
2580            None,
2581            Some("poll_future<tokio::runtime::blocking::task::BlockingTask<T>, S>"),
2582        ));
2583        // Qualified-path renderings keep their leading `<` and still resolve
2584        // the crate segment.
2585        assert!(!options.is_in_app_frame(
2586            None,
2587            Some("<alloc::boxed::Box<F,A> as core::ops::function::FnOnce<Args>>::call_once"),
2588        ));
2589
2590        // Bare thread/process bootstrap symbols with no source file are not
2591        // app code, even though no crate denylist entry can match them.
2592        assert!(!options.is_in_app_frame(None, Some("__clone")));
2593        assert!(!options.is_in_app_frame(None, Some("start_thread")));
2594        assert!(!options.is_in_app_frame(None, Some("main")));
2595        // ...but a pathless name backed by a source file keeps the path verdict,
2596        // and other bare symbols (`#[no_mangle]` exports, linked-in C code)
2597        // stay in-app.
2598        assert!(options.is_in_app_frame(Some("/app/src/main.rs"), Some("main")));
2599        assert!(options.is_in_app_frame(None, Some("my_exported_callback")));
2600    }
2601
2602    #[test]
2603    fn function_names_strip_rust_symbol_hashes() {
2604        assert_eq!(
2605            normalize_function_name("checkout_service::submit::h9ae4817223dd0b22"),
2606            "checkout_service::submit"
2607        );
2608        assert_eq!(
2609            normalize_function_name("std::rt::lang_start::{{closure}}::ha1fd5c62e470a8cc"),
2610            "std::rt::lang_start::{{closure}}"
2611        );
2612        assert_eq!(
2613            normalize_function_name("checkout_service::submit"),
2614            "checkout_service::submit"
2615        );
2616    }
2617
2618    #[test]
2619    fn type_names_keep_path_and_strip_generics() {
2620        // Full path is kept so idiomatic `Error`-named types stay distinguishable.
2621        assert_eq!(
2622            simple_type_name("std::io::error::Error"),
2623            "std::io::error::Error"
2624        );
2625        assert_eq!(
2626            simple_type_name("mycrate::CheckoutError"),
2627            "mycrate::CheckoutError"
2628        );
2629        assert_eq!(simple_type_name("mycrate::Error"), "mycrate::Error");
2630
2631        // Generic arguments and `&`/`dyn` markers are stripped.
2632        assert_eq!(simple_type_name("foo::Bar<baz::Qux>"), "foo::Bar");
2633        assert_eq!(
2634            simple_type_name(type_name::<Box<dyn StdError>>()),
2635            "alloc::boxed::Box"
2636        );
2637
2638        // A type-erased `dyn Error` carries no concrete type, so it degrades to "Error".
2639        assert_eq!(simple_type_name("dyn core::error::Error"), "Error");
2640        assert_eq!(simple_type_name(type_name::<&dyn StdError>()), "Error");
2641    }
2642
2643    #[test]
2644    fn frames_are_trimmed_to_max_frames_keeping_the_crash_site() {
2645        let synthetic_frame = |index: usize| StackFrame {
2646            filename: None,
2647            line_no: None,
2648            function: format!("frame_{index}"),
2649            lang: "rust".to_string(),
2650            in_app: true,
2651            synthetic: false,
2652            platform: "native".to_string(),
2653            instruction_addr: None,
2654            symbol_addr: None,
2655            image_addr: None,
2656            client_resolved: false,
2657            inline: false,
2658        };
2659        let exception = Exception {
2660            items: vec![ExceptionItem {
2661                exception_type: "Error".to_string(),
2662                value: "trimmed".to_string(),
2663                mechanism: ExceptionMechanism::default(),
2664                stacktrace: None,
2665            }],
2666            captured_frames: Some((0..MAX_FRAMES + 5).map(synthetic_frame).collect()),
2667            captured_images: Vec::new(),
2668            fingerprint: None,
2669            level: "error".to_string(),
2670        };
2671
2672        let json = event_json_with(exception, &ErrorTrackingOptions::default());
2673        let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2674            .as_array()
2675            .expect("expected stack frames");
2676        assert_eq!(frames.len(), MAX_FRAMES);
2677        // Frames arrive in wire order (outermost first, crash site last), so
2678        // trimming drops the outermost frames from the front and keeps the
2679        // crash-site tail: `frame_0` is gone and the last frame survives.
2680        assert_eq!(frames[0]["function"], "frame_5");
2681        assert_eq!(
2682            frames[MAX_FRAMES - 1]["function"],
2683            format!("frame_{}", MAX_FRAMES + 4)
2684        );
2685    }
2686
2687    #[test]
2688    fn stacktrace_keeps_crash_frame_last() {
2689        fn capture() -> ExceptionStacktrace {
2690            // Go through the real capture path so we assert the wire order the
2691            // SDK actually emits (outermost first, crash/capture site last),
2692            // not the raw innermost-first order of `capture_frames_current_first`.
2693            let mut frames = capture_raw_application_frames().0;
2694            trim_to_max_frames(&mut frames, 8);
2695            ExceptionStacktrace::raw(frames)
2696        }
2697
2698        let frames = capture().frames;
2699        let functions: Vec<&str> = frames
2700            .iter()
2701            .map(|frame| frame.function.as_str())
2702            .filter(|function| !function.is_empty())
2703            .collect();
2704
2705        let capture_index = functions
2706            .iter()
2707            .position(|function| function.contains("stacktrace_keeps_crash_frame_last::capture"))
2708            .expect("expected capture frame");
2709        let test_index = functions
2710            .iter()
2711            .position(|function| function.ends_with("stacktrace_keeps_crash_frame_last"))
2712            .expect("expected test frame");
2713
2714        assert!(
2715            test_index < capture_index,
2716            "expected the caller before the innermost (capture-site) frame, got {:?}",
2717            functions
2718        );
2719    }
2720
2721    #[test]
2722    fn inlined_frames_emit_expanded_groups() {
2723        // inline(always) is honored in debug builds, so these helpers share
2724        // their caller's physical frame. Every logical layer is emitted; when
2725        // the address is resolvable the layers form a marked group (physical
2726        // frame first, inline members after, all sharing the address) that the
2727        // server replaces atomically with its own expansion — so the inline
2728        // chain survives without uploaded symbols and doesn't duplicate with
2729        // them.
2730        #[inline(always)]
2731        fn inline_leaf() -> Vec<StackFrame> {
2732            capture_raw_application_frames().0
2733        }
2734
2735        #[inline(always)]
2736        fn inline_mid() -> Vec<StackFrame> {
2737            inline_leaf()
2738        }
2739
2740        let frames = inline_mid();
2741        let functions: Vec<&str> = frames.iter().map(|frame| frame.function.as_str()).collect();
2742
2743        // client_resolved means exactly "this frame carries locally-resolved
2744        // symbols"; stripped addressed frames stay false.
2745        assert!(
2746            frames
2747                .iter()
2748                .all(|f| f.client_resolved == (!f.function.is_empty() || f.filename.is_some())),
2749            "client_resolved must mirror locally-resolved symbol presence: {:?}",
2750            frames
2751        );
2752
2753        // Inline members always follow a physical frame carrying the same
2754        // address — a shared address is only ever expressed as a marked group.
2755        let mut group_addr: Option<&str> = None;
2756        for frame in &frames {
2757            if frame.inline {
2758                assert!(
2759                    frame.instruction_addr.is_some()
2760                        && frame.instruction_addr.as_deref() == group_addr,
2761                    "inline member must carry its physical frame's address: {:?}",
2762                    frames
2763                );
2764            } else {
2765                group_addr = frame.instruction_addr.as_deref();
2766            }
2767        }
2768
2769        let leaf_index = functions.iter().position(|f| f.contains("inline_leaf"));
2770        let mid_index = functions.iter().position(|f| f.contains("inline_mid"));
2771
2772        if frames.iter().any(|frame| frame.instruction_addr.is_some()) {
2773            // Resolvable: the layers form one group led by the test fn's
2774            // physical frame — members marked inline, sharing its address,
2775            // ordered like the server's own expansion (outermost first).
2776            assert!(
2777                matches!((leaf_index, mid_index), (Some(l), Some(m)) if m < l),
2778                "expected the group's inline chain outermost-first, got {:?}",
2779                functions
2780            );
2781            let leaf_frame = &frames[leaf_index.unwrap()];
2782            let mid_frame = &frames[mid_index.unwrap()];
2783            assert!(
2784                leaf_frame.inline && mid_frame.inline,
2785                "expected inlined layers marked as group members: {:?}",
2786                frames
2787            );
2788            assert!(
2789                leaf_frame.instruction_addr.is_some()
2790                    && leaf_frame.instruction_addr == mid_frame.instruction_addr,
2791                "expected group members to share the physical address: {:?}",
2792                frames
2793            );
2794            let physical_index = frames[..mid_index.unwrap()]
2795                .iter()
2796                .rposition(|f| !f.inline)
2797                .expect("expected a physical frame leading the group");
2798            assert_eq!(
2799                frames[physical_index].instruction_addr, leaf_frame.instruction_addr,
2800                "group must be led by the physical frame that owns the address"
2801            );
2802        } else {
2803            // Not resolvable: client-side expansion preserves the inline chain
2804            // as plain address-less frames. In canonical wire order the
2805            // outermost logical layer leads and the inlined leaf is last, so
2806            // `inline_mid` (the caller) precedes `inline_leaf` (the callee).
2807            assert!(
2808                matches!((leaf_index, mid_index), (Some(l), Some(m)) if m < l),
2809                "expected client-side inline expansion outermost first, got {:?}",
2810                functions
2811            );
2812            assert!(
2813                frames.iter().all(|f| !f.inline),
2814                "address-less frames must not carry inline markers: {:?}",
2815                frames
2816            );
2817        }
2818    }
2819
2820    #[test]
2821    fn trim_drops_whole_inline_groups() {
2822        let frame = |function: &str, addr: Option<&str>, inline: bool| StackFrame {
2823            filename: None,
2824            line_no: None,
2825            function: function.to_string(),
2826            lang: "rust".to_string(),
2827            in_app: true,
2828            synthetic: false,
2829            platform: "native".to_string(),
2830            instruction_addr: addr.map(str::to_string),
2831            symbol_addr: None,
2832            image_addr: None,
2833            client_resolved: true,
2834            inline,
2835        };
2836
2837        // Call-ordered: [outer, outer, physical, member, member, inner, inner, inner]
2838        let mut frames = vec![
2839            frame("outer_0", None, false),
2840            frame("outer_1", None, false),
2841            frame("lead", Some("0x10"), false),
2842            frame("member_0", Some("0x10"), true),
2843            frame("member_1", Some("0x10"), true),
2844            frame("inner_0", None, false),
2845            frame("inner_1", None, false),
2846            frame("inner_2", None, false),
2847        ];
2848
2849        // A budget of 5 would cut between the group's members; the whole group
2850        // is dropped instead of leaving orphaned inline frames.
2851        trim_to_max_frames(&mut frames, 5);
2852        let functions: Vec<&str> = frames.iter().map(|f| f.function.as_str()).collect();
2853        assert_eq!(functions, ["inner_0", "inner_1", "inner_2"]);
2854    }
2855
2856    #[test]
2857    fn build_exception_event_defaults_to_personless() {
2858        let error = OuterError { source: InnerError };
2859        let event = build_exception_event(
2860            &error,
2861            CaptureExceptionOptions::default(),
2862            &ErrorTrackingOptions::default(),
2863        )
2864        .unwrap();
2865        let json = built_event_json(event);
2866
2867        assert_eq!(json["event"], "$exception");
2868        assert_eq!(json["properties"]["$process_person_profile"], false);
2869        assert_eq!(json["properties"]["$exception_level"], "error");
2870    }
2871
2872    #[test]
2873    fn build_exception_event_applies_options() {
2874        let error = OuterError { source: InnerError };
2875        let options = CaptureExceptionOptions::new()
2876            .distinct_id("user-1")
2877            .property("route", "/checkout")
2878            .unwrap()
2879            .group("company", "acme")
2880            .trace_context("00000000000000000000000000000123", "0000000000000456")
2881            .fingerprint("checkout-error")
2882            .level("warning");
2883        let event =
2884            build_exception_event(&error, options, &ErrorTrackingOptions::default()).unwrap();
2885        let json = built_event_json(event);
2886
2887        assert_eq!(json["distinct_id"], "user-1");
2888        assert_eq!(json["properties"]["route"], "/checkout");
2889        assert_eq!(json["properties"]["$groups"]["company"], "acme");
2890        assert_eq!(
2891            json["properties"]["$trace_id"],
2892            "00000000000000000000000000000123"
2893        );
2894        assert_eq!(json["properties"]["$span_id"], "0000000000000456");
2895        assert_eq!(
2896            json["properties"]["$exception_fingerprint"],
2897            "checkout-error"
2898        );
2899        assert_eq!(json["properties"]["$exception_level"], "warning");
2900    }
2901
2902    #[test]
2903    fn build_exception_event_reads_trace_context_provider() {
2904        let options = ErrorTrackingOptionsBuilder::default()
2905            .trace_context_provider(|| {
2906                Some(TraceContext::new("provider-trace-id", "provider-span-id"))
2907            })
2908            .build()
2909            .unwrap();
2910        let error = OuterError { source: InnerError };
2911        let event =
2912            build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2913        let json = built_event_json(event);
2914
2915        assert_eq!(json["properties"]["$trace_id"], "provider-trace-id");
2916        assert_eq!(json["properties"]["$span_id"], "provider-span-id");
2917    }
2918
2919    #[test]
2920    fn explicit_trace_context_takes_precedence_over_provider() {
2921        let provider_called = Arc::new(AtomicBool::new(false));
2922        let provider_called_in_callback = Arc::clone(&provider_called);
2923        let options = ErrorTrackingOptionsBuilder::default()
2924            .trace_context_provider(move || {
2925                provider_called_in_callback.store(true, Ordering::SeqCst);
2926                Some(TraceContext::new("provider-trace-id", "provider-span-id"))
2927            })
2928            .build()
2929            .unwrap();
2930        let error = OuterError { source: InnerError };
2931        let event = build_exception_event(
2932            &error,
2933            CaptureExceptionOptions::new().trace_context("explicit-trace-id", "explicit-span-id"),
2934            &options,
2935        )
2936        .unwrap();
2937        let json = built_event_json(event);
2938
2939        assert_eq!(json["properties"]["$trace_id"], "explicit-trace-id");
2940        assert_eq!(json["properties"]["$span_id"], "explicit-span-id");
2941        assert!(!provider_called.load(Ordering::SeqCst));
2942    }
2943
2944    #[test]
2945    fn panicking_trace_context_provider_is_ignored() {
2946        let options = ErrorTrackingOptionsBuilder::default()
2947            .trace_context_provider(|| panic!("provider failed"))
2948            .build()
2949            .unwrap();
2950        let error = OuterError { source: InnerError };
2951        let event =
2952            build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2953        let json = built_event_json(event);
2954
2955        assert!(json["properties"].get("$trace_id").is_none());
2956        assert!(json["properties"].get("$span_id").is_none());
2957    }
2958}