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};
12use crate::sql_statement::{self, SqlObjects};
13
14/// Caps how many backtrace frames a single event carries, the same limit every other client in
15/// this repo applies.
16pub const MAX_FRAMES: usize = 500;
17
18/// This crate's own module path prefix, as `backtrace`'s symbol names render it: used to skip
19/// this SDK's own leading frames the same way every other client's backtrace builder excludes its
20/// own internals.
21const CRATE_PREFIX: &str = "forge_ops_tracker::";
22
23/// How many lines of source to grab on either side of an in-app frame's culprit line (see
24/// `attach_source_context`), and the longest a single captured line is allowed to be before
25/// getting truncated: guards against a single pathological minified/generated line ballooning
26/// the payload. ForgeOps itself re-truncates on arrival too, the same "don't just trust the SDK"
27/// posture MAX_FRAMES already gets on the server side.
28const CONTEXT_LINES: usize = 5;
29const MAX_CONTEXT_LINE_LENGTH: usize = 500;
30
31/// Identifies this crate to the server's auto language-detection on the project the event lands
32/// in (see Project#note_sdk_platform server-side); matches this repo's own sdks/rust directory
33/// name, the same convention every other language's client follows.
34const SDK_NAME: &str = "rust";
35
36#[derive(Clone, Debug, PartialEq)]
37pub struct Frame {
38    pub file: String,
39    pub line: u32,
40    pub method: String,
41    pub in_app: bool,
42    pub context_line: Option<String>,
43    pub pre_context: Option<Vec<String>>,
44    pub post_context: Option<Vec<String>>,
45}
46
47impl Frame {
48    /// Convenience constructor for the common case (no source context attached yet): keeps call
49    /// sites and tests from having to spell out the three context fields every time.
50    pub fn new(file: String, line: u32, method: String, in_app: bool) -> Self {
51        Frame {
52            file,
53            line,
54            method,
55            in_app,
56            context_line: None,
57            pre_context: None,
58            post_context: None,
59        }
60    }
61}
62
63#[derive(Clone, Debug)]
64pub struct Event {
65    pub exception_class: String,
66    pub message: String,
67    pub backtrace: Vec<Frame>,
68    pub occurred_at: String,
69    pub environment: String,
70    pub release: Option<String>,
71    pub server_name: Option<String>,
72    pub context: HashMap<String, Value>,
73    pub tags: HashMap<String, Value>,
74    pub sdk_name: String,
75    pub user: Option<HashMap<String, Value>>,
76    pub breadcrumbs: Vec<Breadcrumb>,
77    /// Procedure/function and table/view names the failing SQL touched, and (opt-in) the statement
78    /// itself with every string and number masked; see sql_statement.rs. Both omitted from the wire
79    /// entirely when `None`.
80    pub sql_objects: Option<SqlObjects>,
81    pub sql_statement: Option<String>,
82    /// The id of the trace open on the reporting thread (see `trace`/`continue_trace`), linking
83    /// this error to that trace's spans and, through `traceparent`, to errors in the other services
84    /// it touched. Omitted from the wire entirely when `None`. A structured id, never scrubbed.
85    pub trace_id: Option<String>,
86}
87
88impl Event {
89    pub fn to_json(&self) -> String {
90        use crate::pii_scrubber::json_string;
91
92        let backtrace: Vec<String> = self
93            .backtrace
94            .iter()
95            .map(|f| {
96                // context_line/pre_context/post_context are only present at all when source
97                // context was actually attached (see attach_source_context): omitted from the
98                // wire entirely rather than sent as null, mirroring the Ruby gem's frame hash,
99                // which simply has no such keys on a frame that never got context.
100                let context_fields = match (&f.context_line, &f.pre_context, &f.post_context) {
101                    (Some(context_line), Some(pre_context), Some(post_context)) => format!(
102                        ",\"context_line\":{},\"pre_context\":{},\"post_context\":{}",
103                        json_string(context_line),
104                        string_array_json(pre_context),
105                        string_array_json(post_context)
106                    ),
107                    _ => String::new(),
108                };
109
110                format!(
111                    "{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}{}}}",
112                    json_string(&f.file),
113                    f.line,
114                    json_string(&f.method),
115                    f.in_app,
116                    context_fields
117                )
118            })
119            .collect();
120
121        let optional_string = |v: &Option<String>| {
122            v.as_deref()
123                .map(json_string)
124                .unwrap_or_else(|| "null".to_string())
125        };
126
127        // Omitted from the wire entirely when absent, the same "no key at all, not a null" shape
128        // context_fields above gives a frame with no source context: matches every other client
129        // in this repo's own "user" field, which is likewise only ever present when actually set.
130        let user_field = match &self.user {
131            Some(user) if !user.is_empty() => {
132                format!(",\"user\":{}", Value::Object(user.clone()).to_json())
133            }
134            _ => String::new(),
135        };
136
137        // Omitted entirely rather than an empty array when there's nothing to send, the same
138        // "no key at all, not an empty placeholder" shape user_field above already uses: matches
139        // gems/forge_ops_tracker's own `if breadcrumbs && !breadcrumbs.empty?`.
140        let breadcrumbs_field = if self.breadcrumbs.is_empty() {
141            String::new()
142        } else {
143            let entries: Vec<String> = self
144                .breadcrumbs
145                .iter()
146                .map(|b| {
147                    format!(
148                        "{{\"category\":{},\"message\":{},\"level\":{},\"timestamp\":{},\"data\":{}}}",
149                        json_string(&b.category),
150                        json_string(&b.message),
151                        json_string(&b.level),
152                        json_string(&b.timestamp),
153                        Value::Object(b.data.clone()).to_json()
154                    )
155                })
156                .collect();
157            format!(",\"breadcrumbs\":[{}]", entries.join(","))
158        };
159
160        let sql_field = format!(
161            "{}{}",
162            self.sql_objects
163                .as_ref()
164                .map(|o| format!(",\"sql_objects\":{}", o.to_json()))
165                .unwrap_or_default(),
166            self.sql_statement
167                .as_deref()
168                .map(|s| format!(",\"sql_statement\":{}", json_string(s)))
169                .unwrap_or_default()
170        );
171
172        let trace_field = self
173            .trace_id
174            .as_deref()
175            .map(|id| format!(",\"trace_id\":{}", json_string(id)))
176            .unwrap_or_default();
177
178        format!(
179            "{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{},\"sdk_name\":{}{}{}{}{}}}",
180            json_string(&self.exception_class),
181            json_string(&self.message),
182            backtrace.join(","),
183            json_string(&self.occurred_at),
184            json_string(&self.environment),
185            optional_string(&self.release),
186            optional_string(&self.server_name),
187            Value::Object(self.context.clone()).to_json(),
188            Value::Object(self.tags.clone()).to_json(),
189            json_string(&self.sdk_name),
190            user_field,
191            breadcrumbs_field,
192            sql_field,
193            trace_field
194        )
195    }
196}
197
198/// Renders a list of source-context lines as a JSON array, reusing `Value`'s own array encoding
199/// (see pii_scrubber.rs) rather than hand-rolling a second array-joining routine.
200fn string_array_json(items: &[String]) -> String {
201    Value::Array(items.iter().cloned().map(Value::String).collect()).to_json()
202}
203
204pub struct EventBuilder<'a> {
205    configuration: &'a Configuration,
206}
207
208impl<'a> EventBuilder<'a> {
209    pub fn new(configuration: &'a Configuration) -> Self {
210        EventBuilder { configuration }
211    }
212
213    // Only the tests build an event without SQL directly now: every real report goes through
214    // `build_with_sql` (via Reporter::report), passing `None` when the caller had no statement.
215    #[cfg(test)]
216    #[allow(clippy::too_many_arguments)]
217    pub fn build(
218        &self,
219        exception_class: &str,
220        message: &str,
221        backtrace: Vec<Frame>,
222        context: HashMap<String, Value>,
223        user: Option<HashMap<String, Value>>,
224        breadcrumbs: Vec<Breadcrumb>,
225    ) -> Event {
226        self.build_with_sql(
227            exception_class,
228            message,
229            backtrace,
230            context,
231            user,
232            breadcrumbs,
233            None,
234        )
235    }
236
237    /// The same as `build`, plus the raw SQL statement behind the error when the caller has one
238    /// (see `capture_error_with_sql`). The statement is masked here, and only the names and/or the
239    /// masked statement it produces ever reach the event, per the two `capture_sql_*` settings.
240    #[allow(clippy::too_many_arguments)]
241    pub fn build_with_sql(
242        &self,
243        exception_class: &str,
244        message: &str,
245        backtrace: Vec<Frame>,
246        context: HashMap<String, Value>,
247        user: Option<HashMap<String, Value>>,
248        breadcrumbs: Vec<Breadcrumb>,
249        sql: Option<&str>,
250    ) -> Event {
251        let mut event = Event {
252            exception_class: exception_class.to_string(),
253            message: message.to_string(),
254            backtrace,
255            occurred_at: format_now(),
256            environment: self.configuration.environment.clone(),
257            release: self.configuration.release.clone(),
258            server_name: self.configuration.server_name.clone(),
259            context,
260            tags: HashMap::new(),
261            sdk_name: SDK_NAME.to_string(),
262            user: user.filter(|u| !u.is_empty()),
263            breadcrumbs,
264            sql_objects: None,
265            sql_statement: None,
266            // Read here, on the reporting thread itself, the same thread-local `set_user` and the
267            // breadcrumb trail already rely on.
268            trace_id: crate::span_buffer::current_trace_id(),
269        };
270
271        if self.configuration.capture_sql_objects || self.configuration.capture_sql_statement {
272            if let Some(masked) = sql.and_then(sql_statement::mask) {
273                if self.configuration.capture_sql_objects {
274                    event.sql_objects = sql_statement::extract_objects(&masked);
275                }
276                if self.configuration.capture_sql_statement {
277                    event.sql_statement = Some(masked);
278                }
279            }
280        }
281
282        if self.configuration.scrub_pii {
283            event = scrub_event(event);
284        }
285        event
286    }
287}
288
289// exception_class/occurred_at/environment/release/server_name/sdk_name/user are left alone:
290// structured fields this client or the host app sets deliberately, not free text an error or its
291// context could accidentally spill sensitive data into. Scrubbing `user` would defeat the whole
292// point of identifying users in the first place.
293fn scrub_event(mut event: Event) -> Event {
294    event.message = scrub_string(&event.message);
295    event.backtrace = event
296        .backtrace
297        .into_iter()
298        .map(|f| Frame {
299            file: scrub_string(&f.file),
300            method: scrub_string(&f.method),
301            ..f
302        })
303        .collect();
304
305    let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
306        unreachable!()
307    };
308    event.context = context;
309    let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
310        unreachable!()
311    };
312    event.tags = tags;
313
314    // category/level/timestamp are left alone, the same "structured fields this client sets
315    // deliberately, not free text" exemption exception_class/environment/etc. already get above:
316    // only message (arbitrary text) and data (arbitrary caller-supplied values, the same shape
317    // context already is) can carry anything worth scrubbing.
318    event.sql_statement = event
319        .sql_statement
320        .map(|statement| scrub_string(&statement));
321
322    event.breadcrumbs = event
323        .breadcrumbs
324        .into_iter()
325        .map(|b| {
326            let Value::Object(data) = scrub_value(&Value::Object(b.data), "") else {
327                unreachable!()
328            };
329            Breadcrumb {
330                message: scrub_string(&b.message),
331                data,
332                ..b
333            }
334        })
335        .collect();
336    event
337}
338
339/// Captures the current call stack via the `backtrace` crate, called at the point CaptureError/
340/// the panic hook fires: this client captures the stack at the call site rather than from the
341/// error value itself, since a plain Rust `std::error::Error` carries no stack of its own, unlike
342/// Python's traceback or Java's Throwable, which travel with the exception.
343pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
344    let bt = backtrace::Backtrace::new();
345    let mut frames = Vec::new();
346    let mut seen_app_frame = false;
347
348    'frames: for frame in bt.frames() {
349        for symbol in frame.symbols() {
350            let name = symbol
351                .name()
352                .map(|n| n.to_string())
353                .unwrap_or_else(|| "<unknown>".to_string());
354
355            // Skip this SDK's own frames: capture_backtrace/capture_error/the panic hook's own
356            // call chain adds no diagnostic value, the same reason interpreter-internal frames
357            // never show up in a Python traceback. Only skipped until real caller code is
358            // reached, so a host app frame that happens to also start with this crate's own name
359            // (unlikely in practice) still gets included once we're past the SDK's own plumbing.
360            if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
361                continue;
362            }
363            seen_app_frame = true;
364
365            let file = symbol
366                .filename()
367                .map(|p| p.to_string_lossy().into_owned())
368                .unwrap_or_default();
369            let line = symbol.lineno().unwrap_or(0);
370            let in_app = is_in_app(configuration, &file);
371            let frame = Frame::new(file, line, name, in_app);
372            frames.push(attach_source_context(configuration, frame));
373
374            if frames.len() >= MAX_FRAMES {
375                break 'frames;
376            }
377        }
378    }
379    frames
380}
381
382fn is_in_app(configuration: &Configuration, file: &str) -> bool {
383    let root = match &configuration.app_root {
384        Some(r) if !r.is_empty() => r,
385        _ => return false,
386    };
387    if file.is_empty() || !file.starts_with(root.as_str()) {
388        return false;
389    }
390    // Third-party crate source under Cargo's registry, and the Rust toolchain's own std/core
391    // source under a rustc sysroot path, are never in_app regardless of app_root: the same role
392    // site-packages/dist-packages plays for Python and the module cache plays for Go.
393    !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
394}
395
396/// Reads a few lines of source straight off disk around the culprit line, at capture time, in the
397/// same running process the panic/error came from. Gated on two things: the frame has to be
398/// in-app (never a third-party dependency: there'd be nothing meaningful of the host app's own
399/// to show), and `configuration.capture_source_context` has to be true (see Configuration for why
400/// it defaults to true and why ForgeOps' own per-project setting, not this field, is the durable,
401/// server-enforced way to turn it off). Best-effort: any file that can't be read (moved, deleted,
402/// permission denied, a path that only ever existed inside a build step and isn't present in this
403/// deployment) just leaves the frame exactly as it was, never a panic of the reporting path
404/// itself.
405fn attach_source_context(configuration: &Configuration, mut frame: Frame) -> Frame {
406    if !configuration.capture_source_context || !frame.in_app {
407        return frame;
408    }
409
410    let Ok(contents) = std::fs::read_to_string(&frame.file) else {
411        return frame;
412    };
413    let lines: Vec<&str> = contents.lines().collect();
414    if frame.line == 0 || frame.line as usize > lines.len() {
415        return frame;
416    }
417    let index = frame.line as usize - 1;
418
419    let from = index.saturating_sub(CONTEXT_LINES);
420    let to = (index + CONTEXT_LINES).min(lines.len() - 1);
421
422    frame.context_line = Some(truncate_line(lines[index]));
423    frame.pre_context = Some(
424        lines[from..index]
425            .iter()
426            .map(|l| truncate_line(l))
427            .collect(),
428    );
429    frame.post_context = Some(
430        lines[(index + 1)..=to]
431            .iter()
432            .map(|l| truncate_line(l))
433            .collect(),
434    );
435    frame
436}
437
438fn truncate_line(line: &str) -> String {
439    if line.chars().count() <= MAX_CONTEXT_LINE_LENGTH {
440        return line.to_string();
441    }
442    let truncated: String = line.chars().take(MAX_CONTEXT_LINE_LENGTH).collect();
443    format!("{truncated}...")
444}
445
446fn format_now() -> String {
447    let secs = SystemTime::now()
448        .duration_since(UNIX_EPOCH)
449        .map(|d| d.as_secs())
450        .unwrap_or(0);
451    format_unix_timestamp(secs)
452}
453
454/// Formats a Unix timestamp as "YYYY-MM-DDTHH:MM:SSZ" using plain civil-calendar arithmetic (the
455/// well-known "days from civil" algorithm) rather than a datetime crate: std has no calendar
456/// formatting at all, but this client only ever needs UTC-and-this-one-format, so the smallest
457/// possible amount of arithmetic beats adding a dependency for it. `pub(crate)`, not private:
458/// breadcrumb_buffer.rs's own timestamps need the identical format and reuse this rather than a
459/// second copy of the algorithm below.
460pub(crate) fn format_unix_timestamp(secs: u64) -> String {
461    let days = secs / 86400;
462    let time_of_day = secs % 86400;
463    let (hour, minute, second) = (
464        time_of_day / 3600,
465        (time_of_day % 3600) / 60,
466        time_of_day % 60,
467    );
468
469    let (year, month, day) = civil_from_days(days as i64);
470    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
471}
472
473/// Howard Hinnant's "days from civil" algorithm, run in reverse (civil_from_days): a standard,
474/// widely-published constant-time conversion from a day count (here, since the Unix epoch) to a
475/// proleptic-Gregorian (year, month, day), used here purely for its adaptation as the
476/// well-documented public-domain algorithm it is at https://howardhinnant.github.io/date_algorithms.html.
477fn civil_from_days(z: i64) -> (i64, u32, u32) {
478    let z = z + 719468;
479    let era = if z >= 0 { z } else { z - 146096 } / 146097;
480    let doe = (z - era * 146097) as u64;
481    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
482    let y = yoe as i64 + era * 400;
483    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
484    let mp = (5 * doy + 2) / 153;
485    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
486    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
487    let year = if m <= 2 { y + 1 } else { y };
488    (year, m, d)
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn test_configuration() -> Configuration {
496        Configuration {
497            environment: "production".to_string(),
498            release: Some("a1b2c3d".to_string()),
499            server_name: Some("test-host".to_string()),
500            app_root: Some("/app".to_string()),
501            scrub_pii: true,
502            ..Configuration::new()
503        }
504    }
505
506    #[test]
507    fn build_basic_fields() {
508        let config = test_configuration();
509        let builder = EventBuilder::new(&config);
510        let mut context = HashMap::new();
511        context.insert("order_id".to_string(), Value::Number(42.0));
512
513        let event = builder.build("std::io::Error", "boom", vec![], context, None, vec![]);
514
515        assert_eq!(event.message, "boom");
516        assert_eq!(event.environment, "production");
517        assert_eq!(event.release, Some("a1b2c3d".to_string()));
518        assert_eq!(event.server_name, Some("test-host".to_string()));
519        assert_eq!(event.context["order_id"], Value::Number(42.0));
520        assert_eq!(event.sdk_name, "rust");
521    }
522
523    #[test]
524    fn build_includes_the_user_when_given_one_never_scrubbed_even_though_its_an_email() {
525        let config = test_configuration();
526        let builder = EventBuilder::new(&config);
527        let mut user = HashMap::new();
528        user.insert("id".to_string(), Value::Number(42.0));
529        user.insert(
530            "email".to_string(),
531            Value::String("ada@example.com".to_string()),
532        );
533
534        let event = builder.build("Error", "boom", vec![], HashMap::new(), Some(user), vec![]);
535
536        assert_eq!(
537            event.user.unwrap()["email"],
538            Value::String("ada@example.com".to_string())
539        );
540    }
541
542    #[test]
543    fn build_omits_the_user_entirely_when_none_was_given() {
544        let config = test_configuration();
545        let builder = EventBuilder::new(&config);
546
547        let event = builder.build("Error", "boom", vec![], HashMap::new(), None, vec![]);
548
549        assert_eq!(event.user, None);
550    }
551
552    #[test]
553    fn build_scrubs_message_and_context_when_enabled() {
554        let config = test_configuration();
555        let builder = EventBuilder::new(&config);
556        let mut context = HashMap::new();
557        context.insert(
558            "api_key".to_string(),
559            Value::String("shh-secret".to_string()),
560        );
561
562        let event = builder.build(
563            "Error",
564            "failed to charge user@example.com",
565            vec![],
566            context,
567            None,
568            vec![],
569        );
570
571        assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
572        assert_eq!(
573            event.context["api_key"],
574            Value::String(crate::pii_scrubber::REDACTED.to_string())
575        );
576    }
577
578    #[test]
579    fn build_does_not_scrub_when_disabled() {
580        let mut config = test_configuration();
581        config.scrub_pii = false;
582        let builder = EventBuilder::new(&config);
583
584        let event = builder.build(
585            "Error",
586            "contact user@example.com",
587            vec![],
588            HashMap::new(),
589            None,
590            vec![],
591        );
592
593        assert_eq!(event.message, "contact user@example.com");
594    }
595
596    #[test]
597    fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
598        let config = test_configuration();
599        assert!(is_in_app(&config, "/app/src/main.rs"));
600        assert!(!is_in_app(&config, "/other/src/main.rs"));
601        assert!(!is_in_app(
602            &config,
603            "/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
604        ));
605        assert!(!is_in_app(&config, ""));
606    }
607
608    #[test]
609    fn capture_backtrace_excludes_this_crates_own_frames() {
610        let config = test_configuration();
611        let frames = capture_backtrace(&config);
612
613        assert!(!frames.is_empty(), "expected at least one backtrace frame");
614        for frame in &frames {
615            assert!(
616                !frame.method.starts_with(CRATE_PREFIX),
617                "frame {:?} should have been filtered out as SDK-internal",
618                frame.method
619            );
620        }
621    }
622
623    #[test]
624    fn format_unix_timestamp_known_value() {
625        // 2024-01-15T10:30:00Z
626        assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
627        // The Unix epoch itself.
628        assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
629    }
630
631    mod source_context {
632        use super::*;
633        use std::sync::atomic::{AtomicU64, Ordering};
634
635        // A real temp file on disk, not a hardcoded fixture path: std::env::temp_dir() plus a
636        // process-id/nanosecond/counter suffix keeps concurrently-run tests from colliding.
637        struct TempFile {
638            path: std::path::PathBuf,
639        }
640
641        impl TempFile {
642            fn with_contents(contents: &str) -> Self {
643                static COUNTER: AtomicU64 = AtomicU64::new(0);
644                let n = COUNTER.fetch_add(1, Ordering::Relaxed);
645                let nanos = SystemTime::now()
646                    .duration_since(UNIX_EPOCH)
647                    .unwrap()
648                    .as_nanos();
649                let path = std::env::temp_dir().join(format!(
650                    "forge_ops_tracker_test_{}_{nanos}_{n}.rs",
651                    std::process::id()
652                ));
653                std::fs::write(&path, contents).expect("failed to write temp test file");
654                TempFile { path }
655            }
656
657            fn path_string(&self) -> String {
658                self.path.to_string_lossy().into_owned()
659            }
660        }
661
662        impl Drop for TempFile {
663            fn drop(&mut self) {
664                let _ = std::fs::remove_file(&self.path);
665            }
666        }
667
668        fn numbered_lines(count: usize) -> String {
669            (1..=count)
670                .map(|n| format!("line {n}"))
671                .collect::<Vec<_>>()
672                .join("\n")
673        }
674
675        fn frame_in_app(file: String, line: u32) -> Frame {
676            Frame::new(file, line, "call".to_string(), true)
677        }
678
679        #[test]
680        fn attaches_window_around_the_culprit_line_by_default() {
681            let file = TempFile::with_contents(&numbered_lines(20));
682            let mut config = test_configuration();
683            config.app_root = Some(std::env::temp_dir().to_string_lossy().into_owned());
684
685            let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
686
687            assert_eq!(frame.context_line, Some("line 10".to_string()));
688            assert_eq!(
689                frame.pre_context,
690                Some((5..=9).map(|n| format!("line {n}")).collect())
691            );
692            assert_eq!(
693                frame.post_context,
694                Some((11..=15).map(|n| format!("line {n}")).collect())
695            );
696        }
697
698        #[test]
699        fn clamps_at_the_start_and_end_of_the_file_rather_than_panicking() {
700            let file = TempFile::with_contents(&numbered_lines(3));
701            let config = test_configuration();
702
703            let first = attach_source_context(&config, frame_in_app(file.path_string(), 1));
704            let last = attach_source_context(&config, frame_in_app(file.path_string(), 3));
705
706            assert_eq!(first.pre_context, Some(vec![]));
707            assert_eq!(
708                first.post_context,
709                Some(vec!["line 2".to_string(), "line 3".to_string()])
710            );
711            assert_eq!(
712                last.pre_context,
713                Some(vec!["line 1".to_string(), "line 2".to_string()])
714            );
715            assert_eq!(last.post_context, Some(vec![]));
716        }
717
718        #[test]
719        fn truncates_a_line_longer_than_max_context_line_length() {
720            let overlong = "x".repeat(600);
721            let file = TempFile::with_contents(&overlong);
722            let config = test_configuration();
723
724            let frame = attach_source_context(&config, frame_in_app(file.path_string(), 1));
725
726            assert_eq!(frame.context_line, Some(format!("{}...", "x".repeat(500))));
727        }
728
729        #[test]
730        fn never_attaches_context_to_a_frame_that_is_not_in_app() {
731            let file = TempFile::with_contents(&numbered_lines(20));
732            let config = test_configuration();
733            let frame = Frame::new(file.path_string(), 10, "call".to_string(), false);
734
735            let frame = attach_source_context(&config, frame);
736
737            assert_eq!(frame.context_line, None);
738            assert_eq!(frame.pre_context, None);
739            assert_eq!(frame.post_context, None);
740        }
741
742        // Rust's std::fs offers no seam to spy on whether read_to_string was actually called (no
743        // trait indirection is used here, matching every other file in this crate), so this
744        // proves the behavioral contract instead: with the flag off, a frame whose file is real
745        // and reads back real, distinguishable content still comes back with no context fields
746        // at all: the only way that's possible is if attach_source_context's config check
747        // short-circuits before ever reaching the fs::read_to_string call below it.
748        #[test]
749        fn leaves_the_frame_untouched_when_capture_source_context_is_disabled() {
750            let file = TempFile::with_contents(&numbered_lines(20));
751            let mut config = test_configuration();
752            config.capture_source_context = false;
753
754            let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
755
756            assert_eq!(frame.context_line, None);
757            assert_eq!(frame.pre_context, None);
758            assert_eq!(frame.post_context, None);
759        }
760
761        #[test]
762        fn leaves_the_frame_untouched_when_the_file_cannot_be_read() {
763            let config = test_configuration();
764            let missing_path = std::env::temp_dir()
765                .join("forge_ops_tracker_test_does_not_exist_12345.rs")
766                .to_string_lossy()
767                .into_owned();
768
769            let frame = attach_source_context(&config, frame_in_app(missing_path, 1));
770
771            assert_eq!(frame.context_line, None);
772            assert_eq!(frame.pre_context, None);
773            assert_eq!(frame.post_context, None);
774        }
775
776        #[test]
777        fn wire_format_uses_snake_case_keys_when_context_is_present_and_omits_them_otherwise() {
778            let with_context = Frame {
779                context_line: Some("line 10".to_string()),
780                pre_context: Some(vec!["line 9".to_string()]),
781                post_context: Some(vec!["line 11".to_string()]),
782                ..frame_in_app("/app/src/main.rs".to_string(), 10)
783            };
784            let event = Event {
785                exception_class: "Error".to_string(),
786                message: "boom".to_string(),
787                backtrace: vec![with_context],
788                occurred_at: "2024-01-15T10:30:00Z".to_string(),
789                environment: "production".to_string(),
790                release: None,
791                server_name: None,
792                context: HashMap::new(),
793                tags: HashMap::new(),
794                sdk_name: "rust".to_string(),
795                user: None,
796                breadcrumbs: vec![],
797                sql_objects: None,
798                sql_statement: None,
799                trace_id: None,
800            };
801
802            let json = event.to_json();
803
804            assert!(json.contains("\"context_line\":\"line 10\""));
805            assert!(json.contains("\"pre_context\":[\"line 9\"]"));
806            assert!(json.contains("\"post_context\":[\"line 11\"]"));
807            assert!(!json.contains("\"user\""));
808
809            let without_context = Event {
810                backtrace: vec![frame_in_app("/app/src/main.rs".to_string(), 10)],
811                ..event
812            };
813            let json = without_context.to_json();
814
815            assert!(!json.contains("context_line"));
816            assert!(!json.contains("pre_context"));
817            assert!(!json.contains("post_context"));
818        }
819
820        #[test]
821        fn wire_format_includes_user_when_present_and_omits_it_otherwise() {
822            let mut user = HashMap::new();
823            user.insert(
824                "email".to_string(),
825                Value::String("ada@example.com".to_string()),
826            );
827            let event = Event {
828                exception_class: "Error".to_string(),
829                message: "boom".to_string(),
830                backtrace: vec![],
831                occurred_at: "2024-01-15T10:30:00Z".to_string(),
832                environment: "production".to_string(),
833                release: None,
834                server_name: None,
835                context: HashMap::new(),
836                tags: HashMap::new(),
837                sdk_name: "rust".to_string(),
838                user: Some(user),
839                breadcrumbs: vec![],
840                sql_objects: None,
841                sql_statement: None,
842                trace_id: None,
843            };
844
845            let json = event.to_json();
846
847            assert!(json.contains("\"user\":{\"email\":\"ada@example.com\"}"));
848
849            let without_user = Event {
850                user: None,
851                ..event
852            };
853            assert!(!without_user.to_json().contains("\"user\""));
854        }
855
856        #[test]
857        fn wire_format_includes_breadcrumbs_when_present_and_omits_them_otherwise() {
858            let crumb = Breadcrumb {
859                category: "controller".to_string(),
860                message: "GET /orders/42".to_string(),
861                level: "info".to_string(),
862                timestamp: "2024-01-15T10:29:58Z".to_string(),
863                data: HashMap::from([("status".to_string(), Value::Number(200.0))]),
864            };
865            let event = Event {
866                exception_class: "Error".to_string(),
867                message: "boom".to_string(),
868                backtrace: vec![],
869                occurred_at: "2024-01-15T10:30:00Z".to_string(),
870                environment: "production".to_string(),
871                release: None,
872                server_name: None,
873                context: HashMap::new(),
874                tags: HashMap::new(),
875                sdk_name: "rust".to_string(),
876                user: None,
877                breadcrumbs: vec![crumb],
878                sql_objects: None,
879                sql_statement: None,
880                trace_id: None,
881            };
882
883            let json = event.to_json();
884
885            assert!(json.contains(
886                "\"breadcrumbs\":[{\"category\":\"controller\",\"message\":\"GET /orders/42\",\"level\":\"info\",\"timestamp\":\"2024-01-15T10:29:58Z\",\"data\":{\"status\":200}}]"
887            ));
888
889            let without_breadcrumbs = Event {
890                breadcrumbs: vec![],
891                ..event
892            };
893            assert!(!without_breadcrumbs.to_json().contains("\"breadcrumbs\""));
894        }
895
896        #[test]
897        fn wire_format_includes_trace_id_when_present_and_omits_it_otherwise() {
898            let event = Event {
899                exception_class: "Error".to_string(),
900                message: "boom".to_string(),
901                backtrace: vec![],
902                occurred_at: "2024-01-15T10:30:00Z".to_string(),
903                environment: "production".to_string(),
904                release: None,
905                server_name: None,
906                context: HashMap::new(),
907                tags: HashMap::new(),
908                sdk_name: "rust".to_string(),
909                user: None,
910                breadcrumbs: vec![],
911                sql_objects: None,
912                sql_statement: None,
913                trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
914            };
915
916            assert!(event
917                .to_json()
918                .ends_with(",\"trace_id\":\"4bf92f3577b34da6a3ce929d0e0e4736\"}"));
919
920            let without_trace = Event {
921                trace_id: None,
922                ..event
923            };
924            assert!(!without_trace.to_json().contains("\"trace_id\""));
925        }
926    }
927
928    mod sql_capture {
929        use super::*;
930        use crate::Configuration;
931
932        fn build(config: &Configuration, sql: Option<&str>) -> Event {
933            EventBuilder::new(config).build_with_sql(
934                "Error",
935                "boom",
936                vec![],
937                HashMap::new(),
938                None,
939                vec![],
940                sql,
941            )
942        }
943
944        const SQL: &str = "EXEC dbo.sp_refund_order @order_id = 8814, @note = 'a@b.co'";
945
946        #[test]
947        fn sends_the_procedure_name_but_not_the_statement_by_default() {
948            let event = build(&Configuration::new(), Some(SQL));
949
950            assert_eq!(
951                event.sql_objects.unwrap().procedures,
952                vec!["dbo.sp_refund_order"]
953            );
954            assert_eq!(event.sql_statement, None);
955        }
956
957        #[test]
958        fn sends_the_masked_statement_when_opted_in() {
959            let mut config = Configuration::new();
960            config.capture_sql_statement = true;
961
962            let event = build(&config, Some(SQL));
963
964            assert_eq!(
965                event.sql_statement.as_deref(),
966                Some("EXEC dbo.sp_refund_order @order_id = ?, @note = ?")
967            );
968            assert!(event.to_json().contains(
969                "\"sql_statement\":\"EXEC dbo.sp_refund_order @order_id = ?, @note = ?\""
970            ));
971            assert!(event
972                .to_json()
973                .contains("\"sql_objects\":{\"operation\":\"EXEC\""));
974        }
975
976        #[test]
977        fn sends_nothing_when_both_are_off_or_no_sql_was_given() {
978            let mut config = Configuration::new();
979            config.capture_sql_objects = false;
980            let off = build(&config, Some(SQL));
981            assert!(off.sql_objects.is_none() && off.sql_statement.is_none());
982
983            let none = build(&Configuration::new(), None);
984            assert!(none.sql_objects.is_none() && none.sql_statement.is_none());
985            assert!(!none.to_json().contains("sql_"));
986        }
987    }
988}