Skip to main content

posthog_rs/
error_tracking.rs

1use std::any::{type_name, type_name_of_val};
2use std::error::Error as StdError;
3
4use derive_builder::Builder;
5use serde::Serialize;
6use serde_json::Value;
7
8use crate::{Error, Event};
9
10/// Hard cap on stack frames per exception; frames beyond it are trimmed from
11/// the outermost end.
12const MAX_FRAMES: usize = 64;
13/// Hard cap on the `source()` chain walk, bounding pathological or cyclic
14/// error chains.
15const MAX_ERROR_SOURCES: usize = 50;
16
17/// Client-level Error Tracking configuration, applied to every exception the
18/// client captures. Set it via [`ErrorTrackingOptionsBuilder`] on
19/// `ClientOptions::error_tracking`.
20///
21/// # Examples
22///
23/// ```
24/// use posthog_rs::ErrorTrackingOptionsBuilder;
25///
26/// let options = ErrorTrackingOptionsBuilder::default()
27///     .capture_stacktrace(true)
28///     // Substring patterns match file paths and function symbols, so a
29///     // crate prefix marks that crate's frames as not in-app.
30///     .in_app_exclude_paths(vec!["other_crate::".to_string()])
31///     .build()
32///     .unwrap();
33/// ```
34#[derive(Builder, Clone, Debug)]
35#[builder(default)]
36pub struct ErrorTrackingOptions {
37    /// Capture a stack trace at the `capture_exception` call site and attach
38    /// it to the first entry of `$exception_list` (default: `true`).
39    ///
40    /// The trace shows where the error was *captured*, not where it was
41    /// created — a bubbled-up `Err` value carries no stack of its own. The
42    /// error type/message chain in `$exception_list` is always sent regardless
43    /// of this setting. Disabling it skips the stack walk and per-frame symbol
44    /// resolution entirely, which can matter when capturing handled errors in
45    /// high-volume paths.
46    capture_stacktrace: bool,
47    /// Treat only frames matching one of these patterns as in-app. Patterns
48    /// are substring matches against a frame's file path *and* function
49    /// symbol, so both path fragments (`"/service/"`) and crate prefixes
50    /// (`"my_service::"`) work. When empty, built-in defaults apply: frames
51    /// from the cargo registry, the standard library, and vendored/target
52    /// paths are library frames, everything else is in-app.
53    in_app_include_paths: Vec<String>,
54    /// Always mark matching frames as not in-app, taking precedence over
55    /// includes and defaults. Same matching rules as `in_app_include_paths`
56    /// — e.g. `"other_crate::"` excludes every frame of that crate.
57    in_app_exclude_paths: Vec<String>,
58}
59
60impl Default for ErrorTrackingOptions {
61    fn default() -> Self {
62        Self {
63            capture_stacktrace: true,
64            in_app_include_paths: Vec::new(),
65            in_app_exclude_paths: Vec::new(),
66        }
67    }
68}
69
70impl ErrorTrackingOptions {
71    fn capture_stacktrace(&self) -> bool {
72        self.capture_stacktrace
73    }
74
75    fn is_in_app_path(&self, filename: &str) -> bool {
76        if self
77            .in_app_exclude_paths
78            .iter()
79            .any(|path| filename.contains(path))
80        {
81            return false;
82        }
83
84        if !self.in_app_include_paths.is_empty() {
85            return self
86                .in_app_include_paths
87                .iter()
88                .any(|path| filename.contains(path));
89        }
90
91        default_in_app_path(filename)
92    }
93
94    fn is_in_app_frame(&self, filename: Option<&str>, function: Option<&str>) -> bool {
95        if self.in_app_exclude_paths.iter().any(|path| {
96            filename.is_some_and(|filename| filename.contains(path))
97                || function.is_some_and(|function| function.contains(path))
98        }) {
99            return false;
100        }
101
102        if !self.in_app_include_paths.is_empty() {
103            return self.in_app_include_paths.iter().any(|path| {
104                filename.is_some_and(|filename| filename.contains(path))
105                    || function.is_some_and(|function| function.contains(path))
106            });
107        }
108
109        if filename.is_some_and(|filename| !self.is_in_app_path(filename)) {
110            return false;
111        }
112
113        if let Some(function) = function {
114            return default_in_app_function(function);
115        }
116
117        filename.is_some()
118    }
119}
120
121/// Optional context for `capture_exception_with`: person identity, custom
122/// properties, groups, and exception fingerprint/level.
123///
124/// All fields are optional. An empty options set (`new()` / `Default`)
125/// captures the exception personlessly with no extra context.
126///
127/// # Examples
128///
129/// ```
130/// use posthog_rs::CaptureExceptionOptions;
131///
132/// let options = CaptureExceptionOptions::new()
133///     .distinct_id("user-123")
134///     .property("route", "/checkout")?
135///     .group("company", "acme")
136///     .fingerprint("checkout-error")
137///     .level("warning");
138/// # Ok::<(), posthog_rs::Error>(())
139/// ```
140#[derive(Clone, Debug, Default)]
141pub struct CaptureExceptionOptions {
142    distinct_id: Option<String>,
143    properties: Vec<(String, Value)>,
144    groups: Vec<(String, String)>,
145    fingerprint: Option<String>,
146    level: Option<String>,
147}
148
149impl CaptureExceptionOptions {
150    /// Create an empty options set: personless, no extra context.
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    /// Associate the exception with a person.
156    pub fn distinct_id<S: Into<String>>(mut self, distinct_id: S) -> Self {
157        self.distinct_id = Some(distinct_id.into());
158        self
159    }
160
161    /// Add a custom property to the exception event.
162    pub fn property<K: Into<String>, V: Serialize>(
163        mut self,
164        key: K,
165        value: V,
166    ) -> Result<Self, Error> {
167        let value = serde_json::to_value(value).map_err(|e| Error::Serialization(e.to_string()))?;
168        self.properties.push((key.into(), value));
169        Ok(self)
170    }
171
172    /// Capture the exception as a group event.
173    pub fn group<N: Into<String>, I: Into<String>>(mut self, group_name: N, group_id: I) -> Self {
174        self.groups.push((group_name.into(), group_id.into()));
175        self
176    }
177
178    /// Set a custom exception fingerprint.
179    pub fn fingerprint<S: Into<String>>(mut self, fingerprint: S) -> Self {
180        self.fingerprint = Some(fingerprint.into());
181        self
182    }
183
184    /// Set the exception severity level. Defaults to `"error"`.
185    pub fn level<S: Into<String>>(mut self, level: S) -> Self {
186        self.level = Some(level.into());
187        self
188    }
189}
190
191/// Build a finalized `$exception` [`Event`] from a Rust error, capture
192/// options, and the capturing client's Error Tracking configuration.
193///
194/// All client policy is applied here, eagerly: the stack walk only runs when
195/// `capture_stacktrace` is enabled, and in-app classification, frame and
196/// source-chain limits, and the reserved `$exception_*` properties are written
197/// before the event is returned. The returned event is an ordinary [`Event`].
198pub(crate) fn build_exception_event<E>(
199    error: &E,
200    options: CaptureExceptionOptions,
201    et_options: &ErrorTrackingOptions,
202) -> Result<Event, Error>
203where
204    E: StdError + ?Sized,
205{
206    let CaptureExceptionOptions {
207        distinct_id,
208        properties,
209        groups,
210        fingerprint,
211        level,
212    } = options;
213
214    let mut exception = Exception::from_error(error, et_options.capture_stacktrace());
215    if let Some(fingerprint) = fingerprint {
216        exception.set_fingerprint(fingerprint);
217    }
218    if let Some(level) = level {
219        exception.set_level(level);
220    }
221
222    let mut event = match distinct_id {
223        Some(distinct_id) => Event::new("$exception".to_string(), distinct_id),
224        None => Event::new_anon("$exception"),
225    };
226    for (key, value) in properties {
227        event.insert_prop(key, value)?;
228    }
229    for (group_name, group_id) in groups {
230        event.add_group(&group_name, &group_id);
231    }
232
233    // Reserved $exception_* properties are written after user-set properties
234    // so they can't be overridden.
235    exception.write_into(&mut event, et_options)?;
236    Ok(event)
237}
238
239/// A PostHog Error Tracking exception payload.
240///
241/// Internal staging type: every construction site lives in this module and is
242/// reached through a client method that holds the client's
243/// [`ErrorTrackingOptions`], so client policy is applied eagerly when the
244/// `$exception` event is built ([`build_exception_event`]). Constructors take
245/// only a `capture_stacktrace` cost hint — the stack walk must happen at the
246/// capture site or not at all, and disabling it skips the walk entirely.
247#[derive(Clone, Debug, PartialEq, Eq)]
248pub(crate) struct Exception {
249    items: Vec<ExceptionItem>,
250    // SDK-captured raw frames pending client policy (in-app classification
251    // and trimming), applied in write_into and attached to items[0]. None when
252    // stacktrace capture is disabled.
253    captured_frames: Option<Vec<StackFrame>>,
254    fingerprint: Option<String>,
255    level: String,
256}
257
258impl Exception {
259    /// Build an exception from a Rust error, walking the `source()` chain and
260    /// capturing the current stacktrace when `capture_stacktrace` is set.
261    pub(crate) fn from_error<E>(error: &E, capture_stacktrace: bool) -> Self
262    where
263        E: StdError + ?Sized,
264    {
265        let mut items = vec![ExceptionItem {
266            exception_type: simple_type_name(type_name::<E>()),
267            value: error_value(error),
268            mechanism: ExceptionMechanism::default(),
269            stacktrace: None,
270        }];
271
272        let mut source = error.source();
273        while let Some(err) = source {
274            if items.len() >= MAX_ERROR_SOURCES {
275                break;
276            }
277            items.push(ExceptionItem {
278                exception_type: source_type_name(err),
279                value: error_value(err),
280                mechanism: ExceptionMechanism::default(),
281                stacktrace: None,
282            });
283            source = err.source();
284        }
285
286        link_exception_chain(&mut items);
287
288        Self {
289            items,
290            captured_frames: if capture_stacktrace {
291                Some(capture_raw_application_frames())
292            } else {
293                None
294            },
295            fingerprint: None,
296            level: "error".to_string(),
297        }
298    }
299
300    /// Build an exception from an arbitrary type/message pair, capturing the
301    /// current stacktrace when `capture_stacktrace` is set.
302    // Only exercised by tests today; kept as the message-capture seam.
303    #[allow(dead_code)]
304    pub(crate) fn from_message<T: Into<String>, V: Into<String>>(
305        exception_type: T,
306        value: V,
307        capture_stacktrace: bool,
308    ) -> Self {
309        Self {
310            items: vec![ExceptionItem {
311                exception_type: exception_type.into(),
312                value: value.into(),
313                mechanism: ExceptionMechanism::default(),
314                stacktrace: None,
315            }],
316            captured_frames: if capture_stacktrace {
317                Some(capture_raw_application_frames())
318            } else {
319                None
320            },
321            fingerprint: None,
322            level: "error".to_string(),
323        }
324    }
325
326    /// Build an exception from normalized exception items.
327    ///
328    /// Caller-provided stacktraces are sent as-is: no stacktrace is captured
329    /// and client-side frame classification is not applied.
330    // Unused until panic autocapture (stacked PR) builds its payloads here.
331    #[allow(dead_code)]
332    pub(crate) fn from_exception_list(items: Vec<ExceptionItem>) -> Self {
333        Self {
334            items,
335            captured_frames: None,
336            fingerprint: None,
337            level: "error".to_string(),
338        }
339    }
340
341    /// Set a custom exception fingerprint.
342    pub(crate) fn set_fingerprint<S: Into<String>>(&mut self, fingerprint: S) {
343        self.fingerprint = Some(fingerprint.into());
344    }
345
346    /// Set the exception severity level. Defaults to `"error"`.
347    pub(crate) fn set_level<S: Into<String>>(&mut self, level: S) {
348        self.level = level.into();
349    }
350
351    /// Apply client-level Error Tracking options (in-app classification, frame
352    /// and source-chain limits) and write the reserved `$exception_*`
353    /// properties onto `event`.
354    fn write_into(self, event: &mut Event, options: &ErrorTrackingOptions) -> Result<(), Error> {
355        let Exception {
356            mut items,
357            captured_frames,
358            fingerprint,
359            level,
360        } = self;
361        if items.is_empty() {
362            return Ok(());
363        }
364
365        if let Some(mut frames) = captured_frames {
366            for frame in frames.iter_mut() {
367                let function = (!frame.function.is_empty()).then_some(frame.function.as_str());
368                frame.in_app = options.is_in_app_frame(frame.filename.as_deref(), function);
369            }
370            trim_to_max_frames(&mut frames, MAX_FRAMES);
371            items[0].stacktrace = Some(ExceptionStacktrace::raw(frames));
372        }
373
374        event.insert_prop("$exception_level", level)?;
375        if let Some(fingerprint) = fingerprint {
376            event.insert_prop("$exception_fingerprint", fingerprint)?;
377        }
378        event.insert_prop("$exception_list", items)?;
379        Ok(())
380    }
381}
382
383/// A normalized exception entry in `$exception_list`.
384#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
385pub(crate) struct ExceptionItem {
386    #[serde(rename = "type")]
387    pub exception_type: String,
388    pub value: String,
389    pub mechanism: ExceptionMechanism,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub stacktrace: Option<ExceptionStacktrace>,
392}
393
394/// How an exception was captured.
395#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
396pub(crate) struct ExceptionMechanism {
397    #[serde(rename = "type")]
398    pub mechanism_type: String,
399    pub handled: bool,
400    pub synthetic: bool,
401    /// Position in the cause chain, `0` being the outermost error. Only set when
402    /// the exception is part of a multi-error chain.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub exception_id: Option<usize>,
405    /// `exception_id` of the error this one was a source of.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub parent_id: Option<usize>,
408}
409
410impl Default for ExceptionMechanism {
411    fn default() -> Self {
412        Self {
413            mechanism_type: "generic".to_string(),
414            handled: true,
415            synthetic: false,
416            exception_id: None,
417            parent_id: None,
418        }
419    }
420}
421
422/// A normalized stacktrace.
423#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
424pub(crate) struct ExceptionStacktrace {
425    #[serde(rename = "type")]
426    pub stacktrace_type: String,
427    pub frames: Vec<StackFrame>,
428}
429
430impl ExceptionStacktrace {
431    fn raw(frames: Vec<StackFrame>) -> Self {
432        Self {
433            stacktrace_type: "raw".to_string(),
434            frames,
435        }
436    }
437}
438
439/// A normalized stack frame.
440#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
441pub(crate) struct StackFrame {
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub filename: Option<String>,
444    #[serde(rename = "lineno")]
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub line_no: Option<u32>,
447    pub function: String,
448    pub lang: String,
449    pub in_app: bool,
450    pub synthetic: bool,
451    pub resolved: bool,
452    pub platform: String,
453}
454
455// Captures raw Rust stack traces for Error Tracking. Frames are unclassified
456// at this point: in-app classification and trimming are client policy, applied
457// when the exception event is built.
458fn capture_frames_current_first(skip: usize) -> Vec<StackFrame> {
459    let mut frames = Vec::new();
460    let mut skipped = 0usize;
461
462    backtrace::trace(|frame| {
463        if skipped < skip {
464            skipped += 1;
465            return true;
466        }
467
468        // One physical frame resolves to multiple symbols when the compiler
469        // inlined functions into it; emit each inlined layer as its own frame
470        // so the logical call chain survives. The resolver yields layers
471        // innermost-first, which matches this stack's current-first order.
472        backtrace::resolve_frame(frame, |symbol| {
473            let filename = symbol.filename().map(path_to_string);
474            let function = symbol
475                .name()
476                .map(|name| normalize_function_name(&name.to_string()));
477
478            if filename.is_none() && function.is_none() {
479                return;
480            }
481
482            frames.push(StackFrame {
483                filename,
484                line_no: symbol.lineno(),
485                function: function.unwrap_or_default(),
486                lang: "rust".to_string(),
487                in_app: false,
488                synthetic: false,
489                resolved: true,
490                platform: "rust".to_string(),
491            });
492        });
493
494        true
495    });
496
497    frames
498}
499
500fn trim_to_max_frames(frames: &mut Vec<StackFrame>, max_frames: usize) {
501    if frames.len() > max_frames {
502        frames.truncate(max_frames);
503    }
504}
505
506/// Capture the current raw stacktrace, dropping the SDK's own capture frames
507/// so the caller's frame comes first.
508fn capture_raw_application_frames() -> Vec<StackFrame> {
509    let mut frames = capture_frames_current_first(0);
510    while frames
511        .first()
512        .map(|frame| is_internal_capture_frame(&frame.function))
513        .unwrap_or(false)
514    {
515        frames.remove(0);
516    }
517
518    frames
519}
520
521fn is_internal_capture_frame(function: &str) -> bool {
522    function.starts_with("backtrace::")
523        || function.contains("capture_frames_current_first")
524        || function.contains("capture_raw_application_frames")
525        || function.contains("Exception::from_error")
526        || function.contains("Exception::from_message")
527        || function.contains("build_exception_event")
528        || function.contains("Client::capture_exception")
529        || function.contains("global::capture_exception")
530}
531
532fn path_to_string(path: &std::path::Path) -> String {
533    path.to_string_lossy().into_owned()
534}
535
536/// Best-effort, human-readable exception type from a Rust type name.
537///
538/// Keeps the full module path (minus generic arguments and `&`/`dyn` markers) so
539/// types whose leaf name is the idiomatic `Error` — `std::io::Error`,
540/// `serde_json::Error`, `mycrate::Error` — stay distinguishable rather than all
541/// collapsing to a single `"Error"`.
542fn simple_type_name(type_name: &str) -> String {
543    let trimmed = type_name.trim().trim_start_matches('&').trim();
544    let trimmed = trimmed.strip_prefix("dyn ").unwrap_or(trimmed).trim();
545    let trimmed = trimmed
546        .split_once('<')
547        .map_or(trimmed, |(outer_type, _)| outer_type)
548        .trim_end();
549    // A type-erased `dyn Error` only reports the trait itself, which carries no
550    // concrete type information, so collapse it to a bare "Error".
551    if trimmed.is_empty() || trimmed == "core::error::Error" || trimmed == "std::error::Error" {
552        return "Error".to_string();
553    }
554    trimmed.to_string()
555}
556
557/// Type name for a chained source.
558///
559/// Sources are exposed as `&dyn Error`, which is type-erased: `type_name_of_val`
560/// can only report the trait, not the original type. Chained sources therefore
561/// carry the value/message but report a generic `"Error"` type — the concrete
562/// type of a `dyn Error` cannot be recovered on stable Rust.
563fn source_type_name(error: &(dyn StdError + 'static)) -> String {
564    simple_type_name(type_name_of_val(error))
565}
566
567/// Link a multi-error chain so each source points at the error it came from,
568/// mirroring the `$exception_list` chaining other PostHog SDKs emit. Single
569/// exceptions are left unlinked.
570fn link_exception_chain(exception_list: &mut [ExceptionItem]) {
571    if exception_list.len() < 2 {
572        return;
573    }
574    for (index, item) in exception_list.iter_mut().enumerate() {
575        item.mechanism.exception_id = Some(index);
576        if index > 0 {
577            item.mechanism.parent_id = Some(index - 1);
578            item.mechanism.mechanism_type = "chained".to_string();
579        }
580    }
581}
582
583fn error_value<E>(error: &E) -> String
584where
585    E: StdError + ?Sized,
586{
587    let value = error.to_string();
588    if value.is_empty() {
589        "Error".to_string()
590    } else {
591        value
592    }
593}
594
595fn normalize_function_name(function: &str) -> String {
596    match function.rsplit_once("::") {
597        Some((prefix, suffix)) if is_rust_symbol_hash(suffix) => prefix.to_string(),
598        _ => function.to_string(),
599    }
600}
601
602fn is_rust_symbol_hash(segment: &str) -> bool {
603    segment.len() >= 9
604        && segment.starts_with('h')
605        && segment[1..].chars().all(|ch| ch.is_ascii_hexdigit())
606}
607
608fn default_in_app_path(filename: &str) -> bool {
609    let normalized = filename.replace('\\', "/");
610    if normalized.contains("/.cargo/registry/")
611        || normalized.contains("/.cargo/git/")
612        || normalized.contains("/rustc/")
613        || normalized.contains("/rustc-")
614        || normalized.contains("/library/alloc/src/")
615        || normalized.contains("/library/core/src/")
616        || normalized.contains("/library/proc_macro/src/")
617        || normalized.contains("/library/std/src/")
618        || normalized.contains("/library/test/src/")
619        || normalized.contains("/toolchains/")
620        || normalized.contains("/target/")
621        || normalized.contains("/vendor/")
622    {
623        return false;
624    }
625
626    true
627}
628
629fn default_in_app_function(function: &str) -> bool {
630    if function.is_empty() || function == "_main" {
631        return false;
632    }
633
634    !matches!(
635        function
636            .trim_start_matches('<')
637            .split("::")
638            .next()
639            .unwrap_or_default(),
640        "alloc" | "backtrace" | "core" | "posthog_rs" | "reqwest" | "std" | "tokio"
641    )
642}
643
644#[cfg(test)]
645mod tests {
646    use std::error::Error as StdError;
647    use std::fmt;
648
649    use serde_json::{json, Value};
650
651    use super::*;
652    use crate::event::InnerEvent;
653
654    #[derive(Debug)]
655    struct OuterError {
656        source: InnerError,
657    }
658
659    impl fmt::Display for OuterError {
660        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
661            write!(f, "checkout failed")
662        }
663    }
664
665    impl StdError for OuterError {
666        fn source(&self) -> Option<&(dyn StdError + 'static)> {
667            Some(&self.source)
668        }
669    }
670
671    #[derive(Debug)]
672    struct InnerError;
673
674    impl fmt::Display for InnerError {
675        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
676            write!(f, "database unavailable")
677        }
678    }
679
680    impl StdError for InnerError {}
681
682    #[derive(Debug)]
683    struct BorrowedError<'a>(&'a str);
684
685    impl fmt::Display for BorrowedError<'_> {
686        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
687            f.write_str(self.0)
688        }
689    }
690
691    impl StdError for BorrowedError<'_> {}
692
693    fn built_event_json(mut event: Event) -> Value {
694        event.prepare_for_v0();
695        serde_json::to_value(InnerEvent::new(event, "api-key".to_string())).unwrap()
696    }
697
698    fn event_json_with(exception: Exception, options: &ErrorTrackingOptions) -> Value {
699        let mut event = Event::new_anon("$exception");
700        exception.write_into(&mut event, options).unwrap();
701        built_event_json(event)
702    }
703
704    fn event_json(exception: Exception) -> Value {
705        event_json_with(exception, &ErrorTrackingOptions::default())
706    }
707
708    #[test]
709    fn from_error_builds_exception_list_with_stacktrace() {
710        let error = OuterError { source: InnerError };
711        let event = build_exception_event(
712            &error,
713            CaptureExceptionOptions::new().distinct_id("user-1"),
714            &ErrorTrackingOptions::default(),
715        )
716        .unwrap();
717        let json = built_event_json(event);
718
719        assert_eq!(json["event"], "$exception");
720        assert_eq!(json["distinct_id"], "user-1");
721        assert_eq!(json["properties"]["$exception_level"], "error");
722
723        let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
724        assert!(exception_list[0]["type"]
725            .as_str()
726            .unwrap()
727            .ends_with("OuterError"));
728        assert_eq!(exception_list[0]["value"], "checkout failed");
729        assert_eq!(exception_list[0]["mechanism"]["type"], "generic");
730        assert_eq!(exception_list[0]["mechanism"]["handled"], true);
731        assert_eq!(exception_list[0]["mechanism"]["synthetic"], false);
732        assert_eq!(exception_list[0]["mechanism"]["exception_id"], 0);
733        assert_eq!(exception_list[0]["stacktrace"]["type"], "raw");
734        assert_eq!(exception_list[1]["value"], "database unavailable");
735        assert_eq!(exception_list[1]["mechanism"]["type"], "chained");
736        assert_eq!(exception_list[1]["mechanism"]["exception_id"], 1);
737        assert_eq!(exception_list[1]["mechanism"]["parent_id"], 0);
738
739        let frames = exception_list[0]["stacktrace"]["frames"]
740            .as_array()
741            .expect("expected stack frames");
742        let top_frame = frames.first().expect("expected top frame");
743        assert_eq!(top_frame["platform"], "rust");
744        assert_eq!(top_frame["lang"], "rust");
745        assert_eq!(top_frame["resolved"], true);
746        let top_function = top_frame["function"].as_str().unwrap_or_default();
747        assert!(
748            top_function.contains("from_error_builds_exception_list_with_stacktrace"),
749            "expected user frame last, got {:?}",
750            top_function
751        );
752        assert!(
753            !top_function.contains("Exception::"),
754            "expected SDK frames to be skipped, got {:?}",
755            top_function
756        );
757    }
758
759    #[test]
760    fn from_error_accepts_borrowed_error_types() {
761        let message = String::from("borrowed parse failure");
762        let error = BorrowedError(&message);
763        let json = event_json(Exception::from_error(&error, true));
764
765        assert_eq!(
766            json["properties"]["$exception_list"][0]["value"],
767            "borrowed parse failure"
768        );
769    }
770
771    #[test]
772    fn personless_capture_disables_person_profile() {
773        let json = event_json(Exception::from_message("Error", "no user context", true));
774
775        assert_eq!(json["event"], "$exception");
776        assert_eq!(json["properties"]["$process_person_profile"], false);
777    }
778
779    #[test]
780    fn custom_properties_cannot_override_reserved_exception_payload() {
781        let error = OuterError { source: InnerError };
782        let event = build_exception_event(
783            &error,
784            CaptureExceptionOptions::new()
785                .property("$exception_list", json!([{"value": "fake"}]))
786                .unwrap(),
787            &ErrorTrackingOptions::default(),
788        )
789        .unwrap();
790
791        let json = built_event_json(event);
792        assert_eq!(
793            json["properties"]["$exception_list"][0]["value"],
794            "checkout failed"
795        );
796    }
797
798    #[test]
799    fn options_can_disable_stacktrace() {
800        let options = ErrorTrackingOptionsBuilder::default()
801            .capture_stacktrace(false)
802            .build()
803            .unwrap();
804        let error = OuterError { source: InnerError };
805        let event =
806            build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
807        let json = built_event_json(event);
808
809        let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
810        assert_eq!(exception_list.len(), 2);
811        assert!(exception_list[0].get("stacktrace").is_none());
812    }
813
814    #[test]
815    fn in_app_path_defaults_and_overrides_are_applied() {
816        let options = ErrorTrackingOptions::default();
817        assert!(options.is_in_app_path("/app/src/main.rs"));
818        assert!(!options.is_in_app_path("/home/user/.cargo/registry/src/lib.rs"));
819        assert!(!options.is_in_app_path(
820            "/private/tmp/nix-build-rustc-1.91.1/rustc-1.91.1-src/library/core/src/ops/function.rs"
821        ));
822        assert!(options.is_in_app_frame(None, Some("checkout_service::submit")));
823        assert!(!options.is_in_app_frame(None, Some("std::rt::lang_start")));
824        assert!(!options.is_in_app_frame(None, Some("core::ops::function::FnOnce::call_once")));
825        assert!(
826            !options.is_in_app_frame(None, Some("posthog_rs::client::Client::capture_exception"))
827        );
828        assert!(!options.is_in_app_frame(None, Some("_main")));
829
830        let options = ErrorTrackingOptionsBuilder::default()
831            .in_app_include_paths(vec!["/service/".to_string(), "my_service::".to_string()])
832            .in_app_exclude_paths(vec!["/service/vendor/".to_string()])
833            .build()
834            .unwrap();
835
836        assert!(options.is_in_app_path("/service/src/main.rs"));
837        assert!(!options.is_in_app_path("/other/src/main.rs"));
838        assert!(!options.is_in_app_path("/service/vendor/lib.rs"));
839        assert!(options.is_in_app_frame(None, Some("my_service::checkout")));
840        assert!(!options.is_in_app_frame(None, Some("other_service::checkout")));
841    }
842
843    #[test]
844    fn function_names_strip_rust_symbol_hashes() {
845        assert_eq!(
846            normalize_function_name("checkout_service::submit::h9ae4817223dd0b22"),
847            "checkout_service::submit"
848        );
849        assert_eq!(
850            normalize_function_name("std::rt::lang_start::{{closure}}::ha1fd5c62e470a8cc"),
851            "std::rt::lang_start::{{closure}}"
852        );
853        assert_eq!(
854            normalize_function_name("checkout_service::submit"),
855            "checkout_service::submit"
856        );
857    }
858
859    #[test]
860    fn type_names_keep_path_and_strip_generics() {
861        // Full path is kept so idiomatic `Error`-named types stay distinguishable.
862        assert_eq!(
863            simple_type_name("std::io::error::Error"),
864            "std::io::error::Error"
865        );
866        assert_eq!(
867            simple_type_name("mycrate::CheckoutError"),
868            "mycrate::CheckoutError"
869        );
870        assert_eq!(simple_type_name("mycrate::Error"), "mycrate::Error");
871
872        // Generic arguments and `&`/`dyn` markers are stripped.
873        assert_eq!(simple_type_name("foo::Bar<baz::Qux>"), "foo::Bar");
874        assert_eq!(
875            simple_type_name(type_name::<Box<dyn StdError>>()),
876            "alloc::boxed::Box"
877        );
878
879        // A type-erased `dyn Error` carries no concrete type, so it degrades to "Error".
880        assert_eq!(simple_type_name("dyn core::error::Error"), "Error");
881        assert_eq!(simple_type_name(type_name::<&dyn StdError>()), "Error");
882    }
883
884    #[test]
885    fn frames_are_trimmed_to_max_frames_keeping_the_top() {
886        let synthetic_frame = |index: usize| StackFrame {
887            filename: None,
888            line_no: None,
889            function: format!("frame_{index}"),
890            lang: "rust".to_string(),
891            in_app: true,
892            synthetic: false,
893            resolved: true,
894            platform: "rust".to_string(),
895        };
896        let exception = Exception {
897            items: vec![ExceptionItem {
898                exception_type: "Error".to_string(),
899                value: "trimmed".to_string(),
900                mechanism: ExceptionMechanism::default(),
901                stacktrace: None,
902            }],
903            captured_frames: Some((0..MAX_FRAMES + 5).map(synthetic_frame).collect()),
904            fingerprint: None,
905            level: "error".to_string(),
906        };
907
908        let json = event_json_with(exception, &ErrorTrackingOptions::default());
909        let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
910            .as_array()
911            .expect("expected stack frames");
912        assert_eq!(frames.len(), MAX_FRAMES);
913        // Trimming drops the outermost tail; the crash-site top frame survives.
914        assert_eq!(frames[0]["function"], "frame_0");
915    }
916
917    #[test]
918    fn stacktrace_keeps_top_frame_first() {
919        fn capture() -> ExceptionStacktrace {
920            let mut frames = capture_frames_current_first(0);
921            trim_to_max_frames(&mut frames, 8);
922            ExceptionStacktrace::raw(frames)
923        }
924
925        let frames = capture().frames;
926        let functions: Vec<&str> = frames
927            .iter()
928            .map(|frame| frame.function.as_str())
929            .filter(|function| !function.is_empty())
930            .collect();
931
932        let capture_index = functions
933            .iter()
934            .position(|function| function.contains("stacktrace_keeps_top_frame_first::capture"))
935            .expect("expected capture frame");
936        let test_index = functions
937            .iter()
938            .position(|function| function.ends_with("stacktrace_keeps_top_frame_first"))
939            .expect("expected test frame");
940
941        assert!(
942            capture_index < test_index,
943            "expected top frame before caller, got {:?}",
944            functions
945        );
946    }
947
948    #[test]
949    fn inlined_functions_become_separate_frames() {
950        // inline(always) is honored in debug builds, so the two helpers share
951        // the test function's physical frame and must surface as their own
952        // logical frames, innermost first.
953        #[inline(always)]
954        fn inline_leaf() -> Vec<StackFrame> {
955            capture_raw_application_frames()
956        }
957
958        #[inline(always)]
959        fn inline_mid() -> Vec<StackFrame> {
960            inline_leaf()
961        }
962
963        let frames = inline_mid();
964        let functions: Vec<&str> = frames.iter().map(|frame| frame.function.as_str()).collect();
965
966        let leaf_index = functions
967            .iter()
968            .position(|function| function.contains("inline_leaf"))
969            .unwrap_or_else(|| panic!("expected inline_leaf frame, got {:?}", functions));
970        let mid_index = functions
971            .iter()
972            .position(|function| function.contains("inline_mid"))
973            .unwrap_or_else(|| panic!("expected inline_mid frame, got {:?}", functions));
974        assert!(
975            leaf_index < mid_index,
976            "expected innermost inlined layer first, got {:?}",
977            functions
978        );
979    }
980
981    #[test]
982    fn build_exception_event_defaults_to_personless() {
983        let error = OuterError { source: InnerError };
984        let event = build_exception_event(
985            &error,
986            CaptureExceptionOptions::default(),
987            &ErrorTrackingOptions::default(),
988        )
989        .unwrap();
990        let json = built_event_json(event);
991
992        assert_eq!(json["event"], "$exception");
993        assert_eq!(json["properties"]["$process_person_profile"], false);
994        assert_eq!(json["properties"]["$exception_level"], "error");
995    }
996
997    #[test]
998    fn build_exception_event_applies_options() {
999        let error = OuterError { source: InnerError };
1000        let options = CaptureExceptionOptions::new()
1001            .distinct_id("user-1")
1002            .property("route", "/checkout")
1003            .unwrap()
1004            .group("company", "acme")
1005            .fingerprint("checkout-error")
1006            .level("warning");
1007        let event =
1008            build_exception_event(&error, options, &ErrorTrackingOptions::default()).unwrap();
1009        let json = built_event_json(event);
1010
1011        assert_eq!(json["distinct_id"], "user-1");
1012        assert_eq!(json["properties"]["route"], "/checkout");
1013        assert_eq!(json["properties"]["$groups"]["company"], "acme");
1014        assert_eq!(
1015            json["properties"]["$exception_fingerprint"],
1016            "checkout-error"
1017        );
1018        assert_eq!(json["properties"]["$exception_level"], "warning");
1019    }
1020}