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
21const CONTEXT_LINES: usize = 5;
27const MAX_CONTEXT_LINE_LENGTH: usize = 500;
28
29const SDK_NAME: &str = "rust";
33
34#[derive(Clone, Debug, PartialEq)]
35pub struct Frame {
36 pub file: String,
37 pub line: u32,
38 pub method: String,
39 pub in_app: bool,
40 pub context_line: Option<String>,
41 pub pre_context: Option<Vec<String>>,
42 pub post_context: Option<Vec<String>>,
43}
44
45impl Frame {
46 pub fn new(file: String, line: u32, method: String, in_app: bool) -> Self {
49 Frame {
50 file,
51 line,
52 method,
53 in_app,
54 context_line: None,
55 pre_context: None,
56 post_context: None,
57 }
58 }
59}
60
61#[derive(Clone, Debug)]
62pub struct Event {
63 pub exception_class: String,
64 pub message: String,
65 pub backtrace: Vec<Frame>,
66 pub occurred_at: String,
67 pub environment: String,
68 pub release: Option<String>,
69 pub server_name: Option<String>,
70 pub context: HashMap<String, Value>,
71 pub tags: HashMap<String, Value>,
72 pub sdk_name: String,
73}
74
75impl Event {
76 pub fn to_json(&self) -> String {
77 use crate::pii_scrubber::json_string;
78
79 let backtrace: Vec<String> = self
80 .backtrace
81 .iter()
82 .map(|f| {
83 let context_fields = match (&f.context_line, &f.pre_context, &f.post_context) {
88 (Some(context_line), Some(pre_context), Some(post_context)) => format!(
89 ",\"context_line\":{},\"pre_context\":{},\"post_context\":{}",
90 json_string(context_line),
91 string_array_json(pre_context),
92 string_array_json(post_context)
93 ),
94 _ => String::new(),
95 };
96
97 format!(
98 "{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}{}}}",
99 json_string(&f.file),
100 f.line,
101 json_string(&f.method),
102 f.in_app,
103 context_fields
104 )
105 })
106 .collect();
107
108 let optional_string = |v: &Option<String>| {
109 v.as_deref()
110 .map(json_string)
111 .unwrap_or_else(|| "null".to_string())
112 };
113
114 format!(
115 "{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{},\"sdk_name\":{}}}",
116 json_string(&self.exception_class),
117 json_string(&self.message),
118 backtrace.join(","),
119 json_string(&self.occurred_at),
120 json_string(&self.environment),
121 optional_string(&self.release),
122 optional_string(&self.server_name),
123 Value::Object(self.context.clone()).to_json(),
124 Value::Object(self.tags.clone()).to_json(),
125 json_string(&self.sdk_name)
126 )
127 }
128}
129
130fn string_array_json(items: &[String]) -> String {
133 Value::Array(items.iter().cloned().map(Value::String).collect()).to_json()
134}
135
136pub struct EventBuilder<'a> {
137 configuration: &'a Configuration,
138}
139
140impl<'a> EventBuilder<'a> {
141 pub fn new(configuration: &'a Configuration) -> Self {
142 EventBuilder { configuration }
143 }
144
145 pub fn build(
146 &self,
147 exception_class: &str,
148 message: &str,
149 backtrace: Vec<Frame>,
150 context: HashMap<String, Value>,
151 ) -> Event {
152 let mut event = Event {
153 exception_class: exception_class.to_string(),
154 message: message.to_string(),
155 backtrace,
156 occurred_at: format_now(),
157 environment: self.configuration.environment.clone(),
158 release: self.configuration.release.clone(),
159 server_name: self.configuration.server_name.clone(),
160 context,
161 tags: HashMap::new(),
162 sdk_name: SDK_NAME.to_string(),
163 };
164
165 if self.configuration.scrub_pii {
166 event = scrub_event(event);
167 }
168 event
169 }
170}
171
172fn scrub_event(mut event: Event) -> Event {
176 event.message = scrub_string(&event.message);
177 event.backtrace = event
178 .backtrace
179 .into_iter()
180 .map(|f| Frame {
181 file: scrub_string(&f.file),
182 method: scrub_string(&f.method),
183 ..f
184 })
185 .collect();
186
187 let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
188 unreachable!()
189 };
190 event.context = context;
191 let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
192 unreachable!()
193 };
194 event.tags = tags;
195 event
196}
197
198pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
203 let bt = backtrace::Backtrace::new();
204 let mut frames = Vec::new();
205 let mut seen_app_frame = false;
206
207 'frames: for frame in bt.frames() {
208 for symbol in frame.symbols() {
209 let name = symbol
210 .name()
211 .map(|n| n.to_string())
212 .unwrap_or_else(|| "<unknown>".to_string());
213
214 if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
220 continue;
221 }
222 seen_app_frame = true;
223
224 let file = symbol
225 .filename()
226 .map(|p| p.to_string_lossy().into_owned())
227 .unwrap_or_default();
228 let line = symbol.lineno().unwrap_or(0);
229 let in_app = is_in_app(configuration, &file);
230 let frame = Frame::new(file, line, name, in_app);
231 frames.push(attach_source_context(configuration, frame));
232
233 if frames.len() >= MAX_FRAMES {
234 break 'frames;
235 }
236 }
237 }
238 frames
239}
240
241fn is_in_app(configuration: &Configuration, file: &str) -> bool {
242 let root = match &configuration.app_root {
243 Some(r) if !r.is_empty() => r,
244 _ => return false,
245 };
246 if file.is_empty() || !file.starts_with(root.as_str()) {
247 return false;
248 }
249 !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
253}
254
255fn attach_source_context(configuration: &Configuration, mut frame: Frame) -> Frame {
265 if !configuration.capture_source_context || !frame.in_app {
266 return frame;
267 }
268
269 let Ok(contents) = std::fs::read_to_string(&frame.file) else {
270 return frame;
271 };
272 let lines: Vec<&str> = contents.lines().collect();
273 if frame.line == 0 || frame.line as usize > lines.len() {
274 return frame;
275 }
276 let index = frame.line as usize - 1;
277
278 let from = index.saturating_sub(CONTEXT_LINES);
279 let to = (index + CONTEXT_LINES).min(lines.len() - 1);
280
281 frame.context_line = Some(truncate_line(lines[index]));
282 frame.pre_context = Some(
283 lines[from..index]
284 .iter()
285 .map(|l| truncate_line(l))
286 .collect(),
287 );
288 frame.post_context = Some(
289 lines[(index + 1)..=to]
290 .iter()
291 .map(|l| truncate_line(l))
292 .collect(),
293 );
294 frame
295}
296
297fn truncate_line(line: &str) -> String {
298 if line.chars().count() <= MAX_CONTEXT_LINE_LENGTH {
299 return line.to_string();
300 }
301 let truncated: String = line.chars().take(MAX_CONTEXT_LINE_LENGTH).collect();
302 format!("{truncated}...")
303}
304
305fn format_now() -> String {
306 let secs = SystemTime::now()
307 .duration_since(UNIX_EPOCH)
308 .map(|d| d.as_secs())
309 .unwrap_or(0);
310 format_unix_timestamp(secs)
311}
312
313fn format_unix_timestamp(secs: u64) -> String {
318 let days = secs / 86400;
319 let time_of_day = secs % 86400;
320 let (hour, minute, second) = (
321 time_of_day / 3600,
322 (time_of_day % 3600) / 60,
323 time_of_day % 60,
324 );
325
326 let (year, month, day) = civil_from_days(days as i64);
327 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
328}
329
330fn civil_from_days(z: i64) -> (i64, u32, u32) {
335 let z = z + 719468;
336 let era = if z >= 0 { z } else { z - 146096 } / 146097;
337 let doe = (z - era * 146097) as u64;
338 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
339 let y = yoe as i64 + era * 400;
340 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
341 let mp = (5 * doy + 2) / 153;
342 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
343 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
344 let year = if m <= 2 { y + 1 } else { y };
345 (year, m, d)
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 fn test_configuration() -> Configuration {
353 Configuration {
354 environment: "production".to_string(),
355 release: Some("a1b2c3d".to_string()),
356 server_name: Some("test-host".to_string()),
357 app_root: Some("/app".to_string()),
358 scrub_pii: true,
359 ..Configuration::new()
360 }
361 }
362
363 #[test]
364 fn build_basic_fields() {
365 let config = test_configuration();
366 let builder = EventBuilder::new(&config);
367 let mut context = HashMap::new();
368 context.insert("order_id".to_string(), Value::Number(42.0));
369
370 let event = builder.build("std::io::Error", "boom", vec![], context);
371
372 assert_eq!(event.message, "boom");
373 assert_eq!(event.environment, "production");
374 assert_eq!(event.release, Some("a1b2c3d".to_string()));
375 assert_eq!(event.server_name, Some("test-host".to_string()));
376 assert_eq!(event.context["order_id"], Value::Number(42.0));
377 assert_eq!(event.sdk_name, "rust");
378 }
379
380 #[test]
381 fn build_scrubs_message_and_context_when_enabled() {
382 let config = test_configuration();
383 let builder = EventBuilder::new(&config);
384 let mut context = HashMap::new();
385 context.insert(
386 "api_key".to_string(),
387 Value::String("shh-secret".to_string()),
388 );
389
390 let event = builder.build(
391 "Error",
392 "failed to charge user@example.com",
393 vec![],
394 context,
395 );
396
397 assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
398 assert_eq!(
399 event.context["api_key"],
400 Value::String(crate::pii_scrubber::REDACTED.to_string())
401 );
402 }
403
404 #[test]
405 fn build_does_not_scrub_when_disabled() {
406 let mut config = test_configuration();
407 config.scrub_pii = false;
408 let builder = EventBuilder::new(&config);
409
410 let event = builder.build("Error", "contact user@example.com", vec![], HashMap::new());
411
412 assert_eq!(event.message, "contact user@example.com");
413 }
414
415 #[test]
416 fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
417 let config = test_configuration();
418 assert!(is_in_app(&config, "/app/src/main.rs"));
419 assert!(!is_in_app(&config, "/other/src/main.rs"));
420 assert!(!is_in_app(
421 &config,
422 "/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
423 ));
424 assert!(!is_in_app(&config, ""));
425 }
426
427 #[test]
428 fn capture_backtrace_excludes_this_crates_own_frames() {
429 let config = test_configuration();
430 let frames = capture_backtrace(&config);
431
432 assert!(!frames.is_empty(), "expected at least one backtrace frame");
433 for frame in &frames {
434 assert!(
435 !frame.method.starts_with(CRATE_PREFIX),
436 "frame {:?} should have been filtered out as SDK-internal",
437 frame.method
438 );
439 }
440 }
441
442 #[test]
443 fn format_unix_timestamp_known_value() {
444 assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
446 assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
448 }
449
450 mod source_context {
451 use super::*;
452 use std::sync::atomic::{AtomicU64, Ordering};
453
454 struct TempFile {
457 path: std::path::PathBuf,
458 }
459
460 impl TempFile {
461 fn with_contents(contents: &str) -> Self {
462 static COUNTER: AtomicU64 = AtomicU64::new(0);
463 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
464 let nanos = SystemTime::now()
465 .duration_since(UNIX_EPOCH)
466 .unwrap()
467 .as_nanos();
468 let path = std::env::temp_dir().join(format!(
469 "forge_ops_tracker_test_{}_{nanos}_{n}.rs",
470 std::process::id()
471 ));
472 std::fs::write(&path, contents).expect("failed to write temp test file");
473 TempFile { path }
474 }
475
476 fn path_string(&self) -> String {
477 self.path.to_string_lossy().into_owned()
478 }
479 }
480
481 impl Drop for TempFile {
482 fn drop(&mut self) {
483 let _ = std::fs::remove_file(&self.path);
484 }
485 }
486
487 fn numbered_lines(count: usize) -> String {
488 (1..=count)
489 .map(|n| format!("line {n}"))
490 .collect::<Vec<_>>()
491 .join("\n")
492 }
493
494 fn frame_in_app(file: String, line: u32) -> Frame {
495 Frame::new(file, line, "call".to_string(), true)
496 }
497
498 #[test]
499 fn attaches_window_around_the_culprit_line_by_default() {
500 let file = TempFile::with_contents(&numbered_lines(20));
501 let mut config = test_configuration();
502 config.app_root = Some(std::env::temp_dir().to_string_lossy().into_owned());
503
504 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
505
506 assert_eq!(frame.context_line, Some("line 10".to_string()));
507 assert_eq!(
508 frame.pre_context,
509 Some((5..=9).map(|n| format!("line {n}")).collect())
510 );
511 assert_eq!(
512 frame.post_context,
513 Some((11..=15).map(|n| format!("line {n}")).collect())
514 );
515 }
516
517 #[test]
518 fn clamps_at_the_start_and_end_of_the_file_rather_than_panicking() {
519 let file = TempFile::with_contents(&numbered_lines(3));
520 let config = test_configuration();
521
522 let first = attach_source_context(&config, frame_in_app(file.path_string(), 1));
523 let last = attach_source_context(&config, frame_in_app(file.path_string(), 3));
524
525 assert_eq!(first.pre_context, Some(vec![]));
526 assert_eq!(
527 first.post_context,
528 Some(vec!["line 2".to_string(), "line 3".to_string()])
529 );
530 assert_eq!(
531 last.pre_context,
532 Some(vec!["line 1".to_string(), "line 2".to_string()])
533 );
534 assert_eq!(last.post_context, Some(vec![]));
535 }
536
537 #[test]
538 fn truncates_a_line_longer_than_max_context_line_length() {
539 let overlong = "x".repeat(600);
540 let file = TempFile::with_contents(&overlong);
541 let config = test_configuration();
542
543 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 1));
544
545 assert_eq!(frame.context_line, Some(format!("{}...", "x".repeat(500))));
546 }
547
548 #[test]
549 fn never_attaches_context_to_a_frame_that_is_not_in_app() {
550 let file = TempFile::with_contents(&numbered_lines(20));
551 let config = test_configuration();
552 let frame = Frame::new(file.path_string(), 10, "call".to_string(), false);
553
554 let frame = attach_source_context(&config, frame);
555
556 assert_eq!(frame.context_line, None);
557 assert_eq!(frame.pre_context, None);
558 assert_eq!(frame.post_context, None);
559 }
560
561 #[test]
568 fn leaves_the_frame_untouched_when_capture_source_context_is_disabled() {
569 let file = TempFile::with_contents(&numbered_lines(20));
570 let mut config = test_configuration();
571 config.capture_source_context = false;
572
573 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
574
575 assert_eq!(frame.context_line, None);
576 assert_eq!(frame.pre_context, None);
577 assert_eq!(frame.post_context, None);
578 }
579
580 #[test]
581 fn leaves_the_frame_untouched_when_the_file_cannot_be_read() {
582 let config = test_configuration();
583 let missing_path = std::env::temp_dir()
584 .join("forge_ops_tracker_test_does_not_exist_12345.rs")
585 .to_string_lossy()
586 .into_owned();
587
588 let frame = attach_source_context(&config, frame_in_app(missing_path, 1));
589
590 assert_eq!(frame.context_line, None);
591 assert_eq!(frame.pre_context, None);
592 assert_eq!(frame.post_context, None);
593 }
594
595 #[test]
596 fn wire_format_uses_snake_case_keys_when_context_is_present_and_omits_them_otherwise() {
597 let with_context = Frame {
598 context_line: Some("line 10".to_string()),
599 pre_context: Some(vec!["line 9".to_string()]),
600 post_context: Some(vec!["line 11".to_string()]),
601 ..frame_in_app("/app/src/main.rs".to_string(), 10)
602 };
603 let event = Event {
604 exception_class: "Error".to_string(),
605 message: "boom".to_string(),
606 backtrace: vec![with_context],
607 occurred_at: "2024-01-15T10:30:00Z".to_string(),
608 environment: "production".to_string(),
609 release: None,
610 server_name: None,
611 context: HashMap::new(),
612 tags: HashMap::new(),
613 sdk_name: "rust".to_string(),
614 };
615
616 let json = event.to_json();
617
618 assert!(json.contains("\"context_line\":\"line 10\""));
619 assert!(json.contains("\"pre_context\":[\"line 9\"]"));
620 assert!(json.contains("\"post_context\":[\"line 11\"]"));
621
622 let without_context = Event {
623 backtrace: vec![frame_in_app("/app/src/main.rs".to_string(), 10)],
624 ..event
625 };
626 let json = without_context.to_json();
627
628 assert!(!json.contains("context_line"));
629 assert!(!json.contains("pre_context"));
630 assert!(!json.contains("post_context"));
631 }
632 }
633}