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#[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
17const MAX_FRAMES: usize = 64;
20const MAX_ERROR_SOURCES: usize = 50;
23
24static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
26
27#[cfg(not(test))]
34const PANIC_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
35#[cfg(test)]
39const PANIC_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(200);
40
41#[derive(Builder, Clone, Debug)]
59#[builder(default)]
60pub struct ErrorTrackingOptions {
61 capture_stacktrace: bool,
71 in_app_include_paths: Vec<String>,
78 in_app_exclude_paths: Vec<String>,
82 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#[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
171pub(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 let _ = install_hook(|panic_info| match crate::global::global_client() {
185 Some(client) => capture_panic(client, panic_info),
186 None => Ok(()),
187 });
188}
189
190fn should_capture_global_panics(client: &Client) -> bool {
194 !client.is_disabled() && client.error_tracking_options().capture_panics()
195}
196
197#[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 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#[allow(deprecated)]
237fn capture_panic(client: &Client, panic_info: &panic::PanicInfo<'_>) -> Result<(), Error> {
238 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 client.enqueue_panic_event(event);
254 client.flush_blocking_timeout(PANIC_FLUSH_TIMEOUT);
260 Ok(())
261}
262
263#[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#[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 pub fn new() -> Self {
314 Self::default()
315 }
316
317 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 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 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 pub fn fingerprint<S: Into<String>>(mut self, fingerprint: S) -> Self {
342 self.fingerprint = Some(fingerprint.into());
343 self
344 }
345
346 pub fn level<S: Into<String>>(mut self, level: S) -> Self {
348 self.level = Some(level.into());
349 self
350 }
351}
352
353pub(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 exception.write_into(&mut event, et_options)?;
398 Ok(event)
399}
400
401#[derive(Clone, Debug, PartialEq, Eq)]
410pub(crate) struct Exception {
411 items: Vec<ExceptionItem>,
412 captured_frames: Option<Vec<StackFrame>>,
416 captured_images: Vec<DebugImage>,
420 fingerprint: Option<String>,
421 level: String,
422}
423
424impl Exception {
425 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 #[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 #[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 level: "fatal".to_string(),
530 }
531 }
532
533 pub(crate) fn set_fingerprint<S: Into<String>>(&mut self, fingerprint: S) {
535 self.fingerprint = Some(fingerprint.into());
536 }
537
538 pub(crate) fn set_level<S: Into<String>>(&mut self, level: S) {
540 self.level = level.into();
541 }
542
543 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 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 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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
615 pub exception_id: Option<usize>,
616 #[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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
671 pub instruction_addr: Option<String>,
672 #[serde(skip_serializing_if = "Option::is_none")]
674 pub symbol_addr: Option<String>,
675 #[serde(skip_serializing_if = "Option::is_none")]
677 pub image_addr: Option<String>,
678 pub client_resolved: bool,
684}
685
686#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
690pub(crate) struct DebugImage {
691 #[serde(rename = "type")]
692 pub image_type: String,
693 pub debug_id: String,
696 #[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
709struct 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
727fn normalize_arch(arch: &str) -> String {
731 match arch {
732 "aarch64" => "arm64".to_string(),
733 other => other.to_string(),
734 }
735}
736
737fn 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
751fn 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 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 _ => None,
796 }
797}
798
799fn 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 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 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#[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 let resolvable = module.is_some_and(|m| !m.image.debug_id.is_empty());
892
893 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 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 client_resolved: false,
939 });
940 } else if !layers.is_empty() {
941 for (filename, line_no, function) in layers {
950 frames.push(StackFrame {
951 filename,
952 line_no,
953 function,
954 lang: "rust".to_string(),
955 in_app: false,
958 synthetic: false,
959 platform: "native".to_string(),
960 instruction_addr: None,
961 symbol_addr: None,
962 image_addr: None,
963 client_resolved: true,
966 });
967 }
968 }
969 true
974 });
975
976 frames
977}
978
979fn trim_to_max_frames(frames: &mut Vec<StackFrame>, max_frames: usize) {
983 if frames.len() > max_frames {
984 frames.drain(..frames.len() - max_frames);
985 }
986}
987
988fn strip_pinned_prefix(frames: &mut Vec<StackFrame>, pinned_entries: &[u64]) {
1002 let scan = frames.len().min(16);
1003 let matches_pinned = |frame: &StackFrame| {
1004 frame
1005 .symbol_addr
1006 .as_deref()
1007 .and_then(|addr| u64::from_str_radix(addr.trim_start_matches("0x"), 16).ok())
1008 .is_some_and(|addr| pinned_entries.contains(&addr))
1009 };
1010 if let Some(last_sdk) = frames[..scan].iter().rposition(matches_pinned) {
1011 frames.drain(..=last_sdk);
1012 }
1013}
1014
1015#[inline(never)]
1034fn capture_raw_frames(
1035 is_internal: impl Fn(&str) -> bool,
1036 pinned_entries: &[u64],
1037) -> (Vec<StackFrame>, Vec<DebugImage>) {
1038 let modules = collect_loaded_modules();
1039 let mut frames = capture_frames_current_first(0, &modules);
1040
1041 strip_pinned_prefix(&mut frames, pinned_entries);
1044
1045 while frames
1046 .first()
1047 .map(|frame| is_internal(&frame.function))
1048 .unwrap_or(false)
1049 {
1050 frames.remove(0);
1051 }
1052
1053 frames.reverse();
1062
1063 let images = referenced_images(modules, &frames);
1064 (frames, images)
1065}
1066
1067fn referenced_images(modules: Vec<LoadedModule>, frames: &[StackFrame]) -> Vec<DebugImage> {
1071 modules
1072 .into_iter()
1073 .filter(|m| !m.image.debug_id.is_empty())
1074 .map(|m| m.image)
1075 .filter(|image| {
1076 frames
1077 .iter()
1078 .any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
1079 })
1080 .collect()
1081}
1082
1083#[inline(never)]
1089fn capture_raw_application_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1090 let pinned = [
1091 capture_frames_current_first as *const () as u64,
1092 capture_raw_application_frames as *const () as u64,
1093 ];
1094 capture_raw_frames(is_internal_capture_frame, &pinned)
1095}
1096
1097#[inline(never)]
1100fn capture_raw_panic_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1101 let modules = collect_loaded_modules();
1128 let mut frames = capture_frames_current_first(0, &modules);
1129
1130 let pinned = [
1131 capture_frames_current_first as *const () as u64,
1132 capture_raw_panic_frames as *const () as u64,
1133 ];
1134 strip_pinned_prefix(&mut frames, &pinned);
1135
1136 let scan = frames.len().min(24);
1137 let dispatcher = frames[..scan]
1138 .iter()
1139 .position(|frame| is_panic_dispatcher_frame(&frame.function));
1140 match dispatcher {
1141 Some(index) => {
1142 frames.drain(..index);
1143 }
1144 None => {
1145 while frames
1146 .first()
1147 .map(|frame| is_internal_capture_frame(&frame.function))
1148 .unwrap_or(false)
1149 {
1150 frames.remove(0);
1151 }
1152 }
1153 }
1154
1155 frames.reverse();
1158 let images = referenced_images(modules, &frames);
1159 (frames, images)
1160}
1161
1162fn is_panic_dispatcher_frame(function: &str) -> bool {
1166 function.contains("rust_panic_with_hook") || function.contains("panicking::panic_with_hook")
1167}
1168
1169fn is_internal_capture_frame(function: &str) -> bool {
1179 let function: String = function.replace(['<', '>'], "");
1184 function.starts_with("backtrace::")
1185 || function.contains("capture_frames_current_first")
1186 || function.contains("capture_raw_frames")
1187 || function.contains("capture_raw_application_frames")
1188 || function.contains("Exception::from_error")
1189 || function.contains("Exception::from_message")
1190 || function.contains("build_exception_event")
1191 || function.contains("Client::capture_exception")
1192 || function.contains("global::capture_exception")
1193}
1194
1195#[allow(deprecated)]
1197fn panic_message(panic_info: &panic::PanicInfo<'_>) -> String {
1198 let value = panic_info
1199 .payload()
1200 .downcast_ref::<&str>()
1201 .map(|value| (*value).to_string())
1202 .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
1203 .unwrap_or_else(|| "panic occurred".to_string());
1204
1205 if value.is_empty() {
1206 "panic occurred".to_string()
1207 } else {
1208 value
1209 }
1210}
1211
1212fn path_to_string(path: &std::path::Path) -> String {
1213 path.to_string_lossy().into_owned()
1214}
1215
1216fn simple_type_name(type_name: &str) -> String {
1223 let trimmed = type_name.trim().trim_start_matches('&').trim();
1224 let trimmed = trimmed.strip_prefix("dyn ").unwrap_or(trimmed).trim();
1225 let trimmed = trimmed
1226 .split_once('<')
1227 .map_or(trimmed, |(outer_type, _)| outer_type)
1228 .trim_end();
1229 if trimmed.is_empty() || trimmed == "core::error::Error" || trimmed == "std::error::Error" {
1232 return "Error".to_string();
1233 }
1234 trimmed.to_string()
1235}
1236
1237fn source_type_name(error: &(dyn StdError + 'static)) -> String {
1244 simple_type_name(type_name_of_val(error))
1245}
1246
1247fn link_exception_chain(exception_list: &mut [ExceptionItem]) {
1251 if exception_list.len() < 2 {
1252 return;
1253 }
1254 for (index, item) in exception_list.iter_mut().enumerate() {
1255 item.mechanism.exception_id = Some(index);
1256 if index > 0 {
1257 item.mechanism.parent_id = Some(index - 1);
1258 item.mechanism.mechanism_type = "chained".to_string();
1259 }
1260 }
1261}
1262
1263fn error_value<E>(error: &E) -> String
1264where
1265 E: StdError + ?Sized,
1266{
1267 let value = error.to_string();
1268 if value.is_empty() {
1269 "Error".to_string()
1270 } else {
1271 value
1272 }
1273}
1274
1275fn normalize_function_name(function: &str) -> String {
1282 let function = strip_crate_disambiguators(function);
1283 match function.rsplit_once("::") {
1284 Some((prefix, suffix)) if is_rust_symbol_hash(suffix) => prefix.to_string(),
1285 _ => function,
1286 }
1287}
1288
1289fn strip_crate_disambiguators(function: &str) -> String {
1290 let mut out = String::with_capacity(function.len());
1291 let mut rest = function;
1292 while let Some(open) = rest.find('[') {
1293 out.push_str(&rest[..open]);
1294 let bracketed = &rest[open..];
1295 match bracketed.find(']') {
1296 Some(close) => {
1297 let content = &bracketed[1..close];
1298 if !is_crate_disambiguator(content) {
1299 out.push_str(&bracketed[..=close]);
1300 }
1301 rest = &bracketed[close + 1..];
1302 }
1303 None => {
1304 out.push_str(bracketed);
1305 rest = "";
1306 }
1307 }
1308 }
1309 out.push_str(rest);
1310 out
1311}
1312
1313fn is_crate_disambiguator(content: &str) -> bool {
1316 content.len() >= 8
1317 && content
1318 .chars()
1319 .all(|ch| ch.is_ascii_digit() || ('a'..='f').contains(&ch))
1320}
1321
1322fn is_rust_symbol_hash(segment: &str) -> bool {
1323 segment.len() >= 9
1324 && segment.starts_with('h')
1325 && segment[1..].chars().all(|ch| ch.is_ascii_hexdigit())
1326}
1327
1328fn default_in_app_path(filename: &str) -> bool {
1329 let normalized = filename.replace('\\', "/");
1330 if normalized.contains("/.cargo/registry/")
1331 || normalized.contains("/.cargo/git/")
1332 || normalized.contains("/rustc/")
1333 || normalized.contains("/rustc-")
1334 || normalized.contains("/library/alloc/src/")
1335 || normalized.contains("/library/core/src/")
1336 || normalized.contains("/library/proc_macro/src/")
1337 || normalized.contains("/library/std/src/")
1338 || normalized.contains("/library/test/src/")
1339 || normalized.contains("/toolchains/")
1340 || normalized.contains("/target/")
1341 || normalized.contains("/vendor/")
1342 {
1343 return false;
1344 }
1345
1346 true
1347}
1348
1349fn default_in_app_function(function: &str) -> bool {
1350 if function.is_empty()
1355 || function == "_main"
1356 || function == "rust_begin_unwind"
1357 || function.starts_with("__rust")
1358 || function.starts_with("___rust")
1359 {
1360 return false;
1361 }
1362
1363 !matches!(
1364 function
1365 .trim_start_matches('<')
1366 .split("::")
1367 .next()
1368 .unwrap_or_default(),
1369 "alloc"
1370 | "anyhow"
1371 | "backtrace"
1372 | "color_eyre"
1373 | "core"
1374 | "eyre"
1375 | "futures_core"
1376 | "futures_util"
1377 | "log"
1378 | "posthog_rs"
1379 | "reqwest"
1380 | "std"
1381 | "stable_eyre"
1382 | "tokio"
1383 | "tracing"
1384 | "tracing_core"
1385 )
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use std::error::Error as StdError;
1391 use std::fmt;
1392 use std::sync::atomic::{AtomicBool, Ordering};
1393 use std::sync::{Arc, Mutex, OnceLock};
1394
1395 use httpmock::prelude::*;
1396 use serde_json::{json, Value};
1397
1398 use super::*;
1399 use crate::client::ClientOptionsBuilder;
1400 use crate::event::InnerEvent;
1401
1402 #[derive(Debug)]
1403 struct OuterError {
1404 source: InnerError,
1405 }
1406
1407 impl fmt::Display for OuterError {
1408 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1409 write!(f, "checkout failed")
1410 }
1411 }
1412
1413 impl StdError for OuterError {
1414 fn source(&self) -> Option<&(dyn StdError + 'static)> {
1415 Some(&self.source)
1416 }
1417 }
1418
1419 #[derive(Debug)]
1420 struct InnerError;
1421
1422 impl fmt::Display for InnerError {
1423 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1424 write!(f, "database unavailable")
1425 }
1426 }
1427
1428 impl StdError for InnerError {}
1429
1430 #[derive(Debug)]
1431 struct BorrowedError<'a>(&'a str);
1432
1433 impl fmt::Display for BorrowedError<'_> {
1434 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1435 f.write_str(self.0)
1436 }
1437 }
1438
1439 impl StdError for BorrowedError<'_> {}
1440
1441 fn built_event_json(mut event: Event) -> Value {
1442 event.prepare_for_v0();
1443 serde_json::to_value(InnerEvent::new(event, "api-key".to_string())).unwrap()
1444 }
1445
1446 fn event_json_with(exception: Exception, options: &ErrorTrackingOptions) -> Value {
1447 let mut event = Event::new_anon("$exception");
1448 exception.write_into(&mut event, options).unwrap();
1449 built_event_json(event)
1450 }
1451
1452 fn event_json(exception: Exception) -> Value {
1453 event_json_with(exception, &ErrorTrackingOptions::default())
1454 }
1455
1456 #[allow(deprecated)]
1457 type PanicHook = Box<dyn Fn(&panic::PanicInfo<'_>) + Sync + Send + 'static>;
1458
1459 struct PanicHookReset {
1462 previous: Option<PanicHook>,
1463 }
1464
1465 impl PanicHookReset {
1466 fn new(previous: PanicHook) -> Self {
1467 Self {
1468 previous: Some(previous),
1469 }
1470 }
1471
1472 fn restore(&mut self) {
1473 if let Some(previous) = self.previous.take() {
1474 panic::set_hook(previous);
1475 }
1476 PANIC_HOOK_INSTALLED.store(false, Ordering::Release);
1477 }
1478 }
1479
1480 impl Drop for PanicHookReset {
1481 fn drop(&mut self) {
1482 if !std::thread::panicking() {
1483 self.restore();
1484 }
1485 }
1486 }
1487
1488 fn panic_hook_test_lock() -> &'static Mutex<()> {
1491 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1492 LOCK.get_or_init(|| Mutex::new(()))
1493 }
1494
1495 #[cfg(feature = "async-client")]
1499 fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1500 Arc::new(futures::executor::block_on(crate::client::client(options)))
1501 }
1502
1503 #[cfg(not(feature = "async-client"))]
1504 fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1505 Arc::new(crate::client::client(options))
1506 }
1507
1508 #[inline(never)]
1509 fn panic_hook_test_panic_site() {
1510 panic!("panic hook boom");
1511 }
1512
1513 #[inline(never)]
1514 fn panic_hook_disabled_test_panic_site() {
1515 panic!("disabled panic hook boom");
1516 }
1517
1518 fn request_has_panic_payload(req: &HttpMockRequest) -> bool {
1521 let Some(body) = req.body.as_deref() else {
1522 return false;
1523 };
1524 let Ok(body) = serde_json::from_slice::<Value>(body) else {
1525 return false;
1526 };
1527 let event = &body["batch"][0];
1528 let exception = &event["properties"]["$exception_list"][0];
1529 let frames = exception["stacktrace"]["frames"].as_array();
1530
1531 let has_panic_site = frames.is_some_and(|frames| {
1534 frames.iter().any(|frame| {
1535 frame["function"]
1536 .as_str()
1537 .is_some_and(|name| name.contains("panic_hook_test_panic_site"))
1538 })
1539 });
1540 let has_machinery_not_in_app = frames.is_some_and(|frames| {
1543 frames.iter().any(|frame| {
1544 frame["in_app"] == false
1545 && frame["function"].as_str().is_some_and(|name| {
1546 name.contains("panicking") || name == "rust_begin_unwind"
1547 })
1548 })
1549 });
1550
1551 event["event"] == "$exception"
1552 && (event["properties"]["$process_person_profile"] == false
1555 || event["options"]["process_person_profile"] == false)
1556 && event["properties"]["$exception_level"] == "fatal"
1557 && exception["type"] == "Panic"
1558 && exception["value"] == "panic hook boom"
1559 && exception["mechanism"]["type"] == "panic"
1560 && exception["mechanism"]["handled"] == false
1561 && event["properties"]["$exception_panic_file"]
1562 .as_str()
1563 .is_some_and(|file| file.contains("error_tracking.rs"))
1564 && event["properties"]["$exception_panic_line"]
1565 .as_u64()
1566 .is_some_and(|line| line > 0)
1567 && event["properties"]["$exception_panic_column"]
1568 .as_u64()
1569 .is_some_and(|column| column > 0)
1570 && has_panic_site
1571 && has_machinery_not_in_app
1572 }
1573
1574 #[test]
1575 fn panic_hook_sends_personless_exception_and_calls_previous_hook() {
1576 let _guard = panic_hook_test_lock()
1577 .lock()
1578 .unwrap_or_else(|e| e.into_inner());
1579 let original_hook = panic::take_hook();
1580 let mut reset = PanicHookReset::new(original_hook);
1581 let previous_called = Arc::new(AtomicBool::new(false));
1582 let previous_called_for_hook = Arc::clone(&previous_called);
1583 panic::set_hook(Box::new(move |_| {
1584 previous_called_for_hook.store(true, Ordering::Release);
1585 }));
1586
1587 let server = MockServer::start();
1588 let capture_mock = server.mock(|when, then| {
1589 when.method(POST).matches(request_has_panic_payload);
1590 then.status(200);
1591 });
1592 let options = ClientOptionsBuilder::default()
1593 .api_key("test_api_key".to_string())
1594 .host(server.base_url())
1595 .build()
1596 .unwrap();
1597 let client = build_test_client(options);
1598
1599 install_panic_hook(Arc::clone(&client)).unwrap();
1600 assert!(matches!(
1601 install_panic_hook(Arc::clone(&client)),
1602 Err(Error::PanicHookAlreadyInstalled)
1603 ));
1604
1605 let result = panic::catch_unwind(panic_hook_test_panic_site);
1606 reset.restore();
1607
1608 assert!(result.is_err());
1609 assert!(previous_called.load(Ordering::Acquire));
1610 capture_mock.assert_hits(1);
1611 }
1612
1613 #[test]
1614 fn disabled_panic_hook_does_not_send() {
1615 let _guard = panic_hook_test_lock()
1616 .lock()
1617 .unwrap_or_else(|e| e.into_inner());
1618 let original_hook = panic::take_hook();
1619 let mut reset = PanicHookReset::new(original_hook);
1620 panic::set_hook(Box::new(|_| {}));
1621
1622 let server = MockServer::start();
1623 let capture_mock = server.mock(|when, then| {
1624 when.method(POST);
1625 then.status(200);
1626 });
1627 let options = ClientOptionsBuilder::default()
1628 .api_key("test_api_key".to_string())
1629 .host(server.base_url())
1630 .disabled(true)
1631 .build()
1632 .unwrap();
1633 let client = build_test_client(options);
1634
1635 install_panic_hook(client).unwrap();
1636 let result = panic::catch_unwind(panic_hook_disabled_test_panic_site);
1637 reset.restore();
1638
1639 assert!(result.is_err());
1640 capture_mock.assert_hits(0);
1641 }
1642
1643 #[cfg(feature = "async-client")]
1647 #[test]
1648 fn panic_hook_captures_panics_on_tokio_runtime_threads() {
1649 let _guard = panic_hook_test_lock()
1650 .lock()
1651 .unwrap_or_else(|e| e.into_inner());
1652 let original_hook = panic::take_hook();
1653 let mut reset = PanicHookReset::new(original_hook);
1654 panic::set_hook(Box::new(|_| {}));
1655
1656 let server = MockServer::start();
1657 let capture_mock = server.mock(|when, then| {
1658 when.method(POST)
1659 .body_contains(r#""value":"tokio task boom""#);
1660 then.status(200);
1661 });
1662 let options = ClientOptionsBuilder::default()
1663 .api_key("test_api_key".to_string())
1664 .host(server.base_url())
1665 .build()
1666 .unwrap();
1667 install_panic_hook(build_test_client(options)).unwrap();
1668
1669 let runtime = tokio::runtime::Builder::new_multi_thread()
1670 .worker_threads(1)
1671 .enable_all()
1672 .build()
1673 .unwrap();
1674 let result = runtime.block_on(async {
1675 tokio::spawn(async {
1676 panic!("tokio task boom");
1677 })
1678 .await
1679 });
1680 drop(runtime);
1681
1682 let current_thread = tokio::runtime::Builder::new_current_thread()
1685 .enable_all()
1686 .build()
1687 .unwrap();
1688 let current_result = current_thread.block_on(async {
1689 panic::catch_unwind(AssertUnwindSafe(|| panic!("tokio task boom")))
1690 });
1691 drop(current_thread);
1692 reset.restore();
1693
1694 assert!(result.is_err());
1695 assert!(current_result.is_err());
1696 capture_mock.assert_hits(2);
1697 }
1698
1699 #[test]
1700 fn panic_in_before_send_on_worker_neither_deadlocks_nor_recurses() {
1701 let _guard = panic_hook_test_lock()
1707 .lock()
1708 .unwrap_or_else(|e| e.into_inner());
1709 let original_hook = panic::take_hook();
1710 let mut reset = PanicHookReset::new(original_hook);
1711 panic::set_hook(Box::new(|_| {}));
1712
1713 let server = MockServer::start();
1714 let _capture_mock = server.mock(|when, then| {
1715 when.method(POST);
1716 then.status(200);
1717 });
1718 let options = ClientOptionsBuilder::default()
1719 .api_key("test_api_key".to_string())
1720 .host(server.base_url())
1721 .before_send(|_event| panic!("before_send boom"))
1722 .build()
1723 .unwrap();
1724 let client = build_test_client(options);
1725 install_panic_hook(Arc::clone(&client)).unwrap();
1726
1727 let finished = Arc::new(AtomicBool::new(false));
1728 let finished_for_worker = Arc::clone(&finished);
1729 let work_client = Arc::clone(&client);
1730 let _worker = std::thread::spawn(move || {
1731 work_client.capture(Event::new("boom", "user-1"));
1732 work_client.flush_blocking();
1735 finished_for_worker.store(true, Ordering::Release);
1736 });
1737
1738 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1739 while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
1740 std::thread::sleep(std::time::Duration::from_millis(20));
1741 }
1742 reset.restore();
1743
1744 assert!(
1745 finished.load(Ordering::Acquire),
1746 "panic in before_send on the worker thread deadlocked or recursed"
1747 );
1748 }
1752
1753 #[test]
1754 fn panic_hook_flush_is_bounded_when_before_send_needs_a_panic_held_lock() {
1755 let _guard = panic_hook_test_lock()
1763 .lock()
1764 .unwrap_or_else(|e| e.into_inner());
1765 let original_hook = panic::take_hook();
1766 let mut reset = PanicHookReset::new(original_hook);
1767 panic::set_hook(Box::new(|_| {}));
1768
1769 static SHARED: Mutex<()> = Mutex::new(());
1772
1773 let server = MockServer::start();
1774 let _capture_mock = server.mock(|when, then| {
1775 when.method(POST);
1776 then.status(200);
1777 });
1778 let options = ClientOptionsBuilder::default()
1779 .api_key("test_api_key".to_string())
1780 .host(server.base_url())
1781 .before_send(|event| {
1782 let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
1783 Some(event)
1784 })
1785 .build()
1786 .unwrap();
1787 let client = build_test_client(options);
1788 install_panic_hook(Arc::clone(&client)).unwrap();
1789
1790 let finished = Arc::new(AtomicBool::new(false));
1791 let finished_for_panicker = Arc::clone(&finished);
1792 let _panicker = std::thread::spawn(move || {
1793 {
1794 let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
1798 let _ = panic::catch_unwind(AssertUnwindSafe(|| {
1799 panic!("boom while holding a before_send lock")
1800 }));
1801 }
1802 finished_for_panicker.store(true, Ordering::Release);
1803 });
1804
1805 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1806 while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
1807 std::thread::sleep(std::time::Duration::from_millis(20));
1808 }
1809 reset.restore();
1810
1811 assert!(
1812 finished.load(Ordering::Acquire),
1813 "panic hook flush hung on a before_send that needed a panic-held lock"
1814 );
1815 }
1818
1819 #[test]
1820 fn global_capture_panics_defaults_off_and_is_configurable() {
1821 assert!(
1822 !ErrorTrackingOptions::default().capture_panics(),
1823 "panic autocapture is opt-in (off by default)"
1824 );
1825 let enabled = ErrorTrackingOptionsBuilder::default()
1826 .capture_panics(true)
1827 .build()
1828 .unwrap();
1829 assert!(enabled.capture_panics(), "capture_panics is configurable");
1830 }
1831
1832 #[test]
1833 fn should_capture_global_panics_gates_on_enabled_and_flag() {
1834 let enabled = build_test_client(
1835 ClientOptionsBuilder::default()
1836 .api_key("test_api_key".to_string())
1837 .error_tracking(
1838 ErrorTrackingOptionsBuilder::default()
1839 .capture_panics(true)
1840 .build()
1841 .unwrap(),
1842 )
1843 .build()
1844 .unwrap(),
1845 );
1846 assert!(should_capture_global_panics(&enabled));
1847
1848 let disabled = build_test_client(
1850 ClientOptionsBuilder::default()
1851 .api_key("test_api_key".to_string())
1852 .disabled(true)
1853 .error_tracking(
1854 ErrorTrackingOptionsBuilder::default()
1855 .capture_panics(true)
1856 .build()
1857 .unwrap(),
1858 )
1859 .build()
1860 .unwrap(),
1861 );
1862 assert!(
1863 !should_capture_global_panics(&disabled),
1864 "a disabled client must not latch the process-wide hook"
1865 );
1866
1867 let default_off = build_test_client(
1869 ClientOptionsBuilder::default()
1870 .api_key("test_api_key".to_string())
1871 .build()
1872 .unwrap(),
1873 );
1874 assert!(!should_capture_global_panics(&default_off));
1875 }
1876
1877 #[test]
1878 fn install_panic_hook_on_disabled_client_does_not_latch() {
1879 let _guard = panic_hook_test_lock()
1880 .lock()
1881 .unwrap_or_else(|e| e.into_inner());
1882 let original_hook = panic::take_hook();
1883 let mut reset = PanicHookReset::new(original_hook);
1884
1885 let disabled = build_test_client(
1886 ClientOptionsBuilder::default()
1887 .api_key("test_api_key".to_string())
1888 .disabled(true)
1889 .build()
1890 .unwrap(),
1891 );
1892 let result = install_panic_hook(disabled);
1893 let latched = PANIC_HOOK_INSTALLED.load(Ordering::Acquire);
1894
1895 reset.restore();
1898 assert!(result.is_ok(), "installing on a disabled client returns Ok");
1899 assert!(
1900 !latched,
1901 "a disabled client must not latch the process-wide hook"
1902 );
1903 }
1904
1905 #[test]
1906 fn panic_machinery_frames_classify_out_of_app() {
1907 let options = ErrorTrackingOptions::default();
1911 for not_in_app in [
1912 "std::panicking::begin_panic_handler",
1913 "core::panicking::panic_fmt",
1914 "std::panic::catch_unwind",
1915 "std::sys::backtrace::__rust_begin_short_backtrace",
1916 "rust_begin_unwind",
1917 "__rust_try",
1918 "backtrace::backtrace::trace",
1919 "posthog_rs::error_tracking::capture_panic",
1920 "posthog_rs::error_tracking::install_hook::{{closure}}",
1921 "core::ops::function::FnOnce::call_once",
1922 "tokio::runtime::task::raw::poll",
1923 "futures_util::future::FutureExt::poll",
1924 "anyhow::error::Error::msg",
1925 "eyre::Report::msg",
1926 "color_eyre::config::EyreHook::into_eyre_hook::{{closure}}",
1927 "tracing::span::Span::record",
1928 "tracing_core::dispatcher::get_default",
1929 "log::__private_api::log",
1930 ] {
1931 assert!(
1932 !options.is_in_app_frame(None, Some(not_in_app)),
1933 "{} should classify as not in-app",
1934 not_in_app
1935 );
1936 }
1937
1938 for in_app in [
1939 "my_app::checkout::process_payment",
1940 "checkout_service::submit",
1941 ] {
1942 assert!(
1943 options.is_in_app_frame(None, Some(in_app)),
1944 "{} should classify as in-app",
1945 in_app
1946 );
1947 }
1948 }
1949
1950 #[test]
1951 fn function_names_strip_v0_crate_disambiguators() {
1952 assert_eq!(
1954 normalize_function_name("std[b887e3750a86e3a0]::panicking::panic_with_hook"),
1955 "std::panicking::panic_with_hook"
1956 );
1957 assert_eq!(
1958 normalize_function_name(
1959 "<alloc[8a71accd1b3711a1]::boxed::Box<dyn core[e000b89356eb4406]::ops::function::Fn<(&std[b887e3750a86e3a0]::panic::PanicHookInfo,)>> as core[e000b89356eb4406]::ops::function::Fn<(&std[b887e3750a86e3a0]::panic::PanicHookInfo,)>>::call"
1960 ),
1961 "<alloc::boxed::Box<dyn core::ops::function::Fn<(&std::panic::PanicHookInfo,)>> as core::ops::function::Fn<(&std::panic::PanicHookInfo,)>>::call"
1962 );
1963 assert_eq!(
1965 normalize_function_name("core::array::<impl [u8; 32]>::map"),
1966 "core::array::<impl [u8; 32]>::map"
1967 );
1968 assert_eq!(
1969 normalize_function_name("<[u8] as checkout_service::Digest>::digest"),
1970 "<[u8] as checkout_service::Digest>::digest"
1971 );
1972 }
1973
1974 #[test]
1975 fn internal_capture_frames_match_both_demangler_renderings() {
1976 for name in [
1980 "posthog_rs::error_tracking::Exception::from_error",
1981 "<posthog_rs::error_tracking::Exception>::from_error::<posthog_rs::error_tracking::tests::OuterError>",
1982 "<posthog_rs::error_tracking::Exception>::from_message",
1983 "<posthog_rs::client::Client>::capture_exception::<E>",
1984 ] {
1985 assert!(is_internal_capture_frame(name), "should strip {name:?}");
1986 }
1987 assert!(!is_internal_capture_frame(
1988 "my_app::checkout::Exception_from_error_report"
1989 ));
1990 }
1991
1992 #[test]
1993 fn from_error_builds_exception_list_with_stacktrace() {
1994 let error = OuterError { source: InnerError };
1995 let event = build_exception_event(
1996 &error,
1997 CaptureExceptionOptions::new().distinct_id("user-1"),
1998 &ErrorTrackingOptions::default(),
1999 )
2000 .unwrap();
2001 let json = built_event_json(event);
2002
2003 assert_eq!(json["event"], "$exception");
2004 assert_eq!(json["distinct_id"], "user-1");
2005 assert_eq!(json["properties"]["$exception_level"], "error");
2006
2007 let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
2008 assert!(exception_list[0]["type"]
2009 .as_str()
2010 .unwrap()
2011 .ends_with("OuterError"));
2012 assert_eq!(exception_list[0]["value"], "checkout failed");
2013 assert_eq!(exception_list[0]["mechanism"]["type"], "generic");
2014 assert_eq!(exception_list[0]["mechanism"]["handled"], true);
2015 assert_eq!(exception_list[0]["mechanism"]["synthetic"], false);
2016 assert_eq!(exception_list[0]["mechanism"]["exception_id"], 0);
2017 assert_eq!(exception_list[0]["stacktrace"]["type"], "raw");
2018 assert_eq!(exception_list[1]["value"], "database unavailable");
2019 assert_eq!(exception_list[1]["mechanism"]["type"], "chained");
2020 assert_eq!(exception_list[1]["mechanism"]["exception_id"], 1);
2021 assert_eq!(exception_list[1]["mechanism"]["parent_id"], 0);
2022
2023 let frames = exception_list[0]["stacktrace"]["frames"]
2024 .as_array()
2025 .expect("expected stack frames");
2026 let crash_frame = frames.last().expect("expected crash frame");
2029 assert_eq!(crash_frame["platform"], "native");
2030 assert_eq!(crash_frame["lang"], "rust");
2031 let instruction_addr = crash_frame["instruction_addr"].as_str().unwrap_or_default();
2032 assert!(
2033 instruction_addr.starts_with("0x"),
2034 "expected hex instruction_addr, got {:?}",
2035 instruction_addr
2036 );
2037 let crash_function = crash_frame["function"].as_str().unwrap_or_default();
2038 assert!(
2039 crash_function.contains("from_error_builds_exception_list_with_stacktrace"),
2040 "expected user frame last, got {:?}",
2041 crash_function
2042 );
2043 assert!(
2044 !crash_function.contains("Exception::"),
2045 "expected SDK frames to be skipped, got {:?}",
2046 crash_function
2047 );
2048 }
2049
2050 #[test]
2051 fn gnu_build_ids_convert_to_debug_ids_like_the_server() {
2052 let build_id: Vec<u8> = (0..20)
2055 .map(|i| {
2056 u8::from_str_radix(
2057 &"555398ebd01c90285a3d85138a19cbf9bbcec352"[i * 2..i * 2 + 2],
2058 16,
2059 )
2060 .unwrap()
2061 })
2062 .collect();
2063 let (full, short) = if cfg!(target_endian = "little") {
2067 (
2068 "eb985355-1cd0-2890-5a3d-85138a19cbf9",
2069 "0000cdab-0000-0000-0000-000000000000",
2070 )
2071 } else {
2072 (
2073 "555398eb-d01c-9028-5a3d-85138a19cbf9",
2074 "abcd0000-0000-0000-0000-000000000000",
2075 )
2076 };
2077 assert_eq!(debug_id_from_gnu_build_id(&build_id).as_deref(), Some(full));
2078
2079 assert_eq!(
2081 debug_id_from_gnu_build_id(&[0xab, 0xcd]).as_deref(),
2082 Some(short)
2083 );
2084 assert_eq!(debug_id_from_gnu_build_id(&[]), None);
2085 }
2086
2087 #[test]
2088 fn arch_normalizes_to_the_shared_native_vocabulary() {
2089 assert_eq!(normalize_arch("aarch64"), "arm64");
2092 assert_eq!(normalize_arch("x86_64"), "x86_64");
2093 assert_eq!(normalize_arch("arm"), "arm");
2094 }
2095
2096 #[test]
2097 fn find_module_matches_address_ranges() {
2098 let module_at = |base: u64, size: u64| LoadedModule {
2099 base,
2100 end: base + size,
2101 image: DebugImage {
2102 image_type: "elf".to_string(),
2103 debug_id: "test".to_string(),
2104 code_id: None,
2105 image_addr: format!("0x{base:x}"),
2106 image_size: Some(size),
2107 image_vmaddr: None,
2108 code_file: None,
2109 arch: "x86_64".to_string(),
2110 },
2111 };
2112
2113 let modules = vec![module_at(0x1000, 0x1000), module_at(0x4000, 0x1000)];
2114
2115 assert_eq!(find_module(&modules, 0x1500).map(|m| m.base), Some(0x1000));
2116 assert_eq!(find_module(&modules, 0x4000).map(|m| m.base), Some(0x4000));
2117 assert!(find_module(&modules, 0x2000).is_none()); assert!(find_module(&modules, 0x500).is_none()); assert!(find_module(&modules, 0x5000).is_none()); }
2121
2122 #[test]
2123 fn captured_stacks_reference_loaded_debug_images() {
2124 let json = event_json(Exception::from_message(
2125 "AddrCheck",
2126 "captures addresses",
2127 true,
2128 ));
2129
2130 let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2131 .as_array()
2132 .expect("expected stack frames");
2133
2134 let mut saw_instruction_addr = false;
2139 for frame in frames {
2140 let Some(addr) = frame["instruction_addr"].as_str() else {
2141 continue;
2142 };
2143 saw_instruction_addr = true;
2144 assert!(
2145 addr.starts_with("0x") && u64::from_str_radix(&addr[2..], 16).is_ok(),
2146 "expected hex instruction_addr, got {:?}",
2147 frame["instruction_addr"]
2148 );
2149 }
2150 assert!(
2151 saw_instruction_addr,
2152 "expected at least one frame to carry an instruction_addr"
2153 );
2154
2155 let images = json["properties"]["$debug_images"]
2159 .as_array()
2160 .expect("expected $debug_images");
2161 assert!(!images.is_empty());
2162 let expected_type = super::native_image_type();
2163 let expected_arch = super::normalize_arch(std::env::consts::ARCH);
2164 for image in images {
2165 assert_eq!(image["type"].as_str(), Some(expected_type));
2166 assert_eq!(
2167 image["arch"].as_str(),
2168 Some(expected_arch.as_str()),
2169 "arch should match the running process"
2170 );
2171 let debug_id = image["debug_id"].as_str().unwrap_or_default();
2172 assert!(
2173 debug_id.len() >= 36,
2174 "expected uuid-shaped debug_id, got {:?}",
2175 debug_id
2176 );
2177 let image_addr = image["image_addr"].as_str().unwrap_or_default();
2178 assert!(
2179 frames
2180 .iter()
2181 .any(|f| f["image_addr"].as_str() == Some(image_addr)),
2182 "image {} not referenced by any frame",
2183 image_addr
2184 );
2185 }
2186 }
2187
2188 #[test]
2189 fn from_error_accepts_borrowed_error_types() {
2190 let message = String::from("borrowed parse failure");
2191 let error = BorrowedError(&message);
2192 let json = event_json(Exception::from_error(&error, true));
2193
2194 assert_eq!(
2195 json["properties"]["$exception_list"][0]["value"],
2196 "borrowed parse failure"
2197 );
2198 }
2199
2200 #[test]
2201 fn personless_capture_disables_person_profile() {
2202 let json = event_json(Exception::from_message("Error", "no user context", true));
2203
2204 assert_eq!(json["event"], "$exception");
2205 assert_eq!(json["properties"]["$process_person_profile"], false);
2206 }
2207
2208 #[test]
2209 fn custom_properties_cannot_override_reserved_exception_payload() {
2210 let error = OuterError { source: InnerError };
2211 let event = build_exception_event(
2212 &error,
2213 CaptureExceptionOptions::new()
2214 .property("$exception_list", json!([{"value": "fake"}]))
2215 .unwrap(),
2216 &ErrorTrackingOptions::default(),
2217 )
2218 .unwrap();
2219
2220 let json = built_event_json(event);
2221 assert_eq!(
2222 json["properties"]["$exception_list"][0]["value"],
2223 "checkout failed"
2224 );
2225 }
2226
2227 #[test]
2228 fn options_can_disable_stacktrace() {
2229 let options = ErrorTrackingOptionsBuilder::default()
2230 .capture_stacktrace(false)
2231 .build()
2232 .unwrap();
2233 let error = OuterError { source: InnerError };
2234 let event =
2235 build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2236 let json = built_event_json(event);
2237
2238 let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
2239 assert_eq!(exception_list.len(), 2);
2240 assert!(exception_list[0].get("stacktrace").is_none());
2241 }
2242
2243 #[test]
2244 fn in_app_path_defaults_and_overrides_are_applied() {
2245 let options = ErrorTrackingOptions::default();
2246 assert!(options.is_in_app_path("/app/src/main.rs"));
2247 assert!(!options.is_in_app_path("/home/user/.cargo/registry/src/lib.rs"));
2248 assert!(!options.is_in_app_path(
2249 "/private/tmp/nix-build-rustc-1.91.1/rustc-1.91.1-src/library/core/src/ops/function.rs"
2250 ));
2251 assert!(options.is_in_app_frame(None, Some("checkout_service::submit")));
2252 assert!(!options.is_in_app_frame(None, Some("std::rt::lang_start")));
2253 assert!(!options.is_in_app_frame(None, Some("core::ops::function::FnOnce::call_once")));
2254 assert!(
2255 !options.is_in_app_frame(None, Some("posthog_rs::client::Client::capture_exception"))
2256 );
2257 assert!(!options.is_in_app_frame(None, Some("_main")));
2258
2259 let options = ErrorTrackingOptionsBuilder::default()
2260 .in_app_include_paths(vec!["/service/".to_string(), "my_service::".to_string()])
2261 .in_app_exclude_paths(vec!["/service/vendor/".to_string()])
2262 .build()
2263 .unwrap();
2264
2265 assert!(options.is_in_app_path("/service/src/main.rs"));
2266 assert!(!options.is_in_app_path("/other/src/main.rs"));
2267 assert!(!options.is_in_app_path("/service/vendor/lib.rs"));
2268 assert!(options.is_in_app_frame(None, Some("my_service::checkout")));
2269 assert!(!options.is_in_app_frame(None, Some("other_service::checkout")));
2270 }
2271
2272 #[test]
2273 fn function_names_strip_rust_symbol_hashes() {
2274 assert_eq!(
2275 normalize_function_name("checkout_service::submit::h9ae4817223dd0b22"),
2276 "checkout_service::submit"
2277 );
2278 assert_eq!(
2279 normalize_function_name("std::rt::lang_start::{{closure}}::ha1fd5c62e470a8cc"),
2280 "std::rt::lang_start::{{closure}}"
2281 );
2282 assert_eq!(
2283 normalize_function_name("checkout_service::submit"),
2284 "checkout_service::submit"
2285 );
2286 }
2287
2288 #[test]
2289 fn type_names_keep_path_and_strip_generics() {
2290 assert_eq!(
2292 simple_type_name("std::io::error::Error"),
2293 "std::io::error::Error"
2294 );
2295 assert_eq!(
2296 simple_type_name("mycrate::CheckoutError"),
2297 "mycrate::CheckoutError"
2298 );
2299 assert_eq!(simple_type_name("mycrate::Error"), "mycrate::Error");
2300
2301 assert_eq!(simple_type_name("foo::Bar<baz::Qux>"), "foo::Bar");
2303 assert_eq!(
2304 simple_type_name(type_name::<Box<dyn StdError>>()),
2305 "alloc::boxed::Box"
2306 );
2307
2308 assert_eq!(simple_type_name("dyn core::error::Error"), "Error");
2310 assert_eq!(simple_type_name(type_name::<&dyn StdError>()), "Error");
2311 }
2312
2313 #[test]
2314 fn frames_are_trimmed_to_max_frames_keeping_the_crash_site() {
2315 let synthetic_frame = |index: usize| StackFrame {
2316 filename: None,
2317 line_no: None,
2318 function: format!("frame_{index}"),
2319 lang: "rust".to_string(),
2320 in_app: true,
2321 synthetic: false,
2322 platform: "native".to_string(),
2323 instruction_addr: None,
2324 symbol_addr: None,
2325 image_addr: None,
2326 client_resolved: false,
2327 };
2328 let exception = Exception {
2329 items: vec![ExceptionItem {
2330 exception_type: "Error".to_string(),
2331 value: "trimmed".to_string(),
2332 mechanism: ExceptionMechanism::default(),
2333 stacktrace: None,
2334 }],
2335 captured_frames: Some((0..MAX_FRAMES + 5).map(synthetic_frame).collect()),
2336 captured_images: Vec::new(),
2337 fingerprint: None,
2338 level: "error".to_string(),
2339 };
2340
2341 let json = event_json_with(exception, &ErrorTrackingOptions::default());
2342 let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2343 .as_array()
2344 .expect("expected stack frames");
2345 assert_eq!(frames.len(), MAX_FRAMES);
2346 assert_eq!(frames[0]["function"], "frame_5");
2350 assert_eq!(
2351 frames[MAX_FRAMES - 1]["function"],
2352 format!("frame_{}", MAX_FRAMES + 4)
2353 );
2354 }
2355
2356 #[test]
2357 fn stacktrace_keeps_crash_frame_last() {
2358 fn capture() -> ExceptionStacktrace {
2359 let mut frames = capture_raw_application_frames().0;
2363 trim_to_max_frames(&mut frames, 8);
2364 ExceptionStacktrace::raw(frames)
2365 }
2366
2367 let frames = capture().frames;
2368 let functions: Vec<&str> = frames
2369 .iter()
2370 .map(|frame| frame.function.as_str())
2371 .filter(|function| !function.is_empty())
2372 .collect();
2373
2374 let capture_index = functions
2375 .iter()
2376 .position(|function| function.contains("stacktrace_keeps_crash_frame_last::capture"))
2377 .expect("expected capture frame");
2378 let test_index = functions
2379 .iter()
2380 .position(|function| function.ends_with("stacktrace_keeps_crash_frame_last"))
2381 .expect("expected test frame");
2382
2383 assert!(
2384 test_index < capture_index,
2385 "expected the caller before the innermost (capture-site) frame, got {:?}",
2386 functions
2387 );
2388 }
2389
2390 #[test]
2391 fn inlined_frames_collapse_for_server_side_expansion() {
2392 #[inline(always)]
2400 fn inline_leaf() -> Vec<StackFrame> {
2401 capture_raw_application_frames().0
2402 }
2403
2404 #[inline(always)]
2405 fn inline_mid() -> Vec<StackFrame> {
2406 inline_leaf()
2407 }
2408
2409 let frames = inline_mid();
2410 let functions: Vec<&str> = frames.iter().map(|frame| frame.function.as_str()).collect();
2411
2412 let mut addrs: Vec<&str> = frames
2416 .iter()
2417 .filter_map(|frame| frame.instruction_addr.as_deref())
2418 .collect();
2419 let emitted = addrs.len();
2420 addrs.sort_unstable();
2421 addrs.dedup();
2422 assert_eq!(
2423 addrs.len(),
2424 emitted,
2425 "instruction_addr duplicated across frames: {:?}",
2426 frames
2427 );
2428
2429 assert!(
2433 frames
2434 .iter()
2435 .all(|f| f.client_resolved == f.instruction_addr.is_none()),
2436 "client_resolved must be the inverse of instruction_addr presence: {:?}",
2437 frames
2438 );
2439
2440 let leaf = functions
2441 .iter()
2442 .filter(|f| f.contains("inline_leaf"))
2443 .count();
2444 let mid = functions
2445 .iter()
2446 .filter(|f| f.contains("inline_mid"))
2447 .count();
2448
2449 if frames.iter().any(|frame| frame.instruction_addr.is_some()) {
2450 assert!(
2453 leaf == 0 && mid == 0,
2454 "expected inlined layers collapsed for server-side expansion, got {:?}",
2455 functions
2456 );
2457 } else {
2458 let leaf_index = functions.iter().position(|f| f.contains("inline_leaf"));
2463 let mid_index = functions.iter().position(|f| f.contains("inline_mid"));
2464 assert!(
2465 matches!((leaf_index, mid_index), (Some(l), Some(m)) if m < l),
2466 "expected client-side inline expansion outermost first, got {:?}",
2467 functions
2468 );
2469 }
2470 }
2471
2472 #[test]
2473 fn build_exception_event_defaults_to_personless() {
2474 let error = OuterError { source: InnerError };
2475 let event = build_exception_event(
2476 &error,
2477 CaptureExceptionOptions::default(),
2478 &ErrorTrackingOptions::default(),
2479 )
2480 .unwrap();
2481 let json = built_event_json(event);
2482
2483 assert_eq!(json["event"], "$exception");
2484 assert_eq!(json["properties"]["$process_person_profile"], false);
2485 assert_eq!(json["properties"]["$exception_level"], "error");
2486 }
2487
2488 #[test]
2489 fn build_exception_event_applies_options() {
2490 let error = OuterError { source: InnerError };
2491 let options = CaptureExceptionOptions::new()
2492 .distinct_id("user-1")
2493 .property("route", "/checkout")
2494 .unwrap()
2495 .group("company", "acme")
2496 .fingerprint("checkout-error")
2497 .level("warning");
2498 let event =
2499 build_exception_event(&error, options, &ErrorTrackingOptions::default()).unwrap();
2500 let json = built_event_json(event);
2501
2502 assert_eq!(json["distinct_id"], "user-1");
2503 assert_eq!(json["properties"]["route"], "/checkout");
2504 assert_eq!(json["properties"]["$groups"]["company"], "acme");
2505 assert_eq!(
2506 json["properties"]["$exception_fingerprint"],
2507 "checkout-error"
2508 );
2509 assert_eq!(json["properties"]["$exception_level"], "warning");
2510 }
2511}