forge-ops-tracker 0.2.0

Rust error reporting client for a ForgeOps instance.
Documentation
use std::collections::HashSet;
use std::env;
use std::time::Duration;

/// Holds a single ForgeOps DSN plus everything else the client needs to build and deliver events.
/// Mirrors gems/forge_ops_tracker's Configuration: a single DSN string carries both
/// the ingestion URL and the project's API key: "https://<api_key>@host/api/v1/events".
pub struct Configuration {
    pub dsn: Option<String>,
    pub environment: String,
    pub release: Option<String>,
    pub server_name: Option<String>,

    /// Decides whether a backtrace frame is "in_app": a frame's file path is compared against
    /// this root, the same file-path matching the Ruby gem does against Rails.root and the Python
    /// client does against os.getcwd(). A Rust binary built with debug info embeds real
    /// build-time source paths, so the same approach works here too. Defaults to the current
    /// working directory; set it explicitly if that doesn't match your app's actual layout.
    pub app_root: Option<String>,

    pub enabled_environments: HashSet<String>,
    pub queue_size: usize,
    pub timeout: Duration,
    pub scrub_pii: bool,

    /// Whether `init()` installs the global panic hook (see lib.rs's install_panic_hook) that
    /// reports anything that panics on any thread, with zero further wiring: the same
    /// "unhandled needs no wiring" case Rails.error/ASP.NET Core's middleware and Python's
    /// excepthook wrapper cover automatically for their own languages. Doesn't change panic
    /// behavior (the previously-installed hook still runs afterward), so on by default is safe;
    /// set false to opt out.
    pub install_panic_hook: bool,

    /// Whether `EventBuilder` reads a few lines of source off disk around each in-app frame's
    /// culprit line (see event_builder.rs's `attach_source_context`). Defaults to `true` so a
    /// snippet shows up with zero extra setup, but this field isn't the durable protection
    /// against literal source code leaving a deployment it shouldn't: ForgeOps' own per-project
    /// setting is, since it applies server-side regardless of what any given app happens to have
    /// this field set to locally. Set false here if this app should never even attempt the disk
    /// read in the first place.
    pub capture_source_context: bool,
}

impl Configuration {
    /// Seeds a Configuration from FORGE_OPS_DSN/FORGE_OPS_ENVIRONMENT/FORGE_OPS_RELEASE and
    /// sensible defaults for everything else: the same env vars and defaults every other client
    /// in this repo reads.
    pub fn new() -> Self {
        let mut enabled_environments = HashSet::new();
        enabled_environments.insert("production".to_string());
        enabled_environments.insert("staging".to_string());

        Configuration {
            dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
            environment: env::var("FORGE_OPS_ENVIRONMENT")
                .unwrap_or_else(|_| "development".to_string()),
            release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
            server_name: safe_hostname(),
            app_root: env::current_dir()
                .ok()
                .map(|p| p.to_string_lossy().into_owned()),
            enabled_environments,
            queue_size: 1000,
            timeout: Duration::from_secs(2),
            scrub_pii: true,
            install_panic_hook: true,
            capture_source_context: true,
        }
    }

    /// The DSN's userinfo component, percent-decoded: None if the DSN is unset or malformed.
    pub fn api_key(&self) -> Option<String> {
        self.parsed_dsn().and_then(|d| d.api_key)
    }

    /// The ingestion URL with credentials stripped out: they travel as the Authorization header
    /// instead, never embedded in the request URI.
    pub fn ingestion_uri(&self) -> Option<String> {
        self.parsed_dsn().map(|d| d.ingestion_uri)
    }

    pub fn is_enabled(&self) -> bool {
        self.dsn.is_some()
            && self.api_key().is_some()
            && self.enabled_environments.contains(&self.environment)
    }

    fn parsed_dsn(&self) -> Option<ParsedDsn> {
        self.dsn.as_deref().and_then(parse_dsn)
    }
}

impl Default for Configuration {
    fn default() -> Self {
        Self::new()
    }
}

struct ParsedDsn {
    api_key: Option<String>,
    ingestion_uri: String,
}

/// Hand-parses a DSN of the form "scheme://api_key@host[:port]/path[?query]" rather than pulling
/// in a URL-parsing crate: the shape is fixed and simple enough that a small dependency-free
/// parser is clearer here than a general-purpose one, the same spirit as the Perl client's own
/// dependency-free design.
fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
    let (scheme, rest) = dsn.split_once("://")?;
    if scheme.is_empty() {
        return None;
    }
    let (userinfo, host_and_path) = rest.split_once('@')?;
    if userinfo.is_empty() || host_and_path.is_empty() {
        return None;
    }

    let api_key = percent_decode(userinfo);
    Some(ParsedDsn {
        api_key: if api_key.is_empty() {
            None
        } else {
            Some(api_key)
        },
        ingestion_uri: format!("{scheme}://{host_and_path}"),
    })
}

/// Minimal percent-decoding for a DSN's userinfo component: the only place this client ever
/// needs it, not a general-purpose URL decoder.
fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
                out.push(byte);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// std has no cross-platform hostname lookup: shells out to the `hostname` command (present on
/// macOS, Linux, and Windows alike) rather than add a crate for one lookup done once at startup.
/// Mirrors the Ruby gem's own Socket.gethostname wrapped in a rescue: any failure here yields
/// None rather than being able to crash the host app.
fn safe_hostname() -> Option<String> {
    std::process::Command::new("hostname")
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

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

    #[test]
    fn api_key_and_ingestion_uri() {
        let config = Configuration {
            dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
            ..Configuration::new()
        };

        assert_eq!(config.api_key(), Some("abc123".to_string()));
        assert_eq!(
            config.ingestion_uri(),
            Some("https://forgeops.example/api/v1/events".to_string())
        );
    }

    #[test]
    fn api_key_percent_decodes() {
        let config = Configuration {
            dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
            ..Configuration::new()
        };

        assert_eq!(config.api_key(), Some("ab/c".to_string()));
    }

    #[test]
    fn empty_or_malformed_dsn() {
        for dsn in [
            "",
            "not-a-url",
            "://broken",
            "https://forgeops.example/no-userinfo",
        ] {
            let config = Configuration {
                dsn: Some(dsn.to_string()),
                ..Configuration::new()
            };
            assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
            assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
        }
    }

    #[test]
    fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
        let mut config = Configuration::new();
        config.dsn = Some("https://key@host/path".to_string());

        config.environment = "production".to_string();
        assert!(config.is_enabled());

        config.environment = "development".to_string();
        assert!(!config.is_enabled());

        config.environment = "production".to_string();
        config.dsn = None;
        assert!(!config.is_enabled());
    }

    #[test]
    fn defaults() {
        let config = Configuration::new();
        assert_eq!(config.queue_size, 1000);
        assert_eq!(config.timeout, Duration::from_secs(2));
        assert!(config.scrub_pii);
        assert!(config.capture_source_context);
        assert!(config.enabled_environments.contains("production"));
        assert!(config.enabled_environments.contains("staging"));
    }
}