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>>,
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);
Self {
items,
captured_frames: if capture_stacktrace {
Some(capture_raw_application_frames())
} else {
None
},
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 {
Self {
items: vec![ExceptionItem {
exception_type: exception_type.into(),
value: value.into(),
mechanism: ExceptionMechanism::default(),
stacktrace: None,
}],
captured_frames: if capture_stacktrace {
Some(capture_raw_application_frames())
} else {
None
},
fingerprint: None,
level: "error".to_string(),
}
}
#[allow(deprecated)]
fn from_panic_info(panic_info: &panic::PanicInfo<'_>, capture_stacktrace: bool) -> Self {
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: if capture_stacktrace {
Some(capture_raw_panic_frames())
} else {
None
},
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,
fingerprint,
level,
} = self;
if items.is_empty() {
return Ok(());
}
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());
frame.in_app = options.is_in_app_frame(frame.filename.as_deref(), function);
}
trim_to_max_frames(&mut frames, MAX_FRAMES);
items[0].stacktrace = Some(ExceptionStacktrace::raw(frames));
}
event.insert_prop("$exception_level", level)?;
if let Some(fingerprint) = fingerprint {
event.insert_prop("$exception_fingerprint", fingerprint)?;
}
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>,
pub function: String,
pub lang: String,
pub in_app: bool,
pub synthetic: bool,
pub resolved: bool,
pub platform: String,
}
fn capture_frames_current_first(skip: usize) -> Vec<StackFrame> {
let mut frames = Vec::new();
let mut skipped = 0usize;
backtrace::trace(|frame| {
if skipped < skip {
skipped += 1;
return true;
}
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;
}
frames.push(StackFrame {
filename,
line_no: symbol.lineno(),
function: function.unwrap_or_default(),
lang: "rust".to_string(),
in_app: false,
synthetic: false,
resolved: true,
platform: "rust".to_string(),
});
});
true
});
frames
}
fn trim_to_max_frames(frames: &mut Vec<StackFrame>, max_frames: usize) {
if frames.len() > max_frames {
frames.truncate(max_frames);
}
}
fn capture_raw_frames(is_internal: impl Fn(&str) -> bool) -> Vec<StackFrame> {
let mut frames = capture_frames_current_first(0);
while frames
.first()
.map(|frame| is_internal(&frame.function))
.unwrap_or(false)
{
frames.remove(0);
}
frames
}
fn capture_raw_application_frames() -> Vec<StackFrame> {
capture_raw_frames(is_internal_capture_frame)
}
fn capture_raw_panic_frames() -> Vec<StackFrame> {
capture_frames_current_first(0)
}
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"
| "core"
| "futures_core"
| "futures_util"
| "log"
| "posthog_rs"
| "reqwest"
| "std"
| "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",
"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"], "rust");
assert_eq!(top_frame["lang"], "rust");
assert_eq!(top_frame["resolved"], true);
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 last, got {:?}",
top_function
);
assert!(
!top_function.contains("Exception::"),
"expected SDK frames to be skipped, got {:?}",
top_function
);
}
#[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,
resolved: true,
platform: "rust".to_string(),
};
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()),
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_functions_become_separate_frames() {
#[inline(always)]
fn inline_leaf() -> Vec<StackFrame> {
capture_raw_application_frames()
}
#[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 leaf_index = functions
.iter()
.position(|function| function.contains("inline_leaf"))
.unwrap_or_else(|| panic!("expected inline_leaf frame, got {:?}", functions));
let mid_index = functions
.iter()
.position(|function| function.contains("inline_mid"))
.unwrap_or_else(|| panic!("expected inline_mid frame, got {:?}", functions));
assert!(
leaf_index < mid_index,
"expected innermost inlined layer 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");
}
}