use std::any::{type_name, type_name_of_val};
use std::error::Error as StdError;
use derive_builder::Builder;
use serde::Serialize;
use serde_json::Value;
use crate::{Error, Event};
const MAX_FRAMES: usize = 64;
const MAX_ERROR_SOURCES: usize = 50;
#[derive(Builder, Clone, Debug)]
#[builder(default)]
pub struct ErrorTrackingOptions {
capture_stacktrace: bool,
in_app_include_paths: Vec<String>,
in_app_exclude_paths: Vec<String>,
}
impl Default for ErrorTrackingOptions {
fn default() -> Self {
Self {
capture_stacktrace: true,
in_app_include_paths: Vec::new(),
in_app_exclude_paths: Vec::new(),
}
}
}
impl ErrorTrackingOptions {
fn capture_stacktrace(&self) -> bool {
self.capture_stacktrace
}
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()
}
}
#[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(dead_code)]
pub(crate) fn from_exception_list(items: Vec<ExceptionItem>) -> Self {
Self {
items,
captured_frames: None,
fingerprint: None,
level: "error".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_application_frames() -> Vec<StackFrame> {
let mut frames = capture_frames_current_first(0);
while frames
.first()
.map(|frame| is_internal_capture_frame(&frame.function))
.unwrap_or(false)
{
frames.remove(0);
}
frames
}
fn is_internal_capture_frame(function: &str) -> bool {
function.starts_with("backtrace::")
|| function.contains("capture_frames_current_first")
|| 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")
}
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 {
match function.rsplit_once("::") {
Some((prefix, suffix)) if is_rust_symbol_hash(suffix) => prefix.to_string(),
_ => function.to_string(),
}
}
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" {
return false;
}
!matches!(
function
.trim_start_matches('<')
.split("::")
.next()
.unwrap_or_default(),
"alloc" | "backtrace" | "core" | "posthog_rs" | "reqwest" | "std" | "tokio"
)
}
#[cfg(test)]
mod tests {
use std::error::Error as StdError;
use std::fmt;
use serde_json::{json, Value};
use super::*;
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())
}
#[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");
}
}