1use std::collections::HashMap;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::configuration::Configuration;
10use crate::pii_scrubber::{scrub_string, scrub_value, Value};
11
12pub const MAX_FRAMES: usize = 500;
15
16const CRATE_PREFIX: &str = "forge_ops_tracker::";
20
21#[derive(Clone, Debug, PartialEq)]
22pub struct Frame {
23 pub file: String,
24 pub line: u32,
25 pub method: String,
26 pub in_app: bool,
27}
28
29#[derive(Clone, Debug)]
30pub struct Event {
31 pub exception_class: String,
32 pub message: String,
33 pub backtrace: Vec<Frame>,
34 pub occurred_at: String,
35 pub environment: String,
36 pub release: Option<String>,
37 pub server_name: Option<String>,
38 pub context: HashMap<String, Value>,
39 pub tags: HashMap<String, Value>,
40}
41
42impl Event {
43 pub fn to_json(&self) -> String {
44 use crate::pii_scrubber::json_string;
45
46 let backtrace: Vec<String> = self
47 .backtrace
48 .iter()
49 .map(|f| {
50 format!(
51 "{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}}}",
52 json_string(&f.file),
53 f.line,
54 json_string(&f.method),
55 f.in_app
56 )
57 })
58 .collect();
59
60 let optional_string = |v: &Option<String>| {
61 v.as_deref()
62 .map(json_string)
63 .unwrap_or_else(|| "null".to_string())
64 };
65
66 format!(
67 "{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{}}}",
68 json_string(&self.exception_class),
69 json_string(&self.message),
70 backtrace.join(","),
71 json_string(&self.occurred_at),
72 json_string(&self.environment),
73 optional_string(&self.release),
74 optional_string(&self.server_name),
75 Value::Object(self.context.clone()).to_json(),
76 Value::Object(self.tags.clone()).to_json()
77 )
78 }
79}
80
81pub struct EventBuilder<'a> {
82 configuration: &'a Configuration,
83}
84
85impl<'a> EventBuilder<'a> {
86 pub fn new(configuration: &'a Configuration) -> Self {
87 EventBuilder { configuration }
88 }
89
90 pub fn build(
91 &self,
92 exception_class: &str,
93 message: &str,
94 backtrace: Vec<Frame>,
95 context: HashMap<String, Value>,
96 ) -> Event {
97 let mut event = Event {
98 exception_class: exception_class.to_string(),
99 message: message.to_string(),
100 backtrace,
101 occurred_at: format_now(),
102 environment: self.configuration.environment.clone(),
103 release: self.configuration.release.clone(),
104 server_name: self.configuration.server_name.clone(),
105 context,
106 tags: HashMap::new(),
107 };
108
109 if self.configuration.scrub_pii {
110 event = scrub_event(event);
111 }
112 event
113 }
114}
115
116fn scrub_event(mut event: Event) -> Event {
120 event.message = scrub_string(&event.message);
121 event.backtrace = event
122 .backtrace
123 .into_iter()
124 .map(|f| Frame {
125 file: scrub_string(&f.file),
126 method: scrub_string(&f.method),
127 ..f
128 })
129 .collect();
130
131 let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
132 unreachable!()
133 };
134 event.context = context;
135 let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
136 unreachable!()
137 };
138 event.tags = tags;
139 event
140}
141
142pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
147 let bt = backtrace::Backtrace::new();
148 let mut frames = Vec::new();
149 let mut seen_app_frame = false;
150
151 'frames: for frame in bt.frames() {
152 for symbol in frame.symbols() {
153 let name = symbol
154 .name()
155 .map(|n| n.to_string())
156 .unwrap_or_else(|| "<unknown>".to_string());
157
158 if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
164 continue;
165 }
166 seen_app_frame = true;
167
168 let file = symbol
169 .filename()
170 .map(|p| p.to_string_lossy().into_owned())
171 .unwrap_or_default();
172 let line = symbol.lineno().unwrap_or(0);
173 let in_app = is_in_app(configuration, &file);
174 frames.push(Frame {
175 file,
176 line,
177 method: name,
178 in_app,
179 });
180
181 if frames.len() >= MAX_FRAMES {
182 break 'frames;
183 }
184 }
185 }
186 frames
187}
188
189fn is_in_app(configuration: &Configuration, file: &str) -> bool {
190 let root = match &configuration.app_root {
191 Some(r) if !r.is_empty() => r,
192 _ => return false,
193 };
194 if file.is_empty() || !file.starts_with(root.as_str()) {
195 return false;
196 }
197 !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
201}
202
203fn format_now() -> String {
204 let secs = SystemTime::now()
205 .duration_since(UNIX_EPOCH)
206 .map(|d| d.as_secs())
207 .unwrap_or(0);
208 format_unix_timestamp(secs)
209}
210
211fn format_unix_timestamp(secs: u64) -> String {
216 let days = secs / 86400;
217 let time_of_day = secs % 86400;
218 let (hour, minute, second) = (
219 time_of_day / 3600,
220 (time_of_day % 3600) / 60,
221 time_of_day % 60,
222 );
223
224 let (year, month, day) = civil_from_days(days as i64);
225 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
226}
227
228fn civil_from_days(z: i64) -> (i64, u32, u32) {
233 let z = z + 719468;
234 let era = if z >= 0 { z } else { z - 146096 } / 146097;
235 let doe = (z - era * 146097) as u64;
236 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
237 let y = yoe as i64 + era * 400;
238 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
239 let mp = (5 * doy + 2) / 153;
240 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
241 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
242 let year = if m <= 2 { y + 1 } else { y };
243 (year, m, d)
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn test_configuration() -> Configuration {
251 Configuration {
252 environment: "production".to_string(),
253 release: Some("a1b2c3d".to_string()),
254 server_name: Some("test-host".to_string()),
255 app_root: Some("/app".to_string()),
256 scrub_pii: true,
257 ..Configuration::new()
258 }
259 }
260
261 #[test]
262 fn build_basic_fields() {
263 let config = test_configuration();
264 let builder = EventBuilder::new(&config);
265 let mut context = HashMap::new();
266 context.insert("order_id".to_string(), Value::Number(42.0));
267
268 let event = builder.build("std::io::Error", "boom", vec![], context);
269
270 assert_eq!(event.message, "boom");
271 assert_eq!(event.environment, "production");
272 assert_eq!(event.release, Some("a1b2c3d".to_string()));
273 assert_eq!(event.server_name, Some("test-host".to_string()));
274 assert_eq!(event.context["order_id"], Value::Number(42.0));
275 }
276
277 #[test]
278 fn build_scrubs_message_and_context_when_enabled() {
279 let config = test_configuration();
280 let builder = EventBuilder::new(&config);
281 let mut context = HashMap::new();
282 context.insert(
283 "api_key".to_string(),
284 Value::String("shh-secret".to_string()),
285 );
286
287 let event = builder.build(
288 "Error",
289 "failed to charge user@example.com",
290 vec![],
291 context,
292 );
293
294 assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
295 assert_eq!(
296 event.context["api_key"],
297 Value::String(crate::pii_scrubber::REDACTED.to_string())
298 );
299 }
300
301 #[test]
302 fn build_does_not_scrub_when_disabled() {
303 let mut config = test_configuration();
304 config.scrub_pii = false;
305 let builder = EventBuilder::new(&config);
306
307 let event = builder.build("Error", "contact user@example.com", vec![], HashMap::new());
308
309 assert_eq!(event.message, "contact user@example.com");
310 }
311
312 #[test]
313 fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
314 let config = test_configuration();
315 assert!(is_in_app(&config, "/app/src/main.rs"));
316 assert!(!is_in_app(&config, "/other/src/main.rs"));
317 assert!(!is_in_app(
318 &config,
319 "/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
320 ));
321 assert!(!is_in_app(&config, ""));
322 }
323
324 #[test]
325 fn capture_backtrace_excludes_this_crates_own_frames() {
326 let config = test_configuration();
327 let frames = capture_backtrace(&config);
328
329 assert!(!frames.is_empty(), "expected at least one backtrace frame");
330 for frame in &frames {
331 assert!(
332 !frame.method.starts_with(CRATE_PREFIX),
333 "frame {:?} should have been filtered out as SDK-internal",
334 frame.method
335 );
336 }
337 }
338
339 #[test]
340 fn format_unix_timestamp_known_value() {
341 assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
343 assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
345 }
346}