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 pub user: Option<HashMap<String, Value>>,
74}
75
76impl Event {
77 pub fn to_json(&self) -> String {
78 use crate::pii_scrubber::json_string;
79
80 let backtrace: Vec<String> = self
81 .backtrace
82 .iter()
83 .map(|f| {
84 let context_fields = match (&f.context_line, &f.pre_context, &f.post_context) {
89 (Some(context_line), Some(pre_context), Some(post_context)) => format!(
90 ",\"context_line\":{},\"pre_context\":{},\"post_context\":{}",
91 json_string(context_line),
92 string_array_json(pre_context),
93 string_array_json(post_context)
94 ),
95 _ => String::new(),
96 };
97
98 format!(
99 "{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}{}}}",
100 json_string(&f.file),
101 f.line,
102 json_string(&f.method),
103 f.in_app,
104 context_fields
105 )
106 })
107 .collect();
108
109 let optional_string = |v: &Option<String>| {
110 v.as_deref()
111 .map(json_string)
112 .unwrap_or_else(|| "null".to_string())
113 };
114
115 let user_field = match &self.user {
119 Some(user) if !user.is_empty() => {
120 format!(",\"user\":{}", Value::Object(user.clone()).to_json())
121 }
122 _ => String::new(),
123 };
124
125 format!(
126 "{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{},\"sdk_name\":{}{}}}",
127 json_string(&self.exception_class),
128 json_string(&self.message),
129 backtrace.join(","),
130 json_string(&self.occurred_at),
131 json_string(&self.environment),
132 optional_string(&self.release),
133 optional_string(&self.server_name),
134 Value::Object(self.context.clone()).to_json(),
135 Value::Object(self.tags.clone()).to_json(),
136 json_string(&self.sdk_name),
137 user_field
138 )
139 }
140}
141
142fn string_array_json(items: &[String]) -> String {
145 Value::Array(items.iter().cloned().map(Value::String).collect()).to_json()
146}
147
148pub struct EventBuilder<'a> {
149 configuration: &'a Configuration,
150}
151
152impl<'a> EventBuilder<'a> {
153 pub fn new(configuration: &'a Configuration) -> Self {
154 EventBuilder { configuration }
155 }
156
157 #[allow(clippy::too_many_arguments)]
158 pub fn build(
159 &self,
160 exception_class: &str,
161 message: &str,
162 backtrace: Vec<Frame>,
163 context: HashMap<String, Value>,
164 user: Option<HashMap<String, Value>>,
165 ) -> Event {
166 let mut event = Event {
167 exception_class: exception_class.to_string(),
168 message: message.to_string(),
169 backtrace,
170 occurred_at: format_now(),
171 environment: self.configuration.environment.clone(),
172 release: self.configuration.release.clone(),
173 server_name: self.configuration.server_name.clone(),
174 context,
175 tags: HashMap::new(),
176 sdk_name: SDK_NAME.to_string(),
177 user: user.filter(|u| !u.is_empty()),
178 };
179
180 if self.configuration.scrub_pii {
181 event = scrub_event(event);
182 }
183 event
184 }
185}
186
187fn scrub_event(mut event: Event) -> Event {
192 event.message = scrub_string(&event.message);
193 event.backtrace = event
194 .backtrace
195 .into_iter()
196 .map(|f| Frame {
197 file: scrub_string(&f.file),
198 method: scrub_string(&f.method),
199 ..f
200 })
201 .collect();
202
203 let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
204 unreachable!()
205 };
206 event.context = context;
207 let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
208 unreachable!()
209 };
210 event.tags = tags;
211 event
212}
213
214pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
219 let bt = backtrace::Backtrace::new();
220 let mut frames = Vec::new();
221 let mut seen_app_frame = false;
222
223 'frames: for frame in bt.frames() {
224 for symbol in frame.symbols() {
225 let name = symbol
226 .name()
227 .map(|n| n.to_string())
228 .unwrap_or_else(|| "<unknown>".to_string());
229
230 if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
236 continue;
237 }
238 seen_app_frame = true;
239
240 let file = symbol
241 .filename()
242 .map(|p| p.to_string_lossy().into_owned())
243 .unwrap_or_default();
244 let line = symbol.lineno().unwrap_or(0);
245 let in_app = is_in_app(configuration, &file);
246 let frame = Frame::new(file, line, name, in_app);
247 frames.push(attach_source_context(configuration, frame));
248
249 if frames.len() >= MAX_FRAMES {
250 break 'frames;
251 }
252 }
253 }
254 frames
255}
256
257fn is_in_app(configuration: &Configuration, file: &str) -> bool {
258 let root = match &configuration.app_root {
259 Some(r) if !r.is_empty() => r,
260 _ => return false,
261 };
262 if file.is_empty() || !file.starts_with(root.as_str()) {
263 return false;
264 }
265 !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
269}
270
271fn attach_source_context(configuration: &Configuration, mut frame: Frame) -> Frame {
281 if !configuration.capture_source_context || !frame.in_app {
282 return frame;
283 }
284
285 let Ok(contents) = std::fs::read_to_string(&frame.file) else {
286 return frame;
287 };
288 let lines: Vec<&str> = contents.lines().collect();
289 if frame.line == 0 || frame.line as usize > lines.len() {
290 return frame;
291 }
292 let index = frame.line as usize - 1;
293
294 let from = index.saturating_sub(CONTEXT_LINES);
295 let to = (index + CONTEXT_LINES).min(lines.len() - 1);
296
297 frame.context_line = Some(truncate_line(lines[index]));
298 frame.pre_context = Some(
299 lines[from..index]
300 .iter()
301 .map(|l| truncate_line(l))
302 .collect(),
303 );
304 frame.post_context = Some(
305 lines[(index + 1)..=to]
306 .iter()
307 .map(|l| truncate_line(l))
308 .collect(),
309 );
310 frame
311}
312
313fn truncate_line(line: &str) -> String {
314 if line.chars().count() <= MAX_CONTEXT_LINE_LENGTH {
315 return line.to_string();
316 }
317 let truncated: String = line.chars().take(MAX_CONTEXT_LINE_LENGTH).collect();
318 format!("{truncated}...")
319}
320
321fn format_now() -> String {
322 let secs = SystemTime::now()
323 .duration_since(UNIX_EPOCH)
324 .map(|d| d.as_secs())
325 .unwrap_or(0);
326 format_unix_timestamp(secs)
327}
328
329fn format_unix_timestamp(secs: u64) -> String {
334 let days = secs / 86400;
335 let time_of_day = secs % 86400;
336 let (hour, minute, second) = (
337 time_of_day / 3600,
338 (time_of_day % 3600) / 60,
339 time_of_day % 60,
340 );
341
342 let (year, month, day) = civil_from_days(days as i64);
343 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
344}
345
346fn civil_from_days(z: i64) -> (i64, u32, u32) {
351 let z = z + 719468;
352 let era = if z >= 0 { z } else { z - 146096 } / 146097;
353 let doe = (z - era * 146097) as u64;
354 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
355 let y = yoe as i64 + era * 400;
356 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
357 let mp = (5 * doy + 2) / 153;
358 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
359 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
360 let year = if m <= 2 { y + 1 } else { y };
361 (year, m, d)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 fn test_configuration() -> Configuration {
369 Configuration {
370 environment: "production".to_string(),
371 release: Some("a1b2c3d".to_string()),
372 server_name: Some("test-host".to_string()),
373 app_root: Some("/app".to_string()),
374 scrub_pii: true,
375 ..Configuration::new()
376 }
377 }
378
379 #[test]
380 fn build_basic_fields() {
381 let config = test_configuration();
382 let builder = EventBuilder::new(&config);
383 let mut context = HashMap::new();
384 context.insert("order_id".to_string(), Value::Number(42.0));
385
386 let event = builder.build("std::io::Error", "boom", vec![], context, None);
387
388 assert_eq!(event.message, "boom");
389 assert_eq!(event.environment, "production");
390 assert_eq!(event.release, Some("a1b2c3d".to_string()));
391 assert_eq!(event.server_name, Some("test-host".to_string()));
392 assert_eq!(event.context["order_id"], Value::Number(42.0));
393 assert_eq!(event.sdk_name, "rust");
394 }
395
396 #[test]
397 fn build_includes_the_user_when_given_one_never_scrubbed_even_though_its_an_email() {
398 let config = test_configuration();
399 let builder = EventBuilder::new(&config);
400 let mut user = HashMap::new();
401 user.insert("id".to_string(), Value::Number(42.0));
402 user.insert(
403 "email".to_string(),
404 Value::String("ada@example.com".to_string()),
405 );
406
407 let event = builder.build("Error", "boom", vec![], HashMap::new(), Some(user));
408
409 assert_eq!(
410 event.user.unwrap()["email"],
411 Value::String("ada@example.com".to_string())
412 );
413 }
414
415 #[test]
416 fn build_omits_the_user_entirely_when_none_was_given() {
417 let config = test_configuration();
418 let builder = EventBuilder::new(&config);
419
420 let event = builder.build("Error", "boom", vec![], HashMap::new(), None);
421
422 assert_eq!(event.user, None);
423 }
424
425 #[test]
426 fn build_scrubs_message_and_context_when_enabled() {
427 let config = test_configuration();
428 let builder = EventBuilder::new(&config);
429 let mut context = HashMap::new();
430 context.insert(
431 "api_key".to_string(),
432 Value::String("shh-secret".to_string()),
433 );
434
435 let event = builder.build(
436 "Error",
437 "failed to charge user@example.com",
438 vec![],
439 context,
440 None,
441 );
442
443 assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
444 assert_eq!(
445 event.context["api_key"],
446 Value::String(crate::pii_scrubber::REDACTED.to_string())
447 );
448 }
449
450 #[test]
451 fn build_does_not_scrub_when_disabled() {
452 let mut config = test_configuration();
453 config.scrub_pii = false;
454 let builder = EventBuilder::new(&config);
455
456 let event = builder.build(
457 "Error",
458 "contact user@example.com",
459 vec![],
460 HashMap::new(),
461 None,
462 );
463
464 assert_eq!(event.message, "contact user@example.com");
465 }
466
467 #[test]
468 fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
469 let config = test_configuration();
470 assert!(is_in_app(&config, "/app/src/main.rs"));
471 assert!(!is_in_app(&config, "/other/src/main.rs"));
472 assert!(!is_in_app(
473 &config,
474 "/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
475 ));
476 assert!(!is_in_app(&config, ""));
477 }
478
479 #[test]
480 fn capture_backtrace_excludes_this_crates_own_frames() {
481 let config = test_configuration();
482 let frames = capture_backtrace(&config);
483
484 assert!(!frames.is_empty(), "expected at least one backtrace frame");
485 for frame in &frames {
486 assert!(
487 !frame.method.starts_with(CRATE_PREFIX),
488 "frame {:?} should have been filtered out as SDK-internal",
489 frame.method
490 );
491 }
492 }
493
494 #[test]
495 fn format_unix_timestamp_known_value() {
496 assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
498 assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
500 }
501
502 mod source_context {
503 use super::*;
504 use std::sync::atomic::{AtomicU64, Ordering};
505
506 struct TempFile {
509 path: std::path::PathBuf,
510 }
511
512 impl TempFile {
513 fn with_contents(contents: &str) -> Self {
514 static COUNTER: AtomicU64 = AtomicU64::new(0);
515 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
516 let nanos = SystemTime::now()
517 .duration_since(UNIX_EPOCH)
518 .unwrap()
519 .as_nanos();
520 let path = std::env::temp_dir().join(format!(
521 "forge_ops_tracker_test_{}_{nanos}_{n}.rs",
522 std::process::id()
523 ));
524 std::fs::write(&path, contents).expect("failed to write temp test file");
525 TempFile { path }
526 }
527
528 fn path_string(&self) -> String {
529 self.path.to_string_lossy().into_owned()
530 }
531 }
532
533 impl Drop for TempFile {
534 fn drop(&mut self) {
535 let _ = std::fs::remove_file(&self.path);
536 }
537 }
538
539 fn numbered_lines(count: usize) -> String {
540 (1..=count)
541 .map(|n| format!("line {n}"))
542 .collect::<Vec<_>>()
543 .join("\n")
544 }
545
546 fn frame_in_app(file: String, line: u32) -> Frame {
547 Frame::new(file, line, "call".to_string(), true)
548 }
549
550 #[test]
551 fn attaches_window_around_the_culprit_line_by_default() {
552 let file = TempFile::with_contents(&numbered_lines(20));
553 let mut config = test_configuration();
554 config.app_root = Some(std::env::temp_dir().to_string_lossy().into_owned());
555
556 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
557
558 assert_eq!(frame.context_line, Some("line 10".to_string()));
559 assert_eq!(
560 frame.pre_context,
561 Some((5..=9).map(|n| format!("line {n}")).collect())
562 );
563 assert_eq!(
564 frame.post_context,
565 Some((11..=15).map(|n| format!("line {n}")).collect())
566 );
567 }
568
569 #[test]
570 fn clamps_at_the_start_and_end_of_the_file_rather_than_panicking() {
571 let file = TempFile::with_contents(&numbered_lines(3));
572 let config = test_configuration();
573
574 let first = attach_source_context(&config, frame_in_app(file.path_string(), 1));
575 let last = attach_source_context(&config, frame_in_app(file.path_string(), 3));
576
577 assert_eq!(first.pre_context, Some(vec![]));
578 assert_eq!(
579 first.post_context,
580 Some(vec!["line 2".to_string(), "line 3".to_string()])
581 );
582 assert_eq!(
583 last.pre_context,
584 Some(vec!["line 1".to_string(), "line 2".to_string()])
585 );
586 assert_eq!(last.post_context, Some(vec![]));
587 }
588
589 #[test]
590 fn truncates_a_line_longer_than_max_context_line_length() {
591 let overlong = "x".repeat(600);
592 let file = TempFile::with_contents(&overlong);
593 let config = test_configuration();
594
595 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 1));
596
597 assert_eq!(frame.context_line, Some(format!("{}...", "x".repeat(500))));
598 }
599
600 #[test]
601 fn never_attaches_context_to_a_frame_that_is_not_in_app() {
602 let file = TempFile::with_contents(&numbered_lines(20));
603 let config = test_configuration();
604 let frame = Frame::new(file.path_string(), 10, "call".to_string(), false);
605
606 let frame = attach_source_context(&config, frame);
607
608 assert_eq!(frame.context_line, None);
609 assert_eq!(frame.pre_context, None);
610 assert_eq!(frame.post_context, None);
611 }
612
613 #[test]
620 fn leaves_the_frame_untouched_when_capture_source_context_is_disabled() {
621 let file = TempFile::with_contents(&numbered_lines(20));
622 let mut config = test_configuration();
623 config.capture_source_context = false;
624
625 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
626
627 assert_eq!(frame.context_line, None);
628 assert_eq!(frame.pre_context, None);
629 assert_eq!(frame.post_context, None);
630 }
631
632 #[test]
633 fn leaves_the_frame_untouched_when_the_file_cannot_be_read() {
634 let config = test_configuration();
635 let missing_path = std::env::temp_dir()
636 .join("forge_ops_tracker_test_does_not_exist_12345.rs")
637 .to_string_lossy()
638 .into_owned();
639
640 let frame = attach_source_context(&config, frame_in_app(missing_path, 1));
641
642 assert_eq!(frame.context_line, None);
643 assert_eq!(frame.pre_context, None);
644 assert_eq!(frame.post_context, None);
645 }
646
647 #[test]
648 fn wire_format_uses_snake_case_keys_when_context_is_present_and_omits_them_otherwise() {
649 let with_context = Frame {
650 context_line: Some("line 10".to_string()),
651 pre_context: Some(vec!["line 9".to_string()]),
652 post_context: Some(vec!["line 11".to_string()]),
653 ..frame_in_app("/app/src/main.rs".to_string(), 10)
654 };
655 let event = Event {
656 exception_class: "Error".to_string(),
657 message: "boom".to_string(),
658 backtrace: vec![with_context],
659 occurred_at: "2024-01-15T10:30:00Z".to_string(),
660 environment: "production".to_string(),
661 release: None,
662 server_name: None,
663 context: HashMap::new(),
664 tags: HashMap::new(),
665 sdk_name: "rust".to_string(),
666 user: None,
667 };
668
669 let json = event.to_json();
670
671 assert!(json.contains("\"context_line\":\"line 10\""));
672 assert!(json.contains("\"pre_context\":[\"line 9\"]"));
673 assert!(json.contains("\"post_context\":[\"line 11\"]"));
674 assert!(!json.contains("\"user\""));
675
676 let without_context = Event {
677 backtrace: vec![frame_in_app("/app/src/main.rs".to_string(), 10)],
678 ..event
679 };
680 let json = without_context.to_json();
681
682 assert!(!json.contains("context_line"));
683 assert!(!json.contains("pre_context"));
684 assert!(!json.contains("post_context"));
685 }
686
687 #[test]
688 fn wire_format_includes_user_when_present_and_omits_it_otherwise() {
689 let mut user = HashMap::new();
690 user.insert(
691 "email".to_string(),
692 Value::String("ada@example.com".to_string()),
693 );
694 let event = Event {
695 exception_class: "Error".to_string(),
696 message: "boom".to_string(),
697 backtrace: vec![],
698 occurred_at: "2024-01-15T10:30:00Z".to_string(),
699 environment: "production".to_string(),
700 release: None,
701 server_name: None,
702 context: HashMap::new(),
703 tags: HashMap::new(),
704 sdk_name: "rust".to_string(),
705 user: Some(user),
706 };
707
708 let json = event.to_json();
709
710 assert!(json.contains("\"user\":{\"email\":\"ada@example.com\"}"));
711
712 let without_user = Event {
713 user: None,
714 ..event
715 };
716 assert!(!without_user.to_json().contains("\"user\""));
717 }
718 }
719}