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.
945            for (filename, line_no, function) in layers {
946                frames.push(StackFrame {
947                    filename,
948                    line_no,
949                    function,
950                    lang: "rust".to_string(),
951                    // Placeholder: these frames carry a name, so `write_into`
952                    // reclassifies in_app from the path/function before sending.
953                    in_app: false,
954                    synthetic: false,
955                    platform: "native".to_string(),
956                    instruction_addr: None,
957                    symbol_addr: None,
958                    image_addr: None,
959                    // Resolved client-side (no debug image for the server to
960                    // use), so the server must not re-expand these.
961                    client_resolved: true,
962                });
963            }
964        }
965        // A non-resolvable frame with no local symbols is dropped: with no name
966        // and no address, neither the client nor the server can resolve it, so a
967        // bare entry would be pure noise.
968
969        true
970    });
971
972    frames
973}
974
975fn trim_to_max_frames(frames: &mut Vec<StackFrame>, max_frames: usize) {
976    if frames.len() > max_frames {
977        frames.truncate(max_frames);
978    }
979}
980
981/// Capture the current raw stacktrace, dropping the leading SDK frames so the
982/// caller's frame comes first, and return the loaded modules those frames point
983/// into for the `$debug_images` property.
984///
985/// Frames are current-first, so the SDK's own frames lead. Two passes drop
986/// them: an address-based pass that matches each frame's symbol entry against
987/// `pinned_entries` (the SDK capture functions), which works even in stripped
988/// builds where there are no names; then the name-based `is_internal` pass for
989/// everything the resolver could name.
990///
991/// inline(never): this generic function sits between the pinned non-generic
992/// wrapper and `capture_frames_current_first`; keeping it a physical frame lets
993/// the name-based pass match it (its monomorphized address can't be pinned),
994/// and draining through the outermost pinned wrapper sweeps it out in stripped
995/// builds.
996#[inline(never)]
997fn capture_raw_frames(
998    is_internal: impl Fn(&str) -> bool,
999    pinned_entries: &[u64],
1000) -> (Vec<StackFrame>, Vec<DebugImage>) {
1001    let modules = collect_loaded_modules();
1002    let mut frames = capture_frames_current_first(0, &modules);
1003
1004    // Address-based stripping first: identify the SDK's own capture frames by
1005    // function entry address, which works even in stripped builds where the
1006    // name-based check below has nothing to match. The SDK frames sit at the
1007    // front (current-first), so dropping through the last match removes the
1008    // whole prefix, including the unwinder frames before our innermost one.
1009    //
1010    // Platform caveat: this only works where the frame's symbol address is the
1011    // runtime function entry (Linux/glibc via `_Unwind_FindEnclosingFunction`).
1012    // On macOS the symbolization backend reports the queried address rather
1013    // than the function entry, so the address pass never matches there and the
1014    // name-based pass below does the stripping instead; fully stripped
1015    // Apple/Windows builds keep the SDK prefix as address-only frames, which
1016    // regain names through server-side symbolication.
1017    let scan = frames.len().min(16);
1018    let matches_pinned = |frame: &StackFrame| {
1019        frame
1020            .symbol_addr
1021            .as_deref()
1022            .and_then(|addr| u64::from_str_radix(addr.trim_start_matches("0x"), 16).ok())
1023            .is_some_and(|addr| pinned_entries.contains(&addr))
1024    };
1025    if let Some(last_sdk) = frames[..scan].iter().rposition(matches_pinned) {
1026        frames.drain(..=last_sdk);
1027    }
1028
1029    while frames
1030        .first()
1031        .map(|frame| is_internal(&frame.function))
1032        .unwrap_or(false)
1033    {
1034        frames.remove(0);
1035    }
1036
1037    let images = referenced_images(modules, &frames);
1038    (frames, images)
1039}
1040
1041/// Only report modules that frames actually point into, and only those with a
1042/// usable debug id; the final filtering against the trimmed frame list happens
1043/// in `write_into`.
1044fn referenced_images(modules: Vec<LoadedModule>, frames: &[StackFrame]) -> Vec<DebugImage> {
1045    modules
1046        .into_iter()
1047        .filter(|m| !m.image.debug_id.is_empty())
1048        .map(|m| m.image)
1049        .filter(|image| {
1050            frames
1051                .iter()
1052                .any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
1053        })
1054        .collect()
1055}
1056
1057// inline(never): this non-generic wrapper sits on the stack directly below the
1058// constructor and its entry address anchors the address-based stripping in
1059// stripped builds (the generic `capture_raw_frames` between it and
1060// `capture_frames_current_first` is matched by name instead — its monomorphized
1061// address isn't nameable as a single fn pointer).
1062#[inline(never)]
1063fn capture_raw_application_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1064    let pinned = [
1065        capture_frames_current_first as *const () as u64,
1066        capture_raw_application_frames as *const () as u64,
1067    ];
1068    capture_raw_frames(is_internal_capture_frame, &pinned)
1069}
1070
1071fn capture_raw_panic_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1072    // Unlike the manual-capture path, keep *every* frame — including the panic
1073    // and capture machinery — and let in-app classification mark the SDK/runtime
1074    // frames as `in_app = false`. Dropping by function name was brittle (the list
1075    // drifted as internals were renamed/inlined) and threw away data the UI can
1076    // collapse on its own via the in-app flag. We still collect the debug images
1077    // the kept frames point into so the server can symbolicate them.
1078    let modules = collect_loaded_modules();
1079    let frames = capture_frames_current_first(0, &modules);
1080    let images = referenced_images(modules, &frames);
1081    (frames, images)
1082}
1083
1084fn is_internal_capture_frame(function: &str) -> bool {
1085    function.starts_with("backtrace::")
1086        || function.contains("capture_frames_current_first")
1087        || function.contains("capture_raw_frames")
1088        || function.contains("capture_raw_application_frames")
1089        || function.contains("Exception::from_error")
1090        || function.contains("Exception::from_message")
1091        || function.contains("build_exception_event")
1092        || function.contains("Client::capture_exception")
1093        || function.contains("global::capture_exception")
1094}
1095
1096/// The panic payload as a string, falling back to a generic message.
1097#[allow(deprecated)]
1098fn panic_message(panic_info: &panic::PanicInfo<'_>) -> String {
1099    let value = panic_info
1100        .payload()
1101        .downcast_ref::<&str>()
1102        .map(|value| (*value).to_string())
1103        .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
1104        .unwrap_or_else(|| "panic occurred".to_string());
1105
1106    if value.is_empty() {
1107        "panic occurred".to_string()
1108    } else {
1109        value
1110    }
1111}
1112
1113fn path_to_string(path: &std::path::Path) -> String {
1114    path.to_string_lossy().into_owned()
1115}
1116
1117/// Best-effort, human-readable exception type from a Rust type name.
1118///
1119/// Keeps the full module path (minus generic arguments and `&`/`dyn` markers) so
1120/// types whose leaf name is the idiomatic `Error` — `std::io::Error`,
1121/// `serde_json::Error`, `mycrate::Error` — stay distinguishable rather than all
1122/// collapsing to a single `"Error"`.
1123fn simple_type_name(type_name: &str) -> String {
1124    let trimmed = type_name.trim().trim_start_matches('&').trim();
1125    let trimmed = trimmed.strip_prefix("dyn ").unwrap_or(trimmed).trim();
1126    let trimmed = trimmed
1127        .split_once('<')
1128        .map_or(trimmed, |(outer_type, _)| outer_type)
1129        .trim_end();
1130    // A type-erased `dyn Error` only reports the trait itself, which carries no
1131    // concrete type information, so collapse it to a bare "Error".
1132    if trimmed.is_empty() || trimmed == "core::error::Error" || trimmed == "std::error::Error" {
1133        return "Error".to_string();
1134    }
1135    trimmed.to_string()
1136}
1137
1138/// Type name for a chained source.
1139///
1140/// Sources are exposed as `&dyn Error`, which is type-erased: `type_name_of_val`
1141/// can only report the trait, not the original type. Chained sources therefore
1142/// carry the value/message but report a generic `"Error"` type — the concrete
1143/// type of a `dyn Error` cannot be recovered on stable Rust.
1144fn source_type_name(error: &(dyn StdError + 'static)) -> String {
1145    simple_type_name(type_name_of_val(error))
1146}
1147
1148/// Link a multi-error chain so each source points at the error it came from,
1149/// mirroring the `$exception_list` chaining other PostHog SDKs emit. Single
1150/// exceptions are left unlinked.
1151fn link_exception_chain(exception_list: &mut [ExceptionItem]) {
1152    if exception_list.len() < 2 {
1153        return;
1154    }
1155    for (index, item) in exception_list.iter_mut().enumerate() {
1156        item.mechanism.exception_id = Some(index);
1157        if index > 0 {
1158            item.mechanism.parent_id = Some(index - 1);
1159            item.mechanism.mechanism_type = "chained".to_string();
1160        }
1161    }
1162}
1163
1164fn error_value<E>(error: &E) -> String
1165where
1166    E: StdError + ?Sized,
1167{
1168    let value = error.to_string();
1169    if value.is_empty() {
1170        "Error".to_string()
1171    } else {
1172        value
1173    }
1174}
1175
1176/// Demangled symbols carry compiler-internal hashes that vary per platform and
1177/// rustc release: legacy mangling appends a trailing `::h<16 hex>`, and v0
1178/// mangling tags crate names with `[<hex>]` disambiguators (std ships v0-mangled
1179/// on Linux, so std frames demangle as `std[b887e3750a86e3a0]::panicking::…`).
1180/// Strip both so internal-frame matching and server-side grouping see stable,
1181/// readable names.
1182fn normalize_function_name(function: &str) -> String {
1183    let function = strip_crate_disambiguators(function);
1184    match function.rsplit_once("::") {
1185        Some((prefix, suffix)) if is_rust_symbol_hash(suffix) => prefix.to_string(),
1186        _ => function,
1187    }
1188}
1189
1190fn strip_crate_disambiguators(function: &str) -> String {
1191    let mut out = String::with_capacity(function.len());
1192    let mut rest = function;
1193    while let Some(open) = rest.find('[') {
1194        out.push_str(&rest[..open]);
1195        let bracketed = &rest[open..];
1196        match bracketed.find(']') {
1197            Some(close) => {
1198                let content = &bracketed[1..close];
1199                if !is_crate_disambiguator(content) {
1200                    out.push_str(&bracketed[..=close]);
1201                }
1202                rest = &bracketed[close + 1..];
1203            }
1204            None => {
1205                out.push_str(bracketed);
1206                rest = "";
1207            }
1208        }
1209    }
1210    out.push_str(rest);
1211    out
1212}
1213
1214/// Lowercase-hex bracket contents of disambiguator length; array/slice type
1215/// brackets (`[u8; 32]`) never qualify.
1216fn is_crate_disambiguator(content: &str) -> bool {
1217    content.len() >= 8
1218        && content
1219            .chars()
1220            .all(|ch| ch.is_ascii_digit() || ('a'..='f').contains(&ch))
1221}
1222
1223fn is_rust_symbol_hash(segment: &str) -> bool {
1224    segment.len() >= 9
1225        && segment.starts_with('h')
1226        && segment[1..].chars().all(|ch| ch.is_ascii_hexdigit())
1227}
1228
1229fn default_in_app_path(filename: &str) -> bool {
1230    let normalized = filename.replace('\\', "/");
1231    if normalized.contains("/.cargo/registry/")
1232        || normalized.contains("/.cargo/git/")
1233        || normalized.contains("/rustc/")
1234        || normalized.contains("/rustc-")
1235        || normalized.contains("/library/alloc/src/")
1236        || normalized.contains("/library/core/src/")
1237        || normalized.contains("/library/proc_macro/src/")
1238        || normalized.contains("/library/std/src/")
1239        || normalized.contains("/library/test/src/")
1240        || normalized.contains("/toolchains/")
1241        || normalized.contains("/target/")
1242        || normalized.contains("/vendor/")
1243    {
1244        return false;
1245    }
1246
1247    true
1248}
1249
1250fn default_in_app_function(function: &str) -> bool {
1251    // Bare runtime/unwind symbols that carry no `crate::` prefix (so the segment
1252    // match below can't catch them). `rust_begin_unwind` is the panic entry; the
1253    // `__rust`/`___rust` shims are the unwind glue (the extra underscore is
1254    // Mach-O's).
1255    if function.is_empty()
1256        || function == "_main"
1257        || function == "rust_begin_unwind"
1258        || function.starts_with("__rust")
1259        || function.starts_with("___rust")
1260    {
1261        return false;
1262    }
1263
1264    !matches!(
1265        function
1266            .trim_start_matches('<')
1267            .split("::")
1268            .next()
1269            .unwrap_or_default(),
1270        "alloc"
1271            | "anyhow"
1272            | "backtrace"
1273            | "color_eyre"
1274            | "core"
1275            | "eyre"
1276            | "futures_core"
1277            | "futures_util"
1278            | "log"
1279            | "posthog_rs"
1280            | "reqwest"
1281            | "std"
1282            | "stable_eyre"
1283            | "tokio"
1284            | "tracing"
1285            | "tracing_core"
1286    )
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use std::error::Error as StdError;
1292    use std::fmt;
1293    use std::sync::atomic::{AtomicBool, Ordering};
1294    use std::sync::{Arc, Mutex, OnceLock};
1295
1296    use httpmock::prelude::*;
1297    use serde_json::{json, Value};
1298
1299    use super::*;
1300    use crate::client::ClientOptionsBuilder;
1301    use crate::event::InnerEvent;
1302
1303    #[derive(Debug)]
1304    struct OuterError {
1305        source: InnerError,
1306    }
1307
1308    impl fmt::Display for OuterError {
1309        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1310            write!(f, "checkout failed")
1311        }
1312    }
1313
1314    impl StdError for OuterError {
1315        fn source(&self) -> Option<&(dyn StdError + 'static)> {
1316            Some(&self.source)
1317        }
1318    }
1319
1320    #[derive(Debug)]
1321    struct InnerError;
1322
1323    impl fmt::Display for InnerError {
1324        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1325            write!(f, "database unavailable")
1326        }
1327    }
1328
1329    impl StdError for InnerError {}
1330
1331    #[derive(Debug)]
1332    struct BorrowedError<'a>(&'a str);
1333
1334    impl fmt::Display for BorrowedError<'_> {
1335        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1336            f.write_str(self.0)
1337        }
1338    }
1339
1340    impl StdError for BorrowedError<'_> {}
1341
1342    fn built_event_json(mut event: Event) -> Value {
1343        event.prepare_for_v0();
1344        serde_json::to_value(InnerEvent::new(event, "api-key".to_string())).unwrap()
1345    }
1346
1347    fn event_json_with(exception: Exception, options: &ErrorTrackingOptions) -> Value {
1348        let mut event = Event::new_anon("$exception");
1349        exception.write_into(&mut event, options).unwrap();
1350        built_event_json(event)
1351    }
1352
1353    fn event_json(exception: Exception) -> Value {
1354        event_json_with(exception, &ErrorTrackingOptions::default())
1355    }
1356
1357    #[allow(deprecated)]
1358    type PanicHook = Box<dyn Fn(&panic::PanicInfo<'_>) + Sync + Send + 'static>;
1359
1360    /// Restores the previous panic hook and clears the install latch so the
1361    /// panic tests don't leak global state into one another.
1362    struct PanicHookReset {
1363        previous: Option<PanicHook>,
1364    }
1365
1366    impl PanicHookReset {
1367        fn new(previous: PanicHook) -> Self {
1368            Self {
1369                previous: Some(previous),
1370            }
1371        }
1372
1373        fn restore(&mut self) {
1374            if let Some(previous) = self.previous.take() {
1375                panic::set_hook(previous);
1376            }
1377            PANIC_HOOK_INSTALLED.store(false, Ordering::Release);
1378        }
1379    }
1380
1381    impl Drop for PanicHookReset {
1382        fn drop(&mut self) {
1383            if !std::thread::panicking() {
1384                self.restore();
1385            }
1386        }
1387    }
1388
1389    /// Serializes the panic tests: they share the process-wide panic hook and
1390    /// the install latch.
1391    fn panic_hook_test_lock() -> &'static Mutex<()> {
1392        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1393        LOCK.get_or_init(|| Mutex::new(()))
1394    }
1395
1396    // The async constructor only awaits when local evaluation is enabled, so a
1397    // plain client builds synchronously under a minimal executor — no Tokio
1398    // runtime needed to set up the panic-hook tests.
1399    #[cfg(feature = "async-client")]
1400    fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1401        Arc::new(futures::executor::block_on(crate::client::client(options)))
1402    }
1403
1404    #[cfg(not(feature = "async-client"))]
1405    fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1406        Arc::new(crate::client::client(options))
1407    }
1408
1409    #[inline(never)]
1410    fn panic_hook_test_panic_site() {
1411        panic!("panic hook boom");
1412    }
1413
1414    #[inline(never)]
1415    fn panic_hook_disabled_test_panic_site() {
1416        panic!("disabled panic hook boom");
1417    }
1418
1419    /// Match the panic `$exception` event inside the transport's batch envelope
1420    /// (`batch[0]`) — the same event shape for the V0 and V1 wire formats.
1421    fn request_has_panic_payload(req: &HttpMockRequest) -> bool {
1422        let Some(body) = req.body.as_deref() else {
1423            return false;
1424        };
1425        let Ok(body) = serde_json::from_slice::<Value>(body) else {
1426            return false;
1427        };
1428        let event = &body["batch"][0];
1429        let exception = &event["properties"]["$exception_list"][0];
1430        let frames = exception["stacktrace"]["frames"].as_array();
1431
1432        // The user's panic site is captured (no longer forced to frame 0 — we
1433        // keep the machinery frames now instead of stripping them).
1434        let has_panic_site = frames.is_some_and(|frames| {
1435            frames.iter().any(|frame| {
1436                frame["function"]
1437                    .as_str()
1438                    .is_some_and(|name| name.contains("panic_hook_test_panic_site"))
1439            })
1440        });
1441        // Panic/unwind machinery is kept and marked out of app rather than
1442        // dropped by name.
1443        let has_machinery_not_in_app = frames.is_some_and(|frames| {
1444            frames.iter().any(|frame| {
1445                frame["in_app"] == false
1446                    && frame["function"].as_str().is_some_and(|name| {
1447                        name.contains("panicking") || name == "rust_begin_unwind"
1448                    })
1449            })
1450        });
1451
1452        event["event"] == "$exception"
1453            // V0 injects `$process_person_profile` into properties; V1 keeps it
1454            // in the typed `options` object.
1455            && (event["properties"]["$process_person_profile"] == false
1456                || event["options"]["process_person_profile"] == false)
1457            && event["properties"]["$exception_level"] == "fatal"
1458            && exception["type"] == "Panic"
1459            && exception["value"] == "panic hook boom"
1460            && exception["mechanism"]["type"] == "panic"
1461            && exception["mechanism"]["handled"] == false
1462            && event["properties"]["$exception_panic_file"]
1463                .as_str()
1464                .is_some_and(|file| file.contains("error_tracking.rs"))
1465            && event["properties"]["$exception_panic_line"]
1466                .as_u64()
1467                .is_some_and(|line| line > 0)
1468            && event["properties"]["$exception_panic_column"]
1469                .as_u64()
1470                .is_some_and(|column| column > 0)
1471            && has_panic_site
1472            && has_machinery_not_in_app
1473    }
1474
1475    #[test]
1476    fn panic_hook_sends_personless_exception_and_calls_previous_hook() {
1477        let _guard = panic_hook_test_lock()
1478            .lock()
1479            .unwrap_or_else(|e| e.into_inner());
1480        let original_hook = panic::take_hook();
1481        let mut reset = PanicHookReset::new(original_hook);
1482        let previous_called = Arc::new(AtomicBool::new(false));
1483        let previous_called_for_hook = Arc::clone(&previous_called);
1484        panic::set_hook(Box::new(move |_| {
1485            previous_called_for_hook.store(true, Ordering::Release);
1486        }));
1487
1488        let server = MockServer::start();
1489        let capture_mock = server.mock(|when, then| {
1490            when.method(POST).matches(request_has_panic_payload);
1491            then.status(200);
1492        });
1493        let options = ClientOptionsBuilder::default()
1494            .api_key("test_api_key".to_string())
1495            .host(server.base_url())
1496            .build()
1497            .unwrap();
1498        let client = build_test_client(options);
1499
1500        install_panic_hook(Arc::clone(&client)).unwrap();
1501        assert!(matches!(
1502            install_panic_hook(Arc::clone(&client)),
1503            Err(Error::PanicHookAlreadyInstalled)
1504        ));
1505
1506        let result = panic::catch_unwind(panic_hook_test_panic_site);
1507        reset.restore();
1508
1509        assert!(result.is_err());
1510        assert!(previous_called.load(Ordering::Acquire));
1511        capture_mock.assert_hits(1);
1512    }
1513
1514    #[test]
1515    fn disabled_panic_hook_does_not_send() {
1516        let _guard = panic_hook_test_lock()
1517            .lock()
1518            .unwrap_or_else(|e| e.into_inner());
1519        let original_hook = panic::take_hook();
1520        let mut reset = PanicHookReset::new(original_hook);
1521        panic::set_hook(Box::new(|_| {}));
1522
1523        let server = MockServer::start();
1524        let capture_mock = server.mock(|when, then| {
1525            when.method(POST);
1526            then.status(200);
1527        });
1528        let options = ClientOptionsBuilder::default()
1529            .api_key("test_api_key".to_string())
1530            .host(server.base_url())
1531            .disabled(true)
1532            .build()
1533            .unwrap();
1534        let client = build_test_client(options);
1535
1536        install_panic_hook(client).unwrap();
1537        let result = panic::catch_unwind(panic_hook_disabled_test_panic_site);
1538        reset.restore();
1539
1540        assert!(result.is_err());
1541        capture_mock.assert_hits(0);
1542    }
1543
1544    /// Panics inside Tokio tasks run the hook on a runtime worker thread; the
1545    /// transport's own worker is a separate std::thread, so the enqueue + flush
1546    /// still deliver the event rather than re-panicking on a runtime thread.
1547    #[cfg(feature = "async-client")]
1548    #[test]
1549    fn panic_hook_captures_panics_on_tokio_runtime_threads() {
1550        let _guard = panic_hook_test_lock()
1551            .lock()
1552            .unwrap_or_else(|e| e.into_inner());
1553        let original_hook = panic::take_hook();
1554        let mut reset = PanicHookReset::new(original_hook);
1555        panic::set_hook(Box::new(|_| {}));
1556
1557        let server = MockServer::start();
1558        let capture_mock = server.mock(|when, then| {
1559            when.method(POST)
1560                .body_contains(r#""value":"tokio task boom""#);
1561            then.status(200);
1562        });
1563        let options = ClientOptionsBuilder::default()
1564            .api_key("test_api_key".to_string())
1565            .host(server.base_url())
1566            .build()
1567            .unwrap();
1568        install_panic_hook(build_test_client(options)).unwrap();
1569
1570        let runtime = tokio::runtime::Builder::new_multi_thread()
1571            .worker_threads(1)
1572            .enable_all()
1573            .build()
1574            .unwrap();
1575        let result = runtime.block_on(async {
1576            tokio::spawn(async {
1577                panic!("tokio task boom");
1578            })
1579            .await
1580        });
1581        drop(runtime);
1582
1583        // Strictest flavor: the hook fires on the very thread driving block_on
1584        // of a current-thread runtime.
1585        let current_thread = tokio::runtime::Builder::new_current_thread()
1586            .enable_all()
1587            .build()
1588            .unwrap();
1589        let current_result = current_thread.block_on(async {
1590            panic::catch_unwind(AssertUnwindSafe(|| panic!("tokio task boom")))
1591        });
1592        drop(current_thread);
1593        reset.restore();
1594
1595        assert!(result.is_err());
1596        assert!(current_result.is_err());
1597        capture_mock.assert_hits(2);
1598    }
1599
1600    #[test]
1601    fn panic_in_before_send_on_worker_neither_deadlocks_nor_recurses() {
1602        // A `before_send` hook that panics unconditionally fires the panic hook
1603        // ON the transport worker thread. Capturing there must be skipped: a
1604        // synchronous self-flush would deadlock the worker, and routing the
1605        // `$exception` back through `before_send` (which panics again) would
1606        // recurse forever. A watchdog turns either regression into a failure.
1607        let _guard = panic_hook_test_lock()
1608            .lock()
1609            .unwrap_or_else(|e| e.into_inner());
1610        let original_hook = panic::take_hook();
1611        let mut reset = PanicHookReset::new(original_hook);
1612        panic::set_hook(Box::new(|_| {}));
1613
1614        let server = MockServer::start();
1615        let _capture_mock = server.mock(|when, then| {
1616            when.method(POST);
1617            then.status(200);
1618        });
1619        let options = ClientOptionsBuilder::default()
1620            .api_key("test_api_key".to_string())
1621            .host(server.base_url())
1622            .before_send(|_event| panic!("before_send boom"))
1623            .build()
1624            .unwrap();
1625        let client = build_test_client(options);
1626        install_panic_hook(Arc::clone(&client)).unwrap();
1627
1628        let finished = Arc::new(AtomicBool::new(false));
1629        let finished_for_worker = Arc::clone(&finished);
1630        let work_client = Arc::clone(&client);
1631        let _worker = std::thread::spawn(move || {
1632            work_client.capture(Event::new("boom", "user-1"));
1633            // From this (non-worker) thread this is a real blocking flush; it
1634            // returns only if the worker neither deadlocked nor spun on recursion.
1635            work_client.flush_blocking();
1636            finished_for_worker.store(true, Ordering::Release);
1637        });
1638
1639        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1640        while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
1641            std::thread::sleep(std::time::Duration::from_millis(20));
1642        }
1643        reset.restore();
1644
1645        assert!(
1646            finished.load(Ordering::Acquire),
1647            "panic in before_send on the worker thread deadlocked or recursed"
1648        );
1649        // The spawned thread is intentionally not joined: on a regression it is
1650        // stuck in flush_blocking and a join would hang too; on success it has
1651        // already finished. Dropping the handle detaches it.
1652    }
1653
1654    #[test]
1655    fn panic_hook_flush_is_bounded_when_before_send_needs_a_panic_held_lock() {
1656        // The panic hook flushes on the *panicking* thread, before unwinding
1657        // releases locks held at the panic site. If a `before_send` hook needs
1658        // such a lock, the worker blocks on it and the hook would block on the
1659        // worker forever — the process hangs instead of crashing. The flush is
1660        // time-bounded (`PANIC_FLUSH_TIMEOUT`), so the hook returns and the panic
1661        // proceeds; the watchdog turns a regression (an unbounded wait) into a
1662        // failure instead of a hang.
1663        let _guard = panic_hook_test_lock()
1664            .lock()
1665            .unwrap_or_else(|e| e.into_inner());
1666        let original_hook = panic::take_hook();
1667        let mut reset = PanicHookReset::new(original_hook);
1668        panic::set_hook(Box::new(|_| {}));
1669
1670        // A lock the application holds across its panic and that `before_send`
1671        // also wants — the classic shape that would deadlock an unbounded flush.
1672        static SHARED: Mutex<()> = Mutex::new(());
1673
1674        let server = MockServer::start();
1675        let _capture_mock = server.mock(|when, then| {
1676            when.method(POST);
1677            then.status(200);
1678        });
1679        let options = ClientOptionsBuilder::default()
1680            .api_key("test_api_key".to_string())
1681            .host(server.base_url())
1682            .before_send(|event| {
1683                let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
1684                Some(event)
1685            })
1686            .build()
1687            .unwrap();
1688        let client = build_test_client(options);
1689        install_panic_hook(Arc::clone(&client)).unwrap();
1690
1691        let finished = Arc::new(AtomicBool::new(false));
1692        let finished_for_panicker = Arc::clone(&finished);
1693        let _panicker = std::thread::spawn(move || {
1694            {
1695                // Hold SHARED across the panic so the hook fires while it is
1696                // locked. Release it (end of scope) *before* signalling, so test
1697                // teardown can drain the worker without blocking on the lock.
1698                let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
1699                let _ = panic::catch_unwind(AssertUnwindSafe(|| {
1700                    panic!("boom while holding a before_send lock")
1701                }));
1702            }
1703            finished_for_panicker.store(true, Ordering::Release);
1704        });
1705
1706        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1707        while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
1708            std::thread::sleep(std::time::Duration::from_millis(20));
1709        }
1710        reset.restore();
1711
1712        assert!(
1713            finished.load(Ordering::Acquire),
1714            "panic hook flush hung on a before_send that needed a panic-held lock"
1715        );
1716        // Not joined: on a regression the thread is stuck in the hook's flush and
1717        // a join would hang too; on success it has already finished.
1718    }
1719
1720    #[test]
1721    fn global_capture_panics_defaults_off_and_is_configurable() {
1722        assert!(
1723            !ErrorTrackingOptions::default().capture_panics(),
1724            "panic autocapture is opt-in (off by default)"
1725        );
1726        let enabled = ErrorTrackingOptionsBuilder::default()
1727            .capture_panics(true)
1728            .build()
1729            .unwrap();
1730        assert!(enabled.capture_panics(), "capture_panics is configurable");
1731    }
1732
1733    #[test]
1734    fn should_capture_global_panics_gates_on_enabled_and_flag() {
1735        let enabled = build_test_client(
1736            ClientOptionsBuilder::default()
1737                .api_key("test_api_key".to_string())
1738                .error_tracking(
1739                    ErrorTrackingOptionsBuilder::default()
1740                        .capture_panics(true)
1741                        .build()
1742                        .unwrap(),
1743                )
1744                .build()
1745                .unwrap(),
1746        );
1747        assert!(should_capture_global_panics(&enabled));
1748
1749        // Disabled gates it even with capture_panics on.
1750        let disabled = build_test_client(
1751            ClientOptionsBuilder::default()
1752                .api_key("test_api_key".to_string())
1753                .disabled(true)
1754                .error_tracking(
1755                    ErrorTrackingOptionsBuilder::default()
1756                        .capture_panics(true)
1757                        .build()
1758                        .unwrap(),
1759                )
1760                .build()
1761                .unwrap(),
1762        );
1763        assert!(
1764            !should_capture_global_panics(&disabled),
1765            "a disabled client must not latch the process-wide hook"
1766        );
1767
1768        // Default options leave capture_panics off, so nothing installs.
1769        let default_off = build_test_client(
1770            ClientOptionsBuilder::default()
1771                .api_key("test_api_key".to_string())
1772                .build()
1773                .unwrap(),
1774        );
1775        assert!(!should_capture_global_panics(&default_off));
1776    }
1777
1778    #[test]
1779    fn install_panic_hook_on_disabled_client_does_not_latch() {
1780        let _guard = panic_hook_test_lock()
1781            .lock()
1782            .unwrap_or_else(|e| e.into_inner());
1783        let original_hook = panic::take_hook();
1784        let mut reset = PanicHookReset::new(original_hook);
1785
1786        let disabled = build_test_client(
1787            ClientOptionsBuilder::default()
1788                .api_key("test_api_key".to_string())
1789                .disabled(true)
1790                .build()
1791                .unwrap(),
1792        );
1793        let result = install_panic_hook(disabled);
1794        let latched = PANIC_HOOK_INSTALLED.load(Ordering::Acquire);
1795
1796        // Restore before asserting so a regression (which would install) can't
1797        // leave a hook dangling for other tests.
1798        reset.restore();
1799        assert!(result.is_ok(), "installing on a disabled client returns Ok");
1800        assert!(
1801            !latched,
1802            "a disabled client must not latch the process-wide hook"
1803        );
1804    }
1805
1806    #[test]
1807    fn panic_machinery_frames_classify_out_of_app() {
1808        // Panic/unwind/SDK machinery is kept in the stacktrace now (not stripped
1809        // by name) and classified out of app by the default in-app rules, so the
1810        // UI can collapse it while the user's frames stay in-app.
1811        let options = ErrorTrackingOptions::default();
1812        for not_in_app in [
1813            "std::panicking::begin_panic_handler",
1814            "core::panicking::panic_fmt",
1815            "std::panic::catch_unwind",
1816            "std::sys::backtrace::__rust_begin_short_backtrace",
1817            "rust_begin_unwind",
1818            "__rust_try",
1819            "backtrace::backtrace::trace",
1820            "posthog_rs::error_tracking::capture_panic",
1821            "posthog_rs::error_tracking::install_hook::{{closure}}",
1822            "core::ops::function::FnOnce::call_once",
1823            "tokio::runtime::task::raw::poll",
1824            "futures_util::future::FutureExt::poll",
1825            "anyhow::error::Error::msg",
1826            "eyre::Report::msg",
1827            "color_eyre::config::EyreHook::into_eyre_hook::{{closure}}",
1828            "tracing::span::Span::record",
1829            "tracing_core::dispatcher::get_default",
1830            "log::__private_api::log",
1831        ] {
1832            assert!(
1833                !options.is_in_app_frame(None, Some(not_in_app)),
1834                "{} should classify as not in-app",
1835                not_in_app
1836            );
1837        }
1838
1839        for in_app in [
1840            "my_app::checkout::process_payment",
1841            "checkout_service::submit",
1842        ] {
1843            assert!(
1844                options.is_in_app_frame(None, Some(in_app)),
1845                "{} should classify as in-app",
1846                in_app
1847            );
1848        }
1849    }
1850
1851    #[test]
1852    fn function_names_strip_v0_crate_disambiguators() {
1853        // std ships v0-mangled on Linux; crate names demangle with `[hex]`.
1854        assert_eq!(
1855            normalize_function_name("std[b887e3750a86e3a0]::panicking::panic_with_hook"),
1856            "std::panicking::panic_with_hook"
1857        );
1858        assert_eq!(
1859            normalize_function_name(
1860                "<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"
1861            ),
1862            "<alloc::boxed::Box<dyn core::ops::function::Fn<(&std::panic::PanicHookInfo,)>> as core::ops::function::Fn<(&std::panic::PanicHookInfo,)>>::call"
1863        );
1864        // Array and slice type brackets are not disambiguators.
1865        assert_eq!(
1866            normalize_function_name("core::array::<impl [u8; 32]>::map"),
1867            "core::array::<impl [u8; 32]>::map"
1868        );
1869        assert_eq!(
1870            normalize_function_name("<[u8] as checkout_service::Digest>::digest"),
1871            "<[u8] as checkout_service::Digest>::digest"
1872        );
1873    }
1874
1875    #[test]
1876    fn from_error_builds_exception_list_with_stacktrace() {
1877        let error = OuterError { source: InnerError };
1878        let event = build_exception_event(
1879            &error,
1880            CaptureExceptionOptions::new().distinct_id("user-1"),
1881            &ErrorTrackingOptions::default(),
1882        )
1883        .unwrap();
1884        let json = built_event_json(event);
1885
1886        assert_eq!(json["event"], "$exception");
1887        assert_eq!(json["distinct_id"], "user-1");
1888        assert_eq!(json["properties"]["$exception_level"], "error");
1889
1890        let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
1891        assert!(exception_list[0]["type"]
1892            .as_str()
1893            .unwrap()
1894            .ends_with("OuterError"));
1895        assert_eq!(exception_list[0]["value"], "checkout failed");
1896        assert_eq!(exception_list[0]["mechanism"]["type"], "generic");
1897        assert_eq!(exception_list[0]["mechanism"]["handled"], true);
1898        assert_eq!(exception_list[0]["mechanism"]["synthetic"], false);
1899        assert_eq!(exception_list[0]["mechanism"]["exception_id"], 0);
1900        assert_eq!(exception_list[0]["stacktrace"]["type"], "raw");
1901        assert_eq!(exception_list[1]["value"], "database unavailable");
1902        assert_eq!(exception_list[1]["mechanism"]["type"], "chained");
1903        assert_eq!(exception_list[1]["mechanism"]["exception_id"], 1);
1904        assert_eq!(exception_list[1]["mechanism"]["parent_id"], 0);
1905
1906        let frames = exception_list[0]["stacktrace"]["frames"]
1907            .as_array()
1908            .expect("expected stack frames");
1909        let top_frame = frames.first().expect("expected top frame");
1910        assert_eq!(top_frame["platform"], "native");
1911        assert_eq!(top_frame["lang"], "rust");
1912        let instruction_addr = top_frame["instruction_addr"].as_str().unwrap_or_default();
1913        assert!(
1914            instruction_addr.starts_with("0x"),
1915            "expected hex instruction_addr, got {:?}",
1916            instruction_addr
1917        );
1918        let top_function = top_frame["function"].as_str().unwrap_or_default();
1919        assert!(
1920            top_function.contains("from_error_builds_exception_list_with_stacktrace"),
1921            "expected user frame first, got {:?}",
1922            top_function
1923        );
1924        assert!(
1925            !top_function.contains("Exception::"),
1926            "expected SDK frames to be skipped, got {:?}",
1927            top_function
1928        );
1929    }
1930
1931    #[test]
1932    fn gnu_build_ids_convert_to_debug_ids_like_the_server() {
1933        // Vector verified against symbolic's ElfObject::debug_id (which the
1934        // server and CLI use): the first 16 bytes as a little-endian GUID.
1935        let build_id: Vec<u8> = (0..20)
1936            .map(|i| {
1937                u8::from_str_radix(
1938                    &"555398ebd01c90285a3d85138a19cbf9bbcec352"[i * 2..i * 2 + 2],
1939                    16,
1940                )
1941                .unwrap()
1942            })
1943            .collect();
1944        // symbolic swaps the first three GUID fields on little-endian ELF and
1945        // leaves them as-is on big-endian; debug_id_from_gnu_build_id mirrors
1946        // that, so the expected ids differ by host endianness.
1947        let (full, short) = if cfg!(target_endian = "little") {
1948            (
1949                "eb985355-1cd0-2890-5a3d-85138a19cbf9",
1950                "0000cdab-0000-0000-0000-000000000000",
1951            )
1952        } else {
1953            (
1954                "555398eb-d01c-9028-5a3d-85138a19cbf9",
1955                "abcd0000-0000-0000-0000-000000000000",
1956            )
1957        };
1958        assert_eq!(debug_id_from_gnu_build_id(&build_id).as_deref(), Some(full));
1959
1960        // Short build ids are zero-padded to 16 bytes
1961        assert_eq!(
1962            debug_id_from_gnu_build_id(&[0xab, 0xcd]).as_deref(),
1963            Some(short)
1964        );
1965        assert_eq!(debug_id_from_gnu_build_id(&[]), None);
1966    }
1967
1968    #[test]
1969    fn arch_normalizes_to_the_shared_native_vocabulary() {
1970        // aarch64 is reported as arm64 to match the other native SDKs; every
1971        // other name passes through unchanged.
1972        assert_eq!(normalize_arch("aarch64"), "arm64");
1973        assert_eq!(normalize_arch("x86_64"), "x86_64");
1974        assert_eq!(normalize_arch("arm"), "arm");
1975    }
1976
1977    #[test]
1978    fn find_module_matches_address_ranges() {
1979        let module_at = |base: u64, size: u64| LoadedModule {
1980            base,
1981            end: base + size,
1982            image: DebugImage {
1983                image_type: "elf".to_string(),
1984                debug_id: "test".to_string(),
1985                code_id: None,
1986                image_addr: format!("0x{base:x}"),
1987                image_size: Some(size),
1988                image_vmaddr: None,
1989                code_file: None,
1990                arch: "x86_64".to_string(),
1991            },
1992        };
1993
1994        let modules = vec![module_at(0x1000, 0x1000), module_at(0x4000, 0x1000)];
1995
1996        assert_eq!(find_module(&modules, 0x1500).map(|m| m.base), Some(0x1000));
1997        assert_eq!(find_module(&modules, 0x4000).map(|m| m.base), Some(0x4000));
1998        assert!(find_module(&modules, 0x2000).is_none()); // gap between modules
1999        assert!(find_module(&modules, 0x500).is_none()); // before first module
2000        assert!(find_module(&modules, 0x5000).is_none()); // past the last module
2001    }
2002
2003    #[test]
2004    fn captured_stacks_reference_loaded_debug_images() {
2005        let json = event_json(Exception::from_message(
2006            "AddrCheck",
2007            "captures addresses",
2008            true,
2009        ));
2010
2011        let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2012            .as_array()
2013            .expect("expected stack frames");
2014
2015        // instruction_addr is set only for frames whose module has a debug id;
2016        // it's omitted otherwise (e.g. system libraries without a GNU build id,
2017        // common on Linux). Assert the format wherever present, and that at
2018        // least one frame carries it.
2019        let mut saw_instruction_addr = false;
2020        for frame in frames {
2021            let Some(addr) = frame["instruction_addr"].as_str() else {
2022                continue;
2023            };
2024            saw_instruction_addr = true;
2025            assert!(
2026                addr.starts_with("0x") && u64::from_str_radix(&addr[2..], 16).is_ok(),
2027                "expected hex instruction_addr, got {:?}",
2028                frame["instruction_addr"]
2029            );
2030        }
2031        assert!(
2032            saw_instruction_addr,
2033            "expected at least one frame to carry an instruction_addr"
2034        );
2035
2036        // The test binary itself is a loaded module with a debug id on the
2037        // platforms we capture modules on, so $debug_images must be present
2038        // and every entry must be referenced by at least one frame.
2039        let images = json["properties"]["$debug_images"]
2040            .as_array()
2041            .expect("expected $debug_images");
2042        assert!(!images.is_empty());
2043        let expected_type = super::native_image_type();
2044        let expected_arch = super::normalize_arch(std::env::consts::ARCH);
2045        for image in images {
2046            assert_eq!(image["type"].as_str(), Some(expected_type));
2047            assert_eq!(
2048                image["arch"].as_str(),
2049                Some(expected_arch.as_str()),
2050                "arch should match the running process"
2051            );
2052            let debug_id = image["debug_id"].as_str().unwrap_or_default();
2053            assert!(
2054                debug_id.len() >= 36,
2055                "expected uuid-shaped debug_id, got {:?}",
2056                debug_id
2057            );
2058            let image_addr = image["image_addr"].as_str().unwrap_or_default();
2059            assert!(
2060                frames
2061                    .iter()
2062                    .any(|f| f["image_addr"].as_str() == Some(image_addr)),
2063                "image {} not referenced by any frame",
2064                image_addr
2065            );
2066        }
2067    }
2068
2069    #[test]
2070    fn from_error_accepts_borrowed_error_types() {
2071        let message = String::from("borrowed parse failure");
2072        let error = BorrowedError(&message);
2073        let json = event_json(Exception::from_error(&error, true));
2074
2075        assert_eq!(
2076            json["properties"]["$exception_list"][0]["value"],
2077            "borrowed parse failure"
2078        );
2079    }
2080
2081    #[test]
2082    fn personless_capture_disables_person_profile() {
2083        let json = event_json(Exception::from_message("Error", "no user context", true));
2084
2085        assert_eq!(json["event"], "$exception");
2086        assert_eq!(json["properties"]["$process_person_profile"], false);
2087    }
2088
2089    #[test]
2090    fn custom_properties_cannot_override_reserved_exception_payload() {
2091        let error = OuterError { source: InnerError };
2092        let event = build_exception_event(
2093            &error,
2094            CaptureExceptionOptions::new()
2095                .property("$exception_list", json!([{"value": "fake"}]))
2096                .unwrap(),
2097            &ErrorTrackingOptions::default(),
2098        )
2099        .unwrap();
2100
2101        let json = built_event_json(event);
2102        assert_eq!(
2103            json["properties"]["$exception_list"][0]["value"],
2104            "checkout failed"
2105        );
2106    }
2107
2108    #[test]
2109    fn options_can_disable_stacktrace() {
2110        let options = ErrorTrackingOptionsBuilder::default()
2111            .capture_stacktrace(false)
2112            .build()
2113            .unwrap();
2114        let error = OuterError { source: InnerError };
2115        let event =
2116            build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2117        let json = built_event_json(event);
2118
2119        let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
2120        assert_eq!(exception_list.len(), 2);
2121        assert!(exception_list[0].get("stacktrace").is_none());
2122    }
2123
2124    #[test]
2125    fn in_app_path_defaults_and_overrides_are_applied() {
2126        let options = ErrorTrackingOptions::default();
2127        assert!(options.is_in_app_path("/app/src/main.rs"));
2128        assert!(!options.is_in_app_path("/home/user/.cargo/registry/src/lib.rs"));
2129        assert!(!options.is_in_app_path(
2130            "/private/tmp/nix-build-rustc-1.91.1/rustc-1.91.1-src/library/core/src/ops/function.rs"
2131        ));
2132        assert!(options.is_in_app_frame(None, Some("checkout_service::submit")));
2133        assert!(!options.is_in_app_frame(None, Some("std::rt::lang_start")));
2134        assert!(!options.is_in_app_frame(None, Some("core::ops::function::FnOnce::call_once")));
2135        assert!(
2136            !options.is_in_app_frame(None, Some("posthog_rs::client::Client::capture_exception"))
2137        );
2138        assert!(!options.is_in_app_frame(None, Some("_main")));
2139
2140        let options = ErrorTrackingOptionsBuilder::default()
2141            .in_app_include_paths(vec!["/service/".to_string(), "my_service::".to_string()])
2142            .in_app_exclude_paths(vec!["/service/vendor/".to_string()])
2143            .build()
2144            .unwrap();
2145
2146        assert!(options.is_in_app_path("/service/src/main.rs"));
2147        assert!(!options.is_in_app_path("/other/src/main.rs"));
2148        assert!(!options.is_in_app_path("/service/vendor/lib.rs"));
2149        assert!(options.is_in_app_frame(None, Some("my_service::checkout")));
2150        assert!(!options.is_in_app_frame(None, Some("other_service::checkout")));
2151    }
2152
2153    #[test]
2154    fn function_names_strip_rust_symbol_hashes() {
2155        assert_eq!(
2156            normalize_function_name("checkout_service::submit::h9ae4817223dd0b22"),
2157            "checkout_service::submit"
2158        );
2159        assert_eq!(
2160            normalize_function_name("std::rt::lang_start::{{closure}}::ha1fd5c62e470a8cc"),
2161            "std::rt::lang_start::{{closure}}"
2162        );
2163        assert_eq!(
2164            normalize_function_name("checkout_service::submit"),
2165            "checkout_service::submit"
2166        );
2167    }
2168
2169    #[test]
2170    fn type_names_keep_path_and_strip_generics() {
2171        // Full path is kept so idiomatic `Error`-named types stay distinguishable.
2172        assert_eq!(
2173            simple_type_name("std::io::error::Error"),
2174            "std::io::error::Error"
2175        );
2176        assert_eq!(
2177            simple_type_name("mycrate::CheckoutError"),
2178            "mycrate::CheckoutError"
2179        );
2180        assert_eq!(simple_type_name("mycrate::Error"), "mycrate::Error");
2181
2182        // Generic arguments and `&`/`dyn` markers are stripped.
2183        assert_eq!(simple_type_name("foo::Bar<baz::Qux>"), "foo::Bar");
2184        assert_eq!(
2185            simple_type_name(type_name::<Box<dyn StdError>>()),
2186            "alloc::boxed::Box"
2187        );
2188
2189        // A type-erased `dyn Error` carries no concrete type, so it degrades to "Error".
2190        assert_eq!(simple_type_name("dyn core::error::Error"), "Error");
2191        assert_eq!(simple_type_name(type_name::<&dyn StdError>()), "Error");
2192    }
2193
2194    #[test]
2195    fn frames_are_trimmed_to_max_frames_keeping_the_top() {
2196        let synthetic_frame = |index: usize| StackFrame {
2197            filename: None,
2198            line_no: None,
2199            function: format!("frame_{index}"),
2200            lang: "rust".to_string(),
2201            in_app: true,
2202            synthetic: false,
2203            platform: "native".to_string(),
2204            instruction_addr: None,
2205            symbol_addr: None,
2206            image_addr: None,
2207            client_resolved: false,
2208        };
2209        let exception = Exception {
2210            items: vec![ExceptionItem {
2211                exception_type: "Error".to_string(),
2212                value: "trimmed".to_string(),
2213                mechanism: ExceptionMechanism::default(),
2214                stacktrace: None,
2215            }],
2216            captured_frames: Some((0..MAX_FRAMES + 5).map(synthetic_frame).collect()),
2217            captured_images: Vec::new(),
2218            fingerprint: None,
2219            level: "error".to_string(),
2220        };
2221
2222        let json = event_json_with(exception, &ErrorTrackingOptions::default());
2223        let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2224            .as_array()
2225            .expect("expected stack frames");
2226        assert_eq!(frames.len(), MAX_FRAMES);
2227        // Trimming drops the outermost tail; the crash-site top frame survives.
2228        assert_eq!(frames[0]["function"], "frame_0");
2229    }
2230
2231    #[test]
2232    fn stacktrace_keeps_top_frame_first() {
2233        fn capture() -> ExceptionStacktrace {
2234            // Empty module slice: this test only asserts frame ordering and
2235            // names, which are independent of the loaded-module lookup.
2236            let mut frames = capture_frames_current_first(0, &[]);
2237            trim_to_max_frames(&mut frames, 8);
2238            ExceptionStacktrace::raw(frames)
2239        }
2240
2241        let frames = capture().frames;
2242        let functions: Vec<&str> = frames
2243            .iter()
2244            .map(|frame| frame.function.as_str())
2245            .filter(|function| !function.is_empty())
2246            .collect();
2247
2248        let capture_index = functions
2249            .iter()
2250            .position(|function| function.contains("stacktrace_keeps_top_frame_first::capture"))
2251            .expect("expected capture frame");
2252        let test_index = functions
2253            .iter()
2254            .position(|function| function.ends_with("stacktrace_keeps_top_frame_first"))
2255            .expect("expected test frame");
2256
2257        assert!(
2258            capture_index < test_index,
2259            "expected top frame before caller, got {:?}",
2260            functions
2261        );
2262    }
2263
2264    #[test]
2265    fn inlined_frames_collapse_for_server_side_expansion() {
2266        // inline(always) is honored in debug builds, so these helpers share
2267        // their caller's physical frame. When the server can symbolicate the
2268        // address we emit ONE frame for that physical frame and let the resolver
2269        // expand the inline chain from the symcache; emitting the inline layers
2270        // here as well would double them, since the resolver re-expands every
2271        // address-bearing native frame. Only a frame the server can't resolve
2272        // keeps the client-side inline expansion.
2273        #[inline(always)]
2274        fn inline_leaf() -> Vec<StackFrame> {
2275            capture_raw_application_frames().0
2276        }
2277
2278        #[inline(always)]
2279        fn inline_mid() -> Vec<StackFrame> {
2280            inline_leaf()
2281        }
2282
2283        let frames = inline_mid();
2284        let functions: Vec<&str> = frames.iter().map(|frame| frame.function.as_str()).collect();
2285
2286        // No two frames may carry the same instruction_addr: that duplicate is
2287        // exactly the double-expansion the resolver would inflict if we
2288        // pre-expanded inlines onto a shared address.
2289        let mut addrs: Vec<&str> = frames
2290            .iter()
2291            .filter_map(|frame| frame.instruction_addr.as_deref())
2292            .collect();
2293        let emitted = addrs.len();
2294        addrs.sort_unstable();
2295        addrs.dedup();
2296        assert_eq!(
2297            addrs.len(),
2298            emitted,
2299            "instruction_addr duplicated across frames: {:?}",
2300            frames
2301        );
2302
2303        // client_resolved is the inverse of carrying an address: an addressed
2304        // frame is left for the server to resolve (false); an address-less frame
2305        // was resolved client-side (true).
2306        assert!(
2307            frames
2308                .iter()
2309                .all(|f| f.client_resolved == f.instruction_addr.is_none()),
2310            "client_resolved must be the inverse of instruction_addr presence: {:?}",
2311            frames
2312        );
2313
2314        let leaf = functions
2315            .iter()
2316            .filter(|f| f.contains("inline_leaf"))
2317            .count();
2318        let mid = functions
2319            .iter()
2320            .filter(|f| f.contains("inline_mid"))
2321            .count();
2322
2323        if frames.iter().any(|frame| frame.instruction_addr.is_some()) {
2324            // Resolvable: the inlined layers collapse into their physical frame,
2325            // which carries the address for the server to expand.
2326            assert!(
2327                leaf == 0 && mid == 0,
2328                "expected inlined layers collapsed for server-side expansion, got {:?}",
2329                functions
2330            );
2331        } else {
2332            // Not resolvable: client-side expansion preserves the inline chain,
2333            // innermost first.
2334            let leaf_index = functions.iter().position(|f| f.contains("inline_leaf"));
2335            let mid_index = functions.iter().position(|f| f.contains("inline_mid"));
2336            assert!(
2337                matches!((leaf_index, mid_index), (Some(l), Some(m)) if l < m),
2338                "expected client-side inline expansion innermost first, got {:?}",
2339                functions
2340            );
2341        }
2342    }
2343
2344    #[test]
2345    fn build_exception_event_defaults_to_personless() {
2346        let error = OuterError { source: InnerError };
2347        let event = build_exception_event(
2348            &error,
2349            CaptureExceptionOptions::default(),
2350            &ErrorTrackingOptions::default(),
2351        )
2352        .unwrap();
2353        let json = built_event_json(event);
2354
2355        assert_eq!(json["event"], "$exception");
2356        assert_eq!(json["properties"]["$process_person_profile"], false);
2357        assert_eq!(json["properties"]["$exception_level"], "error");
2358    }
2359
2360    #[test]
2361    fn build_exception_event_applies_options() {
2362        let error = OuterError { source: InnerError };
2363        let options = CaptureExceptionOptions::new()
2364            .distinct_id("user-1")
2365            .property("route", "/checkout")
2366            .unwrap()
2367            .group("company", "acme")
2368            .fingerprint("checkout-error")
2369            .level("warning");
2370        let event =
2371            build_exception_event(&error, options, &ErrorTrackingOptions::default()).unwrap();
2372        let json = built_event_json(event);
2373
2374        assert_eq!(json["distinct_id"], "user-1");
2375        assert_eq!(json["properties"]["route"], "/checkout");
2376        assert_eq!(json["properties"]["$groups"]["company"], "acme");
2377        assert_eq!(
2378            json["properties"]["$exception_fingerprint"],
2379            "checkout-error"
2380        );
2381        assert_eq!(json["properties"]["$exception_level"], "warning");
2382    }
2383}