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 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    /// Whether `EventBuilder` reads a few lines of source off disk around each in-app frame's
35    /// culprit line (see event_builder.rs's `attach_source_context`). Defaults to `true` so a
36    /// snippet shows up with zero extra setup, but this field isn't the durable protection
37    /// against literal source code leaving a deployment it shouldn't: ForgeOps' own per-project
38    /// setting is, since it applies server-side regardless of what any given app happens to have
39    /// this field set to locally. Set false here if this app should never even attempt the disk
40    /// read in the first place.
41    pub capture_source_context: bool,
42}
43
44impl Configuration {
45    /// Seeds a Configuration from FORGE_OPS_DSN/FORGE_OPS_ENVIRONMENT/FORGE_OPS_RELEASE and
46    /// sensible defaults for everything else: the same env vars and defaults every other client
47    /// in this repo reads.
48    pub fn new() -> Self {
49        let mut enabled_environments = HashSet::new();
50        enabled_environments.insert("production".to_string());
51        enabled_environments.insert("staging".to_string());
52
53        Configuration {
54            dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
55            environment: env::var("FORGE_OPS_ENVIRONMENT")
56                .unwrap_or_else(|_| "development".to_string()),
57            release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
58            server_name: safe_hostname(),
59            app_root: env::current_dir()
60                .ok()
61                .map(|p| p.to_string_lossy().into_owned()),
62            enabled_environments,
63            queue_size: 1000,
64            timeout: Duration::from_secs(2),
65            scrub_pii: true,
66            install_panic_hook: true,
67            capture_source_context: true,
68        }
69    }
70
71    /// The DSN's userinfo component, percent-decoded: None if the DSN is unset or malformed.
72    pub fn api_key(&self) -> Option<String> {
73        self.parsed_dsn().and_then(|d| d.api_key)
74    }
75
76    /// The ingestion URL with credentials stripped out: they travel as the Authorization header
77    /// instead, never embedded in the request URI.
78    pub fn ingestion_uri(&self) -> Option<String> {
79        self.parsed_dsn().map(|d| d.ingestion_uri)
80    }
81
82    pub fn is_enabled(&self) -> bool {
83        self.dsn.is_some()
84            && self.api_key().is_some()
85            && self.enabled_environments.contains(&self.environment)
86    }
87
88    fn parsed_dsn(&self) -> Option<ParsedDsn> {
89        self.dsn.as_deref().and_then(parse_dsn)
90    }
91}
92
93impl Default for Configuration {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99struct ParsedDsn {
100    api_key: Option<String>,
101    ingestion_uri: String,
102}
103
104/// Hand-parses a DSN of the form "scheme://api_key@host[:port]/path[?query]" rather than pulling
105/// in a URL-parsing crate: the shape is fixed and simple enough that a small dependency-free
106/// parser is clearer here than a general-purpose one, the same spirit as the Perl client's own
107/// dependency-free design.
108fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
109    let (scheme, rest) = dsn.split_once("://")?;
110    if scheme.is_empty() {
111        return None;
112    }
113    let (userinfo, host_and_path) = rest.split_once('@')?;
114    if userinfo.is_empty() || host_and_path.is_empty() {
115        return None;
116    }
117
118    let api_key = percent_decode(userinfo);
119    Some(ParsedDsn {
120        api_key: if api_key.is_empty() {
121            None
122        } else {
123            Some(api_key)
124        },
125        ingestion_uri: format!("{scheme}://{host_and_path}"),
126    })
127}
128
129/// Minimal percent-decoding for a DSN's userinfo component: the only place this client ever
130/// needs it, not a general-purpose URL decoder.
131fn percent_decode(s: &str) -> String {
132    let bytes = s.as_bytes();
133    let mut out = Vec::with_capacity(bytes.len());
134    let mut i = 0;
135    while i < bytes.len() {
136        if bytes[i] == b'%' && i + 2 < bytes.len() {
137            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
138                out.push(byte);
139                i += 3;
140                continue;
141            }
142        }
143        out.push(bytes[i]);
144        i += 1;
145    }
146    String::from_utf8_lossy(&out).into_owned()
147}
148
149/// std has no cross-platform hostname lookup: shells out to the `hostname` command (present on
150/// macOS, Linux, and Windows alike) rather than add a crate for one lookup done once at startup.
151/// Mirrors the Ruby gem's own Socket.gethostname wrapped in a rescue: any failure here yields
152/// None rather than being able to crash the host app.
153fn safe_hostname() -> Option<String> {
154    std::process::Command::new("hostname")
155        .output()
156        .ok()
157        .filter(|o| o.status.success())
158        .and_then(|o| String::from_utf8(o.stdout).ok())
159        .map(|s| s.trim().to_string())
160        .filter(|s| !s.is_empty())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn api_key_and_ingestion_uri() {
169        let config = Configuration {
170            dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
171            ..Configuration::new()
172        };
173
174        assert_eq!(config.api_key(), Some("abc123".to_string()));
175        assert_eq!(
176            config.ingestion_uri(),
177            Some("https://forgeops.example/api/v1/events".to_string())
178        );
179    }
180
181    #[test]
182    fn api_key_percent_decodes() {
183        let config = Configuration {
184            dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
185            ..Configuration::new()
186        };
187
188        assert_eq!(config.api_key(), Some("ab/c".to_string()));
189    }
190
191    #[test]
192    fn empty_or_malformed_dsn() {
193        for dsn in [
194            "",
195            "not-a-url",
196            "://broken",
197            "https://forgeops.example/no-userinfo",
198        ] {
199            let config = Configuration {
200                dsn: Some(dsn.to_string()),
201                ..Configuration::new()
202            };
203            assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
204            assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
205        }
206    }
207
208    #[test]
209    fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
210        let mut config = Configuration::new();
211        config.dsn = Some("https://key@host/path".to_string());
212
213        config.environment = "production".to_string();
214        assert!(config.is_enabled());
215
216        config.environment = "development".to_string();
217        assert!(!config.is_enabled());
218
219        config.environment = "production".to_string();
220        config.dsn = None;
221        assert!(!config.is_enabled());
222    }
223
224    #[test]
225    fn defaults() {
226        let config = Configuration::new();
227        assert_eq!(config.queue_size, 1000);
228        assert_eq!(config.timeout, Duration::from_secs(2));
229        assert!(config.scrub_pii);
230        assert!(config.capture_source_context);
231        assert!(config.enabled_environments.contains("production"));
232        assert!(config.enabled_environments.contains("staging"));
233    }
234}