use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::breadcrumb_buffer::Breadcrumb;
use crate::configuration::Configuration;
use crate::pii_scrubber::{scrub_string, scrub_value, Value};
pub const MAX_FRAMES: usize = 500;
const CRATE_PREFIX: &str = "forge_ops_tracker::";
const CONTEXT_LINES: usize = 5;
const MAX_CONTEXT_LINE_LENGTH: usize = 500;
const SDK_NAME: &str = "rust";
#[derive(Clone, Debug, PartialEq)]
pub struct Frame {
pub file: String,
pub line: u32,
pub method: String,
pub in_app: bool,
pub context_line: Option<String>,
pub pre_context: Option<Vec<String>>,
pub post_context: Option<Vec<String>>,
}
impl Frame {
pub fn new(file: String, line: u32, method: String, in_app: bool) -> Self {
Frame {
file,
line,
method,
in_app,
context_line: None,
pre_context: None,
post_context: None,
}
}
}
#[derive(Clone, Debug)]
pub struct Event {
pub exception_class: String,
pub message: String,
pub backtrace: Vec<Frame>,
pub occurred_at: String,
pub environment: String,
pub release: Option<String>,
pub server_name: Option<String>,
pub context: HashMap<String, Value>,
pub tags: HashMap<String, Value>,
pub sdk_name: String,
pub user: Option<HashMap<String, Value>>,
pub breadcrumbs: Vec<Breadcrumb>,
}
impl Event {
pub fn to_json(&self) -> String {
use crate::pii_scrubber::json_string;
let backtrace: Vec<String> = self
.backtrace
.iter()
.map(|f| {
let context_fields = match (&f.context_line, &f.pre_context, &f.post_context) {
(Some(context_line), Some(pre_context), Some(post_context)) => format!(
",\"context_line\":{},\"pre_context\":{},\"post_context\":{}",
json_string(context_line),
string_array_json(pre_context),
string_array_json(post_context)
),
_ => String::new(),
};
format!(
"{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}{}}}",
json_string(&f.file),
f.line,
json_string(&f.method),
f.in_app,
context_fields
)
})
.collect();
let optional_string = |v: &Option<String>| {
v.as_deref()
.map(json_string)
.unwrap_or_else(|| "null".to_string())
};
let user_field = match &self.user {
Some(user) if !user.is_empty() => {
format!(",\"user\":{}", Value::Object(user.clone()).to_json())
}
_ => String::new(),
};
let breadcrumbs_field = if self.breadcrumbs.is_empty() {
String::new()
} else {
let entries: Vec<String> = self
.breadcrumbs
.iter()
.map(|b| {
format!(
"{{\"category\":{},\"message\":{},\"level\":{},\"timestamp\":{},\"data\":{}}}",
json_string(&b.category),
json_string(&b.message),
json_string(&b.level),
json_string(&b.timestamp),
Value::Object(b.data.clone()).to_json()
)
})
.collect();
format!(",\"breadcrumbs\":[{}]", entries.join(","))
};
format!(
"{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{},\"sdk_name\":{}{}{}}}",
json_string(&self.exception_class),
json_string(&self.message),
backtrace.join(","),
json_string(&self.occurred_at),
json_string(&self.environment),
optional_string(&self.release),
optional_string(&self.server_name),
Value::Object(self.context.clone()).to_json(),
Value::Object(self.tags.clone()).to_json(),
json_string(&self.sdk_name),
user_field,
breadcrumbs_field
)
}
}
fn string_array_json(items: &[String]) -> String {
Value::Array(items.iter().cloned().map(Value::String).collect()).to_json()
}
pub struct EventBuilder<'a> {
configuration: &'a Configuration,
}
impl<'a> EventBuilder<'a> {
pub fn new(configuration: &'a Configuration) -> Self {
EventBuilder { configuration }
}
#[allow(clippy::too_many_arguments)]
pub fn build(
&self,
exception_class: &str,
message: &str,
backtrace: Vec<Frame>,
context: HashMap<String, Value>,
user: Option<HashMap<String, Value>>,
breadcrumbs: Vec<Breadcrumb>,
) -> Event {
let mut event = Event {
exception_class: exception_class.to_string(),
message: message.to_string(),
backtrace,
occurred_at: format_now(),
environment: self.configuration.environment.clone(),
release: self.configuration.release.clone(),
server_name: self.configuration.server_name.clone(),
context,
tags: HashMap::new(),
sdk_name: SDK_NAME.to_string(),
user: user.filter(|u| !u.is_empty()),
breadcrumbs,
};
if self.configuration.scrub_pii {
event = scrub_event(event);
}
event
}
}
fn scrub_event(mut event: Event) -> Event {
event.message = scrub_string(&event.message);
event.backtrace = event
.backtrace
.into_iter()
.map(|f| Frame {
file: scrub_string(&f.file),
method: scrub_string(&f.method),
..f
})
.collect();
let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
unreachable!()
};
event.context = context;
let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
unreachable!()
};
event.tags = tags;
event.breadcrumbs = event
.breadcrumbs
.into_iter()
.map(|b| {
let Value::Object(data) = scrub_value(&Value::Object(b.data), "") else {
unreachable!()
};
Breadcrumb {
message: scrub_string(&b.message),
data,
..b
}
})
.collect();
event
}
pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
let bt = backtrace::Backtrace::new();
let mut frames = Vec::new();
let mut seen_app_frame = false;
'frames: for frame in bt.frames() {
for symbol in frame.symbols() {
let name = symbol
.name()
.map(|n| n.to_string())
.unwrap_or_else(|| "<unknown>".to_string());
if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
continue;
}
seen_app_frame = true;
let file = symbol
.filename()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let line = symbol.lineno().unwrap_or(0);
let in_app = is_in_app(configuration, &file);
let frame = Frame::new(file, line, name, in_app);
frames.push(attach_source_context(configuration, frame));
if frames.len() >= MAX_FRAMES {
break 'frames;
}
}
}
frames
}
fn is_in_app(configuration: &Configuration, file: &str) -> bool {
let root = match &configuration.app_root {
Some(r) if !r.is_empty() => r,
_ => return false,
};
if file.is_empty() || !file.starts_with(root.as_str()) {
return false;
}
!file.contains("/.cargo/registry/") && !file.contains("/rustc/")
}
fn attach_source_context(configuration: &Configuration, mut frame: Frame) -> Frame {
if !configuration.capture_source_context || !frame.in_app {
return frame;
}
let Ok(contents) = std::fs::read_to_string(&frame.file) else {
return frame;
};
let lines: Vec<&str> = contents.lines().collect();
if frame.line == 0 || frame.line as usize > lines.len() {
return frame;
}
let index = frame.line as usize - 1;
let from = index.saturating_sub(CONTEXT_LINES);
let to = (index + CONTEXT_LINES).min(lines.len() - 1);
frame.context_line = Some(truncate_line(lines[index]));
frame.pre_context = Some(
lines[from..index]
.iter()
.map(|l| truncate_line(l))
.collect(),
);
frame.post_context = Some(
lines[(index + 1)..=to]
.iter()
.map(|l| truncate_line(l))
.collect(),
);
frame
}
fn truncate_line(line: &str) -> String {
if line.chars().count() <= MAX_CONTEXT_LINE_LENGTH {
return line.to_string();
}
let truncated: String = line.chars().take(MAX_CONTEXT_LINE_LENGTH).collect();
format!("{truncated}...")
}
fn format_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format_unix_timestamp(secs)
}
pub(crate) fn format_unix_timestamp(secs: u64) -> String {
let days = secs / 86400;
let time_of_day = secs % 86400;
let (hour, minute, second) = (
time_of_day / 3600,
(time_of_day % 3600) / 60,
time_of_day % 60,
);
let (year, month, day) = civil_from_days(days as i64);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = if m <= 2 { y + 1 } else { y };
(year, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_configuration() -> Configuration {
Configuration {
environment: "production".to_string(),
release: Some("a1b2c3d".to_string()),
server_name: Some("test-host".to_string()),
app_root: Some("/app".to_string()),
scrub_pii: true,
..Configuration::new()
}
}
#[test]
fn build_basic_fields() {
let config = test_configuration();
let builder = EventBuilder::new(&config);
let mut context = HashMap::new();
context.insert("order_id".to_string(), Value::Number(42.0));
let event = builder.build("std::io::Error", "boom", vec![], context, None, vec![]);
assert_eq!(event.message, "boom");
assert_eq!(event.environment, "production");
assert_eq!(event.release, Some("a1b2c3d".to_string()));
assert_eq!(event.server_name, Some("test-host".to_string()));
assert_eq!(event.context["order_id"], Value::Number(42.0));
assert_eq!(event.sdk_name, "rust");
}
#[test]
fn build_includes_the_user_when_given_one_never_scrubbed_even_though_its_an_email() {
let config = test_configuration();
let builder = EventBuilder::new(&config);
let mut user = HashMap::new();
user.insert("id".to_string(), Value::Number(42.0));
user.insert(
"email".to_string(),
Value::String("ada@example.com".to_string()),
);
let event = builder.build("Error", "boom", vec![], HashMap::new(), Some(user), vec![]);
assert_eq!(
event.user.unwrap()["email"],
Value::String("ada@example.com".to_string())
);
}
#[test]
fn build_omits_the_user_entirely_when_none_was_given() {
let config = test_configuration();
let builder = EventBuilder::new(&config);
let event = builder.build("Error", "boom", vec![], HashMap::new(), None, vec![]);
assert_eq!(event.user, None);
}
#[test]
fn build_scrubs_message_and_context_when_enabled() {
let config = test_configuration();
let builder = EventBuilder::new(&config);
let mut context = HashMap::new();
context.insert(
"api_key".to_string(),
Value::String("shh-secret".to_string()),
);
let event = builder.build(
"Error",
"failed to charge user@example.com",
vec![],
context,
None,
vec![],
);
assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
assert_eq!(
event.context["api_key"],
Value::String(crate::pii_scrubber::REDACTED.to_string())
);
}
#[test]
fn build_does_not_scrub_when_disabled() {
let mut config = test_configuration();
config.scrub_pii = false;
let builder = EventBuilder::new(&config);
let event = builder.build(
"Error",
"contact user@example.com",
vec![],
HashMap::new(),
None,
vec![],
);
assert_eq!(event.message, "contact user@example.com");
}
#[test]
fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
let config = test_configuration();
assert!(is_in_app(&config, "/app/src/main.rs"));
assert!(!is_in_app(&config, "/other/src/main.rs"));
assert!(!is_in_app(
&config,
"/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
));
assert!(!is_in_app(&config, ""));
}
#[test]
fn capture_backtrace_excludes_this_crates_own_frames() {
let config = test_configuration();
let frames = capture_backtrace(&config);
assert!(!frames.is_empty(), "expected at least one backtrace frame");
for frame in &frames {
assert!(
!frame.method.starts_with(CRATE_PREFIX),
"frame {:?} should have been filtered out as SDK-internal",
frame.method
);
}
}
#[test]
fn format_unix_timestamp_known_value() {
assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
}
mod source_context {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct TempFile {
path: std::path::PathBuf,
}
impl TempFile {
fn with_contents(contents: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"forge_ops_tracker_test_{}_{nanos}_{n}.rs",
std::process::id()
));
std::fs::write(&path, contents).expect("failed to write temp test file");
TempFile { path }
}
fn path_string(&self) -> String {
self.path.to_string_lossy().into_owned()
}
}
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn numbered_lines(count: usize) -> String {
(1..=count)
.map(|n| format!("line {n}"))
.collect::<Vec<_>>()
.join("\n")
}
fn frame_in_app(file: String, line: u32) -> Frame {
Frame::new(file, line, "call".to_string(), true)
}
#[test]
fn attaches_window_around_the_culprit_line_by_default() {
let file = TempFile::with_contents(&numbered_lines(20));
let mut config = test_configuration();
config.app_root = Some(std::env::temp_dir().to_string_lossy().into_owned());
let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
assert_eq!(frame.context_line, Some("line 10".to_string()));
assert_eq!(
frame.pre_context,
Some((5..=9).map(|n| format!("line {n}")).collect())
);
assert_eq!(
frame.post_context,
Some((11..=15).map(|n| format!("line {n}")).collect())
);
}
#[test]
fn clamps_at_the_start_and_end_of_the_file_rather_than_panicking() {
let file = TempFile::with_contents(&numbered_lines(3));
let config = test_configuration();
let first = attach_source_context(&config, frame_in_app(file.path_string(), 1));
let last = attach_source_context(&config, frame_in_app(file.path_string(), 3));
assert_eq!(first.pre_context, Some(vec![]));
assert_eq!(
first.post_context,
Some(vec!["line 2".to_string(), "line 3".to_string()])
);
assert_eq!(
last.pre_context,
Some(vec!["line 1".to_string(), "line 2".to_string()])
);
assert_eq!(last.post_context, Some(vec![]));
}
#[test]
fn truncates_a_line_longer_than_max_context_line_length() {
let overlong = "x".repeat(600);
let file = TempFile::with_contents(&overlong);
let config = test_configuration();
let frame = attach_source_context(&config, frame_in_app(file.path_string(), 1));
assert_eq!(frame.context_line, Some(format!("{}...", "x".repeat(500))));
}
#[test]
fn never_attaches_context_to_a_frame_that_is_not_in_app() {
let file = TempFile::with_contents(&numbered_lines(20));
let config = test_configuration();
let frame = Frame::new(file.path_string(), 10, "call".to_string(), false);
let frame = attach_source_context(&config, frame);
assert_eq!(frame.context_line, None);
assert_eq!(frame.pre_context, None);
assert_eq!(frame.post_context, None);
}
#[test]
fn leaves_the_frame_untouched_when_capture_source_context_is_disabled() {
let file = TempFile::with_contents(&numbered_lines(20));
let mut config = test_configuration();
config.capture_source_context = false;
let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
assert_eq!(frame.context_line, None);
assert_eq!(frame.pre_context, None);
assert_eq!(frame.post_context, None);
}
#[test]
fn leaves_the_frame_untouched_when_the_file_cannot_be_read() {
let config = test_configuration();
let missing_path = std::env::temp_dir()
.join("forge_ops_tracker_test_does_not_exist_12345.rs")
.to_string_lossy()
.into_owned();
let frame = attach_source_context(&config, frame_in_app(missing_path, 1));
assert_eq!(frame.context_line, None);
assert_eq!(frame.pre_context, None);
assert_eq!(frame.post_context, None);
}
#[test]
fn wire_format_uses_snake_case_keys_when_context_is_present_and_omits_them_otherwise() {
let with_context = Frame {
context_line: Some("line 10".to_string()),
pre_context: Some(vec!["line 9".to_string()]),
post_context: Some(vec!["line 11".to_string()]),
..frame_in_app("/app/src/main.rs".to_string(), 10)
};
let event = Event {
exception_class: "Error".to_string(),
message: "boom".to_string(),
backtrace: vec![with_context],
occurred_at: "2024-01-15T10:30:00Z".to_string(),
environment: "production".to_string(),
release: None,
server_name: None,
context: HashMap::new(),
tags: HashMap::new(),
sdk_name: "rust".to_string(),
user: None,
breadcrumbs: vec![],
};
let json = event.to_json();
assert!(json.contains("\"context_line\":\"line 10\""));
assert!(json.contains("\"pre_context\":[\"line 9\"]"));
assert!(json.contains("\"post_context\":[\"line 11\"]"));
assert!(!json.contains("\"user\""));
let without_context = Event {
backtrace: vec![frame_in_app("/app/src/main.rs".to_string(), 10)],
..event
};
let json = without_context.to_json();
assert!(!json.contains("context_line"));
assert!(!json.contains("pre_context"));
assert!(!json.contains("post_context"));
}
#[test]
fn wire_format_includes_user_when_present_and_omits_it_otherwise() {
let mut user = HashMap::new();
user.insert(
"email".to_string(),
Value::String("ada@example.com".to_string()),
);
let event = Event {
exception_class: "Error".to_string(),
message: "boom".to_string(),
backtrace: vec![],
occurred_at: "2024-01-15T10:30:00Z".to_string(),
environment: "production".to_string(),
release: None,
server_name: None,
context: HashMap::new(),
tags: HashMap::new(),
sdk_name: "rust".to_string(),
user: Some(user),
breadcrumbs: vec![],
};
let json = event.to_json();
assert!(json.contains("\"user\":{\"email\":\"ada@example.com\"}"));
let without_user = Event {
user: None,
..event
};
assert!(!without_user.to_json().contains("\"user\""));
}
#[test]
fn wire_format_includes_breadcrumbs_when_present_and_omits_them_otherwise() {
let crumb = Breadcrumb {
category: "controller".to_string(),
message: "GET /orders/42".to_string(),
level: "info".to_string(),
timestamp: "2024-01-15T10:29:58Z".to_string(),
data: HashMap::from([("status".to_string(), Value::Number(200.0))]),
};
let event = Event {
exception_class: "Error".to_string(),
message: "boom".to_string(),
backtrace: vec![],
occurred_at: "2024-01-15T10:30:00Z".to_string(),
environment: "production".to_string(),
release: None,
server_name: None,
context: HashMap::new(),
tags: HashMap::new(),
sdk_name: "rust".to_string(),
user: None,
breadcrumbs: vec![crumb],
};
let json = event.to_json();
assert!(json.contains(
"\"breadcrumbs\":[{\"category\":\"controller\",\"message\":\"GET /orders/42\",\"level\":\"info\",\"timestamp\":\"2024-01-15T10:29:58Z\",\"data\":{\"status\":200}}]"
));
let without_breadcrumbs = Event {
breadcrumbs: vec![],
..event
};
assert!(!without_breadcrumbs.to_json().contains("\"breadcrumbs\""));
}
}
}