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    /// Whether `add_breadcrumb` actually records anything, and whether a report reads the current
44    /// thread's trail back at all: `add_breadcrumb` itself never panics or errors when this is
45    /// false, it just becomes a no-op, the same "the call site never has to check first" posture
46    /// every other independent tracking mechanism in this crate already has. On by default.
47    pub track_breadcrumbs: bool,
48    /// The most recent entries a single thread's trail keeps; the oldest is dropped once full.
49    /// Matches gems/forge_ops_tracker's own default exactly.
50    pub max_breadcrumbs: usize,
51
52    /// Whether `record_performance`/`time_transaction` time anything at all. On by default, the
53    /// same "on unless you turn it off" posture error reporting itself already has. This crate has
54    /// no web framework integration, so nothing is timed automatically: this only gates the manual
55    /// API below.
56    pub track_performance: bool,
57    /// How often the in-process tallies are flushed as one small aggregate report, rather than one
58    /// network call per timed call. Matches gems/forge_ops_tracker's own default (60s).
59    pub performance_flush_interval: Duration,
60
61    /// Whether `trace` starts a trace and reports it (when slow) to `/spans`. `span` and
62    /// `record_span` only record inside a trace, so this gates the whole feature. This crate has no
63    /// web framework integration, so nothing starts a trace automatically.
64    pub track_tracing: bool,
65    /// How often the buffered `capture_metric` entries are flushed as one batch. There is no
66    /// `track_metrics` flag the way `track_performance` has one: these are explicit calls the host
67    /// app's own code makes, not automatic instrumentation, so there is nothing to turn off that
68    /// simply not calling them doesn't already do.
69    pub metric_flush_interval: Duration,
70    /// The same for `capture_infrastructure_metric`.
71    pub infrastructure_metric_flush_interval: Duration,
72    /// A trace is only sent when its root span took at least this long.
73    pub trace_capture_threshold: Duration,
74}
75
76impl Configuration {
77    /// Seeds a Configuration from FORGE_OPS_DSN/FORGE_OPS_ENVIRONMENT/FORGE_OPS_RELEASE and
78    /// sensible defaults for everything else: the same env vars and defaults every other client
79    /// in this repo reads.
80    pub fn new() -> Self {
81        let mut enabled_environments = HashSet::new();
82        enabled_environments.insert("production".to_string());
83        enabled_environments.insert("staging".to_string());
84
85        Configuration {
86            dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
87            environment: env::var("FORGE_OPS_ENVIRONMENT")
88                .unwrap_or_else(|_| "development".to_string()),
89            release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
90            server_name: safe_hostname(),
91            app_root: env::current_dir()
92                .ok()
93                .map(|p| p.to_string_lossy().into_owned()),
94            enabled_environments,
95            queue_size: 1000,
96            timeout: Duration::from_secs(2),
97            scrub_pii: true,
98            install_panic_hook: true,
99            capture_source_context: true,
100            track_breadcrumbs: true,
101            max_breadcrumbs: 30,
102            track_performance: true,
103            performance_flush_interval: Duration::from_secs(60),
104            track_tracing: true,
105            metric_flush_interval: Duration::from_secs(60),
106            infrastructure_metric_flush_interval: Duration::from_secs(60),
107            trace_capture_threshold: Duration::from_secs(1),
108        }
109    }
110
111    /// The DSN's userinfo component, percent-decoded: None if the DSN is unset or malformed.
112    pub fn api_key(&self) -> Option<String> {
113        self.parsed_dsn().and_then(|d| d.api_key)
114    }
115
116    /// The ingestion URL with credentials stripped out: they travel as the Authorization header
117    /// instead, never embedded in the request URI.
118    pub fn ingestion_uri(&self) -> Option<String> {
119        self.parsed_dsn().map(|d| d.ingestion_uri)
120    }
121
122    /// Same derivation as `ingestion_uri`, with the trailing "/events" swapped for
123    /// "/performance_samples": one DSN, two endpoints, matching the Ruby gem's own
124    /// `Configuration#performance_samples_uri`.
125    pub fn performance_samples_uri(&self) -> Option<String> {
126        self.ingestion_uri()
127            .map(|uri| match uri.strip_suffix("/events") {
128                Some(base) => format!("{base}/performance_samples"),
129                None => uri,
130            })
131    }
132
133    /// Same derivation again, swapping the trailing "/events" for "/custom_metrics".
134    pub fn custom_metrics_uri(&self) -> Option<String> {
135        self.swap_events_suffix("/custom_metrics")
136    }
137
138    /// Same derivation again, swapping the trailing "/events" for "/infrastructure_metrics".
139    pub fn infrastructure_metrics_uri(&self) -> Option<String> {
140        self.swap_events_suffix("/infrastructure_metrics")
141    }
142
143    fn swap_events_suffix(&self, replacement: &str) -> Option<String> {
144        self.ingestion_uri()
145            .map(|uri| match uri.strip_suffix("/events") {
146                Some(base) => format!("{base}{replacement}"),
147                None => uri,
148            })
149    }
150
151    /// Same derivation again, swapping the trailing "/events" for "/spans".
152    pub fn spans_uri(&self) -> Option<String> {
153        self.ingestion_uri()
154            .map(|uri| match uri.strip_suffix("/events") {
155                Some(base) => format!("{base}/spans"),
156                None => uri,
157            })
158    }
159
160    pub fn is_enabled(&self) -> bool {
161        self.dsn.is_some()
162            && self.api_key().is_some()
163            && self.enabled_environments.contains(&self.environment)
164    }
165
166    fn parsed_dsn(&self) -> Option<ParsedDsn> {
167        self.dsn.as_deref().and_then(parse_dsn)
168    }
169}
170
171impl Default for Configuration {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177struct ParsedDsn {
178    api_key: Option<String>,
179    ingestion_uri: String,
180}
181
182/// Hand-parses a DSN of the form "scheme://api_key@host[:port]/path[?query]" rather than pulling
183/// in a URL-parsing crate: the shape is fixed and simple enough that a small dependency-free
184/// parser is clearer here than a general-purpose one, the same spirit as the Perl client's own
185/// dependency-free design.
186fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
187    let (scheme, rest) = dsn.split_once("://")?;
188    if scheme.is_empty() {
189        return None;
190    }
191    let (userinfo, host_and_path) = rest.split_once('@')?;
192    if userinfo.is_empty() || host_and_path.is_empty() {
193        return None;
194    }
195
196    let api_key = percent_decode(userinfo);
197    Some(ParsedDsn {
198        api_key: if api_key.is_empty() {
199            None
200        } else {
201            Some(api_key)
202        },
203        ingestion_uri: format!("{scheme}://{host_and_path}"),
204    })
205}
206
207/// Minimal percent-decoding for a DSN's userinfo component: the only place this client ever
208/// needs it, not a general-purpose URL decoder.
209fn percent_decode(s: &str) -> String {
210    let bytes = s.as_bytes();
211    let mut out = Vec::with_capacity(bytes.len());
212    let mut i = 0;
213    while i < bytes.len() {
214        if bytes[i] == b'%' && i + 2 < bytes.len() {
215            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
216                out.push(byte);
217                i += 3;
218                continue;
219            }
220        }
221        out.push(bytes[i]);
222        i += 1;
223    }
224    String::from_utf8_lossy(&out).into_owned()
225}
226
227/// std has no cross-platform hostname lookup: shells out to the `hostname` command (present on
228/// macOS, Linux, and Windows alike) rather than add a crate for one lookup done once at startup.
229/// Mirrors the Ruby gem's own Socket.gethostname wrapped in a rescue: any failure here yields
230/// None rather than being able to crash the host app.
231fn safe_hostname() -> Option<String> {
232    std::process::Command::new("hostname")
233        .output()
234        .ok()
235        .filter(|o| o.status.success())
236        .and_then(|o| String::from_utf8(o.stdout).ok())
237        .map(|s| s.trim().to_string())
238        .filter(|s| !s.is_empty())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn api_key_and_ingestion_uri() {
247        let config = Configuration {
248            dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
249            ..Configuration::new()
250        };
251
252        assert_eq!(config.api_key(), Some("abc123".to_string()));
253        assert_eq!(
254            config.ingestion_uri(),
255            Some("https://forgeops.example/api/v1/events".to_string())
256        );
257    }
258
259    #[test]
260    fn api_key_percent_decodes() {
261        let config = Configuration {
262            dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
263            ..Configuration::new()
264        };
265
266        assert_eq!(config.api_key(), Some("ab/c".to_string()));
267    }
268
269    #[test]
270    fn empty_or_malformed_dsn() {
271        for dsn in [
272            "",
273            "not-a-url",
274            "://broken",
275            "https://forgeops.example/no-userinfo",
276        ] {
277            let config = Configuration {
278                dsn: Some(dsn.to_string()),
279                ..Configuration::new()
280            };
281            assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
282            assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
283        }
284    }
285
286    #[test]
287    fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
288        let mut config = Configuration::new();
289        config.dsn = Some("https://key@host/path".to_string());
290
291        config.environment = "production".to_string();
292        assert!(config.is_enabled());
293
294        config.environment = "development".to_string();
295        assert!(!config.is_enabled());
296
297        config.environment = "production".to_string();
298        config.dsn = None;
299        assert!(!config.is_enabled());
300    }
301
302    #[test]
303    fn defaults() {
304        let config = Configuration::new();
305        assert_eq!(config.queue_size, 1000);
306        assert_eq!(config.timeout, Duration::from_secs(2));
307        assert!(config.scrub_pii);
308        assert!(config.capture_source_context);
309        assert!(config.enabled_environments.contains("production"));
310        assert!(config.enabled_environments.contains("staging"));
311    }
312}