forge-ops-tracker 0.1.1

Rust error reporting client for a private, self-hosted ForgeOps tracker instance.
Documentation
// Turns a reported error/panic into the payload shape the ingestion API expects. Ported from
// gems/forge_ops_tracker/lib/forge_ops_tracker/event_builder.rb -- backtrace frames come from the
// `backtrace` crate rather than regex-parsing MRI backtrace lines, but the resulting shape
// (file/line/method/in_app) is the same.

use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::configuration::Configuration;
use crate::pii_scrubber::{scrub_string, scrub_value, Value};

/// Caps how many backtrace frames a single event carries, the same limit every other client in
/// this repo applies.
pub const MAX_FRAMES: usize = 500;

/// This crate's own module path prefix, as `backtrace`'s symbol names render it -- used to skip
/// this SDK's own leading frames the same way every other client's backtrace builder excludes its
/// own internals.
const CRATE_PREFIX: &str = "forge_ops_tracker::";

#[derive(Clone, Debug, PartialEq)]
pub struct Frame {
    pub file: String,
    pub line: u32,
    pub method: String,
    pub in_app: bool,
}

#[derive(Clone, Debug)]
pub struct Event {
    pub exception_class: String,
    pub message: String,
    pub backtrace: Vec<Frame>,
    pub occurred_at: String,
    pub environment: String,
    pub release: Option<String>,
    pub server_name: Option<String>,
    pub context: HashMap<String, Value>,
    pub tags: HashMap<String, Value>,
}

impl Event {
    pub fn to_json(&self) -> String {
        use crate::pii_scrubber::json_string;

        let backtrace: Vec<String> = self
            .backtrace
            .iter()
            .map(|f| {
                format!(
                    "{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}}}",
                    json_string(&f.file),
                    f.line,
                    json_string(&f.method),
                    f.in_app
                )
            })
            .collect();

        let optional_string = |v: &Option<String>| {
            v.as_deref()
                .map(json_string)
                .unwrap_or_else(|| "null".to_string())
        };

        format!(
            "{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{}}}",
            json_string(&self.exception_class),
            json_string(&self.message),
            backtrace.join(","),
            json_string(&self.occurred_at),
            json_string(&self.environment),
            optional_string(&self.release),
            optional_string(&self.server_name),
            Value::Object(self.context.clone()).to_json(),
            Value::Object(self.tags.clone()).to_json()
        )
    }
}

pub struct EventBuilder<'a> {
    configuration: &'a Configuration,
}

impl<'a> EventBuilder<'a> {
    pub fn new(configuration: &'a Configuration) -> Self {
        EventBuilder { configuration }
    }

    pub fn build(
        &self,
        exception_class: &str,
        message: &str,
        backtrace: Vec<Frame>,
        context: HashMap<String, Value>,
    ) -> Event {
        let mut event = Event {
            exception_class: exception_class.to_string(),
            message: message.to_string(),
            backtrace,
            occurred_at: format_now(),
            environment: self.configuration.environment.clone(),
            release: self.configuration.release.clone(),
            server_name: self.configuration.server_name.clone(),
            context,
            tags: HashMap::new(),
        };

        if self.configuration.scrub_pii {
            event = scrub_event(event);
        }
        event
    }
}

// exception_class/occurred_at/environment/release/server_name are left alone -- structured fields
// this client or the host app sets deliberately, not free text an error or its context could
// accidentally spill sensitive data into.
fn scrub_event(mut event: Event) -> Event {
    event.message = scrub_string(&event.message);
    event.backtrace = event
        .backtrace
        .into_iter()
        .map(|f| Frame {
            file: scrub_string(&f.file),
            method: scrub_string(&f.method),
            ..f
        })
        .collect();

    let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
        unreachable!()
    };
    event.context = context;
    let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
        unreachable!()
    };
    event.tags = tags;
    event
}

/// Captures the current call stack via the `backtrace` crate, called at the point CaptureError/
/// the panic hook fires -- this client captures the stack at the call site rather than from the
/// error value itself, since a plain Rust `std::error::Error` carries no stack of its own, unlike
/// Python's traceback or Java's Throwable, which travel with the exception.
pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
    let bt = backtrace::Backtrace::new();
    let mut frames = Vec::new();
    let mut seen_app_frame = false;

    'frames: for frame in bt.frames() {
        for symbol in frame.symbols() {
            let name = symbol
                .name()
                .map(|n| n.to_string())
                .unwrap_or_else(|| "<unknown>".to_string());

            // Skip this SDK's own frames -- capture_backtrace/capture_error/the panic hook's own
            // call chain adds no diagnostic value, the same reason interpreter-internal frames
            // never show up in a Python traceback. Only skipped until real caller code is
            // reached, so a host app frame that happens to also start with this crate's own name
            // (unlikely in practice) still gets included once we're past the SDK's own plumbing.
            if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
                continue;
            }
            seen_app_frame = true;

            let file = symbol
                .filename()
                .map(|p| p.to_string_lossy().into_owned())
                .unwrap_or_default();
            let line = symbol.lineno().unwrap_or(0);
            let in_app = is_in_app(configuration, &file);
            frames.push(Frame {
                file,
                line,
                method: name,
                in_app,
            });

            if frames.len() >= MAX_FRAMES {
                break 'frames;
            }
        }
    }
    frames
}

fn is_in_app(configuration: &Configuration, file: &str) -> bool {
    let root = match &configuration.app_root {
        Some(r) if !r.is_empty() => r,
        _ => return false,
    };
    if file.is_empty() || !file.starts_with(root.as_str()) {
        return false;
    }
    // Third-party crate source under Cargo's registry, and the Rust toolchain's own std/core
    // source under a rustc sysroot path, are never in_app regardless of app_root -- the same role
    // site-packages/dist-packages plays for Python and the module cache plays for Go.
    !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
}

fn format_now() -> String {
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    format_unix_timestamp(secs)
}

/// Formats a Unix timestamp as "YYYY-MM-DDTHH:MM:SSZ" using plain civil-calendar arithmetic (the
/// well-known "days from civil" algorithm) rather than a datetime crate -- std has no calendar
/// formatting at all, but this client only ever needs UTC-and-this-one-format, so the smallest
/// possible amount of arithmetic beats adding a dependency for it.
fn format_unix_timestamp(secs: u64) -> String {
    let days = secs / 86400;
    let time_of_day = secs % 86400;
    let (hour, minute, second) = (
        time_of_day / 3600,
        (time_of_day % 3600) / 60,
        time_of_day % 60,
    );

    let (year, month, day) = civil_from_days(days as i64);
    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

/// Howard Hinnant's "days from civil" algorithm, run in reverse (civil_from_days) -- a standard,
/// widely-published constant-time conversion from a day count (here, since the Unix epoch) to a
/// proleptic-Gregorian (year, month, day), used here purely for its adaptation as the
/// well-documented public-domain algorithm it is at https://howardhinnant.github.io/date_algorithms.html.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = (z - era * 146097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
    let year = if m <= 2 { y + 1 } else { y };
    (year, m, d)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_configuration() -> Configuration {
        Configuration {
            environment: "production".to_string(),
            release: Some("a1b2c3d".to_string()),
            server_name: Some("test-host".to_string()),
            app_root: Some("/app".to_string()),
            scrub_pii: true,
            ..Configuration::new()
        }
    }

    #[test]
    fn build_basic_fields() {
        let config = test_configuration();
        let builder = EventBuilder::new(&config);
        let mut context = HashMap::new();
        context.insert("order_id".to_string(), Value::Number(42.0));

        let event = builder.build("std::io::Error", "boom", vec![], context);

        assert_eq!(event.message, "boom");
        assert_eq!(event.environment, "production");
        assert_eq!(event.release, Some("a1b2c3d".to_string()));
        assert_eq!(event.server_name, Some("test-host".to_string()));
        assert_eq!(event.context["order_id"], Value::Number(42.0));
    }

    #[test]
    fn build_scrubs_message_and_context_when_enabled() {
        let config = test_configuration();
        let builder = EventBuilder::new(&config);
        let mut context = HashMap::new();
        context.insert(
            "api_key".to_string(),
            Value::String("shh-secret".to_string()),
        );

        let event = builder.build(
            "Error",
            "failed to charge user@example.com",
            vec![],
            context,
        );

        assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
        assert_eq!(
            event.context["api_key"],
            Value::String(crate::pii_scrubber::REDACTED.to_string())
        );
    }

    #[test]
    fn build_does_not_scrub_when_disabled() {
        let mut config = test_configuration();
        config.scrub_pii = false;
        let builder = EventBuilder::new(&config);

        let event = builder.build("Error", "contact user@example.com", vec![], HashMap::new());

        assert_eq!(event.message, "contact user@example.com");
    }

    #[test]
    fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
        let config = test_configuration();
        assert!(is_in_app(&config, "/app/src/main.rs"));
        assert!(!is_in_app(&config, "/other/src/main.rs"));
        assert!(!is_in_app(
            &config,
            "/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
        ));
        assert!(!is_in_app(&config, ""));
    }

    #[test]
    fn capture_backtrace_excludes_this_crates_own_frames() {
        let config = test_configuration();
        let frames = capture_backtrace(&config);

        assert!(!frames.is_empty(), "expected at least one backtrace frame");
        for frame in &frames {
            assert!(
                !frame.method.starts_with(CRATE_PREFIX),
                "frame {:?} should have been filtered out as SDK-internal",
                frame.method
            );
        }
    }

    #[test]
    fn format_unix_timestamp_known_value() {
        // 2024-01-15T10:30:00Z
        assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
        // The Unix epoch itself.
        assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
    }
}