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#[derive(Clone, Debug, PartialEq)]
22pub struct Frame {
23    pub file: String,
24    pub line: u32,
25    pub method: String,
26    pub in_app: bool,
27}
28
29#[derive(Clone, Debug)]
30pub struct Event {
31    pub exception_class: String,
32    pub message: String,
33    pub backtrace: Vec<Frame>,
34    pub occurred_at: String,
35    pub environment: String,
36    pub release: Option<String>,
37    pub server_name: Option<String>,
38    pub context: HashMap<String, Value>,
39    pub tags: HashMap<String, Value>,
40}
41
42impl Event {
43    pub fn to_json(&self) -> String {
44        use crate::pii_scrubber::json_string;
45
46        let backtrace: Vec<String> = self
47            .backtrace
48            .iter()
49            .map(|f| {
50                format!(
51                    "{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}}}",
52                    json_string(&f.file),
53                    f.line,
54                    json_string(&f.method),
55                    f.in_app
56                )
57            })
58            .collect();
59
60        let optional_string = |v: &Option<String>| {
61            v.as_deref()
62                .map(json_string)
63                .unwrap_or_else(|| "null".to_string())
64        };
65
66        format!(
67            "{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{}}}",
68            json_string(&self.exception_class),
69            json_string(&self.message),
70            backtrace.join(","),
71            json_string(&self.occurred_at),
72            json_string(&self.environment),
73            optional_string(&self.release),
74            optional_string(&self.server_name),
75            Value::Object(self.context.clone()).to_json(),
76            Value::Object(self.tags.clone()).to_json()
77        )
78    }
79}
80
81pub struct EventBuilder<'a> {
82    configuration: &'a Configuration,
83}
84
85impl<'a> EventBuilder<'a> {
86    pub fn new(configuration: &'a Configuration) -> Self {
87        EventBuilder { configuration }
88    }
89
90    pub fn build(
91        &self,
92        exception_class: &str,
93        message: &str,
94        backtrace: Vec<Frame>,
95        context: HashMap<String, Value>,
96    ) -> Event {
97        let mut event = Event {
98            exception_class: exception_class.to_string(),
99            message: message.to_string(),
100            backtrace,
101            occurred_at: format_now(),
102            environment: self.configuration.environment.clone(),
103            release: self.configuration.release.clone(),
104            server_name: self.configuration.server_name.clone(),
105            context,
106            tags: HashMap::new(),
107        };
108
109        if self.configuration.scrub_pii {
110            event = scrub_event(event);
111        }
112        event
113    }
114}
115
116// exception_class/occurred_at/environment/release/server_name are left alone -- structured fields
117// this client or the host app sets deliberately, not free text an error or its context could
118// accidentally spill sensitive data into.
119fn scrub_event(mut event: Event) -> Event {
120    event.message = scrub_string(&event.message);
121    event.backtrace = event
122        .backtrace
123        .into_iter()
124        .map(|f| Frame {
125            file: scrub_string(&f.file),
126            method: scrub_string(&f.method),
127            ..f
128        })
129        .collect();
130
131    let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
132        unreachable!()
133    };
134    event.context = context;
135    let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
136        unreachable!()
137    };
138    event.tags = tags;
139    event
140}
141
142/// Captures the current call stack via the `backtrace` crate, called at the point CaptureError/
143/// the panic hook fires -- this client captures the stack at the call site rather than from the
144/// error value itself, since a plain Rust `std::error::Error` carries no stack of its own, unlike
145/// Python's traceback or Java's Throwable, which travel with the exception.
146pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
147    let bt = backtrace::Backtrace::new();
148    let mut frames = Vec::new();
149    let mut seen_app_frame = false;
150
151    'frames: for frame in bt.frames() {
152        for symbol in frame.symbols() {
153            let name = symbol
154                .name()
155                .map(|n| n.to_string())
156                .unwrap_or_else(|| "<unknown>".to_string());
157
158            // Skip this SDK's own frames -- capture_backtrace/capture_error/the panic hook's own
159            // call chain adds no diagnostic value, the same reason interpreter-internal frames
160            // never show up in a Python traceback. Only skipped until real caller code is
161            // reached, so a host app frame that happens to also start with this crate's own name
162            // (unlikely in practice) still gets included once we're past the SDK's own plumbing.
163            if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
164                continue;
165            }
166            seen_app_frame = true;
167
168            let file = symbol
169                .filename()
170                .map(|p| p.to_string_lossy().into_owned())
171                .unwrap_or_default();
172            let line = symbol.lineno().unwrap_or(0);
173            let in_app = is_in_app(configuration, &file);
174            frames.push(Frame {
175                file,
176                line,
177                method: name,
178                in_app,
179            });
180
181            if frames.len() >= MAX_FRAMES {
182                break 'frames;
183            }
184        }
185    }
186    frames
187}
188
189fn is_in_app(configuration: &Configuration, file: &str) -> bool {
190    let root = match &configuration.app_root {
191        Some(r) if !r.is_empty() => r,
192        _ => return false,
193    };
194    if file.is_empty() || !file.starts_with(root.as_str()) {
195        return false;
196    }
197    // Third-party crate source under Cargo's registry, and the Rust toolchain's own std/core
198    // source under a rustc sysroot path, are never in_app regardless of app_root -- the same role
199    // site-packages/dist-packages plays for Python and the module cache plays for Go.
200    !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
201}
202
203fn format_now() -> String {
204    let secs = SystemTime::now()
205        .duration_since(UNIX_EPOCH)
206        .map(|d| d.as_secs())
207        .unwrap_or(0);
208    format_unix_timestamp(secs)
209}
210
211/// Formats a Unix timestamp as "YYYY-MM-DDTHH:MM:SSZ" using plain civil-calendar arithmetic (the
212/// well-known "days from civil" algorithm) rather than a datetime crate -- std has no calendar
213/// formatting at all, but this client only ever needs UTC-and-this-one-format, so the smallest
214/// possible amount of arithmetic beats adding a dependency for it.
215fn format_unix_timestamp(secs: u64) -> String {
216    let days = secs / 86400;
217    let time_of_day = secs % 86400;
218    let (hour, minute, second) = (
219        time_of_day / 3600,
220        (time_of_day % 3600) / 60,
221        time_of_day % 60,
222    );
223
224    let (year, month, day) = civil_from_days(days as i64);
225    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
226}
227
228/// Howard Hinnant's "days from civil" algorithm, run in reverse (civil_from_days) -- a standard,
229/// widely-published constant-time conversion from a day count (here, since the Unix epoch) to a
230/// proleptic-Gregorian (year, month, day), used here purely for its adaptation as the
231/// well-documented public-domain algorithm it is at https://howardhinnant.github.io/date_algorithms.html.
232fn civil_from_days(z: i64) -> (i64, u32, u32) {
233    let z = z + 719468;
234    let era = if z >= 0 { z } else { z - 146096 } / 146097;
235    let doe = (z - era * 146097) as u64;
236    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
237    let y = yoe as i64 + era * 400;
238    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
239    let mp = (5 * doy + 2) / 153;
240    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
241    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
242    let year = if m <= 2 { y + 1 } else { y };
243    (year, m, d)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn test_configuration() -> Configuration {
251        Configuration {
252            environment: "production".to_string(),
253            release: Some("a1b2c3d".to_string()),
254            server_name: Some("test-host".to_string()),
255            app_root: Some("/app".to_string()),
256            scrub_pii: true,
257            ..Configuration::new()
258        }
259    }
260
261    #[test]
262    fn build_basic_fields() {
263        let config = test_configuration();
264        let builder = EventBuilder::new(&config);
265        let mut context = HashMap::new();
266        context.insert("order_id".to_string(), Value::Number(42.0));
267
268        let event = builder.build("std::io::Error", "boom", vec![], context);
269
270        assert_eq!(event.message, "boom");
271        assert_eq!(event.environment, "production");
272        assert_eq!(event.release, Some("a1b2c3d".to_string()));
273        assert_eq!(event.server_name, Some("test-host".to_string()));
274        assert_eq!(event.context["order_id"], Value::Number(42.0));
275    }
276
277    #[test]
278    fn build_scrubs_message_and_context_when_enabled() {
279        let config = test_configuration();
280        let builder = EventBuilder::new(&config);
281        let mut context = HashMap::new();
282        context.insert(
283            "api_key".to_string(),
284            Value::String("shh-secret".to_string()),
285        );
286
287        let event = builder.build(
288            "Error",
289            "failed to charge user@example.com",
290            vec![],
291            context,
292        );
293
294        assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
295        assert_eq!(
296            event.context["api_key"],
297            Value::String(crate::pii_scrubber::REDACTED.to_string())
298        );
299    }
300
301    #[test]
302    fn build_does_not_scrub_when_disabled() {
303        let mut config = test_configuration();
304        config.scrub_pii = false;
305        let builder = EventBuilder::new(&config);
306
307        let event = builder.build("Error", "contact user@example.com", vec![], HashMap::new());
308
309        assert_eq!(event.message, "contact user@example.com");
310    }
311
312    #[test]
313    fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
314        let config = test_configuration();
315        assert!(is_in_app(&config, "/app/src/main.rs"));
316        assert!(!is_in_app(&config, "/other/src/main.rs"));
317        assert!(!is_in_app(
318            &config,
319            "/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
320        ));
321        assert!(!is_in_app(&config, ""));
322    }
323
324    #[test]
325    fn capture_backtrace_excludes_this_crates_own_frames() {
326        let config = test_configuration();
327        let frames = capture_backtrace(&config);
328
329        assert!(!frames.is_empty(), "expected at least one backtrace frame");
330        for frame in &frames {
331            assert!(
332                !frame.method.starts_with(CRATE_PREFIX),
333                "frame {:?} should have been filtered out as SDK-internal",
334                frame.method
335            );
336        }
337    }
338
339    #[test]
340    fn format_unix_timestamp_known_value() {
341        // 2024-01-15T10:30:00Z
342        assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
343        // The Unix epoch itself.
344        assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
345    }
346}