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