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};
6use std::sync::Arc;
7
8use derive_builder::Builder;
9use serde::Serialize;
10use serde_json::Value;
11
12use crate::{Client, Error, Event};
13
14const MAX_FRAMES: usize = 64;
17const MAX_ERROR_SOURCES: usize = 50;
20
21static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
23
24#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct TraceContext {
28 trace_id: String,
29 span_id: String,
30}
31
32impl TraceContext {
33 pub fn new<T: Into<String>, S: Into<String>>(trace_id: T, span_id: S) -> Self {
35 Self {
36 trace_id: trace_id.into(),
37 span_id: span_id.into(),
38 }
39 }
40}
41
42#[derive(Clone)]
43struct TraceContextProvider(Arc<dyn Fn() -> Option<TraceContext> + Send + Sync>);
44
45impl TraceContextProvider {
46 fn new<F>(provider: F) -> Self
47 where
48 F: Fn() -> Option<TraceContext> + Send + Sync + 'static,
49 {
50 Self(Arc::new(provider))
51 }
52
53 fn provide(&self) -> Option<TraceContext> {
54 match panic::catch_unwind(AssertUnwindSafe(|| (self.0)())) {
55 Ok(context) => context,
56 Err(_) => {
57 tracing::warn!(
58 "posthog-rs trace context provider panicked; omitting trace context"
59 );
60 None
61 }
62 }
63 }
64}
65
66impl Default for TraceContextProvider {
67 fn default() -> Self {
68 Self::new(|| None)
69 }
70}
71
72impl std::fmt::Debug for TraceContextProvider {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.write_str("TraceContextProvider(..)")
75 }
76}
77
78#[cfg(not(test))]
85const PANIC_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
86#[cfg(test)]
90const PANIC_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(200);
91
92#[derive(Builder, Clone, Debug)]
114#[builder(default)]
115pub struct ErrorTrackingOptions {
116 capture_stacktrace: bool,
126 in_app_include_paths: Vec<String>,
133 in_app_exclude_paths: Vec<String>,
137 capture_panics: bool,
145 #[builder(setter(custom))]
150 trace_context_provider: TraceContextProvider,
151}
152
153impl Default for ErrorTrackingOptions {
154 fn default() -> Self {
155 Self {
156 capture_stacktrace: true,
157 in_app_include_paths: Vec::new(),
158 in_app_exclude_paths: Vec::new(),
159 capture_panics: false,
160 trace_context_provider: TraceContextProvider::default(),
161 }
162 }
163}
164
165impl ErrorTrackingOptions {
166 fn capture_stacktrace(&self) -> bool {
167 self.capture_stacktrace
168 }
169
170 fn capture_panics(&self) -> bool {
171 self.capture_panics
172 }
173
174 fn trace_context(&self) -> Option<TraceContext> {
175 self.trace_context_provider.provide()
176 }
177
178 fn is_in_app_path(&self, filename: &str) -> bool {
179 if self
180 .in_app_exclude_paths
181 .iter()
182 .any(|path| filename.contains(path))
183 {
184 return false;
185 }
186
187 if !self.in_app_include_paths.is_empty() {
188 return self
189 .in_app_include_paths
190 .iter()
191 .any(|path| filename.contains(path));
192 }
193
194 default_in_app_path(filename)
195 }
196
197 fn is_in_app_frame(&self, filename: Option<&str>, function: Option<&str>) -> bool {
198 if self.in_app_exclude_paths.iter().any(|path| {
199 filename.is_some_and(|filename| filename.contains(path))
200 || function.is_some_and(|function| function.contains(path))
201 }) {
202 return false;
203 }
204
205 if !self.in_app_include_paths.is_empty() {
206 return self.in_app_include_paths.iter().any(|path| {
207 filename.is_some_and(|filename| filename.contains(path))
208 || function.is_some_and(|function| function.contains(path))
209 });
210 }
211
212 if filename.is_some_and(|filename| !self.is_in_app_path(filename)) {
213 return false;
214 }
215
216 if let Some(function) = function {
217 if !default_in_app_function(function) {
218 return false;
219 }
220 if filename.is_none() && is_bootstrap_symbol(function) {
225 return false;
226 }
227 return true;
228 }
229
230 filename.is_some()
231 }
232}
233
234impl ErrorTrackingOptionsBuilder {
235 pub fn trace_context_provider<F>(&mut self, provider: F) -> &mut Self
242 where
243 F: Fn() -> Option<TraceContext> + Send + Sync + 'static,
244 {
245 self.trace_context_provider = Some(TraceContextProvider::new(provider));
246 self
247 }
248}
249
250#[cfg(test)]
256fn install_panic_hook(client: Arc<Client>) -> Result<(), Error> {
257 if client.is_disabled() {
258 return Ok(());
259 }
260 install_hook(move |panic_info| capture_panic(&client, panic_info))
261}
262
263pub(crate) fn maybe_install_global_panic_hook() {
268 let Some(client) = crate::global::global_client() else {
269 return;
270 };
271 if !should_capture_global_panics(client) {
272 return;
273 }
274 let _ = install_hook(|panic_info| match crate::global::global_client() {
277 Some(client) => capture_panic(client, panic_info),
278 None => Ok(()),
279 });
280}
281
282fn should_capture_global_panics(client: &Client) -> bool {
286 !client.is_disabled() && client.error_tracking_options().capture_panics()
287}
288
289#[allow(deprecated)]
293fn install_hook<F>(capture: F) -> Result<(), Error>
294where
295 F: Fn(&panic::PanicInfo<'_>) -> Result<(), Error> + Send + Sync + 'static,
296{
297 if PANIC_HOOK_INSTALLED
298 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
299 .is_err()
300 {
301 return Err(Error::PanicHookAlreadyInstalled);
302 }
303
304 let previous_hook = panic::take_hook();
305 panic::set_hook(Box::new(move |panic_info| {
306 if let Ok(Err(error)) = panic::catch_unwind(AssertUnwindSafe(|| capture(panic_info))) {
312 let _ = writeln!(
313 std::io::stderr(),
314 "posthog-rs: failed to capture panic: {error}"
315 );
316 }
317
318 previous_hook(panic_info);
319 }));
320
321 Ok(())
322}
323
324#[allow(deprecated)]
329fn capture_panic(client: &Client, panic_info: &panic::PanicInfo<'_>) -> Result<(), Error> {
330 if client.is_disabled() || client.on_transport_worker() {
337 return Ok(());
338 }
339 let et_options = client.error_tracking_options();
340 let event = build_panic_event(panic_info, et_options)?;
341 client.enqueue_panic_event(event);
346 client.flush_blocking_timeout(PANIC_FLUSH_TIMEOUT);
352 Ok(())
353}
354
355#[allow(deprecated)]
359fn build_panic_event(
360 panic_info: &panic::PanicInfo<'_>,
361 et_options: &ErrorTrackingOptions,
362) -> Result<Event, Error> {
363 let exception = Exception::from_panic_info(panic_info, et_options.capture_stacktrace());
364
365 let mut event = Event::new_anon("$exception");
366 if let Some(location) = panic_info.location() {
367 event.insert_prop("$exception_panic_file", location.file())?;
368 event.insert_prop("$exception_panic_line", location.line())?;
369 event.insert_prop("$exception_panic_column", location.column())?;
370 }
371 exception.write_into(&mut event, et_options)?;
372 Ok(event)
373}
374
375#[derive(Clone, Debug, Default)]
401pub struct CaptureExceptionOptions {
402 distinct_id: Option<String>,
403 properties: Vec<(String, Value)>,
404 groups: Vec<(String, String)>,
405 trace_context: Option<TraceContext>,
406 fingerprint: Option<String>,
407 level: Option<String>,
408}
409
410impl CaptureExceptionOptions {
411 pub fn new() -> Self {
413 Self::default()
414 }
415
416 pub fn distinct_id<S: Into<String>>(mut self, distinct_id: S) -> Self {
418 self.distinct_id = Some(distinct_id.into());
419 self
420 }
421
422 pub fn property<K: Into<String>, V: Serialize>(
424 mut self,
425 key: K,
426 value: V,
427 ) -> Result<Self, Error> {
428 let value = serde_json::to_value(value).map_err(|e| Error::Serialization(e.to_string()))?;
429 self.properties.push((key.into(), value));
430 Ok(self)
431 }
432
433 pub fn group<N: Into<String>, I: Into<String>>(mut self, group_name: N, group_id: I) -> Self {
435 self.groups.push((group_name.into(), group_id.into()));
436 self
437 }
438
439 pub fn trace_context<T: Into<String>, S: Into<String>>(
445 mut self,
446 trace_id: T,
447 span_id: S,
448 ) -> Self {
449 self.trace_context = Some(TraceContext::new(trace_id, span_id));
450 self
451 }
452
453 pub fn fingerprint<S: Into<String>>(mut self, fingerprint: S) -> Self {
455 self.fingerprint = Some(fingerprint.into());
456 self
457 }
458
459 pub fn level<S: Into<String>>(mut self, level: S) -> Self {
461 self.level = Some(level.into());
462 self
463 }
464}
465
466pub(crate) fn build_exception_event<E>(
474 error: &E,
475 options: CaptureExceptionOptions,
476 et_options: &ErrorTrackingOptions,
477) -> Result<Event, Error>
478where
479 E: StdError + ?Sized,
480{
481 let CaptureExceptionOptions {
482 distinct_id,
483 properties,
484 groups,
485 trace_context,
486 fingerprint,
487 level,
488 } = options;
489
490 let trace_context = trace_context.or_else(|| et_options.trace_context());
491
492 let mut exception = Exception::from_error(error, et_options.capture_stacktrace());
493 if let Some(fingerprint) = fingerprint {
494 exception.set_fingerprint(fingerprint);
495 }
496 if let Some(level) = level {
497 exception.set_level(level);
498 }
499
500 let mut event = match distinct_id {
501 Some(distinct_id) => Event::new("$exception".to_string(), distinct_id),
502 None => Event::new_anon("$exception"),
503 };
504 for (key, value) in properties {
505 event.insert_prop(key, value)?;
506 }
507 for (group_name, group_id) in groups {
508 event.add_group(&group_name, &group_id);
509 }
510
511 if let Some(trace_context) = trace_context {
514 event.insert_prop("$trace_id", trace_context.trace_id)?;
515 event.insert_prop("$span_id", trace_context.span_id)?;
516 }
517 exception.write_into(&mut event, et_options)?;
518 Ok(event)
519}
520
521#[derive(Clone, Debug, PartialEq, Eq)]
530pub(crate) struct Exception {
531 items: Vec<ExceptionItem>,
532 captured_frames: Option<Vec<StackFrame>>,
536 captured_images: Vec<DebugImage>,
540 fingerprint: Option<String>,
541 level: String,
542}
543
544impl Exception {
545 pub(crate) fn from_error<E>(error: &E, capture_stacktrace: bool) -> Self
548 where
549 E: StdError + ?Sized,
550 {
551 let mut items = vec![ExceptionItem {
552 exception_type: simple_type_name(type_name::<E>()),
553 value: error_value(error),
554 mechanism: ExceptionMechanism::default(),
555 stacktrace: None,
556 }];
557
558 let mut source = error.source();
559 while let Some(err) = source {
560 if items.len() >= MAX_ERROR_SOURCES {
561 break;
562 }
563 items.push(ExceptionItem {
564 exception_type: source_type_name(err),
565 value: error_value(err),
566 mechanism: ExceptionMechanism::default(),
567 stacktrace: None,
568 });
569 source = err.source();
570 }
571
572 link_exception_chain(&mut items);
573
574 let (captured_frames, captured_images) = if capture_stacktrace {
575 let (frames, images) = capture_raw_application_frames();
576 (Some(frames), images)
577 } else {
578 (None, Vec::new())
579 };
580
581 Self {
582 items,
583 captured_frames,
584 captured_images,
585 fingerprint: None,
586 level: "error".to_string(),
587 }
588 }
589
590 #[allow(dead_code)]
594 pub(crate) fn from_message<T: Into<String>, V: Into<String>>(
595 exception_type: T,
596 value: V,
597 capture_stacktrace: bool,
598 ) -> Self {
599 let (captured_frames, captured_images) = if capture_stacktrace {
600 let (frames, images) = capture_raw_application_frames();
601 (Some(frames), images)
602 } else {
603 (None, Vec::new())
604 };
605
606 Self {
607 items: vec![ExceptionItem {
608 exception_type: exception_type.into(),
609 value: value.into(),
610 mechanism: ExceptionMechanism::default(),
611 stacktrace: None,
612 }],
613 captured_frames,
614 captured_images,
615 fingerprint: None,
616 level: "error".to_string(),
617 }
618 }
619
620 #[allow(deprecated)]
623 fn from_panic_info(panic_info: &panic::PanicInfo<'_>, capture_stacktrace: bool) -> Self {
624 let (captured_frames, captured_images) = if capture_stacktrace {
625 let (frames, images) = capture_raw_panic_frames();
626 (Some(frames), images)
627 } else {
628 (None, Vec::new())
629 };
630
631 Self {
632 items: vec![ExceptionItem {
633 exception_type: "Panic".to_string(),
634 value: panic_message(panic_info),
635 mechanism: ExceptionMechanism {
636 mechanism_type: "panic".to_string(),
637 handled: false,
638 synthetic: false,
639 exception_id: None,
640 parent_id: None,
641 },
642 stacktrace: None,
643 }],
644 captured_frames,
645 captured_images,
646 fingerprint: None,
647 level: "fatal".to_string(),
650 }
651 }
652
653 pub(crate) fn set_fingerprint<S: Into<String>>(&mut self, fingerprint: S) {
655 self.fingerprint = Some(fingerprint.into());
656 }
657
658 pub(crate) fn set_level<S: Into<String>>(&mut self, level: S) {
660 self.level = level.into();
661 }
662
663 fn write_into(self, event: &mut Event, options: &ErrorTrackingOptions) -> Result<(), Error> {
667 let Exception {
668 mut items,
669 captured_frames,
670 captured_images,
671 fingerprint,
672 level,
673 } = self;
674 if items.is_empty() {
675 return Ok(());
676 }
677
678 let mut debug_images = Vec::new();
679 if let Some(mut frames) = captured_frames {
680 for frame in frames.iter_mut() {
681 let function = (!frame.function.is_empty()).then_some(frame.function.as_str());
682 if function.is_some() || frame.filename.is_some() {
686 frame.in_app = options.is_in_app_frame(frame.filename.as_deref(), function);
687 }
688 }
689 trim_to_max_frames(&mut frames, MAX_FRAMES);
690 debug_images = captured_images
692 .into_iter()
693 .filter(|image| {
694 frames
695 .iter()
696 .any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
697 })
698 .collect();
699 items[0].stacktrace = Some(ExceptionStacktrace::raw(frames));
700 }
701
702 event.insert_prop("$exception_level", level)?;
703 if let Some(fingerprint) = fingerprint {
704 event.insert_prop("$exception_fingerprint", fingerprint)?;
705 }
706 if !debug_images.is_empty() {
707 event.insert_prop("$debug_images", debug_images)?;
708 }
709 event.insert_prop("$exception_list", items)?;
710 Ok(())
711 }
712}
713
714#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
716pub(crate) struct ExceptionItem {
717 #[serde(rename = "type")]
718 pub exception_type: String,
719 pub value: String,
720 pub mechanism: ExceptionMechanism,
721 #[serde(skip_serializing_if = "Option::is_none")]
722 pub stacktrace: Option<ExceptionStacktrace>,
723}
724
725#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
727pub(crate) struct ExceptionMechanism {
728 #[serde(rename = "type")]
729 pub mechanism_type: String,
730 pub handled: bool,
731 pub synthetic: bool,
732 #[serde(skip_serializing_if = "Option::is_none")]
735 pub exception_id: Option<usize>,
736 #[serde(skip_serializing_if = "Option::is_none")]
738 pub parent_id: Option<usize>,
739}
740
741impl Default for ExceptionMechanism {
742 fn default() -> Self {
743 Self {
744 mechanism_type: "generic".to_string(),
745 handled: true,
746 synthetic: false,
747 exception_id: None,
748 parent_id: None,
749 }
750 }
751}
752
753#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
755pub(crate) struct ExceptionStacktrace {
756 #[serde(rename = "type")]
757 pub stacktrace_type: String,
758 pub frames: Vec<StackFrame>,
759}
760
761impl ExceptionStacktrace {
762 fn raw(frames: Vec<StackFrame>) -> Self {
763 Self {
764 stacktrace_type: "raw".to_string(),
765 frames,
766 }
767 }
768}
769
770#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
777pub(crate) struct StackFrame {
778 #[serde(skip_serializing_if = "Option::is_none")]
779 pub filename: Option<String>,
780 #[serde(rename = "lineno")]
781 #[serde(skip_serializing_if = "Option::is_none")]
782 pub line_no: Option<u32>,
783 #[serde(skip_serializing_if = "String::is_empty")]
784 pub function: String,
785 pub lang: String,
786 pub in_app: bool,
787 pub synthetic: bool,
788 pub platform: String,
789 #[serde(skip_serializing_if = "Option::is_none")]
791 pub instruction_addr: Option<String>,
792 #[serde(skip_serializing_if = "Option::is_none")]
794 pub symbol_addr: Option<String>,
795 #[serde(skip_serializing_if = "Option::is_none")]
797 pub image_addr: Option<String>,
798 pub client_resolved: bool,
804 #[serde(skip_serializing_if = "std::ops::Not::not")]
811 pub inline: bool,
812}
813
814#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
818pub(crate) struct DebugImage {
819 #[serde(rename = "type")]
820 pub image_type: String,
821 pub debug_id: String,
824 #[serde(skip_serializing_if = "Option::is_none")]
826 pub code_id: Option<String>,
827 pub image_addr: String,
828 #[serde(skip_serializing_if = "Option::is_none")]
829 pub image_size: Option<u64>,
830 #[serde(skip_serializing_if = "Option::is_none")]
831 pub image_vmaddr: Option<String>,
832 #[serde(skip_serializing_if = "Option::is_none")]
833 pub code_file: Option<String>,
834 pub arch: String,
835}
836
837struct LoadedModule {
840 base: u64,
841 end: u64,
842 image: DebugImage,
843}
844
845const fn native_image_type() -> &'static str {
846 if cfg!(any(target_os = "macos", target_os = "ios")) {
847 "macho"
848 } else if cfg!(target_os = "windows") {
849 "pe"
850 } else {
851 "elf"
852 }
853}
854
855fn normalize_arch(arch: &str) -> String {
859 match arch {
860 "aarch64" => "arm64".to_string(),
861 other => other.to_string(),
862 }
863}
864
865fn guid_le_to_uuid(mut data: [u8; 16]) -> String {
873 data[0..4].reverse();
874 data[4..6].reverse();
875 data[6..8].reverse();
876 uuid::Uuid::from_bytes(data).to_string()
877}
878
879fn debug_id_from_gnu_build_id(build_id: &[u8]) -> Option<String> {
890 if build_id.is_empty() {
891 return None;
892 }
893 let mut data = [0u8; 16];
894 let len = build_id.len().min(16);
895 data[..len].copy_from_slice(&build_id[..len]);
896 if cfg!(target_endian = "little") {
897 data[0..4].reverse();
898 data[4..6].reverse();
899 data[6..8].reverse();
900 }
901 Some(uuid::Uuid::from_bytes(data).to_string())
902}
903
904fn debug_id_for(id: &findshlibs::SharedLibraryId) -> Option<String> {
905 use findshlibs::SharedLibraryId;
906
907 match id {
908 SharedLibraryId::GnuBuildId(bytes) => debug_id_from_gnu_build_id(bytes),
909 SharedLibraryId::Uuid(bytes) => {
912 Some(uuid::Uuid::from_bytes(*bytes).to_string().to_uppercase())
913 }
914 SharedLibraryId::PdbSignature(guid, age) => {
915 let uuid = guid_le_to_uuid(*guid);
916 Some(if *age > 0 {
917 format!("{uuid}-{age:x}")
918 } else {
919 uuid
920 })
921 }
922 _ => None,
924 }
925}
926
927fn collect_loaded_modules() -> Vec<LoadedModule> {
932 use findshlibs::{IterationControl, SharedLibrary, TargetSharedLibrary};
933
934 let mut modules = Vec::new();
935
936 TargetSharedLibrary::each(|shlib| {
937 let base = shlib.actual_load_addr().0 as u64;
938 let size = shlib.len() as u64;
939
940 let name = shlib.name().to_string_lossy().into_owned();
943 let code_file = if name.is_empty() {
944 std::env::current_exe()
945 .ok()
946 .map(|p| p.to_string_lossy().into_owned())
947 } else {
948 Some(name)
949 };
950
951 let debug_id = shlib
955 .debug_id()
956 .as_ref()
957 .and_then(debug_id_for)
958 .unwrap_or_default();
959 let code_id = match shlib.id() {
960 Some(findshlibs::SharedLibraryId::GnuBuildId(bytes)) => {
961 Some(bytes.iter().map(|b| format!("{b:02x}")).collect::<String>())
962 }
963 _ => None,
964 };
965
966 modules.push(LoadedModule {
967 base,
968 end: base.saturating_add(size),
969 image: DebugImage {
970 image_type: native_image_type().to_string(),
971 debug_id,
972 code_id,
973 image_addr: format!("0x{base:x}"),
974 image_size: Some(size),
975 image_vmaddr: Some(format!("0x{:x}", shlib.stated_load_addr().0 as u64)),
976 code_file,
977 arch: normalize_arch(std::env::consts::ARCH),
978 },
979 });
980
981 IterationControl::Continue
982 });
983
984 modules.sort_by_key(|m| m.base);
985 modules
986}
987
988fn find_module(modules: &[LoadedModule], addr: u64) -> Option<&LoadedModule> {
989 let idx = modules.partition_point(|m| m.base <= addr);
990 let module = modules[..idx].last()?;
991 (addr < module.end).then_some(module)
992}
993
994#[inline(never)]
1003fn capture_frames_current_first(skip: usize, modules: &[LoadedModule]) -> Vec<StackFrame> {
1004 let mut frames = Vec::new();
1005 let mut skipped = 0usize;
1006
1007 backtrace::trace(|frame| {
1008 if skipped < skip {
1009 skipped += 1;
1010 return true;
1011 }
1012
1013 let instruction_addr = frame.ip() as u64;
1014 let frame_symbol_addr = frame.symbol_address() as u64;
1015 let module = find_module(modules, instruction_addr);
1016 let resolvable = module.is_some_and(|m| !m.image.debug_id.is_empty());
1020
1021 let mut layers: Vec<(Option<String>, Option<u32>, String)> = Vec::new();
1026 backtrace::resolve_frame(frame, |symbol| {
1027 let filename = symbol.filename().map(path_to_string);
1028 let function = symbol
1029 .name()
1030 .map(|name| normalize_function_name(&name.to_string()));
1031
1032 if filename.is_none() && function.is_none() {
1033 return;
1034 }
1035
1036 layers.push((filename, symbol.lineno(), function.unwrap_or_default()));
1037 });
1038
1039 if resolvable {
1040 let wire_instruction_addr = Some(format!("0x{instruction_addr:x}"));
1041 let wire_symbol_addr =
1045 (frame_symbol_addr != 0).then(|| format!("0x{frame_symbol_addr:x}"));
1046 let wire_image_addr = module.map(|m| m.image.image_addr.clone());
1047
1048 if layers.is_empty() {
1049 frames.push(StackFrame {
1052 filename: None,
1053 line_no: None,
1054 function: String::new(),
1055 lang: "rust".to_string(),
1056 in_app: false,
1057 synthetic: false,
1058 platform: "native".to_string(),
1059 instruction_addr: wire_instruction_addr,
1060 symbol_addr: wire_symbol_addr,
1061 image_addr: wire_image_addr,
1062 client_resolved: false,
1063 inline: false,
1064 });
1065 } else {
1066 let physical_index = layers.len() - 1;
1078 for (index, (filename, line_no, function)) in layers.into_iter().enumerate() {
1079 frames.push(StackFrame {
1080 filename,
1081 line_no,
1082 function,
1083 lang: "rust".to_string(),
1084 in_app: false,
1085 synthetic: false,
1086 platform: "native".to_string(),
1087 instruction_addr: wire_instruction_addr.clone(),
1088 symbol_addr: wire_symbol_addr.clone(),
1089 image_addr: wire_image_addr.clone(),
1090 client_resolved: true,
1091 inline: index != physical_index,
1092 });
1093 }
1094 }
1095 } else if !layers.is_empty() {
1096 for (filename, line_no, function) in layers {
1105 frames.push(StackFrame {
1106 filename,
1107 line_no,
1108 function,
1109 lang: "rust".to_string(),
1110 in_app: false,
1113 synthetic: false,
1114 platform: "native".to_string(),
1115 instruction_addr: None,
1116 symbol_addr: None,
1117 image_addr: None,
1118 client_resolved: true,
1121 inline: false,
1122 });
1123 }
1124 }
1125 true
1130 });
1131
1132 frames
1133}
1134
1135fn trim_to_max_frames(frames: &mut Vec<StackFrame>, max_frames: usize) {
1139 if frames.len() <= max_frames {
1140 return;
1141 }
1142 let mut cut = frames.len() - max_frames;
1143 while frames.get(cut).is_some_and(|frame| frame.inline) {
1147 cut += 1;
1148 }
1149 frames.drain(..cut);
1150}
1151
1152fn strip_pinned_prefix(frames: &mut Vec<StackFrame>, pinned_entries: &[u64]) {
1166 let scan = frames.len().min(32);
1169 let matches_pinned = |frame: &StackFrame| {
1170 frame
1171 .symbol_addr
1172 .as_deref()
1173 .and_then(|addr| u64::from_str_radix(addr.trim_start_matches("0x"), 16).ok())
1174 .is_some_and(|addr| pinned_entries.contains(&addr))
1175 };
1176 if let Some(last_sdk) = frames[..scan].iter().rposition(matches_pinned) {
1177 frames.drain(..=last_sdk);
1178 }
1179}
1180
1181#[inline(never)]
1200fn capture_raw_frames(
1201 is_internal: impl Fn(&str) -> bool,
1202 pinned_entries: &[u64],
1203) -> (Vec<StackFrame>, Vec<DebugImage>) {
1204 let modules = collect_loaded_modules();
1205 let mut frames = capture_frames_current_first(0, &modules);
1206
1207 strip_pinned_prefix(&mut frames, pinned_entries);
1210
1211 while frames
1212 .first()
1213 .map(|frame| is_internal(&frame.function))
1214 .unwrap_or(false)
1215 {
1216 frames.remove(0);
1217 }
1218
1219 frames.reverse();
1228
1229 let images = referenced_images(modules, &frames);
1230 (frames, images)
1231}
1232
1233fn referenced_images(modules: Vec<LoadedModule>, frames: &[StackFrame]) -> Vec<DebugImage> {
1237 modules
1238 .into_iter()
1239 .filter(|m| !m.image.debug_id.is_empty())
1240 .map(|m| m.image)
1241 .filter(|image| {
1242 frames
1243 .iter()
1244 .any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
1245 })
1246 .collect()
1247}
1248
1249#[inline(never)]
1255fn capture_raw_application_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1256 let pinned = [
1257 capture_frames_current_first as *const () as u64,
1258 capture_raw_application_frames as *const () as u64,
1259 ];
1260 capture_raw_frames(is_internal_capture_frame, &pinned)
1261}
1262
1263#[inline(never)]
1266fn capture_raw_panic_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
1267 let modules = collect_loaded_modules();
1294 let mut frames = capture_frames_current_first(0, &modules);
1295
1296 let pinned = [
1297 capture_frames_current_first as *const () as u64,
1298 capture_raw_panic_frames as *const () as u64,
1299 ];
1300 strip_pinned_prefix(&mut frames, &pinned);
1301
1302 let scan = frames.len().min(24);
1303 let dispatcher = frames[..scan]
1304 .iter()
1305 .position(|frame| is_panic_dispatcher_frame(&frame.function));
1306 match dispatcher {
1307 Some(index) => {
1308 frames.drain(..index);
1309 }
1310 None => {
1311 while frames
1312 .first()
1313 .map(|frame| is_internal_capture_frame(&frame.function))
1314 .unwrap_or(false)
1315 {
1316 frames.remove(0);
1317 }
1318 }
1319 }
1320
1321 frames.reverse();
1324 let images = referenced_images(modules, &frames);
1325 (frames, images)
1326}
1327
1328fn is_panic_dispatcher_frame(function: &str) -> bool {
1332 function.contains("rust_panic_with_hook") || function.contains("panicking::panic_with_hook")
1333}
1334
1335fn is_internal_capture_frame(function: &str) -> bool {
1345 let function: String = function.replace(['<', '>'], "");
1350 function.starts_with("backtrace::")
1351 || function.contains("capture_frames_current_first")
1352 || function.contains("capture_raw_frames")
1353 || function.contains("capture_raw_application_frames")
1354 || function.contains("Exception::from_error")
1355 || function.contains("Exception::from_message")
1356 || function.contains("build_exception_event")
1357 || function.contains("Client::capture_exception")
1358 || function.contains("global::capture_exception")
1359}
1360
1361#[allow(deprecated)]
1363fn panic_message(panic_info: &panic::PanicInfo<'_>) -> String {
1364 let value = panic_info
1365 .payload()
1366 .downcast_ref::<&str>()
1367 .map(|value| (*value).to_string())
1368 .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
1369 .unwrap_or_else(|| "panic occurred".to_string());
1370
1371 if value.is_empty() {
1372 "panic occurred".to_string()
1373 } else {
1374 value
1375 }
1376}
1377
1378fn path_to_string(path: &std::path::Path) -> String {
1379 path.to_string_lossy().into_owned()
1380}
1381
1382fn simple_type_name(type_name: &str) -> String {
1389 let trimmed = type_name.trim().trim_start_matches('&').trim();
1390 let trimmed = trimmed.strip_prefix("dyn ").unwrap_or(trimmed).trim();
1391 let trimmed = trimmed
1392 .split_once('<')
1393 .map_or(trimmed, |(outer_type, _)| outer_type)
1394 .trim_end();
1395 if trimmed.is_empty() || trimmed == "core::error::Error" || trimmed == "std::error::Error" {
1398 return "Error".to_string();
1399 }
1400 trimmed.to_string()
1401}
1402
1403fn source_type_name(error: &(dyn StdError + 'static)) -> String {
1410 simple_type_name(type_name_of_val(error))
1411}
1412
1413fn link_exception_chain(exception_list: &mut [ExceptionItem]) {
1417 if exception_list.len() < 2 {
1418 return;
1419 }
1420 for (index, item) in exception_list.iter_mut().enumerate() {
1421 item.mechanism.exception_id = Some(index);
1422 if index > 0 {
1423 item.mechanism.parent_id = Some(index - 1);
1424 item.mechanism.mechanism_type = "chained".to_string();
1425 }
1426 }
1427}
1428
1429fn error_value<E>(error: &E) -> String
1430where
1431 E: StdError + ?Sized,
1432{
1433 let value = error.to_string();
1434 if value.is_empty() {
1435 "Error".to_string()
1436 } else {
1437 value
1438 }
1439}
1440
1441fn normalize_function_name(function: &str) -> String {
1448 let function = strip_crate_disambiguators(function);
1449 match function.rsplit_once("::") {
1450 Some((prefix, suffix)) if is_rust_symbol_hash(suffix) => prefix.to_string(),
1451 _ => function,
1452 }
1453}
1454
1455fn strip_crate_disambiguators(function: &str) -> String {
1456 let mut out = String::with_capacity(function.len());
1457 let mut rest = function;
1458 while let Some(open) = rest.find('[') {
1459 out.push_str(&rest[..open]);
1460 let bracketed = &rest[open..];
1461 match bracketed.find(']') {
1462 Some(close) => {
1463 let content = &bracketed[1..close];
1464 if !is_crate_disambiguator(content) {
1465 out.push_str(&bracketed[..=close]);
1466 }
1467 rest = &bracketed[close + 1..];
1468 }
1469 None => {
1470 out.push_str(bracketed);
1471 rest = "";
1472 }
1473 }
1474 }
1475 out.push_str(rest);
1476 out
1477}
1478
1479fn is_crate_disambiguator(content: &str) -> bool {
1482 content.len() >= 8
1483 && content
1484 .chars()
1485 .all(|ch| ch.is_ascii_digit() || ('a'..='f').contains(&ch))
1486}
1487
1488fn is_rust_symbol_hash(segment: &str) -> bool {
1489 segment.len() >= 9
1490 && segment.starts_with('h')
1491 && segment[1..].chars().all(|ch| ch.is_ascii_hexdigit())
1492}
1493
1494fn is_bootstrap_symbol(function: &str) -> bool {
1499 matches!(
1500 function,
1501 "main"
1502 | "_start"
1503 | "__libc_start_main"
1504 | "clone"
1505 | "clone3"
1506 | "__clone"
1507 | "__clone3"
1508 | "start_thread"
1509 | "_pthread_start"
1510 | "thread_start"
1511 )
1512}
1513
1514fn has_cargo_hash_suffix(dir: &str) -> bool {
1519 dir.rsplit_once('-')
1520 .is_some_and(|(_, hash)| hash.len() == 16 && hash.chars().all(|ch| ch.is_ascii_hexdigit()))
1521}
1522
1523fn is_cargo_registry_src(normalized: &str) -> bool {
1529 let Some(idx) = normalized.find("/registry/src/") else {
1530 return false;
1531 };
1532 let rest = &normalized[idx + "/registry/src/".len()..];
1533 let Some((registry_dir, rest)) = rest.split_once('/') else {
1534 return false;
1535 };
1536 has_cargo_hash_suffix(registry_dir) && rest.contains('/')
1537}
1538
1539fn is_cargo_git_checkout(normalized: &str) -> bool {
1545 let Some(idx) = normalized.find("/git/checkouts/") else {
1546 return false;
1547 };
1548 let rest = &normalized[idx + "/git/checkouts/".len()..];
1549 let Some((repo_dir, rest)) = rest.split_once('/') else {
1550 return false;
1551 };
1552 let ident_ok = has_cargo_hash_suffix(repo_dir);
1553 let Some((rev_dir, rest)) = rest.split_once('/') else {
1554 return false;
1555 };
1556 let rev_ok = (7..=40).contains(&rev_dir.len())
1557 && rev_dir.chars().all(|ch| ch.is_ascii_hexdigit())
1558 && !rest.is_empty();
1559 ident_ok && rev_ok
1560}
1561
1562fn default_in_app_path(filename: &str) -> bool {
1563 let normalized = filename.replace('\\', "/");
1564 if normalized.contains("/.cargo/registry/")
1570 || normalized.contains("/.cargo/git/")
1571 || is_cargo_registry_src(&normalized)
1572 || is_cargo_git_checkout(&normalized)
1573 || normalized.contains("/rustc/")
1574 || normalized.contains("/rustc-")
1575 || normalized.contains("/library/alloc/src/")
1576 || normalized.contains("/library/core/src/")
1577 || normalized.contains("/library/proc_macro/src/")
1578 || normalized.contains("/library/std/src/")
1579 || normalized.contains("/library/test/src/")
1580 || normalized.contains("/toolchains/")
1581 || normalized.contains("/target/")
1582 || normalized.contains("/vendor/")
1583 {
1584 return false;
1585 }
1586
1587 true
1588}
1589
1590fn strip_generic_args(function: &str) -> &str {
1600 match function.find('<') {
1601 Some(idx) if idx > 0 => &function[..idx],
1602 _ => function,
1603 }
1604}
1605
1606fn default_in_app_function(function: &str) -> bool {
1607 if function.is_empty()
1612 || function == "_main"
1613 || function == "rust_begin_unwind"
1614 || function.starts_with("__rust")
1615 || function.starts_with("___rust")
1616 {
1617 return false;
1618 }
1619
1620 !matches!(
1621 strip_generic_args(function)
1622 .trim_start_matches('<')
1623 .split("::")
1624 .next()
1625 .unwrap_or_default(),
1626 "alloc"
1627 | "anyhow"
1628 | "backtrace"
1629 | "color_eyre"
1630 | "core"
1631 | "eyre"
1632 | "futures_core"
1633 | "futures_util"
1634 | "log"
1635 | "posthog_rs"
1636 | "reqwest"
1637 | "std"
1638 | "stable_eyre"
1639 | "tokio"
1640 | "tracing"
1641 | "tracing_core"
1642 )
1643}
1644
1645#[cfg(test)]
1646mod tests {
1647 use std::error::Error as StdError;
1648 use std::fmt;
1649 use std::sync::atomic::{AtomicBool, Ordering};
1650 use std::sync::{Arc, Mutex, OnceLock};
1651
1652 use httpmock::prelude::*;
1653 use serde_json::{json, Value};
1654
1655 use super::*;
1656 use crate::client::ClientOptionsBuilder;
1657 use crate::event::InnerEvent;
1658
1659 #[derive(Debug)]
1660 struct OuterError {
1661 source: InnerError,
1662 }
1663
1664 impl fmt::Display for OuterError {
1665 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1666 write!(f, "checkout failed")
1667 }
1668 }
1669
1670 impl StdError for OuterError {
1671 fn source(&self) -> Option<&(dyn StdError + 'static)> {
1672 Some(&self.source)
1673 }
1674 }
1675
1676 #[derive(Debug)]
1677 struct InnerError;
1678
1679 impl fmt::Display for InnerError {
1680 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1681 write!(f, "database unavailable")
1682 }
1683 }
1684
1685 impl StdError for InnerError {}
1686
1687 #[derive(Debug)]
1688 struct BorrowedError<'a>(&'a str);
1689
1690 impl fmt::Display for BorrowedError<'_> {
1691 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1692 f.write_str(self.0)
1693 }
1694 }
1695
1696 impl StdError for BorrowedError<'_> {}
1697
1698 fn built_event_json(mut event: Event) -> Value {
1699 event.prepare_for_v0();
1700 serde_json::to_value(InnerEvent::new(event, "api-key".to_string())).unwrap()
1701 }
1702
1703 fn event_json_with(exception: Exception, options: &ErrorTrackingOptions) -> Value {
1704 let mut event = Event::new_anon("$exception");
1705 exception.write_into(&mut event, options).unwrap();
1706 built_event_json(event)
1707 }
1708
1709 fn event_json(exception: Exception) -> Value {
1710 event_json_with(exception, &ErrorTrackingOptions::default())
1711 }
1712
1713 #[allow(deprecated)]
1714 type PanicHook = Box<dyn Fn(&panic::PanicInfo<'_>) + Sync + Send + 'static>;
1715
1716 struct PanicHookReset {
1719 previous: Option<PanicHook>,
1720 }
1721
1722 impl PanicHookReset {
1723 fn new(previous: PanicHook) -> Self {
1724 Self {
1725 previous: Some(previous),
1726 }
1727 }
1728
1729 fn restore(&mut self) {
1730 if let Some(previous) = self.previous.take() {
1731 panic::set_hook(previous);
1732 }
1733 PANIC_HOOK_INSTALLED.store(false, Ordering::Release);
1734 }
1735 }
1736
1737 impl Drop for PanicHookReset {
1738 fn drop(&mut self) {
1739 if !std::thread::panicking() {
1740 self.restore();
1741 }
1742 }
1743 }
1744
1745 fn panic_hook_test_lock() -> &'static Mutex<()> {
1748 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1749 LOCK.get_or_init(|| Mutex::new(()))
1750 }
1751
1752 #[cfg(feature = "async-client")]
1756 fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1757 Arc::new(futures::executor::block_on(crate::client::client(options)))
1758 }
1759
1760 #[cfg(not(feature = "async-client"))]
1761 fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
1762 Arc::new(crate::client::client(options))
1763 }
1764
1765 #[inline(never)]
1766 fn panic_hook_test_panic_site() {
1767 panic!("panic hook boom");
1768 }
1769
1770 #[inline(never)]
1771 fn panic_hook_disabled_test_panic_site() {
1772 panic!("disabled panic hook boom");
1773 }
1774
1775 fn request_has_panic_payload(req: &HttpMockRequest) -> bool {
1778 let Ok(body) = serde_json::from_slice::<Value>(req.body_ref()) else {
1779 return false;
1780 };
1781 let event = &body["batch"][0];
1782 let exception = &event["properties"]["$exception_list"][0];
1783 let frames = exception["stacktrace"]["frames"].as_array();
1784
1785 let has_panic_site = frames.is_some_and(|frames| {
1788 frames.iter().any(|frame| {
1789 frame["function"]
1790 .as_str()
1791 .is_some_and(|name| name.contains("panic_hook_test_panic_site"))
1792 })
1793 });
1794 let has_machinery_not_in_app = frames.is_some_and(|frames| {
1797 frames.iter().any(|frame| {
1798 frame["in_app"] == false
1799 && frame["function"].as_str().is_some_and(|name| {
1800 name.contains("panicking") || name == "rust_begin_unwind"
1801 })
1802 })
1803 });
1804
1805 event["event"] == "$exception"
1806 && (event["properties"]["$process_person_profile"] == false
1809 || event["options"]["process_person_profile"] == false)
1810 && event["properties"]["$exception_level"] == "fatal"
1811 && exception["type"] == "Panic"
1812 && exception["value"] == "panic hook boom"
1813 && exception["mechanism"]["type"] == "panic"
1814 && exception["mechanism"]["handled"] == false
1815 && event["properties"]["$exception_panic_file"]
1816 .as_str()
1817 .is_some_and(|file| file.contains("error_tracking.rs"))
1818 && event["properties"]["$exception_panic_line"]
1819 .as_u64()
1820 .is_some_and(|line| line > 0)
1821 && event["properties"]["$exception_panic_column"]
1822 .as_u64()
1823 .is_some_and(|column| column > 0)
1824 && has_panic_site
1825 && has_machinery_not_in_app
1826 }
1827
1828 #[test]
1829 fn panic_hook_sends_personless_exception_and_calls_previous_hook() {
1830 let _guard = panic_hook_test_lock()
1831 .lock()
1832 .unwrap_or_else(|e| e.into_inner());
1833 let original_hook = panic::take_hook();
1834 let mut reset = PanicHookReset::new(original_hook);
1835 let previous_called = Arc::new(AtomicBool::new(false));
1836 let previous_called_for_hook = Arc::clone(&previous_called);
1837 panic::set_hook(Box::new(move |_| {
1838 previous_called_for_hook.store(true, Ordering::Release);
1839 }));
1840
1841 let server = MockServer::start();
1842 let capture_mock = server.mock(|when, then| {
1843 when.method(POST).matches(request_has_panic_payload);
1844 then.status(200);
1845 });
1846 let options = ClientOptionsBuilder::default()
1847 .api_key("test_api_key".to_string())
1848 .host(server.base_url())
1849 .build()
1850 .unwrap();
1851 let client = build_test_client(options);
1852
1853 install_panic_hook(Arc::clone(&client)).unwrap();
1854 assert!(matches!(
1855 install_panic_hook(Arc::clone(&client)),
1856 Err(Error::PanicHookAlreadyInstalled)
1857 ));
1858
1859 let result = panic::catch_unwind(panic_hook_test_panic_site);
1860 reset.restore();
1861
1862 assert!(result.is_err());
1863 assert!(previous_called.load(Ordering::Acquire));
1864 capture_mock.assert_hits(1);
1865 }
1866
1867 #[test]
1868 fn disabled_panic_hook_does_not_send() {
1869 let _guard = panic_hook_test_lock()
1870 .lock()
1871 .unwrap_or_else(|e| e.into_inner());
1872 let original_hook = panic::take_hook();
1873 let mut reset = PanicHookReset::new(original_hook);
1874 panic::set_hook(Box::new(|_| {}));
1875
1876 let server = MockServer::start();
1877 let capture_mock = server.mock(|when, then| {
1878 when.method(POST);
1879 then.status(200);
1880 });
1881 let options = ClientOptionsBuilder::default()
1882 .api_key("test_api_key".to_string())
1883 .host(server.base_url())
1884 .disabled(true)
1885 .build()
1886 .unwrap();
1887 let client = build_test_client(options);
1888
1889 install_panic_hook(client).unwrap();
1890 let result = panic::catch_unwind(panic_hook_disabled_test_panic_site);
1891 reset.restore();
1892
1893 assert!(result.is_err());
1894 capture_mock.assert_hits(0);
1895 }
1896
1897 #[cfg(feature = "async-client")]
1901 #[test]
1902 fn panic_hook_captures_panics_on_tokio_runtime_threads() {
1903 let _guard = panic_hook_test_lock()
1904 .lock()
1905 .unwrap_or_else(|e| e.into_inner());
1906 let original_hook = panic::take_hook();
1907 let mut reset = PanicHookReset::new(original_hook);
1908 panic::set_hook(Box::new(|_| {}));
1909
1910 let server = MockServer::start();
1911 let capture_mock = server.mock(|when, then| {
1912 when.method(POST)
1913 .body_includes(r#""value":"tokio task boom""#);
1914 then.status(200);
1915 });
1916 let options = ClientOptionsBuilder::default()
1917 .api_key("test_api_key".to_string())
1918 .host(server.base_url())
1919 .build()
1920 .unwrap();
1921 install_panic_hook(build_test_client(options)).unwrap();
1922
1923 let runtime = tokio::runtime::Builder::new_multi_thread()
1924 .worker_threads(1)
1925 .enable_all()
1926 .build()
1927 .unwrap();
1928 let result = runtime.block_on(async {
1929 tokio::spawn(async {
1930 panic!("tokio task boom");
1931 })
1932 .await
1933 });
1934 drop(runtime);
1935
1936 let current_thread = tokio::runtime::Builder::new_current_thread()
1939 .enable_all()
1940 .build()
1941 .unwrap();
1942 let current_result = current_thread.block_on(async {
1943 panic::catch_unwind(AssertUnwindSafe(|| panic!("tokio task boom")))
1944 });
1945 drop(current_thread);
1946 reset.restore();
1947
1948 assert!(result.is_err());
1949 assert!(current_result.is_err());
1950 capture_mock.assert_hits(2);
1951 }
1952
1953 #[test]
1954 fn panic_in_before_send_on_worker_neither_deadlocks_nor_recurses() {
1955 let _guard = panic_hook_test_lock()
1961 .lock()
1962 .unwrap_or_else(|e| e.into_inner());
1963 let original_hook = panic::take_hook();
1964 let mut reset = PanicHookReset::new(original_hook);
1965 panic::set_hook(Box::new(|_| {}));
1966
1967 let server = MockServer::start();
1968 let _capture_mock = server.mock(|when, then| {
1969 when.method(POST);
1970 then.status(200);
1971 });
1972 let options = ClientOptionsBuilder::default()
1973 .api_key("test_api_key".to_string())
1974 .host(server.base_url())
1975 .before_send(|_event| panic!("before_send boom"))
1976 .build()
1977 .unwrap();
1978 let client = build_test_client(options);
1979 install_panic_hook(Arc::clone(&client)).unwrap();
1980
1981 let finished = Arc::new(AtomicBool::new(false));
1982 let finished_for_worker = Arc::clone(&finished);
1983 let work_client = Arc::clone(&client);
1984 let _worker = std::thread::spawn(move || {
1985 work_client.capture(Event::new("boom", "user-1"));
1986 work_client.flush_blocking();
1989 finished_for_worker.store(true, Ordering::Release);
1990 });
1991
1992 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1993 while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
1994 std::thread::sleep(std::time::Duration::from_millis(20));
1995 }
1996 reset.restore();
1997
1998 assert!(
1999 finished.load(Ordering::Acquire),
2000 "panic in before_send on the worker thread deadlocked or recursed"
2001 );
2002 }
2006
2007 #[test]
2008 fn panic_hook_flush_is_bounded_when_before_send_needs_a_panic_held_lock() {
2009 let _guard = panic_hook_test_lock()
2017 .lock()
2018 .unwrap_or_else(|e| e.into_inner());
2019 let original_hook = panic::take_hook();
2020 let mut reset = PanicHookReset::new(original_hook);
2021 panic::set_hook(Box::new(|_| {}));
2022
2023 static SHARED: Mutex<()> = Mutex::new(());
2026
2027 let server = MockServer::start();
2028 let _capture_mock = server.mock(|when, then| {
2029 when.method(POST);
2030 then.status(200);
2031 });
2032 let options = ClientOptionsBuilder::default()
2033 .api_key("test_api_key".to_string())
2034 .host(server.base_url())
2035 .before_send(|event| {
2036 let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
2037 Some(event)
2038 })
2039 .build()
2040 .unwrap();
2041 let client = build_test_client(options);
2042 install_panic_hook(Arc::clone(&client)).unwrap();
2043
2044 let finished = Arc::new(AtomicBool::new(false));
2045 let finished_for_panicker = Arc::clone(&finished);
2046 let _panicker = std::thread::spawn(move || {
2047 {
2048 let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
2052 let _ = panic::catch_unwind(AssertUnwindSafe(|| {
2053 panic!("boom while holding a before_send lock")
2054 }));
2055 }
2056 finished_for_panicker.store(true, Ordering::Release);
2057 });
2058
2059 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2060 while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
2061 std::thread::sleep(std::time::Duration::from_millis(20));
2062 }
2063 reset.restore();
2064
2065 assert!(
2066 finished.load(Ordering::Acquire),
2067 "panic hook flush hung on a before_send that needed a panic-held lock"
2068 );
2069 }
2072
2073 #[test]
2074 fn global_capture_panics_defaults_off_and_is_configurable() {
2075 assert!(
2076 !ErrorTrackingOptions::default().capture_panics(),
2077 "panic autocapture is opt-in (off by default)"
2078 );
2079 let enabled = ErrorTrackingOptionsBuilder::default()
2080 .capture_panics(true)
2081 .build()
2082 .unwrap();
2083 assert!(enabled.capture_panics(), "capture_panics is configurable");
2084 }
2085
2086 #[test]
2087 fn should_capture_global_panics_gates_on_enabled_and_flag() {
2088 let enabled = build_test_client(
2089 ClientOptionsBuilder::default()
2090 .api_key("test_api_key".to_string())
2091 .error_tracking(
2092 ErrorTrackingOptionsBuilder::default()
2093 .capture_panics(true)
2094 .build()
2095 .unwrap(),
2096 )
2097 .build()
2098 .unwrap(),
2099 );
2100 assert!(should_capture_global_panics(&enabled));
2101
2102 let disabled = build_test_client(
2104 ClientOptionsBuilder::default()
2105 .api_key("test_api_key".to_string())
2106 .disabled(true)
2107 .error_tracking(
2108 ErrorTrackingOptionsBuilder::default()
2109 .capture_panics(true)
2110 .build()
2111 .unwrap(),
2112 )
2113 .build()
2114 .unwrap(),
2115 );
2116 assert!(
2117 !should_capture_global_panics(&disabled),
2118 "a disabled client must not latch the process-wide hook"
2119 );
2120
2121 let default_off = build_test_client(
2123 ClientOptionsBuilder::default()
2124 .api_key("test_api_key".to_string())
2125 .build()
2126 .unwrap(),
2127 );
2128 assert!(!should_capture_global_panics(&default_off));
2129 }
2130
2131 #[test]
2132 fn install_panic_hook_on_disabled_client_does_not_latch() {
2133 let _guard = panic_hook_test_lock()
2134 .lock()
2135 .unwrap_or_else(|e| e.into_inner());
2136 let original_hook = panic::take_hook();
2137 let mut reset = PanicHookReset::new(original_hook);
2138
2139 let disabled = build_test_client(
2140 ClientOptionsBuilder::default()
2141 .api_key("test_api_key".to_string())
2142 .disabled(true)
2143 .build()
2144 .unwrap(),
2145 );
2146 let result = install_panic_hook(disabled);
2147 let latched = PANIC_HOOK_INSTALLED.load(Ordering::Acquire);
2148
2149 reset.restore();
2152 assert!(result.is_ok(), "installing on a disabled client returns Ok");
2153 assert!(
2154 !latched,
2155 "a disabled client must not latch the process-wide hook"
2156 );
2157 }
2158
2159 #[test]
2160 fn panic_machinery_frames_classify_out_of_app() {
2161 let options = ErrorTrackingOptions::default();
2165 for not_in_app in [
2166 "std::panicking::begin_panic_handler",
2167 "core::panicking::panic_fmt",
2168 "std::panic::catch_unwind",
2169 "std::sys::backtrace::__rust_begin_short_backtrace",
2170 "rust_begin_unwind",
2171 "__rust_try",
2172 "backtrace::backtrace::trace",
2173 "posthog_rs::error_tracking::capture_panic",
2174 "posthog_rs::error_tracking::install_hook::{{closure}}",
2175 "core::ops::function::FnOnce::call_once",
2176 "tokio::runtime::task::raw::poll",
2177 "futures_util::future::FutureExt::poll",
2178 "anyhow::error::Error::msg",
2179 "eyre::Report::msg",
2180 "color_eyre::config::EyreHook::into_eyre_hook::{{closure}}",
2181 "tracing::span::Span::record",
2182 "tracing_core::dispatcher::get_default",
2183 "log::__private_api::log",
2184 ] {
2185 assert!(
2186 !options.is_in_app_frame(None, Some(not_in_app)),
2187 "{} should classify as not in-app",
2188 not_in_app
2189 );
2190 }
2191
2192 for in_app in [
2193 "my_app::checkout::process_payment",
2194 "checkout_service::submit",
2195 ] {
2196 assert!(
2197 options.is_in_app_frame(None, Some(in_app)),
2198 "{} should classify as in-app",
2199 in_app
2200 );
2201 }
2202 }
2203
2204 #[test]
2205 fn function_names_strip_v0_crate_disambiguators() {
2206 assert_eq!(
2208 normalize_function_name("std[b887e3750a86e3a0]::panicking::panic_with_hook"),
2209 "std::panicking::panic_with_hook"
2210 );
2211 assert_eq!(
2212 normalize_function_name(
2213 "<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"
2214 ),
2215 "<alloc::boxed::Box<dyn core::ops::function::Fn<(&std::panic::PanicHookInfo,)>> as core::ops::function::Fn<(&std::panic::PanicHookInfo,)>>::call"
2216 );
2217 assert_eq!(
2219 normalize_function_name("core::array::<impl [u8; 32]>::map"),
2220 "core::array::<impl [u8; 32]>::map"
2221 );
2222 assert_eq!(
2223 normalize_function_name("<[u8] as checkout_service::Digest>::digest"),
2224 "<[u8] as checkout_service::Digest>::digest"
2225 );
2226 }
2227
2228 #[test]
2229 fn internal_capture_frames_match_both_demangler_renderings() {
2230 for name in [
2234 "posthog_rs::error_tracking::Exception::from_error",
2235 "<posthog_rs::error_tracking::Exception>::from_error::<posthog_rs::error_tracking::tests::OuterError>",
2236 "<posthog_rs::error_tracking::Exception>::from_message",
2237 "<posthog_rs::client::Client>::capture_exception::<E>",
2238 ] {
2239 assert!(is_internal_capture_frame(name), "should strip {name:?}");
2240 }
2241 assert!(!is_internal_capture_frame(
2242 "my_app::checkout::Exception_from_error_report"
2243 ));
2244 }
2245
2246 #[test]
2247 fn from_error_builds_exception_list_with_stacktrace() {
2248 let error = OuterError { source: InnerError };
2249 let event = build_exception_event(
2250 &error,
2251 CaptureExceptionOptions::new().distinct_id("user-1"),
2252 &ErrorTrackingOptions::default(),
2253 )
2254 .unwrap();
2255 let json = built_event_json(event);
2256
2257 assert_eq!(json["event"], "$exception");
2258 assert_eq!(json["distinct_id"], "user-1");
2259 assert_eq!(json["properties"]["$exception_level"], "error");
2260
2261 let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
2262 assert!(exception_list[0]["type"]
2263 .as_str()
2264 .unwrap()
2265 .ends_with("OuterError"));
2266 assert_eq!(exception_list[0]["value"], "checkout failed");
2267 assert_eq!(exception_list[0]["mechanism"]["type"], "generic");
2268 assert_eq!(exception_list[0]["mechanism"]["handled"], true);
2269 assert_eq!(exception_list[0]["mechanism"]["synthetic"], false);
2270 assert_eq!(exception_list[0]["mechanism"]["exception_id"], 0);
2271 assert_eq!(exception_list[0]["stacktrace"]["type"], "raw");
2272 assert_eq!(exception_list[1]["value"], "database unavailable");
2273 assert_eq!(exception_list[1]["mechanism"]["type"], "chained");
2274 assert_eq!(exception_list[1]["mechanism"]["exception_id"], 1);
2275 assert_eq!(exception_list[1]["mechanism"]["parent_id"], 0);
2276
2277 let frames = exception_list[0]["stacktrace"]["frames"]
2278 .as_array()
2279 .expect("expected stack frames");
2280 let crash_frame = frames.last().expect("expected crash frame");
2283 assert_eq!(crash_frame["platform"], "native");
2284 assert_eq!(crash_frame["lang"], "rust");
2285 let instruction_addr = crash_frame["instruction_addr"].as_str().unwrap_or_default();
2286 assert!(
2287 instruction_addr.starts_with("0x"),
2288 "expected hex instruction_addr, got {:?}",
2289 instruction_addr
2290 );
2291 let crash_function = crash_frame["function"].as_str().unwrap_or_default();
2292 assert!(
2293 crash_function.contains("from_error_builds_exception_list_with_stacktrace"),
2294 "expected user frame last, got {:?}",
2295 crash_function
2296 );
2297 assert!(
2298 !crash_function.contains("Exception::"),
2299 "expected SDK frames to be skipped, got {:?}",
2300 crash_function
2301 );
2302 }
2303
2304 #[test]
2305 fn gnu_build_ids_convert_to_debug_ids_like_the_server() {
2306 let build_id: Vec<u8> = (0..20)
2309 .map(|i| {
2310 u8::from_str_radix(
2311 &"555398ebd01c90285a3d85138a19cbf9bbcec352"[i * 2..i * 2 + 2],
2312 16,
2313 )
2314 .unwrap()
2315 })
2316 .collect();
2317 let (full, short) = if cfg!(target_endian = "little") {
2321 (
2322 "eb985355-1cd0-2890-5a3d-85138a19cbf9",
2323 "0000cdab-0000-0000-0000-000000000000",
2324 )
2325 } else {
2326 (
2327 "555398eb-d01c-9028-5a3d-85138a19cbf9",
2328 "abcd0000-0000-0000-0000-000000000000",
2329 )
2330 };
2331 assert_eq!(debug_id_from_gnu_build_id(&build_id).as_deref(), Some(full));
2332
2333 assert_eq!(
2335 debug_id_from_gnu_build_id(&[0xab, 0xcd]).as_deref(),
2336 Some(short)
2337 );
2338 assert_eq!(debug_id_from_gnu_build_id(&[]), None);
2339 }
2340
2341 #[test]
2342 fn arch_normalizes_to_the_shared_native_vocabulary() {
2343 assert_eq!(normalize_arch("aarch64"), "arm64");
2346 assert_eq!(normalize_arch("x86_64"), "x86_64");
2347 assert_eq!(normalize_arch("arm"), "arm");
2348 }
2349
2350 #[test]
2351 fn find_module_matches_address_ranges() {
2352 let module_at = |base: u64, size: u64| LoadedModule {
2353 base,
2354 end: base + size,
2355 image: DebugImage {
2356 image_type: "elf".to_string(),
2357 debug_id: "test".to_string(),
2358 code_id: None,
2359 image_addr: format!("0x{base:x}"),
2360 image_size: Some(size),
2361 image_vmaddr: None,
2362 code_file: None,
2363 arch: "x86_64".to_string(),
2364 },
2365 };
2366
2367 let modules = vec![module_at(0x1000, 0x1000), module_at(0x4000, 0x1000)];
2368
2369 assert_eq!(find_module(&modules, 0x1500).map(|m| m.base), Some(0x1000));
2370 assert_eq!(find_module(&modules, 0x4000).map(|m| m.base), Some(0x4000));
2371 assert!(find_module(&modules, 0x2000).is_none()); assert!(find_module(&modules, 0x500).is_none()); assert!(find_module(&modules, 0x5000).is_none()); }
2375
2376 #[test]
2377 fn captured_stacks_reference_loaded_debug_images() {
2378 let json = event_json(Exception::from_message(
2379 "AddrCheck",
2380 "captures addresses",
2381 true,
2382 ));
2383
2384 let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2385 .as_array()
2386 .expect("expected stack frames");
2387
2388 let mut saw_instruction_addr = false;
2393 for frame in frames {
2394 let Some(addr) = frame["instruction_addr"].as_str() else {
2395 continue;
2396 };
2397 saw_instruction_addr = true;
2398 assert!(
2399 addr.starts_with("0x") && u64::from_str_radix(&addr[2..], 16).is_ok(),
2400 "expected hex instruction_addr, got {:?}",
2401 frame["instruction_addr"]
2402 );
2403 }
2404 assert!(
2405 saw_instruction_addr,
2406 "expected at least one frame to carry an instruction_addr"
2407 );
2408
2409 let images = json["properties"]["$debug_images"]
2413 .as_array()
2414 .expect("expected $debug_images");
2415 assert!(!images.is_empty());
2416 let expected_type = super::native_image_type();
2417 let expected_arch = super::normalize_arch(std::env::consts::ARCH);
2418 for image in images {
2419 assert_eq!(image["type"].as_str(), Some(expected_type));
2420 assert_eq!(
2421 image["arch"].as_str(),
2422 Some(expected_arch.as_str()),
2423 "arch should match the running process"
2424 );
2425 let debug_id = image["debug_id"].as_str().unwrap_or_default();
2426 assert!(
2427 debug_id.len() >= 36,
2428 "expected uuid-shaped debug_id, got {:?}",
2429 debug_id
2430 );
2431 let image_addr = image["image_addr"].as_str().unwrap_or_default();
2432 assert!(
2433 frames
2434 .iter()
2435 .any(|f| f["image_addr"].as_str() == Some(image_addr)),
2436 "image {} not referenced by any frame",
2437 image_addr
2438 );
2439 }
2440 }
2441
2442 #[test]
2443 fn from_error_accepts_borrowed_error_types() {
2444 let message = String::from("borrowed parse failure");
2445 let error = BorrowedError(&message);
2446 let json = event_json(Exception::from_error(&error, true));
2447
2448 assert_eq!(
2449 json["properties"]["$exception_list"][0]["value"],
2450 "borrowed parse failure"
2451 );
2452 }
2453
2454 #[test]
2455 fn personless_capture_disables_person_profile() {
2456 let json = event_json(Exception::from_message("Error", "no user context", true));
2457
2458 assert_eq!(json["event"], "$exception");
2459 assert_eq!(json["properties"]["$process_person_profile"], false);
2460 }
2461
2462 #[test]
2463 fn custom_properties_cannot_override_reserved_exception_payload() {
2464 let error = OuterError { source: InnerError };
2465 let event = build_exception_event(
2466 &error,
2467 CaptureExceptionOptions::new()
2468 .property("$exception_list", json!([{"value": "fake"}]))
2469 .unwrap(),
2470 &ErrorTrackingOptions::default(),
2471 )
2472 .unwrap();
2473
2474 let json = built_event_json(event);
2475 assert_eq!(
2476 json["properties"]["$exception_list"][0]["value"],
2477 "checkout failed"
2478 );
2479 }
2480
2481 #[test]
2482 fn options_can_disable_stacktrace() {
2483 let options = ErrorTrackingOptionsBuilder::default()
2484 .capture_stacktrace(false)
2485 .build()
2486 .unwrap();
2487 let error = OuterError { source: InnerError };
2488 let event =
2489 build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2490 let json = built_event_json(event);
2491
2492 let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
2493 assert_eq!(exception_list.len(), 2);
2494 assert!(exception_list[0].get("stacktrace").is_none());
2495 }
2496
2497 #[test]
2498 fn in_app_path_defaults_and_overrides_are_applied() {
2499 let options = ErrorTrackingOptions::default();
2500 assert!(options.is_in_app_path("/app/src/main.rs"));
2501 assert!(!options.is_in_app_path("/home/user/.cargo/registry/src/lib.rs"));
2502 assert!(!options.is_in_app_path(
2503 "/private/tmp/nix-build-rustc-1.91.1/rustc-1.91.1-src/library/core/src/ops/function.rs"
2504 ));
2505 assert!(options.is_in_app_frame(None, Some("checkout_service::submit")));
2506 assert!(!options.is_in_app_frame(None, Some("std::rt::lang_start")));
2507 assert!(!options.is_in_app_frame(None, Some("core::ops::function::FnOnce::call_once")));
2508 assert!(
2509 !options.is_in_app_frame(None, Some("posthog_rs::client::Client::capture_exception"))
2510 );
2511 assert!(!options.is_in_app_frame(None, Some("_main")));
2512
2513 let options = ErrorTrackingOptionsBuilder::default()
2514 .in_app_include_paths(vec!["/service/".to_string(), "my_service::".to_string()])
2515 .in_app_exclude_paths(vec!["/service/vendor/".to_string()])
2516 .build()
2517 .unwrap();
2518
2519 assert!(options.is_in_app_path("/service/src/main.rs"));
2520 assert!(!options.is_in_app_path("/other/src/main.rs"));
2521 assert!(!options.is_in_app_path("/service/vendor/lib.rs"));
2522 assert!(options.is_in_app_frame(None, Some("my_service::checkout")));
2523 assert!(!options.is_in_app_frame(None, Some("other_service::checkout")));
2524 }
2525
2526 #[test]
2527 fn in_app_defaults_cover_docker_cargo_home_and_dwarf_names() {
2528 let options = ErrorTrackingOptions::default();
2529
2530 assert!(!options.is_in_app_path(
2533 "/usr/local/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.1/src/runtime/task/harness.rs"
2534 ));
2535 assert!(!options.is_in_app_path(
2536 "/usr/local/cargo/git/checkouts/somecrate-9a8b7c6d5e4f3a2b/0f1e2d3/src/lib.rs"
2537 ));
2538 assert!(!options.is_in_app_path(
2540 "/cache/rust-deps/registry/src/index.crates.io-1949cf8c6b5b557f/serde-1.0.219/src/de/mod.rs"
2541 ));
2542 assert!(!options.is_in_app_path(
2543 "/cache/rust-deps/git/checkouts/somecrate-9a8b7c6d5e4f3a2b/0f1e2d3/src/lib.rs"
2544 ));
2545 assert!(options.is_in_app_path("/srv/git/checkouts/my-service/src/main.rs"));
2549 assert!(options.is_in_app_path("/srv/git/checkouts/my-service-deadbeef/src/main.rs"));
2550 assert!(options.is_in_app_path("/srv/mycargo/registry/src/model.rs"));
2553 assert!(options.is_in_app_path("/srv/cargo/registry/my-service/src/main.rs"));
2554 assert!(options
2555 .is_in_app_path("/srv/registry/src/index.crates.io-local/my-service/src/main.rs"));
2556 assert!(options.is_in_app_path("/opt/app/registry/src/service-a1b2c3d4/modules/api.rs"));
2559 assert!(options.is_in_app_path("/srv/git/checkouts/service-deadbeef/0f1e2d3/src/main.rs"));
2560
2561 assert!(!options.is_in_app_frame(
2566 Some("/usr/local/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.1/src/runtime/task/harness.rs"),
2567 Some("poll_future<tokio::runtime::blocking::task::BlockingTask<tokio::runtime::scheduler::multi_thread::worker::{impl#0}::launch::{closure_env#0}>, tokio::runtime::blocking::schedule::BlockingSchedule>"),
2568 ));
2569 assert!(
2572 options.is_in_app_frame(Some("/app/src/worker.rs"), Some("run<tokio::time::Sleep>"),)
2573 );
2574 assert!(options.is_in_app_frame(
2580 None,
2581 Some("poll_future<tokio::runtime::blocking::task::BlockingTask<T>, S>"),
2582 ));
2583 assert!(!options.is_in_app_frame(
2586 None,
2587 Some("<alloc::boxed::Box<F,A> as core::ops::function::FnOnce<Args>>::call_once"),
2588 ));
2589
2590 assert!(!options.is_in_app_frame(None, Some("__clone")));
2593 assert!(!options.is_in_app_frame(None, Some("start_thread")));
2594 assert!(!options.is_in_app_frame(None, Some("main")));
2595 assert!(options.is_in_app_frame(Some("/app/src/main.rs"), Some("main")));
2599 assert!(options.is_in_app_frame(None, Some("my_exported_callback")));
2600 }
2601
2602 #[test]
2603 fn function_names_strip_rust_symbol_hashes() {
2604 assert_eq!(
2605 normalize_function_name("checkout_service::submit::h9ae4817223dd0b22"),
2606 "checkout_service::submit"
2607 );
2608 assert_eq!(
2609 normalize_function_name("std::rt::lang_start::{{closure}}::ha1fd5c62e470a8cc"),
2610 "std::rt::lang_start::{{closure}}"
2611 );
2612 assert_eq!(
2613 normalize_function_name("checkout_service::submit"),
2614 "checkout_service::submit"
2615 );
2616 }
2617
2618 #[test]
2619 fn type_names_keep_path_and_strip_generics() {
2620 assert_eq!(
2622 simple_type_name("std::io::error::Error"),
2623 "std::io::error::Error"
2624 );
2625 assert_eq!(
2626 simple_type_name("mycrate::CheckoutError"),
2627 "mycrate::CheckoutError"
2628 );
2629 assert_eq!(simple_type_name("mycrate::Error"), "mycrate::Error");
2630
2631 assert_eq!(simple_type_name("foo::Bar<baz::Qux>"), "foo::Bar");
2633 assert_eq!(
2634 simple_type_name(type_name::<Box<dyn StdError>>()),
2635 "alloc::boxed::Box"
2636 );
2637
2638 assert_eq!(simple_type_name("dyn core::error::Error"), "Error");
2640 assert_eq!(simple_type_name(type_name::<&dyn StdError>()), "Error");
2641 }
2642
2643 #[test]
2644 fn frames_are_trimmed_to_max_frames_keeping_the_crash_site() {
2645 let synthetic_frame = |index: usize| StackFrame {
2646 filename: None,
2647 line_no: None,
2648 function: format!("frame_{index}"),
2649 lang: "rust".to_string(),
2650 in_app: true,
2651 synthetic: false,
2652 platform: "native".to_string(),
2653 instruction_addr: None,
2654 symbol_addr: None,
2655 image_addr: None,
2656 client_resolved: false,
2657 inline: false,
2658 };
2659 let exception = Exception {
2660 items: vec![ExceptionItem {
2661 exception_type: "Error".to_string(),
2662 value: "trimmed".to_string(),
2663 mechanism: ExceptionMechanism::default(),
2664 stacktrace: None,
2665 }],
2666 captured_frames: Some((0..MAX_FRAMES + 5).map(synthetic_frame).collect()),
2667 captured_images: Vec::new(),
2668 fingerprint: None,
2669 level: "error".to_string(),
2670 };
2671
2672 let json = event_json_with(exception, &ErrorTrackingOptions::default());
2673 let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
2674 .as_array()
2675 .expect("expected stack frames");
2676 assert_eq!(frames.len(), MAX_FRAMES);
2677 assert_eq!(frames[0]["function"], "frame_5");
2681 assert_eq!(
2682 frames[MAX_FRAMES - 1]["function"],
2683 format!("frame_{}", MAX_FRAMES + 4)
2684 );
2685 }
2686
2687 #[test]
2688 fn stacktrace_keeps_crash_frame_last() {
2689 fn capture() -> ExceptionStacktrace {
2690 let mut frames = capture_raw_application_frames().0;
2694 trim_to_max_frames(&mut frames, 8);
2695 ExceptionStacktrace::raw(frames)
2696 }
2697
2698 let frames = capture().frames;
2699 let functions: Vec<&str> = frames
2700 .iter()
2701 .map(|frame| frame.function.as_str())
2702 .filter(|function| !function.is_empty())
2703 .collect();
2704
2705 let capture_index = functions
2706 .iter()
2707 .position(|function| function.contains("stacktrace_keeps_crash_frame_last::capture"))
2708 .expect("expected capture frame");
2709 let test_index = functions
2710 .iter()
2711 .position(|function| function.ends_with("stacktrace_keeps_crash_frame_last"))
2712 .expect("expected test frame");
2713
2714 assert!(
2715 test_index < capture_index,
2716 "expected the caller before the innermost (capture-site) frame, got {:?}",
2717 functions
2718 );
2719 }
2720
2721 #[test]
2722 fn inlined_frames_emit_expanded_groups() {
2723 #[inline(always)]
2731 fn inline_leaf() -> Vec<StackFrame> {
2732 capture_raw_application_frames().0
2733 }
2734
2735 #[inline(always)]
2736 fn inline_mid() -> Vec<StackFrame> {
2737 inline_leaf()
2738 }
2739
2740 let frames = inline_mid();
2741 let functions: Vec<&str> = frames.iter().map(|frame| frame.function.as_str()).collect();
2742
2743 assert!(
2746 frames
2747 .iter()
2748 .all(|f| f.client_resolved == (!f.function.is_empty() || f.filename.is_some())),
2749 "client_resolved must mirror locally-resolved symbol presence: {:?}",
2750 frames
2751 );
2752
2753 let mut group_addr: Option<&str> = None;
2756 for frame in &frames {
2757 if frame.inline {
2758 assert!(
2759 frame.instruction_addr.is_some()
2760 && frame.instruction_addr.as_deref() == group_addr,
2761 "inline member must carry its physical frame's address: {:?}",
2762 frames
2763 );
2764 } else {
2765 group_addr = frame.instruction_addr.as_deref();
2766 }
2767 }
2768
2769 let leaf_index = functions.iter().position(|f| f.contains("inline_leaf"));
2770 let mid_index = functions.iter().position(|f| f.contains("inline_mid"));
2771
2772 if frames.iter().any(|frame| frame.instruction_addr.is_some()) {
2773 assert!(
2777 matches!((leaf_index, mid_index), (Some(l), Some(m)) if m < l),
2778 "expected the group's inline chain outermost-first, got {:?}",
2779 functions
2780 );
2781 let leaf_frame = &frames[leaf_index.unwrap()];
2782 let mid_frame = &frames[mid_index.unwrap()];
2783 assert!(
2784 leaf_frame.inline && mid_frame.inline,
2785 "expected inlined layers marked as group members: {:?}",
2786 frames
2787 );
2788 assert!(
2789 leaf_frame.instruction_addr.is_some()
2790 && leaf_frame.instruction_addr == mid_frame.instruction_addr,
2791 "expected group members to share the physical address: {:?}",
2792 frames
2793 );
2794 let physical_index = frames[..mid_index.unwrap()]
2795 .iter()
2796 .rposition(|f| !f.inline)
2797 .expect("expected a physical frame leading the group");
2798 assert_eq!(
2799 frames[physical_index].instruction_addr, leaf_frame.instruction_addr,
2800 "group must be led by the physical frame that owns the address"
2801 );
2802 } else {
2803 assert!(
2808 matches!((leaf_index, mid_index), (Some(l), Some(m)) if m < l),
2809 "expected client-side inline expansion outermost first, got {:?}",
2810 functions
2811 );
2812 assert!(
2813 frames.iter().all(|f| !f.inline),
2814 "address-less frames must not carry inline markers: {:?}",
2815 frames
2816 );
2817 }
2818 }
2819
2820 #[test]
2821 fn trim_drops_whole_inline_groups() {
2822 let frame = |function: &str, addr: Option<&str>, inline: bool| StackFrame {
2823 filename: None,
2824 line_no: None,
2825 function: function.to_string(),
2826 lang: "rust".to_string(),
2827 in_app: true,
2828 synthetic: false,
2829 platform: "native".to_string(),
2830 instruction_addr: addr.map(str::to_string),
2831 symbol_addr: None,
2832 image_addr: None,
2833 client_resolved: true,
2834 inline,
2835 };
2836
2837 let mut frames = vec![
2839 frame("outer_0", None, false),
2840 frame("outer_1", None, false),
2841 frame("lead", Some("0x10"), false),
2842 frame("member_0", Some("0x10"), true),
2843 frame("member_1", Some("0x10"), true),
2844 frame("inner_0", None, false),
2845 frame("inner_1", None, false),
2846 frame("inner_2", None, false),
2847 ];
2848
2849 trim_to_max_frames(&mut frames, 5);
2852 let functions: Vec<&str> = frames.iter().map(|f| f.function.as_str()).collect();
2853 assert_eq!(functions, ["inner_0", "inner_1", "inner_2"]);
2854 }
2855
2856 #[test]
2857 fn build_exception_event_defaults_to_personless() {
2858 let error = OuterError { source: InnerError };
2859 let event = build_exception_event(
2860 &error,
2861 CaptureExceptionOptions::default(),
2862 &ErrorTrackingOptions::default(),
2863 )
2864 .unwrap();
2865 let json = built_event_json(event);
2866
2867 assert_eq!(json["event"], "$exception");
2868 assert_eq!(json["properties"]["$process_person_profile"], false);
2869 assert_eq!(json["properties"]["$exception_level"], "error");
2870 }
2871
2872 #[test]
2873 fn build_exception_event_applies_options() {
2874 let error = OuterError { source: InnerError };
2875 let options = CaptureExceptionOptions::new()
2876 .distinct_id("user-1")
2877 .property("route", "/checkout")
2878 .unwrap()
2879 .group("company", "acme")
2880 .trace_context("00000000000000000000000000000123", "0000000000000456")
2881 .fingerprint("checkout-error")
2882 .level("warning");
2883 let event =
2884 build_exception_event(&error, options, &ErrorTrackingOptions::default()).unwrap();
2885 let json = built_event_json(event);
2886
2887 assert_eq!(json["distinct_id"], "user-1");
2888 assert_eq!(json["properties"]["route"], "/checkout");
2889 assert_eq!(json["properties"]["$groups"]["company"], "acme");
2890 assert_eq!(
2891 json["properties"]["$trace_id"],
2892 "00000000000000000000000000000123"
2893 );
2894 assert_eq!(json["properties"]["$span_id"], "0000000000000456");
2895 assert_eq!(
2896 json["properties"]["$exception_fingerprint"],
2897 "checkout-error"
2898 );
2899 assert_eq!(json["properties"]["$exception_level"], "warning");
2900 }
2901
2902 #[test]
2903 fn build_exception_event_reads_trace_context_provider() {
2904 let options = ErrorTrackingOptionsBuilder::default()
2905 .trace_context_provider(|| {
2906 Some(TraceContext::new("provider-trace-id", "provider-span-id"))
2907 })
2908 .build()
2909 .unwrap();
2910 let error = OuterError { source: InnerError };
2911 let event =
2912 build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2913 let json = built_event_json(event);
2914
2915 assert_eq!(json["properties"]["$trace_id"], "provider-trace-id");
2916 assert_eq!(json["properties"]["$span_id"], "provider-span-id");
2917 }
2918
2919 #[test]
2920 fn explicit_trace_context_takes_precedence_over_provider() {
2921 let provider_called = Arc::new(AtomicBool::new(false));
2922 let provider_called_in_callback = Arc::clone(&provider_called);
2923 let options = ErrorTrackingOptionsBuilder::default()
2924 .trace_context_provider(move || {
2925 provider_called_in_callback.store(true, Ordering::SeqCst);
2926 Some(TraceContext::new("provider-trace-id", "provider-span-id"))
2927 })
2928 .build()
2929 .unwrap();
2930 let error = OuterError { source: InnerError };
2931 let event = build_exception_event(
2932 &error,
2933 CaptureExceptionOptions::new().trace_context("explicit-trace-id", "explicit-span-id"),
2934 &options,
2935 )
2936 .unwrap();
2937 let json = built_event_json(event);
2938
2939 assert_eq!(json["properties"]["$trace_id"], "explicit-trace-id");
2940 assert_eq!(json["properties"]["$span_id"], "explicit-span-id");
2941 assert!(!provider_called.load(Ordering::SeqCst));
2942 }
2943
2944 #[test]
2945 fn panicking_trace_context_provider_is_ignored() {
2946 let options = ErrorTrackingOptionsBuilder::default()
2947 .trace_context_provider(|| panic!("provider failed"))
2948 .build()
2949 .unwrap();
2950 let error = OuterError { source: InnerError };
2951 let event =
2952 build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
2953 let json = built_event_json(event);
2954
2955 assert!(json["properties"].get("$trace_id").is_none());
2956 assert!(json["properties"].get("$span_id").is_none());
2957 }
2958}