1use std::collections::HashMap;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::breadcrumb_buffer::Breadcrumb;
10use crate::configuration::Configuration;
11use crate::pii_scrubber::{scrub_string, scrub_value, Value};
12
13pub const MAX_FRAMES: usize = 500;
16
17const CRATE_PREFIX: &str = "forge_ops_tracker::";
21
22const CONTEXT_LINES: usize = 5;
28const MAX_CONTEXT_LINE_LENGTH: usize = 500;
29
30const SDK_NAME: &str = "rust";
34
35#[derive(Clone, Debug, PartialEq)]
36pub struct Frame {
37 pub file: String,
38 pub line: u32,
39 pub method: String,
40 pub in_app: bool,
41 pub context_line: Option<String>,
42 pub pre_context: Option<Vec<String>>,
43 pub post_context: Option<Vec<String>>,
44}
45
46impl Frame {
47 pub fn new(file: String, line: u32, method: String, in_app: bool) -> Self {
50 Frame {
51 file,
52 line,
53 method,
54 in_app,
55 context_line: None,
56 pre_context: None,
57 post_context: None,
58 }
59 }
60}
61
62#[derive(Clone, Debug)]
63pub struct Event {
64 pub exception_class: String,
65 pub message: String,
66 pub backtrace: Vec<Frame>,
67 pub occurred_at: String,
68 pub environment: String,
69 pub release: Option<String>,
70 pub server_name: Option<String>,
71 pub context: HashMap<String, Value>,
72 pub tags: HashMap<String, Value>,
73 pub sdk_name: String,
74 pub user: Option<HashMap<String, Value>>,
75 pub breadcrumbs: Vec<Breadcrumb>,
76}
77
78impl Event {
79 pub fn to_json(&self) -> String {
80 use crate::pii_scrubber::json_string;
81
82 let backtrace: Vec<String> = self
83 .backtrace
84 .iter()
85 .map(|f| {
86 let context_fields = match (&f.context_line, &f.pre_context, &f.post_context) {
91 (Some(context_line), Some(pre_context), Some(post_context)) => format!(
92 ",\"context_line\":{},\"pre_context\":{},\"post_context\":{}",
93 json_string(context_line),
94 string_array_json(pre_context),
95 string_array_json(post_context)
96 ),
97 _ => String::new(),
98 };
99
100 format!(
101 "{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}{}}}",
102 json_string(&f.file),
103 f.line,
104 json_string(&f.method),
105 f.in_app,
106 context_fields
107 )
108 })
109 .collect();
110
111 let optional_string = |v: &Option<String>| {
112 v.as_deref()
113 .map(json_string)
114 .unwrap_or_else(|| "null".to_string())
115 };
116
117 let user_field = match &self.user {
121 Some(user) if !user.is_empty() => {
122 format!(",\"user\":{}", Value::Object(user.clone()).to_json())
123 }
124 _ => String::new(),
125 };
126
127 let breadcrumbs_field = if self.breadcrumbs.is_empty() {
131 String::new()
132 } else {
133 let entries: Vec<String> = self
134 .breadcrumbs
135 .iter()
136 .map(|b| {
137 format!(
138 "{{\"category\":{},\"message\":{},\"level\":{},\"timestamp\":{},\"data\":{}}}",
139 json_string(&b.category),
140 json_string(&b.message),
141 json_string(&b.level),
142 json_string(&b.timestamp),
143 Value::Object(b.data.clone()).to_json()
144 )
145 })
146 .collect();
147 format!(",\"breadcrumbs\":[{}]", entries.join(","))
148 };
149
150 format!(
151 "{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{},\"sdk_name\":{}{}{}}}",
152 json_string(&self.exception_class),
153 json_string(&self.message),
154 backtrace.join(","),
155 json_string(&self.occurred_at),
156 json_string(&self.environment),
157 optional_string(&self.release),
158 optional_string(&self.server_name),
159 Value::Object(self.context.clone()).to_json(),
160 Value::Object(self.tags.clone()).to_json(),
161 json_string(&self.sdk_name),
162 user_field,
163 breadcrumbs_field
164 )
165 }
166}
167
168fn string_array_json(items: &[String]) -> String {
171 Value::Array(items.iter().cloned().map(Value::String).collect()).to_json()
172}
173
174pub struct EventBuilder<'a> {
175 configuration: &'a Configuration,
176}
177
178impl<'a> EventBuilder<'a> {
179 pub fn new(configuration: &'a Configuration) -> Self {
180 EventBuilder { configuration }
181 }
182
183 #[allow(clippy::too_many_arguments)]
184 pub fn build(
185 &self,
186 exception_class: &str,
187 message: &str,
188 backtrace: Vec<Frame>,
189 context: HashMap<String, Value>,
190 user: Option<HashMap<String, Value>>,
191 breadcrumbs: Vec<Breadcrumb>,
192 ) -> Event {
193 let mut event = Event {
194 exception_class: exception_class.to_string(),
195 message: message.to_string(),
196 backtrace,
197 occurred_at: format_now(),
198 environment: self.configuration.environment.clone(),
199 release: self.configuration.release.clone(),
200 server_name: self.configuration.server_name.clone(),
201 context,
202 tags: HashMap::new(),
203 sdk_name: SDK_NAME.to_string(),
204 user: user.filter(|u| !u.is_empty()),
205 breadcrumbs,
206 };
207
208 if self.configuration.scrub_pii {
209 event = scrub_event(event);
210 }
211 event
212 }
213}
214
215fn scrub_event(mut event: Event) -> Event {
220 event.message = scrub_string(&event.message);
221 event.backtrace = event
222 .backtrace
223 .into_iter()
224 .map(|f| Frame {
225 file: scrub_string(&f.file),
226 method: scrub_string(&f.method),
227 ..f
228 })
229 .collect();
230
231 let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
232 unreachable!()
233 };
234 event.context = context;
235 let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
236 unreachable!()
237 };
238 event.tags = tags;
239
240 event.breadcrumbs = event
245 .breadcrumbs
246 .into_iter()
247 .map(|b| {
248 let Value::Object(data) = scrub_value(&Value::Object(b.data), "") else {
249 unreachable!()
250 };
251 Breadcrumb {
252 message: scrub_string(&b.message),
253 data,
254 ..b
255 }
256 })
257 .collect();
258 event
259}
260
261pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
266 let bt = backtrace::Backtrace::new();
267 let mut frames = Vec::new();
268 let mut seen_app_frame = false;
269
270 'frames: for frame in bt.frames() {
271 for symbol in frame.symbols() {
272 let name = symbol
273 .name()
274 .map(|n| n.to_string())
275 .unwrap_or_else(|| "<unknown>".to_string());
276
277 if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
283 continue;
284 }
285 seen_app_frame = true;
286
287 let file = symbol
288 .filename()
289 .map(|p| p.to_string_lossy().into_owned())
290 .unwrap_or_default();
291 let line = symbol.lineno().unwrap_or(0);
292 let in_app = is_in_app(configuration, &file);
293 let frame = Frame::new(file, line, name, in_app);
294 frames.push(attach_source_context(configuration, frame));
295
296 if frames.len() >= MAX_FRAMES {
297 break 'frames;
298 }
299 }
300 }
301 frames
302}
303
304fn is_in_app(configuration: &Configuration, file: &str) -> bool {
305 let root = match &configuration.app_root {
306 Some(r) if !r.is_empty() => r,
307 _ => return false,
308 };
309 if file.is_empty() || !file.starts_with(root.as_str()) {
310 return false;
311 }
312 !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
316}
317
318fn attach_source_context(configuration: &Configuration, mut frame: Frame) -> Frame {
328 if !configuration.capture_source_context || !frame.in_app {
329 return frame;
330 }
331
332 let Ok(contents) = std::fs::read_to_string(&frame.file) else {
333 return frame;
334 };
335 let lines: Vec<&str> = contents.lines().collect();
336 if frame.line == 0 || frame.line as usize > lines.len() {
337 return frame;
338 }
339 let index = frame.line as usize - 1;
340
341 let from = index.saturating_sub(CONTEXT_LINES);
342 let to = (index + CONTEXT_LINES).min(lines.len() - 1);
343
344 frame.context_line = Some(truncate_line(lines[index]));
345 frame.pre_context = Some(
346 lines[from..index]
347 .iter()
348 .map(|l| truncate_line(l))
349 .collect(),
350 );
351 frame.post_context = Some(
352 lines[(index + 1)..=to]
353 .iter()
354 .map(|l| truncate_line(l))
355 .collect(),
356 );
357 frame
358}
359
360fn truncate_line(line: &str) -> String {
361 if line.chars().count() <= MAX_CONTEXT_LINE_LENGTH {
362 return line.to_string();
363 }
364 let truncated: String = line.chars().take(MAX_CONTEXT_LINE_LENGTH).collect();
365 format!("{truncated}...")
366}
367
368fn format_now() -> String {
369 let secs = SystemTime::now()
370 .duration_since(UNIX_EPOCH)
371 .map(|d| d.as_secs())
372 .unwrap_or(0);
373 format_unix_timestamp(secs)
374}
375
376pub(crate) fn format_unix_timestamp(secs: u64) -> String {
383 let days = secs / 86400;
384 let time_of_day = secs % 86400;
385 let (hour, minute, second) = (
386 time_of_day / 3600,
387 (time_of_day % 3600) / 60,
388 time_of_day % 60,
389 );
390
391 let (year, month, day) = civil_from_days(days as i64);
392 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
393}
394
395fn civil_from_days(z: i64) -> (i64, u32, u32) {
400 let z = z + 719468;
401 let era = if z >= 0 { z } else { z - 146096 } / 146097;
402 let doe = (z - era * 146097) as u64;
403 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
404 let y = yoe as i64 + era * 400;
405 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
406 let mp = (5 * doy + 2) / 153;
407 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
408 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
409 let year = if m <= 2 { y + 1 } else { y };
410 (year, m, d)
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 fn test_configuration() -> Configuration {
418 Configuration {
419 environment: "production".to_string(),
420 release: Some("a1b2c3d".to_string()),
421 server_name: Some("test-host".to_string()),
422 app_root: Some("/app".to_string()),
423 scrub_pii: true,
424 ..Configuration::new()
425 }
426 }
427
428 #[test]
429 fn build_basic_fields() {
430 let config = test_configuration();
431 let builder = EventBuilder::new(&config);
432 let mut context = HashMap::new();
433 context.insert("order_id".to_string(), Value::Number(42.0));
434
435 let event = builder.build("std::io::Error", "boom", vec![], context, None, vec![]);
436
437 assert_eq!(event.message, "boom");
438 assert_eq!(event.environment, "production");
439 assert_eq!(event.release, Some("a1b2c3d".to_string()));
440 assert_eq!(event.server_name, Some("test-host".to_string()));
441 assert_eq!(event.context["order_id"], Value::Number(42.0));
442 assert_eq!(event.sdk_name, "rust");
443 }
444
445 #[test]
446 fn build_includes_the_user_when_given_one_never_scrubbed_even_though_its_an_email() {
447 let config = test_configuration();
448 let builder = EventBuilder::new(&config);
449 let mut user = HashMap::new();
450 user.insert("id".to_string(), Value::Number(42.0));
451 user.insert(
452 "email".to_string(),
453 Value::String("ada@example.com".to_string()),
454 );
455
456 let event = builder.build("Error", "boom", vec![], HashMap::new(), Some(user), vec![]);
457
458 assert_eq!(
459 event.user.unwrap()["email"],
460 Value::String("ada@example.com".to_string())
461 );
462 }
463
464 #[test]
465 fn build_omits_the_user_entirely_when_none_was_given() {
466 let config = test_configuration();
467 let builder = EventBuilder::new(&config);
468
469 let event = builder.build("Error", "boom", vec![], HashMap::new(), None, vec![]);
470
471 assert_eq!(event.user, None);
472 }
473
474 #[test]
475 fn build_scrubs_message_and_context_when_enabled() {
476 let config = test_configuration();
477 let builder = EventBuilder::new(&config);
478 let mut context = HashMap::new();
479 context.insert(
480 "api_key".to_string(),
481 Value::String("shh-secret".to_string()),
482 );
483
484 let event = builder.build(
485 "Error",
486 "failed to charge user@example.com",
487 vec![],
488 context,
489 None,
490 vec![],
491 );
492
493 assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
494 assert_eq!(
495 event.context["api_key"],
496 Value::String(crate::pii_scrubber::REDACTED.to_string())
497 );
498 }
499
500 #[test]
501 fn build_does_not_scrub_when_disabled() {
502 let mut config = test_configuration();
503 config.scrub_pii = false;
504 let builder = EventBuilder::new(&config);
505
506 let event = builder.build(
507 "Error",
508 "contact user@example.com",
509 vec![],
510 HashMap::new(),
511 None,
512 vec![],
513 );
514
515 assert_eq!(event.message, "contact user@example.com");
516 }
517
518 #[test]
519 fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
520 let config = test_configuration();
521 assert!(is_in_app(&config, "/app/src/main.rs"));
522 assert!(!is_in_app(&config, "/other/src/main.rs"));
523 assert!(!is_in_app(
524 &config,
525 "/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
526 ));
527 assert!(!is_in_app(&config, ""));
528 }
529
530 #[test]
531 fn capture_backtrace_excludes_this_crates_own_frames() {
532 let config = test_configuration();
533 let frames = capture_backtrace(&config);
534
535 assert!(!frames.is_empty(), "expected at least one backtrace frame");
536 for frame in &frames {
537 assert!(
538 !frame.method.starts_with(CRATE_PREFIX),
539 "frame {:?} should have been filtered out as SDK-internal",
540 frame.method
541 );
542 }
543 }
544
545 #[test]
546 fn format_unix_timestamp_known_value() {
547 assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
549 assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
551 }
552
553 mod source_context {
554 use super::*;
555 use std::sync::atomic::{AtomicU64, Ordering};
556
557 struct TempFile {
560 path: std::path::PathBuf,
561 }
562
563 impl TempFile {
564 fn with_contents(contents: &str) -> Self {
565 static COUNTER: AtomicU64 = AtomicU64::new(0);
566 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
567 let nanos = SystemTime::now()
568 .duration_since(UNIX_EPOCH)
569 .unwrap()
570 .as_nanos();
571 let path = std::env::temp_dir().join(format!(
572 "forge_ops_tracker_test_{}_{nanos}_{n}.rs",
573 std::process::id()
574 ));
575 std::fs::write(&path, contents).expect("failed to write temp test file");
576 TempFile { path }
577 }
578
579 fn path_string(&self) -> String {
580 self.path.to_string_lossy().into_owned()
581 }
582 }
583
584 impl Drop for TempFile {
585 fn drop(&mut self) {
586 let _ = std::fs::remove_file(&self.path);
587 }
588 }
589
590 fn numbered_lines(count: usize) -> String {
591 (1..=count)
592 .map(|n| format!("line {n}"))
593 .collect::<Vec<_>>()
594 .join("\n")
595 }
596
597 fn frame_in_app(file: String, line: u32) -> Frame {
598 Frame::new(file, line, "call".to_string(), true)
599 }
600
601 #[test]
602 fn attaches_window_around_the_culprit_line_by_default() {
603 let file = TempFile::with_contents(&numbered_lines(20));
604 let mut config = test_configuration();
605 config.app_root = Some(std::env::temp_dir().to_string_lossy().into_owned());
606
607 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
608
609 assert_eq!(frame.context_line, Some("line 10".to_string()));
610 assert_eq!(
611 frame.pre_context,
612 Some((5..=9).map(|n| format!("line {n}")).collect())
613 );
614 assert_eq!(
615 frame.post_context,
616 Some((11..=15).map(|n| format!("line {n}")).collect())
617 );
618 }
619
620 #[test]
621 fn clamps_at_the_start_and_end_of_the_file_rather_than_panicking() {
622 let file = TempFile::with_contents(&numbered_lines(3));
623 let config = test_configuration();
624
625 let first = attach_source_context(&config, frame_in_app(file.path_string(), 1));
626 let last = attach_source_context(&config, frame_in_app(file.path_string(), 3));
627
628 assert_eq!(first.pre_context, Some(vec![]));
629 assert_eq!(
630 first.post_context,
631 Some(vec!["line 2".to_string(), "line 3".to_string()])
632 );
633 assert_eq!(
634 last.pre_context,
635 Some(vec!["line 1".to_string(), "line 2".to_string()])
636 );
637 assert_eq!(last.post_context, Some(vec![]));
638 }
639
640 #[test]
641 fn truncates_a_line_longer_than_max_context_line_length() {
642 let overlong = "x".repeat(600);
643 let file = TempFile::with_contents(&overlong);
644 let config = test_configuration();
645
646 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 1));
647
648 assert_eq!(frame.context_line, Some(format!("{}...", "x".repeat(500))));
649 }
650
651 #[test]
652 fn never_attaches_context_to_a_frame_that_is_not_in_app() {
653 let file = TempFile::with_contents(&numbered_lines(20));
654 let config = test_configuration();
655 let frame = Frame::new(file.path_string(), 10, "call".to_string(), false);
656
657 let frame = attach_source_context(&config, frame);
658
659 assert_eq!(frame.context_line, None);
660 assert_eq!(frame.pre_context, None);
661 assert_eq!(frame.post_context, None);
662 }
663
664 #[test]
671 fn leaves_the_frame_untouched_when_capture_source_context_is_disabled() {
672 let file = TempFile::with_contents(&numbered_lines(20));
673 let mut config = test_configuration();
674 config.capture_source_context = false;
675
676 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
677
678 assert_eq!(frame.context_line, None);
679 assert_eq!(frame.pre_context, None);
680 assert_eq!(frame.post_context, None);
681 }
682
683 #[test]
684 fn leaves_the_frame_untouched_when_the_file_cannot_be_read() {
685 let config = test_configuration();
686 let missing_path = std::env::temp_dir()
687 .join("forge_ops_tracker_test_does_not_exist_12345.rs")
688 .to_string_lossy()
689 .into_owned();
690
691 let frame = attach_source_context(&config, frame_in_app(missing_path, 1));
692
693 assert_eq!(frame.context_line, None);
694 assert_eq!(frame.pre_context, None);
695 assert_eq!(frame.post_context, None);
696 }
697
698 #[test]
699 fn wire_format_uses_snake_case_keys_when_context_is_present_and_omits_them_otherwise() {
700 let with_context = Frame {
701 context_line: Some("line 10".to_string()),
702 pre_context: Some(vec!["line 9".to_string()]),
703 post_context: Some(vec!["line 11".to_string()]),
704 ..frame_in_app("/app/src/main.rs".to_string(), 10)
705 };
706 let event = Event {
707 exception_class: "Error".to_string(),
708 message: "boom".to_string(),
709 backtrace: vec![with_context],
710 occurred_at: "2024-01-15T10:30:00Z".to_string(),
711 environment: "production".to_string(),
712 release: None,
713 server_name: None,
714 context: HashMap::new(),
715 tags: HashMap::new(),
716 sdk_name: "rust".to_string(),
717 user: None,
718 breadcrumbs: vec![],
719 };
720
721 let json = event.to_json();
722
723 assert!(json.contains("\"context_line\":\"line 10\""));
724 assert!(json.contains("\"pre_context\":[\"line 9\"]"));
725 assert!(json.contains("\"post_context\":[\"line 11\"]"));
726 assert!(!json.contains("\"user\""));
727
728 let without_context = Event {
729 backtrace: vec![frame_in_app("/app/src/main.rs".to_string(), 10)],
730 ..event
731 };
732 let json = without_context.to_json();
733
734 assert!(!json.contains("context_line"));
735 assert!(!json.contains("pre_context"));
736 assert!(!json.contains("post_context"));
737 }
738
739 #[test]
740 fn wire_format_includes_user_when_present_and_omits_it_otherwise() {
741 let mut user = HashMap::new();
742 user.insert(
743 "email".to_string(),
744 Value::String("ada@example.com".to_string()),
745 );
746 let event = Event {
747 exception_class: "Error".to_string(),
748 message: "boom".to_string(),
749 backtrace: vec![],
750 occurred_at: "2024-01-15T10:30:00Z".to_string(),
751 environment: "production".to_string(),
752 release: None,
753 server_name: None,
754 context: HashMap::new(),
755 tags: HashMap::new(),
756 sdk_name: "rust".to_string(),
757 user: Some(user),
758 breadcrumbs: vec![],
759 };
760
761 let json = event.to_json();
762
763 assert!(json.contains("\"user\":{\"email\":\"ada@example.com\"}"));
764
765 let without_user = Event {
766 user: None,
767 ..event
768 };
769 assert!(!without_user.to_json().contains("\"user\""));
770 }
771
772 #[test]
773 fn wire_format_includes_breadcrumbs_when_present_and_omits_them_otherwise() {
774 let crumb = Breadcrumb {
775 category: "controller".to_string(),
776 message: "GET /orders/42".to_string(),
777 level: "info".to_string(),
778 timestamp: "2024-01-15T10:29:58Z".to_string(),
779 data: HashMap::from([("status".to_string(), Value::Number(200.0))]),
780 };
781 let event = Event {
782 exception_class: "Error".to_string(),
783 message: "boom".to_string(),
784 backtrace: vec![],
785 occurred_at: "2024-01-15T10:30:00Z".to_string(),
786 environment: "production".to_string(),
787 release: None,
788 server_name: None,
789 context: HashMap::new(),
790 tags: HashMap::new(),
791 sdk_name: "rust".to_string(),
792 user: None,
793 breadcrumbs: vec![crumb],
794 };
795
796 let json = event.to_json();
797
798 assert!(json.contains(
799 "\"breadcrumbs\":[{\"category\":\"controller\",\"message\":\"GET /orders/42\",\"level\":\"info\",\"timestamp\":\"2024-01-15T10:29:58Z\",\"data\":{\"status\":200}}]"
800 ));
801
802 let without_breadcrumbs = Event {
803 breadcrumbs: vec![],
804 ..event
805 };
806 assert!(!without_breadcrumbs.to_json().contains("\"breadcrumbs\""));
807 }
808 }
809}