Skip to main content

forge_ops_tracker/
event_builder.rs

1// Turns a reported error/panic into the payload shape the ingestion API expects. Ported from
2// gems/forge_ops_tracker/lib/forge_ops_tracker/event_builder.rb: backtrace frames come from the
3// `backtrace` crate rather than regex-parsing MRI backtrace lines, but the resulting shape
4// (file/line/method/in_app) is the same.
5
6use std::collections::HashMap;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::configuration::Configuration;
10use crate::pii_scrubber::{scrub_string, scrub_value, Value};
11
12/// Caps how many backtrace frames a single event carries, the same limit every other client in
13/// this repo applies.
14pub const MAX_FRAMES: usize = 500;
15
16/// This crate's own module path prefix, as `backtrace`'s symbol names render it: used to skip
17/// this SDK's own leading frames the same way every other client's backtrace builder excludes its
18/// own internals.
19const CRATE_PREFIX: &str = "forge_ops_tracker::";
20
21/// How many lines of source to grab on either side of an in-app frame's culprit line (see
22/// `attach_source_context`), and the longest a single captured line is allowed to be before
23/// getting truncated: guards against a single pathological minified/generated line ballooning
24/// the payload. ForgeOps itself re-truncates on arrival too, the same "don't just trust the SDK"
25/// posture MAX_FRAMES already gets on the server side.
26const CONTEXT_LINES: usize = 5;
27const MAX_CONTEXT_LINE_LENGTH: usize = 500;
28
29/// Identifies this crate to the server's auto language-detection on the project the event lands
30/// in (see Project#note_sdk_platform server-side); matches this repo's own sdks/rust directory
31/// name, the same convention every other language's client follows.
32const 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    /// Convenience constructor for the common case (no source context attached yet): keeps call
47    /// sites and tests from having to spell out the three context fields every time.
48    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                // context_line/pre_context/post_context are only present at all when source
84                // context was actually attached (see attach_source_context): omitted from the
85                // wire entirely rather than sent as null, mirroring the Ruby gem's frame hash,
86                // which simply has no such keys on a frame that never got context.
87                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
130/// Renders a list of source-context lines as a JSON array, reusing `Value`'s own array encoding
131/// (see pii_scrubber.rs) rather than hand-rolling a second array-joining routine.
132fn 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
172// exception_class/occurred_at/environment/release/server_name/sdk_name are left alone: structured fields
173// this client or the host app sets deliberately, not free text an error or its context could
174// accidentally spill sensitive data into.
175fn 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
198/// Captures the current call stack via the `backtrace` crate, called at the point CaptureError/
199/// the panic hook fires: this client captures the stack at the call site rather than from the
200/// error value itself, since a plain Rust `std::error::Error` carries no stack of its own, unlike
201/// Python's traceback or Java's Throwable, which travel with the exception.
202pub 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            // Skip this SDK's own frames: capture_backtrace/capture_error/the panic hook's own
215            // call chain adds no diagnostic value, the same reason interpreter-internal frames
216            // never show up in a Python traceback. Only skipped until real caller code is
217            // reached, so a host app frame that happens to also start with this crate's own name
218            // (unlikely in practice) still gets included once we're past the SDK's own plumbing.
219            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    // Third-party crate source under Cargo's registry, and the Rust toolchain's own std/core
250    // source under a rustc sysroot path, are never in_app regardless of app_root: the same role
251    // site-packages/dist-packages plays for Python and the module cache plays for Go.
252    !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
253}
254
255/// Reads a few lines of source straight off disk around the culprit line, at capture time, in the
256/// same running process the panic/error came from. Gated on two things: the frame has to be
257/// in-app (never a third-party dependency: there'd be nothing meaningful of the host app's own
258/// to show), and `configuration.capture_source_context` has to be true (see Configuration for why
259/// it defaults to true and why ForgeOps' own per-project setting, not this field, is the durable,
260/// server-enforced way to turn it off). Best-effort: any file that can't be read (moved, deleted,
261/// permission denied, a path that only ever existed inside a build step and isn't present in this
262/// deployment) just leaves the frame exactly as it was, never a panic of the reporting path
263/// itself.
264fn 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
313/// Formats a Unix timestamp as "YYYY-MM-DDTHH:MM:SSZ" using plain civil-calendar arithmetic (the
314/// well-known "days from civil" algorithm) rather than a datetime crate: std has no calendar
315/// formatting at all, but this client only ever needs UTC-and-this-one-format, so the smallest
316/// possible amount of arithmetic beats adding a dependency for it.
317fn 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
330/// Howard Hinnant's "days from civil" algorithm, run in reverse (civil_from_days): a standard,
331/// widely-published constant-time conversion from a day count (here, since the Unix epoch) to a
332/// proleptic-Gregorian (year, month, day), used here purely for its adaptation as the
333/// well-documented public-domain algorithm it is at https://howardhinnant.github.io/date_algorithms.html.
334fn 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        // 2024-01-15T10:30:00Z
445        assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
446        // The Unix epoch itself.
447        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        // A real temp file on disk, not a hardcoded fixture path: std::env::temp_dir() plus a
455        // process-id/nanosecond/counter suffix keeps concurrently-run tests from colliding.
456        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        // Rust's std::fs offers no seam to spy on whether read_to_string was actually called (no
562        // trait indirection is used here, matching every other file in this crate), so this
563        // proves the behavioral contract instead: with the flag off, a frame whose file is real
564        // and reads back real, distinguishable content still comes back with no context fields
565        // at all: the only way that's possible is if attach_source_context's config check
566        // short-circuits before ever reaching the fs::read_to_string call below it.
567        #[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}