Skip to main content

forge_ops_tracker/
configuration.rs

1use std::collections::HashSet;
2use std::env;
3use std::time::Duration;
4
5/// Holds a single ForgeOps DSN plus everything else the client needs to build and deliver events.
6/// Mirrors gems/forge_ops_tracker's Configuration -- a single Sentry-style DSN string carries both
7/// the ingestion URL and the project's API key: "https://<api_key>@host/api/v1/events".
8pub struct Configuration {
9    pub dsn: Option<String>,
10    pub environment: String,
11    pub release: Option<String>,
12    pub server_name: Option<String>,
13
14    /// Decides whether a backtrace frame is "in_app": a frame's file path is compared against
15    /// this root, the same file-path matching the Ruby gem does against Rails.root and the Python
16    /// client does against os.getcwd(). A Rust binary built with debug info embeds real
17    /// build-time source paths, so the same approach works here too. Defaults to the current
18    /// working directory; set it explicitly if that doesn't match your app's actual layout.
19    pub app_root: Option<String>,
20
21    pub enabled_environments: HashSet<String>,
22    pub queue_size: usize,
23    pub timeout: Duration,
24    pub scrub_pii: bool,
25
26    /// Whether `init()` installs the global panic hook (see lib.rs's install_panic_hook) that
27    /// reports anything that panics on any thread, with zero further wiring -- the same
28    /// "unhandled needs no wiring" case Rails.error/ASP.NET Core's middleware and Python's
29    /// excepthook wrapper cover automatically for their own languages. Doesn't change panic
30    /// behavior (the previously-installed hook still runs afterward), so on by default is safe;
31    /// set false to opt out.
32    pub install_panic_hook: bool,
33}
34
35impl Configuration {
36    /// Seeds a Configuration from FORGE_OPS_DSN/FORGE_OPS_ENVIRONMENT/FORGE_OPS_RELEASE and
37    /// sensible defaults for everything else -- the same env vars and defaults every other client
38    /// in this repo reads.
39    pub fn new() -> Self {
40        let mut enabled_environments = HashSet::new();
41        enabled_environments.insert("production".to_string());
42        enabled_environments.insert("staging".to_string());
43
44        Configuration {
45            dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
46            environment: env::var("FORGE_OPS_ENVIRONMENT")
47                .unwrap_or_else(|_| "development".to_string()),
48            release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
49            server_name: safe_hostname(),
50            app_root: env::current_dir()
51                .ok()
52                .map(|p| p.to_string_lossy().into_owned()),
53            enabled_environments,
54            queue_size: 1000,
55            timeout: Duration::from_secs(2),
56            scrub_pii: true,
57            install_panic_hook: true,
58        }
59    }
60
61    /// The DSN's userinfo component, percent-decoded -- None if the DSN is unset or malformed.
62    pub fn api_key(&self) -> Option<String> {
63        self.parsed_dsn().and_then(|d| d.api_key)
64    }
65
66    /// The ingestion URL with credentials stripped out -- they travel as the Authorization header
67    /// instead, never embedded in the request URI.
68    pub fn ingestion_uri(&self) -> Option<String> {
69        self.parsed_dsn().map(|d| d.ingestion_uri)
70    }
71
72    pub fn is_enabled(&self) -> bool {
73        self.dsn.is_some()
74            && self.api_key().is_some()
75            && self.enabled_environments.contains(&self.environment)
76    }
77
78    fn parsed_dsn(&self) -> Option<ParsedDsn> {
79        self.dsn.as_deref().and_then(parse_dsn)
80    }
81}
82
83impl Default for Configuration {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89struct ParsedDsn {
90    api_key: Option<String>,
91    ingestion_uri: String,
92}
93
94/// Hand-parses a DSN of the form "scheme://api_key@host[:port]/path[?query]" rather than pulling
95/// in a URL-parsing crate -- the shape is fixed and simple enough that a small dependency-free
96/// parser is clearer here than a general-purpose one, the same spirit as the Perl client's own
97/// dependency-free design.
98fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
99    let (scheme, rest) = dsn.split_once("://")?;
100    if scheme.is_empty() {
101        return None;
102    }
103    let (userinfo, host_and_path) = rest.split_once('@')?;
104    if userinfo.is_empty() || host_and_path.is_empty() {
105        return None;
106    }
107
108    let api_key = percent_decode(userinfo);
109    Some(ParsedDsn {
110        api_key: if api_key.is_empty() {
111            None
112        } else {
113            Some(api_key)
114        },
115        ingestion_uri: format!("{scheme}://{host_and_path}"),
116    })
117}
118
119/// Minimal percent-decoding for a DSN's userinfo component -- the only place this client ever
120/// needs it, not a general-purpose URL decoder.
121fn percent_decode(s: &str) -> String {
122    let bytes = s.as_bytes();
123    let mut out = Vec::with_capacity(bytes.len());
124    let mut i = 0;
125    while i < bytes.len() {
126        if bytes[i] == b'%' && i + 2 < bytes.len() {
127            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
128                out.push(byte);
129                i += 3;
130                continue;
131            }
132        }
133        out.push(bytes[i]);
134        i += 1;
135    }
136    String::from_utf8_lossy(&out).into_owned()
137}
138
139/// std has no cross-platform hostname lookup -- shells out to the `hostname` command (present on
140/// macOS, Linux, and Windows alike) rather than add a crate for one lookup done once at startup.
141/// Mirrors the Ruby gem's own Socket.gethostname wrapped in a rescue: any failure here yields
142/// None rather than being able to crash the host app.
143fn safe_hostname() -> Option<String> {
144    std::process::Command::new("hostname")
145        .output()
146        .ok()
147        .filter(|o| o.status.success())
148        .and_then(|o| String::from_utf8(o.stdout).ok())
149        .map(|s| s.trim().to_string())
150        .filter(|s| !s.is_empty())
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn api_key_and_ingestion_uri() {
159        let config = Configuration {
160            dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
161            ..Configuration::new()
162        };
163
164        assert_eq!(config.api_key(), Some("abc123".to_string()));
165        assert_eq!(
166            config.ingestion_uri(),
167            Some("https://forgeops.example/api/v1/events".to_string())
168        );
169    }
170
171    #[test]
172    fn api_key_percent_decodes() {
173        let config = Configuration {
174            dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
175            ..Configuration::new()
176        };
177
178        assert_eq!(config.api_key(), Some("ab/c".to_string()));
179    }
180
181    #[test]
182    fn empty_or_malformed_dsn() {
183        for dsn in [
184            "",
185            "not-a-url",
186            "://broken",
187            "https://forgeops.example/no-userinfo",
188        ] {
189            let config = Configuration {
190                dsn: Some(dsn.to_string()),
191                ..Configuration::new()
192            };
193            assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
194            assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
195        }
196    }
197
198    #[test]
199    fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
200        let mut config = Configuration::new();
201        config.dsn = Some("https://key@host/path".to_string());
202
203        config.environment = "production".to_string();
204        assert!(config.is_enabled());
205
206        config.environment = "development".to_string();
207        assert!(!config.is_enabled());
208
209        config.environment = "production".to_string();
210        config.dsn = None;
211        assert!(!config.is_enabled());
212    }
213
214    #[test]
215    fn defaults() {
216        let config = Configuration::new();
217        assert_eq!(config.queue_size, 1000);
218        assert_eq!(config.timeout, Duration::from_secs(2));
219        assert!(config.scrub_pii);
220        assert!(config.enabled_environments.contains("production"));
221        assert!(config.enabled_environments.contains("staging"));
222    }
223}