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 {
946 frames.push(StackFrame {
947 filename,
948 line_no,
949 function,
950 lang: "rust".to_string(),
951 in_app: false,
954 synthetic: false,
955 platform: "native".to_string(),
956 instruction_addr: None,
957 symbol_addr: None,
958 image_addr: None,
959 client_resolved: true,
962 });
963 }
964 }
965 true
970 });
971
972 frames
973}
974
975fn trim_to_max_frames(frames: &mut Vec<StackFrame>, max_frames: usize) {
976 if frames.len() > max_frames {
977 frames.truncate(max_frames);
978 }
979}
980
981#[inline(never)]
997fn capture_raw_frames(
998 is_internal: impl Fn(&str) -> bool,
999 pinned_entries: &[u64],
1000) -> (Vec<StackFrame>, Vec<DebugImage>) {
1001 let modules = collect_loaded_modules();
1002 let mut frames = capture_frames_current_first(0, &modules);
1003
1004 let scan = frames.len().min(16);
1018 let matches_pinned = |frame: &StackFrame| {
1019 frame
1020 .symbol_addr
1021 .as_deref()
1022 .and_then(|addr| u64::from_str_radix(addr.trim_start_matches("0x"), 16).ok())
1023 .is_some_and(|addr| pinned_entries.contains(&addr))
1024 };
1025 if let Some(last_sdk) = frames[..scan].iter().rposition(matches_pinned) {
1026 frames.drain(..=last_sdk);
1027 }
1028
1029 while frames
1030 .first()
1031 .map(|frame| is_internal(&frame.function))
1032 .unwrap_or(false)
1033 {
1034 frames.remove(0);
1035 }
1036
1037 let images = referenced_images(modules, &frames);
1038 (frames, images)
1039}
1040
1041fn referenced_images(modules: Vec<LoadedModule>, frames: &[StackFrame]) -> Vec<DebugImage> {
1045 modules
1046 .into_iter()
1047 .filter(|m| !m.image.debug_id.is_empty())
1048 .map(|m| m.image)
1049 .filter(|image| {
1050 frames
1051 .iter()
1052 .any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
1053 })
1054 .collect()
1055}
1056
1057#[inline(never)]
1063fn capture_raw_application_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1064 let pinned = [
1065 capture_frames_current_first as *const () as u64,
1066 capture_raw_application_frames as *const () as u64,
1067 ];
1068 capture_raw_frames(is_internal_capture_frame, &pinned)
1069}
1070
1071fn capture_raw_panic_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1072 let modules = collect_loaded_modules();
1079 let frames = capture_frames_current_first(0, &modules);
1080 let images = referenced_images(modules, &frames);
1081 (frames, images)
1082}
1083
1084fn is_internal_capture_frame(function: &str) -> bool {
1085 function.starts_with("backtrace::")
1086 || function.contains("capture_frames_current_first")
1087 || function.contains("capture_raw_frames")
1088 || function.contains("capture_raw_application_frames")
1089 || function.contains("Exception::from_error")
1090 || function.contains("Exception::from_message")
1091 || function.contains("build_exception_event")
1092 || function.contains("Client::capture_exception")
1093 || function.contains("global::capture_exception")
1094}
1095
1096#[allow(deprecated)]
1098fn panic_message(panic_info: &panic::PanicInfo<'_>) -> String {
1099 let value = panic_info
1100 .payload()
1101 .downcast_ref::<&str>()
1102 .map(|value| (*value).to_string())
1103 .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
1104 .unwrap_or_else(|| "panic occurred".to_string());
1105
1106 if value.is_empty() {
1107 "panic occurred".to_string()
1108 } else {
1109 value
1110 }
1111}
1112
1113fn path_to_string(path: &std::path::Path) -> String {
1114 path.to_string_lossy().into_owned()
1115}
1116
1117fn simple_type_name(type_name: &str) -> String {
1124 let trimmed = type_name.trim().trim_start_matches('&').trim();
1125 let trimmed = trimmed.strip_prefix("dyn ").unwrap_or(trimmed).trim();
1126 let trimmed = trimmed
1127 .split_once('<')
1128 .map_or(trimmed, |(outer_type, _)| outer_type)
1129 .trim_end();
1130 if trimmed.is_empty() || trimmed == "core::error::Error" || trimmed == "std::error::Error" {
1133 return "Error".to_string();
1134 }
1135 trimmed.to_string()
1136}
1137
1138fn source_type_name(error: &(dyn StdError + 'static)) -> String {
1145 simple_type_name(type_name_of_val(error))
1146}
1147
1148fn link_exception_chain(exception_list: &mut [ExceptionItem]) {
1152 if exception_list.len() < 2 {
1153 return;
1154 }
1155 for (index, item) in exception_list.iter_mut().enumerate() {
1156 item.mechanism.exception_id = Some(index);
1157 if index > 0 {
1158 item.mechanism.parent_id = Some(index - 1);
1159 item.mechanism.mechanism_type = "chained".to_string();
1160 }
1161 }
1162}
1163
1164fn error_value<E>(error: &E) -> String
1165where
1166 E: StdError + ?Sized,
1167{
1168 let value = error.to_string();
1169 if value.is_empty() {
1170 "Error".to_string()
1171 } else {
1172 value
1173 }
1174}
1175
1176fn normalize_function_name(function: &str) -> String {
1183 let function = strip_crate_disambiguators(function);
1184 match function.rsplit_once("::") {
1185 Some((prefix, suffix)) if is_rust_symbol_hash(suffix) => prefix.to_string(),
1186 _ => function,
1187 }
1188}
1189
1190fn strip_crate_disambiguators(function: &str) -> String {
1191 let mut out = String::with_capacity(function.len());
1192 let mut rest = function;
1193 while let Some(open) = rest.find('[') {
1194 out.push_str(&rest[..open]);
1195 let bracketed = &rest[open..];
1196 match bracketed.find(']') {
1197 Some(close) => {
1198 let content = &bracketed[1..close];
1199 if !is_crate_disambiguator(content) {
1200 out.push_str(&bracketed[..=close]);
1201 }
1202 rest = &bracketed[close + 1..];
1203 }
1204 None => {
1205 out.push_str(bracketed);
1206 rest = "";
1207 }
1208 }
1209 }
1210 out.push_str(rest);
1211 out
1212}
1213
1214fn is_crate_disambiguator(content: &str) -> bool {
1217 content.len() >= 8
1218 && content
1219 .chars()
1220 .all(|ch| ch.is_ascii_digit() || ('a'..='f').contains(&ch))
1221}
1222
1223fn is_rust_symbol_hash(segment: &str) -> bool {
1224 segment.len() >= 9
1225 && segment.starts_with('h')
1226 && segment[1..].chars().all(|ch| ch.is_ascii_hexdigit())
1227}
1228
1229fn default_in_app_path(filename: &str) -> bool {
1230 let normalized = filename.replace('\\', "/");
1231 if normalized.contains("/.cargo/registry/")
1232 || normalized.contains("/.cargo/git/")
1233 || normalized.contains("/rustc/")
1234 || normalized.contains("/rustc-")
1235 || normalized.contains("/library/alloc/src/")
1236 || normalized.contains("/library/core/src/")
1237 || normalized.contains("/library/proc_macro/src/")
1238 || normalized.contains("/library/std/src/")
1239 || normalized.contains("/library/test/src/")
1240 || normalized.contains("/toolchains/")
1241 || normalized.contains("/target/")
1242 || normalized.contains("/vendor/")
1243 {
1244 return false;
1245 }
1246
1247 true
1248}
1249
1250fn default_in_app_function(function: &str) -> bool {
1251 if function.is_empty()
1256 || function == "_main"
1257 || function == "rust_begin_unwind"
1258 || function.starts_with("__rust")
1259 || function.starts_with("___rust")
1260 {
1261 return false;
1262 }
1263
1264 !matches!(
1265 function
1266 .trim_start_matches('<')
1267 .split("::")
1268 .next()
1269 .unwrap_or_default(),
1270 "alloc"
1271 | "anyhow"
1272 | "backtrace"
1273 | "color_eyre"
1274 | "core"
1275 | "eyre"
1276 | "futures_core"
1277 | "futures_util"
1278 | "log"
1279 | "posthog_rs"
1280 | "reqwest"
1281 | "std"
1282 | "stable_eyre"
1283 | "tokio"
1284 | "tracing"
1285 | "tracing_core"
1286 )
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use std::error::Error as StdError;
1292 use std::fmt;
1293 use std::sync::atomic::{AtomicBool, Ordering};
1294 use std::sync::{Arc, Mutex, OnceLock};
1295
1296 use httpmock::prelude::*;
1297 use serde_json::{json, Value};
1298
1299 use super::*;
1300 use crate::client::ClientOptionsBuilder;
1301 use crate::event::InnerEvent;
1302
1303 #[derive(Debug)]
1304 struct OuterError {
1305 source: InnerError,
1306 }
1307
1308 impl fmt::Display for OuterError {
1309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1310 write!(f, "checkout failed")
1311 }
1312 }
1313
1314 impl StdError for OuterError {
1315 fn source(&self) -> Option<&(dyn StdError + 'static)> {
1316 Some(&self.source)
1317 }
1318 }
1319
1320 #[derive(Debug)]
1321 struct InnerError;
1322
1323 impl fmt::Display for InnerError {
1324 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1325 write!(f, "database unavailable")
1326 }
1327 }
1328
1329 impl StdError for InnerError {}
1330
1331 #[derive(Debug)]
1332 struct BorrowedError<'a>(&'a str);
1333
1334 impl fmt::Display for BorrowedError<'_> {
1335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1336 f.write_str(self.0)
1337 }
1338 }
1339
1340 impl StdError for BorrowedError<'_> {}
1341
1342 fn built_event_json(mut event: Event) -> Value {
1343 event.prepare_for_v0();
1344 serde_json::to_value(InnerEvent::new(event, "api-key".to_string())).unwrap()
1345 }
1346
1347 fn event_json_with(exception: Exception, options: &ErrorTrackingOptions) -> Value {
1348 let mut event = Event::new_anon("$exception");
1349 exception.write_into(&mut event, options).unwrap();
1350 built_event_json(event)
1351 }
1352
1353 fn event_json(exception: Exception) -> Value {
1354 event_json_with(exception, &ErrorTrackingOptions::default())
1355 }
1356
1357 #[allow(deprecated)]
1358 type PanicHook = Box<dyn Fn(&panic::PanicInfo<'_>) + Sync + Send + 'static>;
1359
1360 struct PanicHookReset {
1363 previous: Option<PanicHook>,
1364 }
1365
1366 impl PanicHookReset {
1367 fn new(previous: PanicHook) -> Self {
1368 Self {
1369 previous: Some(previous),
1370 }
1371 }
1372
1373 fn restore(&mut self) {
1374 if let Some(previous) = self.previous.take() {
1375 panic::set_hook(previous);
1376 }
1377 PANIC_HOOK_INSTALLED.store(false, Ordering::Release);
1378 }
1379 }
1380
1381 impl Drop for PanicHookReset {
1382 fn drop(&mut self) {
1383 if !std::thread::panicking() {
1384 self.restore();
1385 }
1386 }
1387 }
1388
1389 fn panic_hook_test_lock() -> &'static Mutex<()> {
1392 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1393 LOCK.get_or_init(|| Mutex::new(()))
1394 }
1395
1396 #[cfg(feature = "async-client")]
1400 fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1401 Arc::new(futures::executor::block_on(crate::client::client(options)))
1402 }
1403
1404 #[cfg(not(feature = "async-client"))]
1405 fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1406 Arc::new(crate::client::client(options))
1407 }
1408
1409 #[inline(never)]
1410 fn panic_hook_test_panic_site() {
1411 panic!("panic hook boom");
1412 }
1413
1414 #[inline(never)]
1415 fn panic_hook_disabled_test_panic_site() {
1416 panic!("disabled panic hook boom");
1417 }
1418
1419 fn request_has_panic_payload(req: &HttpMockRequest) -> bool {
1422 let Some(body) = req.body.as_deref() else {
1423 return false;
1424 };
1425 let Ok(body) = serde_json::from_slice::<Value>(body) else {
1426 return false;
1427 };
1428 let event = &body["batch"][0];
1429 let exception = &event["properties"]["$exception_list"][0];
1430 let frames = exception["stacktrace"]["frames"].as_array();
1431
1432 let has_panic_site = frames.is_some_and(|frames| {
1435 frames.iter().any(|frame| {
1436 frame["function"]
1437 .as_str()
1438 .is_some_and(|name| name.contains("panic_hook_test_panic_site"))
1439 })
1440 });
1441 let has_machinery_not_in_app = frames.is_some_and(|frames| {
1444 frames.iter().any(|frame| {
1445 frame["in_app"] == false
1446 && frame["function"].as_str().is_some_and(|name| {
1447 name.contains("panicking") || name == "rust_begin_unwind"
1448 })
1449 })
1450 });
1451
1452 event["event"] == "$exception"
1453 && (event["properties"]["$process_person_profile"] == false
1456 || event["options"]["process_person_profile"] == false)
1457 && event["properties"]["$exception_level"] == "fatal"
1458 && exception["type"] == "Panic"
1459 && exception["value"] == "panic hook boom"
1460 && exception["mechanism"]["type"] == "panic"
1461 && exception["mechanism"]["handled"] == false
1462 && event["properties"]["$exception_panic_file"]
1463 .as_str()
1464 .is_some_and(|file| file.contains("error_tracking.rs"))
1465 && event["properties"]["$exception_panic_line"]
1466 .as_u64()
1467 .is_some_and(|line| line > 0)
1468 && event["properties"]["$exception_panic_column"]
1469 .as_u64()
1470 .is_some_and(|column| column > 0)
1471 && has_panic_site
1472 && has_machinery_not_in_app
1473 }
1474
1475 #[test]
1476 fn panic_hook_sends_personless_exception_and_calls_previous_hook() {
1477 let _guard = panic_hook_test_lock()
1478 .lock()
1479 .unwrap_or_else(|e| e.into_inner());
1480 let original_hook = panic::take_hook();
1481 let mut reset = PanicHookReset::new(original_hook);
1482 let previous_called = Arc::new(AtomicBool::new(false));
1483 let previous_called_for_hook = Arc::clone(&previous_called);
1484 panic::set_hook(Box::new(move |_| {
1485 previous_called_for_hook.store(true, Ordering::Release);
1486 }));
1487
1488 let server = MockServer::start();
1489 let capture_mock = server.mock(|when, then| {
1490 when.method(POST).matches(request_has_panic_payload);
1491 then.status(200);
1492 });
1493 let options = ClientOptionsBuilder::default()
1494 .api_key("test_api_key".to_string())
1495 .host(server.base_url())
1496 .build()
1497 .unwrap();
1498 let client = build_test_client(options);
1499
1500 install_panic_hook(Arc::clone(&client)).unwrap();
1501 assert!(matches!(
1502 install_panic_hook(Arc::clone(&client)),
1503 Err(Error::PanicHookAlreadyInstalled)
1504 ));
1505
1506 let result = panic::catch_unwind(panic_hook_test_panic_site);
1507 reset.restore();
1508
1509 assert!(result.is_err());
1510 assert!(previous_called.load(Ordering::Acquire));
1511 capture_mock.assert_hits(1);
1512 }
1513
1514 #[test]
1515 fn disabled_panic_hook_does_not_send() {
1516 let _guard = panic_hook_test_lock()
1517 .lock()
1518 .unwrap_or_else(|e| e.into_inner());
1519 let original_hook = panic::take_hook();
1520 let mut reset = PanicHookReset::new(original_hook);
1521 panic::set_hook(Box::new(|_| {}));
1522
1523 let server = MockServer::start();
1524 let capture_mock = server.mock(|when, then| {
1525 when.method(POST);
1526 then.status(200);
1527 });
1528 let options = ClientOptionsBuilder::default()
1529 .api_key("test_api_key".to_string())
1530 .host(server.base_url())
1531 .disabled(true)
1532 .build()
1533 .unwrap();
1534 let client = build_test_client(options);
1535
1536 install_panic_hook(client).unwrap();
1537 let result = panic::catch_unwind(panic_hook_disabled_test_panic_site);
1538 reset.restore();
1539
1540 assert!(result.is_err());
1541 capture_mock.assert_hits(0);
1542 }
1543
1544 #[cfg(feature = "async-client")]
1548 #[test]
1549 fn panic_hook_captures_panics_on_tokio_runtime_threads() {
1550 let _guard = panic_hook_test_lock()
1551 .lock()
1552 .unwrap_or_else(|e| e.into_inner());
1553 let original_hook = panic::take_hook();
1554 let mut reset = PanicHookReset::new(original_hook);
1555 panic::set_hook(Box::new(|_| {}));
1556
1557 let server = MockServer::start();
1558 let capture_mock = server.mock(|when, then| {
1559 when.method(POST)
1560 .body_contains(r#""value":"tokio task boom""#);
1561 then.status(200);
1562 });
1563 let options = ClientOptionsBuilder::default()
1564 .api_key("test_api_key".to_string())
1565 .host(server.base_url())
1566 .build()
1567 .unwrap();
1568 install_panic_hook(build_test_client(options)).unwrap();
1569
1570 let runtime = tokio::runtime::Builder::new_multi_thread()
1571 .worker_threads(1)
1572 .enable_all()
1573 .build()
1574 .unwrap();
1575 let result = runtime.block_on(async {
1576 tokio::spawn(async {
1577 panic!("tokio task boom");
1578 })
1579 .await
1580 });
1581 drop(runtime);
1582
1583 let current_thread = tokio::runtime::Builder::new_current_thread()
1586 .enable_all()
1587 .build()
1588 .unwrap();
1589 let current_result = current_thread.block_on(async {
1590 panic::catch_unwind(AssertUnwindSafe(|| panic!("tokio task boom")))
1591 });
1592 drop(current_thread);
1593 reset.restore();
1594
1595 assert!(result.is_err());
1596 assert!(current_result.is_err());
1597 capture_mock.assert_hits(2);
1598 }
1599
1600 #[test]
1601 fn panic_in_before_send_on_worker_neither_deadlocks_nor_recurses() {
1602 let _guard = panic_hook_test_lock()
1608 .lock()
1609 .unwrap_or_else(|e| e.into_inner());
1610 let original_hook = panic::take_hook();
1611 let mut reset = PanicHookReset::new(original_hook);
1612 panic::set_hook(Box::new(|_| {}));
1613
1614 let server = MockServer::start();
1615 let _capture_mock = server.mock(|when, then| {
1616 when.method(POST);
1617 then.status(200);
1618 });
1619 let options = ClientOptionsBuilder::default()
1620 .api_key("test_api_key".to_string())
1621 .host(server.base_url())
1622 .before_send(|_event| panic!("before_send boom"))
1623 .build()
1624 .unwrap();
1625 let client = build_test_client(options);
1626 install_panic_hook(Arc::clone(&client)).unwrap();
1627
1628 let finished = Arc::new(AtomicBool::new(false));
1629 let finished_for_worker = Arc::clone(&finished);
1630 let work_client = Arc::clone(&client);
1631 let _worker = std::thread::spawn(move || {
1632 work_client.capture(Event::new("boom", "user-1"));
1633 work_client.flush_blocking();
1636 finished_for_worker.store(true, Ordering::Release);
1637 });
1638
1639 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1640 while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
1641 std::thread::sleep(std::time::Duration::from_millis(20));
1642 }
1643 reset.restore();
1644
1645 assert!(
1646 finished.load(Ordering::Acquire),
1647 "panic in before_send on the worker thread deadlocked or recursed"
1648 );
1649 }
1653
1654 #[test]
1655 fn panic_hook_flush_is_bounded_when_before_send_needs_a_panic_held_lock() {
1656 let _guard = panic_hook_test_lock()
1664 .lock()
1665 .unwrap_or_else(|e| e.into_inner());
1666 let original_hook = panic::take_hook();
1667 let mut reset = PanicHookReset::new(original_hook);
1668 panic::set_hook(Box::new(|_| {}));
1669
1670 static SHARED: Mutex<()> = Mutex::new(());
1673
1674 let server = MockServer::start();
1675 let _capture_mock = server.mock(|when, then| {
1676 when.method(POST);
1677 then.status(200);
1678 });
1679 let options = ClientOptionsBuilder::default()
1680 .api_key("test_api_key".to_string())
1681 .host(server.base_url())
1682 .before_send(|event| {
1683 let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
1684 Some(event)
1685 })
1686 .build()
1687 .unwrap();
1688 let client = build_test_client(options);
1689 install_panic_hook(Arc::clone(&client)).unwrap();
1690
1691 let finished = Arc::new(AtomicBool::new(false));
1692 let finished_for_panicker = Arc::clone(&finished);
1693 let _panicker = std::thread::spawn(move || {
1694 {
1695 let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
1699 let _ = panic::catch_unwind(AssertUnwindSafe(|| {
1700 panic!("boom while holding a before_send lock")
1701 }));
1702 }
1703 finished_for_panicker.store(true, Ordering::Release);
1704 });
1705
1706 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1707 while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
1708 std::thread::sleep(std::time::Duration::from_millis(20));
1709 }
1710 reset.restore();
1711
1712 assert!(
1713 finished.load(Ordering::Acquire),
1714 "panic hook flush hung on a before_send that needed a panic-held lock"
1715 );
1716 }
1719
1720 #[test]
1721 fn global_capture_panics_defaults_off_and_is_configurable() {
1722 assert!(
1723 !ErrorTrackingOptions::default().capture_panics(),
1724 "panic autocapture is opt-in (off by default)"
1725 );
1726 let enabled = ErrorTrackingOptionsBuilder::default()
1727 .capture_panics(true)
1728 .build()
1729 .unwrap();
1730 assert!(enabled.capture_panics(), "capture_panics is configurable");
1731 }
1732
1733 #[test]
1734 fn should_capture_global_panics_gates_on_enabled_and_flag() {
1735 let enabled = build_test_client(
1736 ClientOptionsBuilder::default()
1737 .api_key("test_api_key".to_string())
1738 .error_tracking(
1739 ErrorTrackingOptionsBuilder::default()
1740 .capture_panics(true)
1741 .build()
1742 .unwrap(),
1743 )
1744 .build()
1745 .unwrap(),
1746 );
1747 assert!(should_capture_global_panics(&enabled));
1748
1749 let disabled = build_test_client(
1751 ClientOptionsBuilder::default()
1752 .api_key("test_api_key".to_string())
1753 .disabled(true)
1754 .error_tracking(
1755 ErrorTrackingOptionsBuilder::default()
1756 .capture_panics(true)
1757 .build()
1758 .unwrap(),
1759 )
1760 .build()
1761 .unwrap(),
1762 );
1763 assert!(
1764 !should_capture_global_panics(&disabled),
1765 "a disabled client must not latch the process-wide hook"
1766 );
1767
1768 let default_off = build_test_client(
1770 ClientOptionsBuilder::default()
1771 .api_key("test_api_key".to_string())
1772 .build()
1773 .unwrap(),
1774 );
1775 assert!(!should_capture_global_panics(&default_off));
1776 }
1777
1778 #[test]
1779 fn install_panic_hook_on_disabled_client_does_not_latch() {
1780 let _guard = panic_hook_test_lock()
1781 .lock()
1782 .unwrap_or_else(|e| e.into_inner());
1783 let original_hook = panic::take_hook();
1784 let mut reset = PanicHookReset::new(original_hook);
1785
1786 let disabled = build_test_client(
1787 ClientOptionsBuilder::default()
1788 .api_key("test_api_key".to_string())
1789 .disabled(true)
1790 .build()
1791 .unwrap(),
1792 );
1793 let result = install_panic_hook(disabled);
1794 let latched = PANIC_HOOK_INSTALLED.load(Ordering::Acquire);
1795
1796 reset.restore();
1799 assert!(result.is_ok(), "installing on a disabled client returns Ok");
1800 assert!(
1801 !latched,
1802 "a disabled client must not latch the process-wide hook"
1803 );
1804 }
1805
1806 #[test]
1807 fn panic_machinery_frames_classify_out_of_app() {
1808 let options = ErrorTrackingOptions::default();
1812 for not_in_app in [
1813 "std::panicking::begin_panic_handler",
1814 "core::panicking::panic_fmt",
1815 "std::panic::catch_unwind",
1816 "std::sys::backtrace::__rust_begin_short_backtrace",
1817 "rust_begin_unwind",
1818 "__rust_try",
1819 "backtrace::backtrace::trace",
1820 "posthog_rs::error_tracking::capture_panic",
1821 "posthog_rs::error_tracking::install_hook::{{closure}}",
1822 "core::ops::function::FnOnce::call_once",
1823 "tokio::runtime::task::raw::poll",
1824 "futures_util::future::FutureExt::poll",
1825 "anyhow::error::Error::msg",
1826 "eyre::Report::msg",
1827 "color_eyre::config::EyreHook::into_eyre_hook::{{closure}}",
1828 "tracing::span::Span::record",
1829 "tracing_core::dispatcher::get_default",
1830 "log::__private_api::log",
1831 ] {
1832 assert!(
1833 !options.is_in_app_frame(None, Some(not_in_app)),
1834 "{} should classify as not in-app",
1835 not_in_app
1836 );
1837 }
1838
1839 for in_app in [
1840 "my_app::checkout::process_payment",
1841 "checkout_service::submit",
1842 ] {
1843 assert!(
1844 options.is_in_app_frame(None, Some(in_app)),
1845 "{} should classify as in-app",
1846 in_app
1847 );
1848 }
1849 }
1850
1851 #[test]
1852 fn function_names_strip_v0_crate_disambiguators() {
1853 assert_eq!(
1855 normalize_function_name("std[b887e3750a86e3a0]::panicking::panic_with_hook"),
1856 "std::panicking::panic_with_hook"
1857 );
1858 assert_eq!(
1859 normalize_function_name(
1860 "<alloc[8a71accd1b3711a1]::boxed::Box<dyn core[e000b89356eb4406]::ops::function::Fn<(&std[b887e3750a86e3a0]::panic::PanicHookInfo,)>> as core[e000b89356eb4406]::ops::function::Fn<(&std[b887e3750a86e3a0]::panic::PanicHookInfo,)>>::call"
1861 ),
1862 "<alloc::boxed::Box<dyn core::ops::function::Fn<(&std::panic::PanicHookInfo,)>> as core::ops::function::Fn<(&std::panic::PanicHookInfo,)>>::call"
1863 );
1864 assert_eq!(
1866 normalize_function_name("core::array::<impl [u8; 32]>::map"),
1867 "core::array::<impl [u8; 32]>::map"
1868 );
1869 assert_eq!(
1870 normalize_function_name("<[u8] as checkout_service::Digest>::digest"),
1871 "<[u8] as checkout_service::Digest>::digest"
1872 );
1873 }
1874
1875 #[test]
1876 fn from_error_builds_exception_list_with_stacktrace() {
1877 let error = OuterError { source: InnerError };
1878 let event = build_exception_event(
1879 &error,
1880 CaptureExceptionOptions::new().distinct_id("user-1"),
1881 &ErrorTrackingOptions::default(),
1882 )
1883 .unwrap();
1884 let json = built_event_json(event);
1885
1886 assert_eq!(json["event"], "$exception");
1887 assert_eq!(json["distinct_id"], "user-1");
1888 assert_eq!(json["properties"]["$exception_level"], "error");
1889
1890 let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
1891 assert!(exception_list[0]["type"]
1892 .as_str()
1893 .unwrap()
1894 .ends_with("OuterError"));
1895 assert_eq!(exception_list[0]["value"], "checkout failed");
1896 assert_eq!(exception_list[0]["mechanism"]["type"], "generic");
1897 assert_eq!(exception_list[0]["mechanism"]["handled"], true);
1898 assert_eq!(exception_list[0]["mechanism"]["synthetic"], false);
1899 assert_eq!(exception_list[0]["mechanism"]["exception_id"], 0);
1900 assert_eq!(exception_list[0]["stacktrace"]["type"], "raw");
1901 assert_eq!(exception_list[1]["value"], "database unavailable");
1902 assert_eq!(exception_list[1]["mechanism"]["type"], "chained");
1903 assert_eq!(exception_list[1]["mechanism"]["exception_id"], 1);
1904 assert_eq!(exception_list[1]["mechanism"]["parent_id"], 0);
1905
1906 let frames = exception_list[0]["stacktrace"]["frames"]
1907 .as_array()
1908 .expect("expected stack frames");
1909 let top_frame = frames.first().expect("expected top frame");
1910 assert_eq!(top_frame["platform"], "native");
1911 assert_eq!(top_frame["lang"], "rust");
1912 let instruction_addr = top_frame["instruction_addr"].as_str().unwrap_or_default();
1913 assert!(
1914 instruction_addr.starts_with("0x"),
1915 "expected hex instruction_addr, got {:?}",
1916 instruction_addr
1917 );
1918 let top_function = top_frame["function"].as_str().unwrap_or_default();
1919 assert!(
1920 top_function.contains("from_error_builds_exception_list_with_stacktrace"),
1921 "expected user frame first, got {:?}",
1922 top_function
1923 );
1924 assert!(
1925 !top_function.contains("Exception::"),
1926 "expected SDK frames to be skipped, got {:?}",
1927 top_function
1928 );
1929 }
1930
1931 #[test]
1932 fn gnu_build_ids_convert_to_debug_ids_like_the_server() {
1933 let build_id: Vec<u8> = (0..20)
1936 .map(|i| {
1937 u8::from_str_radix(
1938 &"555398ebd01c90285a3d85138a19cbf9bbcec352"[i * 2..i * 2 + 2],
1939 16,
1940 )
1941 .unwrap()
1942 })
1943 .collect();
1944 let (full, short) = if cfg!(target_endian = "little") {
1948 (
1949 "eb985355-1cd0-2890-5a3d-85138a19cbf9",
1950 "0000cdab-0000-0000-0000-000000000000",
1951 )
1952 } else {
1953 (
1954 "555398eb-d01c-9028-5a3d-85138a19cbf9",
1955 "abcd0000-0000-0000-0000-000000000000",
1956 )
1957 };
1958 assert_eq!(debug_id_from_gnu_build_id(&build_id).as_deref(), Some(full));
1959
1960 assert_eq!(
1962 debug_id_from_gnu_build_id(&[0xab, 0xcd]).as_deref(),
1963 Some(short)
1964 );
1965 assert_eq!(debug_id_from_gnu_build_id(&[]), None);
1966 }
1967
1968 #[test]
1969 fn arch_normalizes_to_the_shared_native_vocabulary() {
1970 assert_eq!(normalize_arch("aarch64"), "arm64");
1973 assert_eq!(normalize_arch("x86_64"), "x86_64");
1974 assert_eq!(normalize_arch("arm"), "arm");
1975 }
1976
1977 #[test]
1978 fn find_module_matches_address_ranges() {
1979 let module_at = |base: u64, size: u64| LoadedModule {
1980 base,
1981 end: base + size,
1982 image: DebugImage {
1983 image_type: "elf".to_string(),
1984 debug_id: "test".to_string(),
1985 code_id: None,
1986 image_addr: format!("0x{base:x}"),
1987 image_size: Some(size),
1988 image_vmaddr: None,
1989 code_file: None,
1990 arch: "x86_64".to_string(),
1991 },
1992 };
1993
1994 let modules = vec![module_at(0x1000, 0x1000), module_at(0x4000, 0x1000)];
1995
1996 assert_eq!(find_module(&modules, 0x1500).map(|m| m.base), Some(0x1000));
1997 assert_eq!(find_module(&modules, 0x4000).map(|m| m.base), Some(0x4000));
1998 assert!(find_module(&modules, 0x2000).is_none()); assert!(find_module(&modules, 0x500).is_none()); assert!(find_module(&modules, 0x5000).is_none()); }
2002
2003 #[test]
2004 fn captured_stacks_reference_loaded_debug_images() {
2005 let json = event_json(Exception::from_message(
2006 "AddrCheck",
2007 "captures addresses",
2008 true,
2009 ));
2010
2011 let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2012 .as_array()
2013 .expect("expected stack frames");
2014
2015 let mut saw_instruction_addr = false;
2020 for frame in frames {
2021 let Some(addr) = frame["instruction_addr"].as_str() else {
2022 continue;
2023 };
2024 saw_instruction_addr = true;
2025 assert!(
2026 addr.starts_with("0x") && u64::from_str_radix(&addr[2..], 16).is_ok(),
2027 "expected hex instruction_addr, got {:?}",
2028 frame["instruction_addr"]
2029 );
2030 }
2031 assert!(
2032 saw_instruction_addr,
2033 "expected at least one frame to carry an instruction_addr"
2034 );
2035
2036 let images = json["properties"]["$debug_images"]
2040 .as_array()
2041 .expect("expected $debug_images");
2042 assert!(!images.is_empty());
2043 let expected_type = super::native_image_type();
2044 let expected_arch = super::normalize_arch(std::env::consts::ARCH);
2045 for image in images {
2046 assert_eq!(image["type"].as_str(), Some(expected_type));
2047 assert_eq!(
2048 image["arch"].as_str(),
2049 Some(expected_arch.as_str()),
2050 "arch should match the running process"
2051 );
2052 let debug_id = image["debug_id"].as_str().unwrap_or_default();
2053 assert!(
2054 debug_id.len() >= 36,
2055 "expected uuid-shaped debug_id, got {:?}",
2056 debug_id
2057 );
2058 let image_addr = image["image_addr"].as_str().unwrap_or_default();
2059 assert!(
2060 frames
2061 .iter()
2062 .any(|f| f["image_addr"].as_str() == Some(image_addr)),
2063 "image {} not referenced by any frame",
2064 image_addr
2065 );
2066 }
2067 }
2068
2069 #[test]
2070 fn from_error_accepts_borrowed_error_types() {
2071 let message = String::from("borrowed parse failure");
2072 let error = BorrowedError(&message);
2073 let json = event_json(Exception::from_error(&error, true));
2074
2075 assert_eq!(
2076 json["properties"]["$exception_list"][0]["value"],
2077 "borrowed parse failure"
2078 );
2079 }
2080
2081 #[test]
2082 fn personless_capture_disables_person_profile() {
2083 let json = event_json(Exception::from_message("Error", "no user context", true));
2084
2085 assert_eq!(json["event"], "$exception");
2086 assert_eq!(json["properties"]["$process_person_profile"], false);
2087 }
2088
2089 #[test]
2090 fn custom_properties_cannot_override_reserved_exception_payload() {
2091 let error = OuterError { source: InnerError };
2092 let event = build_exception_event(
2093 &error,
2094 CaptureExceptionOptions::new()
2095 .property("$exception_list", json!([{"value": "fake"}]))
2096 .unwrap(),
2097 &ErrorTrackingOptions::default(),
2098 )
2099 .unwrap();
2100
2101 let json = built_event_json(event);
2102 assert_eq!(
2103 json["properties"]["$exception_list"][0]["value"],
2104 "checkout failed"
2105 );
2106 }
2107
2108 #[test]
2109 fn options_can_disable_stacktrace() {
2110 let options = ErrorTrackingOptionsBuilder::default()
2111 .capture_stacktrace(false)
2112 .build()
2113 .unwrap();
2114 let error = OuterError { source: InnerError };
2115 let event =
2116 build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2117 let json = built_event_json(event);
2118
2119 let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
2120 assert_eq!(exception_list.len(), 2);
2121 assert!(exception_list[0].get("stacktrace").is_none());
2122 }
2123
2124 #[test]
2125 fn in_app_path_defaults_and_overrides_are_applied() {
2126 let options = ErrorTrackingOptions::default();
2127 assert!(options.is_in_app_path("/app/src/main.rs"));
2128 assert!(!options.is_in_app_path("/home/user/.cargo/registry/src/lib.rs"));
2129 assert!(!options.is_in_app_path(
2130 "/private/tmp/nix-build-rustc-1.91.1/rustc-1.91.1-src/library/core/src/ops/function.rs"
2131 ));
2132 assert!(options.is_in_app_frame(None, Some("checkout_service::submit")));
2133 assert!(!options.is_in_app_frame(None, Some("std::rt::lang_start")));
2134 assert!(!options.is_in_app_frame(None, Some("core::ops::function::FnOnce::call_once")));
2135 assert!(
2136 !options.is_in_app_frame(None, Some("posthog_rs::client::Client::capture_exception"))
2137 );
2138 assert!(!options.is_in_app_frame(None, Some("_main")));
2139
2140 let options = ErrorTrackingOptionsBuilder::default()
2141 .in_app_include_paths(vec!["/service/".to_string(), "my_service::".to_string()])
2142 .in_app_exclude_paths(vec!["/service/vendor/".to_string()])
2143 .build()
2144 .unwrap();
2145
2146 assert!(options.is_in_app_path("/service/src/main.rs"));
2147 assert!(!options.is_in_app_path("/other/src/main.rs"));
2148 assert!(!options.is_in_app_path("/service/vendor/lib.rs"));
2149 assert!(options.is_in_app_frame(None, Some("my_service::checkout")));
2150 assert!(!options.is_in_app_frame(None, Some("other_service::checkout")));
2151 }
2152
2153 #[test]
2154 fn function_names_strip_rust_symbol_hashes() {
2155 assert_eq!(
2156 normalize_function_name("checkout_service::submit::h9ae4817223dd0b22"),
2157 "checkout_service::submit"
2158 );
2159 assert_eq!(
2160 normalize_function_name("std::rt::lang_start::{{closure}}::ha1fd5c62e470a8cc"),
2161 "std::rt::lang_start::{{closure}}"
2162 );
2163 assert_eq!(
2164 normalize_function_name("checkout_service::submit"),
2165 "checkout_service::submit"
2166 );
2167 }
2168
2169 #[test]
2170 fn type_names_keep_path_and_strip_generics() {
2171 assert_eq!(
2173 simple_type_name("std::io::error::Error"),
2174 "std::io::error::Error"
2175 );
2176 assert_eq!(
2177 simple_type_name("mycrate::CheckoutError"),
2178 "mycrate::CheckoutError"
2179 );
2180 assert_eq!(simple_type_name("mycrate::Error"), "mycrate::Error");
2181
2182 assert_eq!(simple_type_name("foo::Bar<baz::Qux>"), "foo::Bar");
2184 assert_eq!(
2185 simple_type_name(type_name::<Box<dyn StdError>>()),
2186 "alloc::boxed::Box"
2187 );
2188
2189 assert_eq!(simple_type_name("dyn core::error::Error"), "Error");
2191 assert_eq!(simple_type_name(type_name::<&dyn StdError>()), "Error");
2192 }
2193
2194 #[test]
2195 fn frames_are_trimmed_to_max_frames_keeping_the_top() {
2196 let synthetic_frame = |index: usize| StackFrame {
2197 filename: None,
2198 line_no: None,
2199 function: format!("frame_{index}"),
2200 lang: "rust".to_string(),
2201 in_app: true,
2202 synthetic: false,
2203 platform: "native".to_string(),
2204 instruction_addr: None,
2205 symbol_addr: None,
2206 image_addr: None,
2207 client_resolved: false,
2208 };
2209 let exception = Exception {
2210 items: vec![ExceptionItem {
2211 exception_type: "Error".to_string(),
2212 value: "trimmed".to_string(),
2213 mechanism: ExceptionMechanism::default(),
2214 stacktrace: None,
2215 }],
2216 captured_frames: Some((0..MAX_FRAMES + 5).map(synthetic_frame).collect()),
2217 captured_images: Vec::new(),
2218 fingerprint: None,
2219 level: "error".to_string(),
2220 };
2221
2222 let json = event_json_with(exception, &ErrorTrackingOptions::default());
2223 let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2224 .as_array()
2225 .expect("expected stack frames");
2226 assert_eq!(frames.len(), MAX_FRAMES);
2227 assert_eq!(frames[0]["function"], "frame_0");
2229 }
2230
2231 #[test]
2232 fn stacktrace_keeps_top_frame_first() {
2233 fn capture() -> ExceptionStacktrace {
2234 let mut frames = capture_frames_current_first(0, &[]);
2237 trim_to_max_frames(&mut frames, 8);
2238 ExceptionStacktrace::raw(frames)
2239 }
2240
2241 let frames = capture().frames;
2242 let functions: Vec<&str> = frames
2243 .iter()
2244 .map(|frame| frame.function.as_str())
2245 .filter(|function| !function.is_empty())
2246 .collect();
2247
2248 let capture_index = functions
2249 .iter()
2250 .position(|function| function.contains("stacktrace_keeps_top_frame_first::capture"))
2251 .expect("expected capture frame");
2252 let test_index = functions
2253 .iter()
2254 .position(|function| function.ends_with("stacktrace_keeps_top_frame_first"))
2255 .expect("expected test frame");
2256
2257 assert!(
2258 capture_index < test_index,
2259 "expected top frame before caller, got {:?}",
2260 functions
2261 );
2262 }
2263
2264 #[test]
2265 fn inlined_frames_collapse_for_server_side_expansion() {
2266 #[inline(always)]
2274 fn inline_leaf() -> Vec<StackFrame> {
2275 capture_raw_application_frames().0
2276 }
2277
2278 #[inline(always)]
2279 fn inline_mid() -> Vec<StackFrame> {
2280 inline_leaf()
2281 }
2282
2283 let frames = inline_mid();
2284 let functions: Vec<&str> = frames.iter().map(|frame| frame.function.as_str()).collect();
2285
2286 let mut addrs: Vec<&str> = frames
2290 .iter()
2291 .filter_map(|frame| frame.instruction_addr.as_deref())
2292 .collect();
2293 let emitted = addrs.len();
2294 addrs.sort_unstable();
2295 addrs.dedup();
2296 assert_eq!(
2297 addrs.len(),
2298 emitted,
2299 "instruction_addr duplicated across frames: {:?}",
2300 frames
2301 );
2302
2303 assert!(
2307 frames
2308 .iter()
2309 .all(|f| f.client_resolved == f.instruction_addr.is_none()),
2310 "client_resolved must be the inverse of instruction_addr presence: {:?}",
2311 frames
2312 );
2313
2314 let leaf = functions
2315 .iter()
2316 .filter(|f| f.contains("inline_leaf"))
2317 .count();
2318 let mid = functions
2319 .iter()
2320 .filter(|f| f.contains("inline_mid"))
2321 .count();
2322
2323 if frames.iter().any(|frame| frame.instruction_addr.is_some()) {
2324 assert!(
2327 leaf == 0 && mid == 0,
2328 "expected inlined layers collapsed for server-side expansion, got {:?}",
2329 functions
2330 );
2331 } else {
2332 let leaf_index = functions.iter().position(|f| f.contains("inline_leaf"));
2335 let mid_index = functions.iter().position(|f| f.contains("inline_mid"));
2336 assert!(
2337 matches!((leaf_index, mid_index), (Some(l), Some(m)) if l < m),
2338 "expected client-side inline expansion innermost first, got {:?}",
2339 functions
2340 );
2341 }
2342 }
2343
2344 #[test]
2345 fn build_exception_event_defaults_to_personless() {
2346 let error = OuterError { source: InnerError };
2347 let event = build_exception_event(
2348 &error,
2349 CaptureExceptionOptions::default(),
2350 &ErrorTrackingOptions::default(),
2351 )
2352 .unwrap();
2353 let json = built_event_json(event);
2354
2355 assert_eq!(json["event"], "$exception");
2356 assert_eq!(json["properties"]["$process_person_profile"], false);
2357 assert_eq!(json["properties"]["$exception_level"], "error");
2358 }
2359
2360 #[test]
2361 fn build_exception_event_applies_options() {
2362 let error = OuterError { source: InnerError };
2363 let options = CaptureExceptionOptions::new()
2364 .distinct_id("user-1")
2365 .property("route", "/checkout")
2366 .unwrap()
2367 .group("company", "acme")
2368 .fingerprint("checkout-error")
2369 .level("warning");
2370 let event =
2371 build_exception_event(&error, options, &ErrorTrackingOptions::default()).unwrap();
2372 let json = built_event_json(event);
2373
2374 assert_eq!(json["distinct_id"], "user-1");
2375 assert_eq!(json["properties"]["route"], "/checkout");
2376 assert_eq!(json["properties"]["$groups"]["company"], "acme");
2377 assert_eq!(
2378 json["properties"]["$exception_fingerprint"],
2379 "checkout-error"
2380 );
2381 assert_eq!(json["properties"]["$exception_level"], "warning");
2382 }
2383}