use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
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::";
#[derive(Clone, Debug, PartialEq)]
pub struct Frame {
pub file: String,
pub line: u32,
pub method: String,
pub in_app: bool,
}
#[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>,
}
impl Event {
pub fn to_json(&self) -> String {
use crate::pii_scrubber::json_string;
let backtrace: Vec<String> = self
.backtrace
.iter()
.map(|f| {
format!(
"{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}}}",
json_string(&f.file),
f.line,
json_string(&f.method),
f.in_app
)
})
.collect();
let optional_string = |v: &Option<String>| {
v.as_deref()
.map(json_string)
.unwrap_or_else(|| "null".to_string())
};
format!(
"{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{}}}",
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()
)
}
}
pub struct EventBuilder<'a> {
configuration: &'a Configuration,
}
impl<'a> EventBuilder<'a> {
pub fn new(configuration: &'a Configuration) -> Self {
EventBuilder { configuration }
}
pub fn build(
&self,
exception_class: &str,
message: &str,
backtrace: Vec<Frame>,
context: HashMap<String, Value>,
) -> 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(),
};
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
}
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);
frames.push(Frame {
file,
line,
method: name,
in_app,
});
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 format_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format_unix_timestamp(secs)
}
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);
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));
}
#[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,
);
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());
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");
}
}