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