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    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                // context_line/pre_context/post_context are only present at all when source
85                // context was actually attached (see attach_source_context): omitted from the
86                // wire entirely rather than sent as null, mirroring the Ruby gem's frame hash,
87                // which simply has no such keys on a frame that never got context.
88                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        // Omitted from the wire entirely when absent, the same "no key at all, not a null" shape
116        // context_fields above gives a frame with no source context: matches every other client
117        // in this repo's own "user" field, which is likewise only ever present when actually set.
118        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
142/// Renders a list of source-context lines as a JSON array, reusing `Value`'s own array encoding
143/// (see pii_scrubber.rs) rather than hand-rolling a second array-joining routine.
144fn 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
187// exception_class/occurred_at/environment/release/server_name/sdk_name/user are left alone:
188// structured fields this client or the host app sets deliberately, not free text an error or its
189// context could accidentally spill sensitive data into. Scrubbing `user` would defeat the whole
190// point of identifying users in the first place.
191fn 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
214/// Captures the current call stack via the `backtrace` crate, called at the point CaptureError/
215/// the panic hook fires: this client captures the stack at the call site rather than from the
216/// error value itself, since a plain Rust `std::error::Error` carries no stack of its own, unlike
217/// Python's traceback or Java's Throwable, which travel with the exception.
218pub 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            // Skip this SDK's own frames: capture_backtrace/capture_error/the panic hook's own
231            // call chain adds no diagnostic value, the same reason interpreter-internal frames
232            // never show up in a Python traceback. Only skipped until real caller code is
233            // reached, so a host app frame that happens to also start with this crate's own name
234            // (unlikely in practice) still gets included once we're past the SDK's own plumbing.
235            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    // Third-party crate source under Cargo's registry, and the Rust toolchain's own std/core
266    // source under a rustc sysroot path, are never in_app regardless of app_root: the same role
267    // site-packages/dist-packages plays for Python and the module cache plays for Go.
268    !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
269}
270
271/// Reads a few lines of source straight off disk around the culprit line, at capture time, in the
272/// same running process the panic/error came from. Gated on two things: the frame has to be
273/// in-app (never a third-party dependency: there'd be nothing meaningful of the host app's own
274/// to show), and `configuration.capture_source_context` has to be true (see Configuration for why
275/// it defaults to true and why ForgeOps' own per-project setting, not this field, is the durable,
276/// server-enforced way to turn it off). Best-effort: any file that can't be read (moved, deleted,
277/// permission denied, a path that only ever existed inside a build step and isn't present in this
278/// deployment) just leaves the frame exactly as it was, never a panic of the reporting path
279/// itself.
280fn 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
329/// Formats a Unix timestamp as "YYYY-MM-DDTHH:MM:SSZ" using plain civil-calendar arithmetic (the
330/// well-known "days from civil" algorithm) rather than a datetime crate: std has no calendar
331/// formatting at all, but this client only ever needs UTC-and-this-one-format, so the smallest
332/// possible amount of arithmetic beats adding a dependency for it.
333fn 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
346/// Howard Hinnant's "days from civil" algorithm, run in reverse (civil_from_days): a standard,
347/// widely-published constant-time conversion from a day count (here, since the Unix epoch) to a
348/// proleptic-Gregorian (year, month, day), used here purely for its adaptation as the
349/// well-documented public-domain algorithm it is at https://howardhinnant.github.io/date_algorithms.html.
350fn 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        // 2024-01-15T10:30:00Z
497        assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
498        // The Unix epoch itself.
499        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        // A real temp file on disk, not a hardcoded fixture path: std::env::temp_dir() plus a
507        // process-id/nanosecond/counter suffix keeps concurrently-run tests from colliding.
508        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        // Rust's std::fs offers no seam to spy on whether read_to_string was actually called (no
614        // trait indirection is used here, matching every other file in this crate), so this
615        // proves the behavioral contract instead: with the flag off, a frame whose file is real
616        // and reads back real, distinguishable content still comes back with no context fields
617        // at all: the only way that's possible is if attach_source_context's config check
618        // short-circuits before ever reaching the fs::read_to_string call below it.
619        #[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}