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::breadcrumb_buffer::Breadcrumb;
10use crate::configuration::Configuration;
11use crate::pii_scrubber::{scrub_string, scrub_value, Value};
12
13/// Caps how many backtrace frames a single event carries, the same limit every other client in
14/// this repo applies.
15pub const MAX_FRAMES: usize = 500;
16
17/// This crate's own module path prefix, as `backtrace`'s symbol names render it: used to skip
18/// this SDK's own leading frames the same way every other client's backtrace builder excludes its
19/// own internals.
20const CRATE_PREFIX: &str = "forge_ops_tracker::";
21
22/// How many lines of source to grab on either side of an in-app frame's culprit line (see
23/// `attach_source_context`), and the longest a single captured line is allowed to be before
24/// getting truncated: guards against a single pathological minified/generated line ballooning
25/// the payload. ForgeOps itself re-truncates on arrival too, the same "don't just trust the SDK"
26/// posture MAX_FRAMES already gets on the server side.
27const CONTEXT_LINES: usize = 5;
28const MAX_CONTEXT_LINE_LENGTH: usize = 500;
29
30/// Identifies this crate to the server's auto language-detection on the project the event lands
31/// in (see Project#note_sdk_platform server-side); matches this repo's own sdks/rust directory
32/// name, the same convention every other language's client follows.
33const 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    /// Convenience constructor for the common case (no source context attached yet): keeps call
48    /// sites and tests from having to spell out the three context fields every time.
49    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                // context_line/pre_context/post_context are only present at all when source
87                // context was actually attached (see attach_source_context): omitted from the
88                // wire entirely rather than sent as null, mirroring the Ruby gem's frame hash,
89                // which simply has no such keys on a frame that never got context.
90                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        // Omitted from the wire entirely when absent, the same "no key at all, not a null" shape
118        // context_fields above gives a frame with no source context: matches every other client
119        // in this repo's own "user" field, which is likewise only ever present when actually set.
120        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        // Omitted entirely rather than an empty array when there's nothing to send, the same
128        // "no key at all, not an empty placeholder" shape user_field above already uses: matches
129        // gems/forge_ops_tracker's own `if breadcrumbs && !breadcrumbs.empty?`.
130        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
168/// Renders a list of source-context lines as a JSON array, reusing `Value`'s own array encoding
169/// (see pii_scrubber.rs) rather than hand-rolling a second array-joining routine.
170fn 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
215// exception_class/occurred_at/environment/release/server_name/sdk_name/user are left alone:
216// structured fields this client or the host app sets deliberately, not free text an error or its
217// context could accidentally spill sensitive data into. Scrubbing `user` would defeat the whole
218// point of identifying users in the first place.
219fn 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    // category/level/timestamp are left alone, the same "structured fields this client sets
241    // deliberately, not free text" exemption exception_class/environment/etc. already get above:
242    // only message (arbitrary text) and data (arbitrary caller-supplied values, the same shape
243    // context already is) can carry anything worth scrubbing.
244    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
261/// Captures the current call stack via the `backtrace` crate, called at the point CaptureError/
262/// the panic hook fires: this client captures the stack at the call site rather than from the
263/// error value itself, since a plain Rust `std::error::Error` carries no stack of its own, unlike
264/// Python's traceback or Java's Throwable, which travel with the exception.
265pub 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            // Skip this SDK's own frames: capture_backtrace/capture_error/the panic hook's own
278            // call chain adds no diagnostic value, the same reason interpreter-internal frames
279            // never show up in a Python traceback. Only skipped until real caller code is
280            // reached, so a host app frame that happens to also start with this crate's own name
281            // (unlikely in practice) still gets included once we're past the SDK's own plumbing.
282            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    // Third-party crate source under Cargo's registry, and the Rust toolchain's own std/core
313    // source under a rustc sysroot path, are never in_app regardless of app_root: the same role
314    // site-packages/dist-packages plays for Python and the module cache plays for Go.
315    !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
316}
317
318/// Reads a few lines of source straight off disk around the culprit line, at capture time, in the
319/// same running process the panic/error came from. Gated on two things: the frame has to be
320/// in-app (never a third-party dependency: there'd be nothing meaningful of the host app's own
321/// to show), and `configuration.capture_source_context` has to be true (see Configuration for why
322/// it defaults to true and why ForgeOps' own per-project setting, not this field, is the durable,
323/// server-enforced way to turn it off). Best-effort: any file that can't be read (moved, deleted,
324/// permission denied, a path that only ever existed inside a build step and isn't present in this
325/// deployment) just leaves the frame exactly as it was, never a panic of the reporting path
326/// itself.
327fn 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
376/// Formats a Unix timestamp as "YYYY-MM-DDTHH:MM:SSZ" using plain civil-calendar arithmetic (the
377/// well-known "days from civil" algorithm) rather than a datetime crate: std has no calendar
378/// formatting at all, but this client only ever needs UTC-and-this-one-format, so the smallest
379/// possible amount of arithmetic beats adding a dependency for it. `pub(crate)`, not private:
380/// breadcrumb_buffer.rs's own timestamps need the identical format and reuse this rather than a
381/// second copy of the algorithm below.
382pub(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
395/// Howard Hinnant's "days from civil" algorithm, run in reverse (civil_from_days): a standard,
396/// widely-published constant-time conversion from a day count (here, since the Unix epoch) to a
397/// proleptic-Gregorian (year, month, day), used here purely for its adaptation as the
398/// well-documented public-domain algorithm it is at https://howardhinnant.github.io/date_algorithms.html.
399fn 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        // 2024-01-15T10:30:00Z
548        assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
549        // The Unix epoch itself.
550        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        // A real temp file on disk, not a hardcoded fixture path: std::env::temp_dir() plus a
558        // process-id/nanosecond/counter suffix keeps concurrently-run tests from colliding.
559        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        // Rust's std::fs offers no seam to spy on whether read_to_string was actually called (no
665        // trait indirection is used here, matching every other file in this crate), so this
666        // proves the behavioral contract instead: with the flag off, a frame whose file is real
667        // and reads back real, distinguishable content still comes back with no context fields
668        // at all: the only way that's possible is if attach_source_context's config check
669        // short-circuits before ever reaching the fs::read_to_string call below it.
670        #[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}