use std::any::{type_name, type_name_of_val};
use std::error::Error as StdError;
use std::io::Write;
use std::panic::{self, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(test)]
use std::sync::Arc;
use derive_builder::Builder;
use serde::Serialize;
use serde_json::Value;
use crate::{Client, Error, Event};
const MAX_FRAMES: usize = 64;
const MAX_ERROR_SOURCES: usize = 50;
static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
#[cfg(not(test))]
const PANIC_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
#[cfg(test)]
const PANIC_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(200);
#[derive(Builder, Clone, Debug)]
#[builder(default)]
pub struct ErrorTrackingOptions {
capture_stacktrace: bool,
in_app_include_paths: Vec<String>,
in_app_exclude_paths: Vec<String>,
capture_panics: bool,
}
impl Default for ErrorTrackingOptions {
fn default() -> Self {
Self {
capture_stacktrace: true,
in_app_include_paths: Vec::new(),
in_app_exclude_paths: Vec::new(),
capture_panics: false,
}
}
}
impl ErrorTrackingOptions {
fn capture_stacktrace(&self) -> bool {
self.capture_stacktrace
}
fn capture_panics(&self) -> bool {
self.capture_panics
}
fn is_in_app_path(&self, filename: &str) -> bool {
if self
.in_app_exclude_paths
.iter()
.any(|path| filename.contains(path))
{
return false;
}
if !self.in_app_include_paths.is_empty() {
return self
.in_app_include_paths
.iter()
.any(|path| filename.contains(path));
}
default_in_app_path(filename)
}
fn is_in_app_frame(&self, filename: Option<&str>, function: Option<&str>) -> bool {
if self.in_app_exclude_paths.iter().any(|path| {
filename.is_some_and(|filename| filename.contains(path))
|| function.is_some_and(|function| function.contains(path))
}) {
return false;
}
if !self.in_app_include_paths.is_empty() {
return self.in_app_include_paths.iter().any(|path| {
filename.is_some_and(|filename| filename.contains(path))
|| function.is_some_and(|function| function.contains(path))
});
}
if filename.is_some_and(|filename| !self.is_in_app_path(filename)) {
return false;
}
if let Some(function) = function {
return default_in_app_function(function);
}
filename.is_some()
}
}
#[cfg(test)]
fn install_panic_hook(client: Arc<Client>) -> Result<(), Error> {
if client.is_disabled() {
return Ok(());
}
install_hook(move |panic_info| capture_panic(&client, panic_info))
}
pub(crate) fn maybe_install_global_panic_hook() {
let Some(client) = crate::global::global_client() else {
return;
};
if !should_capture_global_panics(client) {
return;
}
let _ = install_hook(|panic_info| match crate::global::global_client() {
Some(client) => capture_panic(client, panic_info),
None => Ok(()),
});
}
fn should_capture_global_panics(client: &Client) -> bool {
!client.is_disabled() && client.error_tracking_options().capture_panics()
}
#[allow(deprecated)]
fn install_hook<F>(capture: F) -> Result<(), Error>
where
F: Fn(&panic::PanicInfo<'_>) -> Result<(), Error> + Send + Sync + 'static,
{
if PANIC_HOOK_INSTALLED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err(Error::PanicHookAlreadyInstalled);
}
let previous_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
if let Ok(Err(error)) = panic::catch_unwind(AssertUnwindSafe(|| capture(panic_info))) {
let _ = writeln!(
std::io::stderr(),
"posthog-rs: failed to capture panic: {error}"
);
}
previous_hook(panic_info);
}));
Ok(())
}
#[allow(deprecated)]
fn capture_panic(client: &Client, panic_info: &panic::PanicInfo<'_>) -> Result<(), Error> {
if client.is_disabled() || client.on_transport_worker() {
return Ok(());
}
let et_options = client.error_tracking_options();
let event = build_panic_event(panic_info, et_options)?;
client.enqueue_panic_event(event);
client.flush_blocking_timeout(PANIC_FLUSH_TIMEOUT);
Ok(())
}
#[allow(deprecated)]
fn build_panic_event(
panic_info: &panic::PanicInfo<'_>,
et_options: &ErrorTrackingOptions,
) -> Result<Event, Error> {
let exception = Exception::from_panic_info(panic_info, et_options.capture_stacktrace());
let mut event = Event::new_anon("$exception");
if let Some(location) = panic_info.location() {
event.insert_prop("$exception_panic_file", location.file())?;
event.insert_prop("$exception_panic_line", location.line())?;
event.insert_prop("$exception_panic_column", location.column())?;
}
exception.write_into(&mut event, et_options)?;
Ok(event)
}
#[derive(Clone, Debug, Default)]
pub struct CaptureExceptionOptions {
distinct_id: Option<String>,
properties: Vec<(String, Value)>,
groups: Vec<(String, String)>,
fingerprint: Option<String>,
level: Option<String>,
}
impl CaptureExceptionOptions {
pub fn new() -> Self {
Self::default()
}
pub fn distinct_id<S: Into<String>>(mut self, distinct_id: S) -> Self {
self.distinct_id = Some(distinct_id.into());
self
}
pub fn property<K: Into<String>, V: Serialize>(
mut self,
key: K,
value: V,
) -> Result<Self, Error> {
let value = serde_json::to_value(value).map_err(|e| Error::Serialization(e.to_string()))?;
self.properties.push((key.into(), value));
Ok(self)
}
pub fn group<N: Into<String>, I: Into<String>>(mut self, group_name: N, group_id: I) -> Self {
self.groups.push((group_name.into(), group_id.into()));
self
}
pub fn fingerprint<S: Into<String>>(mut self, fingerprint: S) -> Self {
self.fingerprint = Some(fingerprint.into());
self
}
pub fn level<S: Into<String>>(mut self, level: S) -> Self {
self.level = Some(level.into());
self
}
}
pub(crate) fn build_exception_event<E>(
error: &E,
options: CaptureExceptionOptions,
et_options: &ErrorTrackingOptions,
) -> Result<Event, Error>
where
E: StdError + ?Sized,
{
let CaptureExceptionOptions {
distinct_id,
properties,
groups,
fingerprint,
level,
} = options;
let mut exception = Exception::from_error(error, et_options.capture_stacktrace());
if let Some(fingerprint) = fingerprint {
exception.set_fingerprint(fingerprint);
}
if let Some(level) = level {
exception.set_level(level);
}
let mut event = match distinct_id {
Some(distinct_id) => Event::new("$exception".to_string(), distinct_id),
None => Event::new_anon("$exception"),
};
for (key, value) in properties {
event.insert_prop(key, value)?;
}
for (group_name, group_id) in groups {
event.add_group(&group_name, &group_id);
}
exception.write_into(&mut event, et_options)?;
Ok(event)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Exception {
items: Vec<ExceptionItem>,
captured_frames: Option<Vec<StackFrame>>,
captured_images: Vec<DebugImage>,
fingerprint: Option<String>,
level: String,
}
impl Exception {
pub(crate) fn from_error<E>(error: &E, capture_stacktrace: bool) -> Self
where
E: StdError + ?Sized,
{
let mut items = vec![ExceptionItem {
exception_type: simple_type_name(type_name::<E>()),
value: error_value(error),
mechanism: ExceptionMechanism::default(),
stacktrace: None,
}];
let mut source = error.source();
while let Some(err) = source {
if items.len() >= MAX_ERROR_SOURCES {
break;
}
items.push(ExceptionItem {
exception_type: source_type_name(err),
value: error_value(err),
mechanism: ExceptionMechanism::default(),
stacktrace: None,
});
source = err.source();
}
link_exception_chain(&mut items);
let (captured_frames, captured_images) = if capture_stacktrace {
let (frames, images) = capture_raw_application_frames();
(Some(frames), images)
} else {
(None, Vec::new())
};
Self {
items,
captured_frames,
captured_images,
fingerprint: None,
level: "error".to_string(),
}
}
#[allow(dead_code)]
pub(crate) fn from_message<T: Into<String>, V: Into<String>>(
exception_type: T,
value: V,
capture_stacktrace: bool,
) -> Self {
let (captured_frames, captured_images) = if capture_stacktrace {
let (frames, images) = capture_raw_application_frames();
(Some(frames), images)
} else {
(None, Vec::new())
};
Self {
items: vec![ExceptionItem {
exception_type: exception_type.into(),
value: value.into(),
mechanism: ExceptionMechanism::default(),
stacktrace: None,
}],
captured_frames,
captured_images,
fingerprint: None,
level: "error".to_string(),
}
}
#[allow(deprecated)]
fn from_panic_info(panic_info: &panic::PanicInfo<'_>, capture_stacktrace: bool) -> Self {
let (captured_frames, captured_images) = if capture_stacktrace {
let (frames, images) = capture_raw_panic_frames();
(Some(frames), images)
} else {
(None, Vec::new())
};
Self {
items: vec![ExceptionItem {
exception_type: "Panic".to_string(),
value: panic_message(panic_info),
mechanism: ExceptionMechanism {
mechanism_type: "panic".to_string(),
handled: false,
synthetic: false,
exception_id: None,
parent_id: None,
},
stacktrace: None,
}],
captured_frames,
captured_images,
fingerprint: None,
level: "fatal".to_string(),
}
}
pub(crate) fn set_fingerprint<S: Into<String>>(&mut self, fingerprint: S) {
self.fingerprint = Some(fingerprint.into());
}
pub(crate) fn set_level<S: Into<String>>(&mut self, level: S) {
self.level = level.into();
}
fn write_into(self, event: &mut Event, options: &ErrorTrackingOptions) -> Result<(), Error> {
let Exception {
mut items,
captured_frames,
captured_images,
fingerprint,
level,
} = self;
if items.is_empty() {
return Ok(());
}
let mut debug_images = Vec::new();
if let Some(mut frames) = captured_frames {
for frame in frames.iter_mut() {
let function = (!frame.function.is_empty()).then_some(frame.function.as_str());
if function.is_some() || frame.filename.is_some() {
frame.in_app = options.is_in_app_frame(frame.filename.as_deref(), function);
}
}
trim_to_max_frames(&mut frames, MAX_FRAMES);
debug_images = captured_images
.into_iter()
.filter(|image| {
frames
.iter()
.any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
})
.collect();
items[0].stacktrace = Some(ExceptionStacktrace::raw(frames));
}
event.insert_prop("$exception_level", level)?;
if let Some(fingerprint) = fingerprint {
event.insert_prop("$exception_fingerprint", fingerprint)?;
}
if !debug_images.is_empty() {
event.insert_prop("$debug_images", debug_images)?;
}
event.insert_prop("$exception_list", items)?;
Ok(())
}
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
pub(crate) struct ExceptionItem {
#[serde(rename = "type")]
pub exception_type: String,
pub value: String,
pub mechanism: ExceptionMechanism,
#[serde(skip_serializing_if = "Option::is_none")]
pub stacktrace: Option<ExceptionStacktrace>,
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
pub(crate) struct ExceptionMechanism {
#[serde(rename = "type")]
pub mechanism_type: String,
pub handled: bool,
pub synthetic: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub exception_id: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<usize>,
}
impl Default for ExceptionMechanism {
fn default() -> Self {
Self {
mechanism_type: "generic".to_string(),
handled: true,
synthetic: false,
exception_id: None,
parent_id: None,
}
}
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
pub(crate) struct ExceptionStacktrace {
#[serde(rename = "type")]
pub stacktrace_type: String,
pub frames: Vec<StackFrame>,
}
impl ExceptionStacktrace {
fn raw(frames: Vec<StackFrame>) -> Self {
Self {
stacktrace_type: "raw".to_string(),
frames,
}
}
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
pub(crate) struct StackFrame {
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(rename = "lineno")]
#[serde(skip_serializing_if = "Option::is_none")]
pub line_no: Option<u32>,
#[serde(skip_serializing_if = "String::is_empty")]
pub function: String,
pub lang: String,
pub in_app: bool,
pub synthetic: bool,
pub platform: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instruction_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_addr: Option<String>,
pub client_resolved: bool,
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
pub(crate) struct DebugImage {
#[serde(rename = "type")]
pub image_type: String,
pub debug_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_id: Option<String>,
pub image_addr: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_vmaddr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_file: Option<String>,
pub arch: String,
}
struct LoadedModule {
base: u64,
end: u64,
image: DebugImage,
}
const fn native_image_type() -> &'static str {
if cfg!(any(target_os = "macos", target_os = "ios")) {
"macho"
} else if cfg!(target_os = "windows") {
"pe"
} else {
"elf"
}
}
fn normalize_arch(arch: &str) -> String {
match arch {
"aarch64" => "arm64".to_string(),
other => other.to_string(),
}
}
fn guid_le_to_uuid(mut data: [u8; 16]) -> String {
data[0..4].reverse();
data[4..6].reverse();
data[6..8].reverse();
uuid::Uuid::from_bytes(data).to_string()
}
fn debug_id_from_gnu_build_id(build_id: &[u8]) -> Option<String> {
if build_id.is_empty() {
return None;
}
let mut data = [0u8; 16];
let len = build_id.len().min(16);
data[..len].copy_from_slice(&build_id[..len]);
if cfg!(target_endian = "little") {
data[0..4].reverse();
data[4..6].reverse();
data[6..8].reverse();
}
Some(uuid::Uuid::from_bytes(data).to_string())
}
fn debug_id_for(id: &findshlibs::SharedLibraryId) -> Option<String> {
use findshlibs::SharedLibraryId;
match id {
SharedLibraryId::GnuBuildId(bytes) => debug_id_from_gnu_build_id(bytes),
SharedLibraryId::Uuid(bytes) => {
Some(uuid::Uuid::from_bytes(*bytes).to_string().to_uppercase())
}
SharedLibraryId::PdbSignature(guid, age) => {
let uuid = guid_le_to_uuid(*guid);
Some(if *age > 0 {
format!("{uuid}-{age:x}")
} else {
uuid
})
}
_ => None,
}
}
fn collect_loaded_modules() -> Vec<LoadedModule> {
use findshlibs::{IterationControl, SharedLibrary, TargetSharedLibrary};
let mut modules = Vec::new();
TargetSharedLibrary::each(|shlib| {
let base = shlib.actual_load_addr().0 as u64;
let size = shlib.len() as u64;
let name = shlib.name().to_string_lossy().into_owned();
let code_file = if name.is_empty() {
std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().into_owned())
} else {
Some(name)
};
let debug_id = shlib
.debug_id()
.as_ref()
.and_then(debug_id_for)
.unwrap_or_default();
let code_id = match shlib.id() {
Some(findshlibs::SharedLibraryId::GnuBuildId(bytes)) => {
Some(bytes.iter().map(|b| format!("{b:02x}")).collect::<String>())
}
_ => None,
};
modules.push(LoadedModule {
base,
end: base.saturating_add(size),
image: DebugImage {
image_type: native_image_type().to_string(),
debug_id,
code_id,
image_addr: format!("0x{base:x}"),
image_size: Some(size),
image_vmaddr: Some(format!("0x{:x}", shlib.stated_load_addr().0 as u64)),
code_file,
arch: normalize_arch(std::env::consts::ARCH),
},
});
IterationControl::Continue
});
modules.sort_by_key(|m| m.base);
modules
}
fn find_module(modules: &[LoadedModule], addr: u64) -> Option<&LoadedModule> {
let idx = modules.partition_point(|m| m.base <= addr);
let module = modules[..idx].last()?;
(addr < module.end).then_some(module)
}
#[inline(never)]
fn capture_frames_current_first(skip: usize, modules: &[LoadedModule]) -> Vec<StackFrame> {
let mut frames = Vec::new();
let mut skipped = 0usize;
backtrace::trace(|frame| {
if skipped < skip {
skipped += 1;
return true;
}
let instruction_addr = frame.ip() as u64;
let frame_symbol_addr = frame.symbol_address() as u64;
let module = find_module(modules, instruction_addr);
let resolvable = module.is_some_and(|m| !m.image.debug_id.is_empty());
let mut layers: Vec<(Option<String>, Option<u32>, String)> = Vec::new();
backtrace::resolve_frame(frame, |symbol| {
let filename = symbol.filename().map(path_to_string);
let function = symbol
.name()
.map(|name| normalize_function_name(&name.to_string()));
if filename.is_none() && function.is_none() {
return;
}
layers.push((filename, symbol.lineno(), function.unwrap_or_default()));
});
if resolvable {
let physical = layers.last();
frames.push(StackFrame {
filename: physical.and_then(|(file, _, _)| file.clone()),
line_no: physical.and_then(|(_, line, _)| *line),
function: physical
.map(|(_, _, function)| function.clone())
.unwrap_or_default(),
lang: "rust".to_string(),
in_app: false,
synthetic: false,
platform: "native".to_string(),
instruction_addr: Some(format!("0x{instruction_addr:x}")),
symbol_addr: (frame_symbol_addr != 0).then(|| format!("0x{frame_symbol_addr:x}")),
image_addr: module.map(|m| m.image.image_addr.clone()),
client_resolved: false,
});
} else if !layers.is_empty() {
for (filename, line_no, function) in layers {
frames.push(StackFrame {
filename,
line_no,
function,
lang: "rust".to_string(),
in_app: false,
synthetic: false,
platform: "native".to_string(),
instruction_addr: None,
symbol_addr: None,
image_addr: None,
client_resolved: true,
});
}
}
true
});
frames
}
fn trim_to_max_frames(frames: &mut Vec<StackFrame>, max_frames: usize) {
if frames.len() > max_frames {
frames.truncate(max_frames);
}
}
#[inline(never)]
fn capture_raw_frames(
is_internal: impl Fn(&str) -> bool,
pinned_entries: &[u64],
) -> (Vec<StackFrame>, Vec<DebugImage>) {
let modules = collect_loaded_modules();
let mut frames = capture_frames_current_first(0, &modules);
let scan = frames.len().min(16);
let matches_pinned = |frame: &StackFrame| {
frame
.symbol_addr
.as_deref()
.and_then(|addr| u64::from_str_radix(addr.trim_start_matches("0x"), 16).ok())
.is_some_and(|addr| pinned_entries.contains(&addr))
};
if let Some(last_sdk) = frames[..scan].iter().rposition(matches_pinned) {
frames.drain(..=last_sdk);
}
while frames
.first()
.map(|frame| is_internal(&frame.function))
.unwrap_or(false)
{
frames.remove(0);
}
let images = referenced_images(modules, &frames);
(frames, images)
}
fn referenced_images(modules: Vec<LoadedModule>, frames: &[StackFrame]) -> Vec<DebugImage> {
modules
.into_iter()
.filter(|m| !m.image.debug_id.is_empty())
.map(|m| m.image)
.filter(|image| {
frames
.iter()
.any(|f| f.image_addr.as_deref() == Some(image.image_addr.as_str()))
})
.collect()
}
#[inline(never)]
fn capture_raw_application_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
let pinned = [
capture_frames_current_first as *const () as u64,
capture_raw_application_frames as *const () as u64,
];
capture_raw_frames(is_internal_capture_frame, &pinned)
}
fn capture_raw_panic_frames() -> (Vec<StackFrame>, Vec<DebugImage>) {
let modules = collect_loaded_modules();
let frames = capture_frames_current_first(0, &modules);
let images = referenced_images(modules, &frames);
(frames, images)
}
fn is_internal_capture_frame(function: &str) -> bool {
function.starts_with("backtrace::")
|| function.contains("capture_frames_current_first")
|| function.contains("capture_raw_frames")
|| function.contains("capture_raw_application_frames")
|| function.contains("Exception::from_error")
|| function.contains("Exception::from_message")
|| function.contains("build_exception_event")
|| function.contains("Client::capture_exception")
|| function.contains("global::capture_exception")
}
#[allow(deprecated)]
fn panic_message(panic_info: &panic::PanicInfo<'_>) -> String {
let value = panic_info
.payload()
.downcast_ref::<&str>()
.map(|value| (*value).to_string())
.or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic occurred".to_string());
if value.is_empty() {
"panic occurred".to_string()
} else {
value
}
}
fn path_to_string(path: &std::path::Path) -> String {
path.to_string_lossy().into_owned()
}
fn simple_type_name(type_name: &str) -> String {
let trimmed = type_name.trim().trim_start_matches('&').trim();
let trimmed = trimmed.strip_prefix("dyn ").unwrap_or(trimmed).trim();
let trimmed = trimmed
.split_once('<')
.map_or(trimmed, |(outer_type, _)| outer_type)
.trim_end();
if trimmed.is_empty() || trimmed == "core::error::Error" || trimmed == "std::error::Error" {
return "Error".to_string();
}
trimmed.to_string()
}
fn source_type_name(error: &(dyn StdError + 'static)) -> String {
simple_type_name(type_name_of_val(error))
}
fn link_exception_chain(exception_list: &mut [ExceptionItem]) {
if exception_list.len() < 2 {
return;
}
for (index, item) in exception_list.iter_mut().enumerate() {
item.mechanism.exception_id = Some(index);
if index > 0 {
item.mechanism.parent_id = Some(index - 1);
item.mechanism.mechanism_type = "chained".to_string();
}
}
}
fn error_value<E>(error: &E) -> String
where
E: StdError + ?Sized,
{
let value = error.to_string();
if value.is_empty() {
"Error".to_string()
} else {
value
}
}
fn normalize_function_name(function: &str) -> String {
let function = strip_crate_disambiguators(function);
match function.rsplit_once("::") {
Some((prefix, suffix)) if is_rust_symbol_hash(suffix) => prefix.to_string(),
_ => function,
}
}
fn strip_crate_disambiguators(function: &str) -> String {
let mut out = String::with_capacity(function.len());
let mut rest = function;
while let Some(open) = rest.find('[') {
out.push_str(&rest[..open]);
let bracketed = &rest[open..];
match bracketed.find(']') {
Some(close) => {
let content = &bracketed[1..close];
if !is_crate_disambiguator(content) {
out.push_str(&bracketed[..=close]);
}
rest = &bracketed[close + 1..];
}
None => {
out.push_str(bracketed);
rest = "";
}
}
}
out.push_str(rest);
out
}
fn is_crate_disambiguator(content: &str) -> bool {
content.len() >= 8
&& content
.chars()
.all(|ch| ch.is_ascii_digit() || ('a'..='f').contains(&ch))
}
fn is_rust_symbol_hash(segment: &str) -> bool {
segment.len() >= 9
&& segment.starts_with('h')
&& segment[1..].chars().all(|ch| ch.is_ascii_hexdigit())
}
fn default_in_app_path(filename: &str) -> bool {
let normalized = filename.replace('\\', "/");
if normalized.contains("/.cargo/registry/")
|| normalized.contains("/.cargo/git/")
|| normalized.contains("/rustc/")
|| normalized.contains("/rustc-")
|| normalized.contains("/library/alloc/src/")
|| normalized.contains("/library/core/src/")
|| normalized.contains("/library/proc_macro/src/")
|| normalized.contains("/library/std/src/")
|| normalized.contains("/library/test/src/")
|| normalized.contains("/toolchains/")
|| normalized.contains("/target/")
|| normalized.contains("/vendor/")
{
return false;
}
true
}
fn default_in_app_function(function: &str) -> bool {
if function.is_empty()
|| function == "_main"
|| function == "rust_begin_unwind"
|| function.starts_with("__rust")
|| function.starts_with("___rust")
{
return false;
}
!matches!(
function
.trim_start_matches('<')
.split("::")
.next()
.unwrap_or_default(),
"alloc"
| "anyhow"
| "backtrace"
| "color_eyre"
| "core"
| "eyre"
| "futures_core"
| "futures_util"
| "log"
| "posthog_rs"
| "reqwest"
| "std"
| "stable_eyre"
| "tokio"
| "tracing"
| "tracing_core"
)
}
#[cfg(test)]
mod tests {
use std::error::Error as StdError;
use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use httpmock::prelude::*;
use serde_json::{json, Value};
use super::*;
use crate::client::ClientOptionsBuilder;
use crate::event::InnerEvent;
#[derive(Debug)]
struct OuterError {
source: InnerError,
}
impl fmt::Display for OuterError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "checkout failed")
}
}
impl StdError for OuterError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.source)
}
}
#[derive(Debug)]
struct InnerError;
impl fmt::Display for InnerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "database unavailable")
}
}
impl StdError for InnerError {}
#[derive(Debug)]
struct BorrowedError<'a>(&'a str);
impl fmt::Display for BorrowedError<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
impl StdError for BorrowedError<'_> {}
fn built_event_json(mut event: Event) -> Value {
event.prepare_for_v0();
serde_json::to_value(InnerEvent::new(event, "api-key".to_string())).unwrap()
}
fn event_json_with(exception: Exception, options: &ErrorTrackingOptions) -> Value {
let mut event = Event::new_anon("$exception");
exception.write_into(&mut event, options).unwrap();
built_event_json(event)
}
fn event_json(exception: Exception) -> Value {
event_json_with(exception, &ErrorTrackingOptions::default())
}
#[allow(deprecated)]
type PanicHook = Box<dyn Fn(&panic::PanicInfo<'_>) + Sync + Send + 'static>;
struct PanicHookReset {
previous: Option<PanicHook>,
}
impl PanicHookReset {
fn new(previous: PanicHook) -> Self {
Self {
previous: Some(previous),
}
}
fn restore(&mut self) {
if let Some(previous) = self.previous.take() {
panic::set_hook(previous);
}
PANIC_HOOK_INSTALLED.store(false, Ordering::Release);
}
}
impl Drop for PanicHookReset {
fn drop(&mut self) {
if !std::thread::panicking() {
self.restore();
}
}
}
fn panic_hook_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[cfg(feature = "async-client")]
fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
Arc::new(futures::executor::block_on(crate::client::client(options)))
}
#[cfg(not(feature = "async-client"))]
fn build_test_client(options: crate::client::ClientOptions) -> Arc<Client> {
Arc::new(crate::client::client(options))
}
#[inline(never)]
fn panic_hook_test_panic_site() {
panic!("panic hook boom");
}
#[inline(never)]
fn panic_hook_disabled_test_panic_site() {
panic!("disabled panic hook boom");
}
fn request_has_panic_payload(req: &HttpMockRequest) -> bool {
let Some(body) = req.body.as_deref() else {
return false;
};
let Ok(body) = serde_json::from_slice::<Value>(body) else {
return false;
};
let event = &body["batch"][0];
let exception = &event["properties"]["$exception_list"][0];
let frames = exception["stacktrace"]["frames"].as_array();
let has_panic_site = frames.is_some_and(|frames| {
frames.iter().any(|frame| {
frame["function"]
.as_str()
.is_some_and(|name| name.contains("panic_hook_test_panic_site"))
})
});
let has_machinery_not_in_app = frames.is_some_and(|frames| {
frames.iter().any(|frame| {
frame["in_app"] == false
&& frame["function"].as_str().is_some_and(|name| {
name.contains("panicking") || name == "rust_begin_unwind"
})
})
});
event["event"] == "$exception"
&& (event["properties"]["$process_person_profile"] == false
|| event["options"]["process_person_profile"] == false)
&& event["properties"]["$exception_level"] == "fatal"
&& exception["type"] == "Panic"
&& exception["value"] == "panic hook boom"
&& exception["mechanism"]["type"] == "panic"
&& exception["mechanism"]["handled"] == false
&& event["properties"]["$exception_panic_file"]
.as_str()
.is_some_and(|file| file.contains("error_tracking.rs"))
&& event["properties"]["$exception_panic_line"]
.as_u64()
.is_some_and(|line| line > 0)
&& event["properties"]["$exception_panic_column"]
.as_u64()
.is_some_and(|column| column > 0)
&& has_panic_site
&& has_machinery_not_in_app
}
#[test]
fn panic_hook_sends_personless_exception_and_calls_previous_hook() {
let _guard = panic_hook_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let original_hook = panic::take_hook();
let mut reset = PanicHookReset::new(original_hook);
let previous_called = Arc::new(AtomicBool::new(false));
let previous_called_for_hook = Arc::clone(&previous_called);
panic::set_hook(Box::new(move |_| {
previous_called_for_hook.store(true, Ordering::Release);
}));
let server = MockServer::start();
let capture_mock = server.mock(|when, then| {
when.method(POST).matches(request_has_panic_payload);
then.status(200);
});
let options = ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.host(server.base_url())
.build()
.unwrap();
let client = build_test_client(options);
install_panic_hook(Arc::clone(&client)).unwrap();
assert!(matches!(
install_panic_hook(Arc::clone(&client)),
Err(Error::PanicHookAlreadyInstalled)
));
let result = panic::catch_unwind(panic_hook_test_panic_site);
reset.restore();
assert!(result.is_err());
assert!(previous_called.load(Ordering::Acquire));
capture_mock.assert_hits(1);
}
#[test]
fn disabled_panic_hook_does_not_send() {
let _guard = panic_hook_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let original_hook = panic::take_hook();
let mut reset = PanicHookReset::new(original_hook);
panic::set_hook(Box::new(|_| {}));
let server = MockServer::start();
let capture_mock = server.mock(|when, then| {
when.method(POST);
then.status(200);
});
let options = ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.host(server.base_url())
.disabled(true)
.build()
.unwrap();
let client = build_test_client(options);
install_panic_hook(client).unwrap();
let result = panic::catch_unwind(panic_hook_disabled_test_panic_site);
reset.restore();
assert!(result.is_err());
capture_mock.assert_hits(0);
}
#[cfg(feature = "async-client")]
#[test]
fn panic_hook_captures_panics_on_tokio_runtime_threads() {
let _guard = panic_hook_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let original_hook = panic::take_hook();
let mut reset = PanicHookReset::new(original_hook);
panic::set_hook(Box::new(|_| {}));
let server = MockServer::start();
let capture_mock = server.mock(|when, then| {
when.method(POST)
.body_contains(r#""value":"tokio task boom""#);
then.status(200);
});
let options = ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.host(server.base_url())
.build()
.unwrap();
install_panic_hook(build_test_client(options)).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
let result = runtime.block_on(async {
tokio::spawn(async {
panic!("tokio task boom");
})
.await
});
drop(runtime);
let current_thread = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let current_result = current_thread.block_on(async {
panic::catch_unwind(AssertUnwindSafe(|| panic!("tokio task boom")))
});
drop(current_thread);
reset.restore();
assert!(result.is_err());
assert!(current_result.is_err());
capture_mock.assert_hits(2);
}
#[test]
fn panic_in_before_send_on_worker_neither_deadlocks_nor_recurses() {
let _guard = panic_hook_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let original_hook = panic::take_hook();
let mut reset = PanicHookReset::new(original_hook);
panic::set_hook(Box::new(|_| {}));
let server = MockServer::start();
let _capture_mock = server.mock(|when, then| {
when.method(POST);
then.status(200);
});
let options = ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.host(server.base_url())
.before_send(|_event| panic!("before_send boom"))
.build()
.unwrap();
let client = build_test_client(options);
install_panic_hook(Arc::clone(&client)).unwrap();
let finished = Arc::new(AtomicBool::new(false));
let finished_for_worker = Arc::clone(&finished);
let work_client = Arc::clone(&client);
let _worker = std::thread::spawn(move || {
work_client.capture(Event::new("boom", "user-1"));
work_client.flush_blocking();
finished_for_worker.store(true, Ordering::Release);
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(20));
}
reset.restore();
assert!(
finished.load(Ordering::Acquire),
"panic in before_send on the worker thread deadlocked or recursed"
);
}
#[test]
fn panic_hook_flush_is_bounded_when_before_send_needs_a_panic_held_lock() {
let _guard = panic_hook_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let original_hook = panic::take_hook();
let mut reset = PanicHookReset::new(original_hook);
panic::set_hook(Box::new(|_| {}));
static SHARED: Mutex<()> = Mutex::new(());
let server = MockServer::start();
let _capture_mock = server.mock(|when, then| {
when.method(POST);
then.status(200);
});
let options = ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.host(server.base_url())
.before_send(|event| {
let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
Some(event)
})
.build()
.unwrap();
let client = build_test_client(options);
install_panic_hook(Arc::clone(&client)).unwrap();
let finished = Arc::new(AtomicBool::new(false));
let finished_for_panicker = Arc::clone(&finished);
let _panicker = std::thread::spawn(move || {
{
let _held = SHARED.lock().unwrap_or_else(|e| e.into_inner());
let _ = panic::catch_unwind(AssertUnwindSafe(|| {
panic!("boom while holding a before_send lock")
}));
}
finished_for_panicker.store(true, Ordering::Release);
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(20));
}
reset.restore();
assert!(
finished.load(Ordering::Acquire),
"panic hook flush hung on a before_send that needed a panic-held lock"
);
}
#[test]
fn global_capture_panics_defaults_off_and_is_configurable() {
assert!(
!ErrorTrackingOptions::default().capture_panics(),
"panic autocapture is opt-in (off by default)"
);
let enabled = ErrorTrackingOptionsBuilder::default()
.capture_panics(true)
.build()
.unwrap();
assert!(enabled.capture_panics(), "capture_panics is configurable");
}
#[test]
fn should_capture_global_panics_gates_on_enabled_and_flag() {
let enabled = build_test_client(
ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.error_tracking(
ErrorTrackingOptionsBuilder::default()
.capture_panics(true)
.build()
.unwrap(),
)
.build()
.unwrap(),
);
assert!(should_capture_global_panics(&enabled));
let disabled = build_test_client(
ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.disabled(true)
.error_tracking(
ErrorTrackingOptionsBuilder::default()
.capture_panics(true)
.build()
.unwrap(),
)
.build()
.unwrap(),
);
assert!(
!should_capture_global_panics(&disabled),
"a disabled client must not latch the process-wide hook"
);
let default_off = build_test_client(
ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.build()
.unwrap(),
);
assert!(!should_capture_global_panics(&default_off));
}
#[test]
fn install_panic_hook_on_disabled_client_does_not_latch() {
let _guard = panic_hook_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let original_hook = panic::take_hook();
let mut reset = PanicHookReset::new(original_hook);
let disabled = build_test_client(
ClientOptionsBuilder::default()
.api_key("test_api_key".to_string())
.disabled(true)
.build()
.unwrap(),
);
let result = install_panic_hook(disabled);
let latched = PANIC_HOOK_INSTALLED.load(Ordering::Acquire);
reset.restore();
assert!(result.is_ok(), "installing on a disabled client returns Ok");
assert!(
!latched,
"a disabled client must not latch the process-wide hook"
);
}
#[test]
fn panic_machinery_frames_classify_out_of_app() {
let options = ErrorTrackingOptions::default();
for not_in_app in [
"std::panicking::begin_panic_handler",
"core::panicking::panic_fmt",
"std::panic::catch_unwind",
"std::sys::backtrace::__rust_begin_short_backtrace",
"rust_begin_unwind",
"__rust_try",
"backtrace::backtrace::trace",
"posthog_rs::error_tracking::capture_panic",
"posthog_rs::error_tracking::install_hook::{{closure}}",
"core::ops::function::FnOnce::call_once",
"tokio::runtime::task::raw::poll",
"futures_util::future::FutureExt::poll",
"anyhow::error::Error::msg",
"eyre::Report::msg",
"color_eyre::config::EyreHook::into_eyre_hook::{{closure}}",
"tracing::span::Span::record",
"tracing_core::dispatcher::get_default",
"log::__private_api::log",
] {
assert!(
!options.is_in_app_frame(None, Some(not_in_app)),
"{} should classify as not in-app",
not_in_app
);
}
for in_app in [
"my_app::checkout::process_payment",
"checkout_service::submit",
] {
assert!(
options.is_in_app_frame(None, Some(in_app)),
"{} should classify as in-app",
in_app
);
}
}
#[test]
fn function_names_strip_v0_crate_disambiguators() {
assert_eq!(
normalize_function_name("std[b887e3750a86e3a0]::panicking::panic_with_hook"),
"std::panicking::panic_with_hook"
);
assert_eq!(
normalize_function_name(
"<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"
),
"<alloc::boxed::Box<dyn core::ops::function::Fn<(&std::panic::PanicHookInfo,)>> as core::ops::function::Fn<(&std::panic::PanicHookInfo,)>>::call"
);
assert_eq!(
normalize_function_name("core::array::<impl [u8; 32]>::map"),
"core::array::<impl [u8; 32]>::map"
);
assert_eq!(
normalize_function_name("<[u8] as checkout_service::Digest>::digest"),
"<[u8] as checkout_service::Digest>::digest"
);
}
#[test]
fn from_error_builds_exception_list_with_stacktrace() {
let error = OuterError { source: InnerError };
let event = build_exception_event(
&error,
CaptureExceptionOptions::new().distinct_id("user-1"),
&ErrorTrackingOptions::default(),
)
.unwrap();
let json = built_event_json(event);
assert_eq!(json["event"], "$exception");
assert_eq!(json["distinct_id"], "user-1");
assert_eq!(json["properties"]["$exception_level"], "error");
let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
assert!(exception_list[0]["type"]
.as_str()
.unwrap()
.ends_with("OuterError"));
assert_eq!(exception_list[0]["value"], "checkout failed");
assert_eq!(exception_list[0]["mechanism"]["type"], "generic");
assert_eq!(exception_list[0]["mechanism"]["handled"], true);
assert_eq!(exception_list[0]["mechanism"]["synthetic"], false);
assert_eq!(exception_list[0]["mechanism"]["exception_id"], 0);
assert_eq!(exception_list[0]["stacktrace"]["type"], "raw");
assert_eq!(exception_list[1]["value"], "database unavailable");
assert_eq!(exception_list[1]["mechanism"]["type"], "chained");
assert_eq!(exception_list[1]["mechanism"]["exception_id"], 1);
assert_eq!(exception_list[1]["mechanism"]["parent_id"], 0);
let frames = exception_list[0]["stacktrace"]["frames"]
.as_array()
.expect("expected stack frames");
let top_frame = frames.first().expect("expected top frame");
assert_eq!(top_frame["platform"], "native");
assert_eq!(top_frame["lang"], "rust");
let instruction_addr = top_frame["instruction_addr"].as_str().unwrap_or_default();
assert!(
instruction_addr.starts_with("0x"),
"expected hex instruction_addr, got {:?}",
instruction_addr
);
let top_function = top_frame["function"].as_str().unwrap_or_default();
assert!(
top_function.contains("from_error_builds_exception_list_with_stacktrace"),
"expected user frame first, got {:?}",
top_function
);
assert!(
!top_function.contains("Exception::"),
"expected SDK frames to be skipped, got {:?}",
top_function
);
}
#[test]
fn gnu_build_ids_convert_to_debug_ids_like_the_server() {
let build_id: Vec<u8> = (0..20)
.map(|i| {
u8::from_str_radix(
&"555398ebd01c90285a3d85138a19cbf9bbcec352"[i * 2..i * 2 + 2],
16,
)
.unwrap()
})
.collect();
let (full, short) = if cfg!(target_endian = "little") {
(
"eb985355-1cd0-2890-5a3d-85138a19cbf9",
"0000cdab-0000-0000-0000-000000000000",
)
} else {
(
"555398eb-d01c-9028-5a3d-85138a19cbf9",
"abcd0000-0000-0000-0000-000000000000",
)
};
assert_eq!(debug_id_from_gnu_build_id(&build_id).as_deref(), Some(full));
assert_eq!(
debug_id_from_gnu_build_id(&[0xab, 0xcd]).as_deref(),
Some(short)
);
assert_eq!(debug_id_from_gnu_build_id(&[]), None);
}
#[test]
fn arch_normalizes_to_the_shared_native_vocabulary() {
assert_eq!(normalize_arch("aarch64"), "arm64");
assert_eq!(normalize_arch("x86_64"), "x86_64");
assert_eq!(normalize_arch("arm"), "arm");
}
#[test]
fn find_module_matches_address_ranges() {
let module_at = |base: u64, size: u64| LoadedModule {
base,
end: base + size,
image: DebugImage {
image_type: "elf".to_string(),
debug_id: "test".to_string(),
code_id: None,
image_addr: format!("0x{base:x}"),
image_size: Some(size),
image_vmaddr: None,
code_file: None,
arch: "x86_64".to_string(),
},
};
let modules = vec![module_at(0x1000, 0x1000), module_at(0x4000, 0x1000)];
assert_eq!(find_module(&modules, 0x1500).map(|m| m.base), Some(0x1000));
assert_eq!(find_module(&modules, 0x4000).map(|m| m.base), Some(0x4000));
assert!(find_module(&modules, 0x2000).is_none()); assert!(find_module(&modules, 0x500).is_none()); assert!(find_module(&modules, 0x5000).is_none()); }
#[test]
fn captured_stacks_reference_loaded_debug_images() {
let json = event_json(Exception::from_message(
"AddrCheck",
"captures addresses",
true,
));
let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
.as_array()
.expect("expected stack frames");
let mut saw_instruction_addr = false;
for frame in frames {
let Some(addr) = frame["instruction_addr"].as_str() else {
continue;
};
saw_instruction_addr = true;
assert!(
addr.starts_with("0x") && u64::from_str_radix(&addr[2..], 16).is_ok(),
"expected hex instruction_addr, got {:?}",
frame["instruction_addr"]
);
}
assert!(
saw_instruction_addr,
"expected at least one frame to carry an instruction_addr"
);
let images = json["properties"]["$debug_images"]
.as_array()
.expect("expected $debug_images");
assert!(!images.is_empty());
let expected_type = super::native_image_type();
let expected_arch = super::normalize_arch(std::env::consts::ARCH);
for image in images {
assert_eq!(image["type"].as_str(), Some(expected_type));
assert_eq!(
image["arch"].as_str(),
Some(expected_arch.as_str()),
"arch should match the running process"
);
let debug_id = image["debug_id"].as_str().unwrap_or_default();
assert!(
debug_id.len() >= 36,
"expected uuid-shaped debug_id, got {:?}",
debug_id
);
let image_addr = image["image_addr"].as_str().unwrap_or_default();
assert!(
frames
.iter()
.any(|f| f["image_addr"].as_str() == Some(image_addr)),
"image {} not referenced by any frame",
image_addr
);
}
}
#[test]
fn from_error_accepts_borrowed_error_types() {
let message = String::from("borrowed parse failure");
let error = BorrowedError(&message);
let json = event_json(Exception::from_error(&error, true));
assert_eq!(
json["properties"]["$exception_list"][0]["value"],
"borrowed parse failure"
);
}
#[test]
fn personless_capture_disables_person_profile() {
let json = event_json(Exception::from_message("Error", "no user context", true));
assert_eq!(json["event"], "$exception");
assert_eq!(json["properties"]["$process_person_profile"], false);
}
#[test]
fn custom_properties_cannot_override_reserved_exception_payload() {
let error = OuterError { source: InnerError };
let event = build_exception_event(
&error,
CaptureExceptionOptions::new()
.property("$exception_list", json!([{"value": "fake"}]))
.unwrap(),
&ErrorTrackingOptions::default(),
)
.unwrap();
let json = built_event_json(event);
assert_eq!(
json["properties"]["$exception_list"][0]["value"],
"checkout failed"
);
}
#[test]
fn options_can_disable_stacktrace() {
let options = ErrorTrackingOptionsBuilder::default()
.capture_stacktrace(false)
.build()
.unwrap();
let error = OuterError { source: InnerError };
let event =
build_exception_event(&error, CaptureExceptionOptions::new(), &options).unwrap();
let json = built_event_json(event);
let exception_list = json["properties"]["$exception_list"].as_array().unwrap();
assert_eq!(exception_list.len(), 2);
assert!(exception_list[0].get("stacktrace").is_none());
}
#[test]
fn in_app_path_defaults_and_overrides_are_applied() {
let options = ErrorTrackingOptions::default();
assert!(options.is_in_app_path("/app/src/main.rs"));
assert!(!options.is_in_app_path("/home/user/.cargo/registry/src/lib.rs"));
assert!(!options.is_in_app_path(
"/private/tmp/nix-build-rustc-1.91.1/rustc-1.91.1-src/library/core/src/ops/function.rs"
));
assert!(options.is_in_app_frame(None, Some("checkout_service::submit")));
assert!(!options.is_in_app_frame(None, Some("std::rt::lang_start")));
assert!(!options.is_in_app_frame(None, Some("core::ops::function::FnOnce::call_once")));
assert!(
!options.is_in_app_frame(None, Some("posthog_rs::client::Client::capture_exception"))
);
assert!(!options.is_in_app_frame(None, Some("_main")));
let options = ErrorTrackingOptionsBuilder::default()
.in_app_include_paths(vec!["/service/".to_string(), "my_service::".to_string()])
.in_app_exclude_paths(vec!["/service/vendor/".to_string()])
.build()
.unwrap();
assert!(options.is_in_app_path("/service/src/main.rs"));
assert!(!options.is_in_app_path("/other/src/main.rs"));
assert!(!options.is_in_app_path("/service/vendor/lib.rs"));
assert!(options.is_in_app_frame(None, Some("my_service::checkout")));
assert!(!options.is_in_app_frame(None, Some("other_service::checkout")));
}
#[test]
fn function_names_strip_rust_symbol_hashes() {
assert_eq!(
normalize_function_name("checkout_service::submit::h9ae4817223dd0b22"),
"checkout_service::submit"
);
assert_eq!(
normalize_function_name("std::rt::lang_start::{{closure}}::ha1fd5c62e470a8cc"),
"std::rt::lang_start::{{closure}}"
);
assert_eq!(
normalize_function_name("checkout_service::submit"),
"checkout_service::submit"
);
}
#[test]
fn type_names_keep_path_and_strip_generics() {
assert_eq!(
simple_type_name("std::io::error::Error"),
"std::io::error::Error"
);
assert_eq!(
simple_type_name("mycrate::CheckoutError"),
"mycrate::CheckoutError"
);
assert_eq!(simple_type_name("mycrate::Error"), "mycrate::Error");
assert_eq!(simple_type_name("foo::Bar<baz::Qux>"), "foo::Bar");
assert_eq!(
simple_type_name(type_name::<Box<dyn StdError>>()),
"alloc::boxed::Box"
);
assert_eq!(simple_type_name("dyn core::error::Error"), "Error");
assert_eq!(simple_type_name(type_name::<&dyn StdError>()), "Error");
}
#[test]
fn frames_are_trimmed_to_max_frames_keeping_the_top() {
let synthetic_frame = |index: usize| StackFrame {
filename: None,
line_no: None,
function: format!("frame_{index}"),
lang: "rust".to_string(),
in_app: true,
synthetic: false,
platform: "native".to_string(),
instruction_addr: None,
symbol_addr: None,
image_addr: None,
client_resolved: false,
};
let exception = Exception {
items: vec![ExceptionItem {
exception_type: "Error".to_string(),
value: "trimmed".to_string(),
mechanism: ExceptionMechanism::default(),
stacktrace: None,
}],
captured_frames: Some((0..MAX_FRAMES + 5).map(synthetic_frame).collect()),
captured_images: Vec::new(),
fingerprint: None,
level: "error".to_string(),
};
let json = event_json_with(exception, &ErrorTrackingOptions::default());
let frames = json["properties"]["$exception_list"][0]["stacktrace"]["frames"]
.as_array()
.expect("expected stack frames");
assert_eq!(frames.len(), MAX_FRAMES);
assert_eq!(frames[0]["function"], "frame_0");
}
#[test]
fn stacktrace_keeps_top_frame_first() {
fn capture() -> ExceptionStacktrace {
let mut frames = capture_frames_current_first(0, &[]);
trim_to_max_frames(&mut frames, 8);
ExceptionStacktrace::raw(frames)
}
let frames = capture().frames;
let functions: Vec<&str> = frames
.iter()
.map(|frame| frame.function.as_str())
.filter(|function| !function.is_empty())
.collect();
let capture_index = functions
.iter()
.position(|function| function.contains("stacktrace_keeps_top_frame_first::capture"))
.expect("expected capture frame");
let test_index = functions
.iter()
.position(|function| function.ends_with("stacktrace_keeps_top_frame_first"))
.expect("expected test frame");
assert!(
capture_index < test_index,
"expected top frame before caller, got {:?}",
functions
);
}
#[test]
fn inlined_frames_collapse_for_server_side_expansion() {
#[inline(always)]
fn inline_leaf() -> Vec<StackFrame> {
capture_raw_application_frames().0
}
#[inline(always)]
fn inline_mid() -> Vec<StackFrame> {
inline_leaf()
}
let frames = inline_mid();
let functions: Vec<&str> = frames.iter().map(|frame| frame.function.as_str()).collect();
let mut addrs: Vec<&str> = frames
.iter()
.filter_map(|frame| frame.instruction_addr.as_deref())
.collect();
let emitted = addrs.len();
addrs.sort_unstable();
addrs.dedup();
assert_eq!(
addrs.len(),
emitted,
"instruction_addr duplicated across frames: {:?}",
frames
);
assert!(
frames
.iter()
.all(|f| f.client_resolved == f.instruction_addr.is_none()),
"client_resolved must be the inverse of instruction_addr presence: {:?}",
frames
);
let leaf = functions
.iter()
.filter(|f| f.contains("inline_leaf"))
.count();
let mid = functions
.iter()
.filter(|f| f.contains("inline_mid"))
.count();
if frames.iter().any(|frame| frame.instruction_addr.is_some()) {
assert!(
leaf == 0 && mid == 0,
"expected inlined layers collapsed for server-side expansion, got {:?}",
functions
);
} else {
let leaf_index = functions.iter().position(|f| f.contains("inline_leaf"));
let mid_index = functions.iter().position(|f| f.contains("inline_mid"));
assert!(
matches!((leaf_index, mid_index), (Some(l), Some(m)) if l < m),
"expected client-side inline expansion innermost first, got {:?}",
functions
);
}
}
#[test]
fn build_exception_event_defaults_to_personless() {
let error = OuterError { source: InnerError };
let event = build_exception_event(
&error,
CaptureExceptionOptions::default(),
&ErrorTrackingOptions::default(),
)
.unwrap();
let json = built_event_json(event);
assert_eq!(json["event"], "$exception");
assert_eq!(json["properties"]["$process_person_profile"], false);
assert_eq!(json["properties"]["$exception_level"], "error");
}
#[test]
fn build_exception_event_applies_options() {
let error = OuterError { source: InnerError };
let options = CaptureExceptionOptions::new()
.distinct_id("user-1")
.property("route", "/checkout")
.unwrap()
.group("company", "acme")
.fingerprint("checkout-error")
.level("warning");
let event =
build_exception_event(&error, options, &ErrorTrackingOptions::default()).unwrap();
let json = built_event_json(event);
assert_eq!(json["distinct_id"], "user-1");
assert_eq!(json["properties"]["route"], "/checkout");
assert_eq!(json["properties"]["$groups"]["company"], "acme");
assert_eq!(
json["properties"]["$exception_fingerprint"],
"checkout-error"
);
assert_eq!(json["properties"]["$exception_level"], "warning");
}
}