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    /// When an error is reported with the SQL behind a failed database call (see
44    /// `capture_error_with_sql`), send the names of the stored procedure, table and view that SQL
45    /// touched, so an issue says where to start looking. Names are identifiers, never values,
46    /// which is why this defaults on. `capture_sql_statement` is the separate, opt-in step of also
47    /// sending the statement itself, with every string and number replaced by `?`; off by default
48    /// because even a masked statement describes your schema, and ForgeOps' own per-project
49    /// setting is what durably governs whether the server stores it.
50    pub capture_sql_objects: bool,
51    pub capture_sql_statement: bool,
52
53    /// Whether `add_breadcrumb` actually records anything, and whether a report reads the current
54    /// thread's trail back at all: `add_breadcrumb` itself never panics or errors when this is
55    /// false, it just becomes a no-op, the same "the call site never has to check first" posture
56    /// every other independent tracking mechanism in this crate already has. On by default.
57    pub track_breadcrumbs: bool,
58    /// The most recent entries a single thread's trail keeps; the oldest is dropped once full.
59    /// Matches gems/forge_ops_tracker's own default exactly.
60    pub max_breadcrumbs: usize,
61
62    /// Whether `record_performance`/`time_transaction` time anything at all. On by default, the
63    /// same "on unless you turn it off" posture error reporting itself already has. This crate has
64    /// no web framework integration, so nothing is timed automatically: this only gates the manual
65    /// API below.
66    pub track_performance: bool,
67    /// How often the in-process tallies are flushed as one small aggregate report, rather than one
68    /// network call per timed call. Matches gems/forge_ops_tracker's own default (60s).
69    pub performance_flush_interval: Duration,
70
71    /// Whether `trace`/`continue_trace` report their trace (when slow) to `/spans`. `span`,
72    /// `http_span` and `record_span` only record inside a trace, so this gates every span. With it
73    /// off, a trace still has an id, attached to errors captured inside it and handed out by
74    /// `http_span` (see `propagate_traces`), since that id is also what links an error here to one
75    /// in another service. This crate has no web framework integration, so nothing starts a trace
76    /// automatically.
77    pub track_tracing: bool,
78    /// How often the buffered `capture_metric` entries are flushed as one batch. There is no
79    /// `track_metrics` flag the way `track_performance` has one: these are explicit calls the host
80    /// app's own code makes, not automatic instrumentation, so there is nothing to turn off that
81    /// simply not calling them doesn't already do.
82    pub metric_flush_interval: Duration,
83    /// The same for `capture_infrastructure_metric`.
84    pub infrastructure_metric_flush_interval: Duration,
85    /// A trace is only sent when its root span took at least this long.
86    pub trace_capture_threshold: Duration,
87
88    /// Whether `http_span` hands its closure a W3C `traceparent` header value for the outgoing
89    /// call, so the service being called continues this trace. On by default, matching
90    /// gems/forge_ops_tracker: the header carries the trace id that links an error here to an error
91    /// there, which is useful with or without spans, so it goes out even with `track_tracing` off.
92    pub propagate_traces: bool,
93    /// Which hosts get that header. `None` (the default) means every host. Otherwise a list of
94    /// [`TracePropagationTarget`]s: a host string matches that host and its subdomains on a dot
95    /// boundary (`"example.com"` matches `"api.example.com"`, never `"badexample.com"`), and a
96    /// `regex::Regex` is searched for anywhere in the (lowercased) host, so anchor it yourself.
97    /// Useful for a third-party API that rejects unknown headers, or that shouldn't learn your
98    /// trace ids at all.
99    pub trace_propagation_targets: Option<Vec<TracePropagationTarget>>,
100}
101
102/// One entry in `Configuration.trace_propagation_targets`. Build one with `.into()` from a `&str`
103/// or `String` (a host) or a `regex::Regex`:
104///
105/// ```no_run
106/// forge_ops_tracker::init(|c| {
107///     c.trace_propagation_targets = Some(vec![
108///         "example.com".into(),
109///         regex::Regex::new(r"^internal-\d+\.corp$").unwrap().into(),
110///     ]);
111/// });
112/// ```
113#[derive(Debug, Clone)]
114pub enum TracePropagationTarget {
115    /// Matches this host and its subdomains on a dot boundary, ignoring case and a leading dot.
116    Host(String),
117    /// Searched for anywhere in the lowercased host.
118    Pattern(regex::Regex),
119}
120
121impl From<&str> for TracePropagationTarget {
122    fn from(host: &str) -> Self {
123        TracePropagationTarget::Host(host.to_string())
124    }
125}
126
127impl From<String> for TracePropagationTarget {
128    fn from(host: String) -> Self {
129        TracePropagationTarget::Host(host)
130    }
131}
132
133impl From<regex::Regex> for TracePropagationTarget {
134    fn from(pattern: regex::Regex) -> Self {
135        TracePropagationTarget::Pattern(pattern)
136    }
137}
138
139impl TracePropagationTarget {
140    fn matches(&self, host: &str) -> bool {
141        match self {
142            TracePropagationTarget::Host(target) => {
143                let domain = target.to_ascii_lowercase();
144                let domain = domain.strip_prefix('.').unwrap_or(&domain);
145                !domain.is_empty()
146                    && (host == domain
147                        || host
148                            .strip_suffix(domain)
149                            .is_some_and(|prefix| prefix.ends_with('.')))
150            }
151            TracePropagationTarget::Pattern(pattern) => pattern.is_match(host),
152        }
153    }
154}
155
156impl Configuration {
157    /// Seeds a Configuration from FORGE_OPS_DSN/FORGE_OPS_ENVIRONMENT/FORGE_OPS_RELEASE and
158    /// sensible defaults for everything else: the same env vars and defaults every other client
159    /// in this repo reads.
160    pub fn new() -> Self {
161        let mut enabled_environments = HashSet::new();
162        enabled_environments.insert("production".to_string());
163        enabled_environments.insert("staging".to_string());
164
165        Configuration {
166            dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
167            environment: env::var("FORGE_OPS_ENVIRONMENT")
168                .unwrap_or_else(|_| "development".to_string()),
169            release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
170            server_name: safe_hostname(),
171            app_root: env::current_dir()
172                .ok()
173                .map(|p| p.to_string_lossy().into_owned()),
174            enabled_environments,
175            queue_size: 1000,
176            timeout: Duration::from_secs(2),
177            scrub_pii: true,
178            install_panic_hook: true,
179            capture_source_context: true,
180            capture_sql_objects: true,
181            capture_sql_statement: false,
182            track_breadcrumbs: true,
183            max_breadcrumbs: 30,
184            track_performance: true,
185            performance_flush_interval: Duration::from_secs(60),
186            track_tracing: true,
187            metric_flush_interval: Duration::from_secs(60),
188            infrastructure_metric_flush_interval: Duration::from_secs(60),
189            trace_capture_threshold: Duration::from_secs(1),
190            propagate_traces: true,
191            trace_propagation_targets: None,
192        }
193    }
194
195    /// Whether an outgoing call to `host` should carry a `traceparent` header; case-insensitive,
196    /// since hostnames are. A call with no host only matches when there is no target list.
197    pub fn should_propagate_trace(&self, host: Option<&str>) -> bool {
198        if !self.propagate_traces {
199            return false;
200        }
201        let Some(targets) = &self.trace_propagation_targets else {
202            return true;
203        };
204        let host = host.unwrap_or("").to_ascii_lowercase();
205        !host.is_empty() && targets.iter().any(|target| target.matches(&host))
206    }
207
208    /// The DSN's userinfo component, percent-decoded: None if the DSN is unset or malformed.
209    pub fn api_key(&self) -> Option<String> {
210        self.parsed_dsn().and_then(|d| d.api_key)
211    }
212
213    /// The ingestion URL with credentials stripped out: they travel as the Authorization header
214    /// instead, never embedded in the request URI.
215    pub fn ingestion_uri(&self) -> Option<String> {
216        self.parsed_dsn().map(|d| d.ingestion_uri)
217    }
218
219    /// Same derivation as `ingestion_uri`, with the trailing "/events" swapped for
220    /// "/performance_samples": one DSN, two endpoints, matching the Ruby gem's own
221    /// `Configuration#performance_samples_uri`.
222    pub fn performance_samples_uri(&self) -> Option<String> {
223        self.ingestion_uri()
224            .map(|uri| match uri.strip_suffix("/events") {
225                Some(base) => format!("{base}/performance_samples"),
226                None => uri,
227            })
228    }
229
230    /// Same derivation again, swapping the trailing "/events" for "/custom_metrics".
231    pub fn custom_metrics_uri(&self) -> Option<String> {
232        self.swap_events_suffix("/custom_metrics")
233    }
234
235    /// Same derivation again, swapping the trailing "/events" for "/infrastructure_metrics".
236    pub fn infrastructure_metrics_uri(&self) -> Option<String> {
237        self.swap_events_suffix("/infrastructure_metrics")
238    }
239
240    fn swap_events_suffix(&self, replacement: &str) -> Option<String> {
241        self.ingestion_uri()
242            .map(|uri| match uri.strip_suffix("/events") {
243                Some(base) => format!("{base}{replacement}"),
244                None => uri,
245            })
246    }
247
248    /// Same derivation again, swapping the trailing "/events" for "/spans".
249    pub fn spans_uri(&self) -> Option<String> {
250        self.ingestion_uri()
251            .map(|uri| match uri.strip_suffix("/events") {
252                Some(base) => format!("{base}/spans"),
253                None => uri,
254            })
255    }
256
257    pub fn is_enabled(&self) -> bool {
258        self.dsn.is_some()
259            && self.api_key().is_some()
260            && self.enabled_environments.contains(&self.environment)
261    }
262
263    fn parsed_dsn(&self) -> Option<ParsedDsn> {
264        self.dsn.as_deref().and_then(parse_dsn)
265    }
266}
267
268impl Default for Configuration {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274struct ParsedDsn {
275    api_key: Option<String>,
276    ingestion_uri: String,
277}
278
279/// Hand-parses a DSN of the form "scheme://api_key@host[:port]/path[?query]" rather than pulling
280/// in a URL-parsing crate: the shape is fixed and simple enough that a small dependency-free
281/// parser is clearer here than a general-purpose one, the same spirit as the Perl client's own
282/// dependency-free design.
283fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
284    let (scheme, rest) = dsn.split_once("://")?;
285    if scheme.is_empty() {
286        return None;
287    }
288    let (userinfo, host_and_path) = rest.split_once('@')?;
289    if userinfo.is_empty() || host_and_path.is_empty() {
290        return None;
291    }
292
293    let api_key = percent_decode(userinfo);
294    Some(ParsedDsn {
295        api_key: if api_key.is_empty() {
296            None
297        } else {
298            Some(api_key)
299        },
300        ingestion_uri: format!("{scheme}://{host_and_path}"),
301    })
302}
303
304/// Minimal percent-decoding for a DSN's userinfo component: the only place this client ever
305/// needs it, not a general-purpose URL decoder.
306fn percent_decode(s: &str) -> String {
307    let bytes = s.as_bytes();
308    let mut out = Vec::with_capacity(bytes.len());
309    let mut i = 0;
310    while i < bytes.len() {
311        if bytes[i] == b'%' && i + 2 < bytes.len() {
312            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
313                out.push(byte);
314                i += 3;
315                continue;
316            }
317        }
318        out.push(bytes[i]);
319        i += 1;
320    }
321    String::from_utf8_lossy(&out).into_owned()
322}
323
324/// std has no cross-platform hostname lookup: shells out to the `hostname` command (present on
325/// macOS, Linux, and Windows alike) rather than add a crate for one lookup done once at startup.
326/// Mirrors the Ruby gem's own Socket.gethostname wrapped in a rescue: any failure here yields
327/// None rather than being able to crash the host app.
328fn safe_hostname() -> Option<String> {
329    std::process::Command::new("hostname")
330        .output()
331        .ok()
332        .filter(|o| o.status.success())
333        .and_then(|o| String::from_utf8(o.stdout).ok())
334        .map(|s| s.trim().to_string())
335        .filter(|s| !s.is_empty())
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn api_key_and_ingestion_uri() {
344        let config = Configuration {
345            dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
346            ..Configuration::new()
347        };
348
349        assert_eq!(config.api_key(), Some("abc123".to_string()));
350        assert_eq!(
351            config.ingestion_uri(),
352            Some("https://forgeops.example/api/v1/events".to_string())
353        );
354    }
355
356    #[test]
357    fn api_key_percent_decodes() {
358        let config = Configuration {
359            dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
360            ..Configuration::new()
361        };
362
363        assert_eq!(config.api_key(), Some("ab/c".to_string()));
364    }
365
366    #[test]
367    fn empty_or_malformed_dsn() {
368        for dsn in [
369            "",
370            "not-a-url",
371            "://broken",
372            "https://forgeops.example/no-userinfo",
373        ] {
374            let config = Configuration {
375                dsn: Some(dsn.to_string()),
376                ..Configuration::new()
377            };
378            assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
379            assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
380        }
381    }
382
383    #[test]
384    fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
385        let mut config = Configuration::new();
386        config.dsn = Some("https://key@host/path".to_string());
387
388        config.environment = "production".to_string();
389        assert!(config.is_enabled());
390
391        config.environment = "development".to_string();
392        assert!(!config.is_enabled());
393
394        config.environment = "production".to_string();
395        config.dsn = None;
396        assert!(!config.is_enabled());
397    }
398
399    #[test]
400    fn defaults() {
401        let config = Configuration::new();
402        assert_eq!(config.queue_size, 1000);
403        assert_eq!(config.timeout, Duration::from_secs(2));
404        assert!(config.scrub_pii);
405        assert!(config.capture_source_context);
406        assert!(config.enabled_environments.contains("production"));
407        assert!(config.enabled_environments.contains("staging"));
408        assert!(config.propagate_traces);
409        assert!(config.trace_propagation_targets.is_none());
410    }
411
412    #[test]
413    fn should_propagate_trace_to_every_host_by_default_and_never_when_off() {
414        let mut config = Configuration::new();
415        assert!(config.should_propagate_trace(Some("anything.example")));
416        assert!(config.should_propagate_trace(None));
417        config.propagate_traces = false;
418        assert!(!config.should_propagate_trace(Some("anything.example")));
419    }
420
421    #[test]
422    fn should_propagate_trace_matches_hosts_on_a_dot_boundary_ignoring_case_and_a_leading_dot() {
423        let mut config = Configuration::new();
424        config.trace_propagation_targets = Some(vec![
425            "Example.com".into(),
426            ".internal.corp".to_string().into(),
427            "".into(),
428        ]);
429        assert!(config.should_propagate_trace(Some("example.com")));
430        assert!(config.should_propagate_trace(Some("API.example.COM")));
431        assert!(config.should_propagate_trace(Some("internal.corp")));
432        assert!(config.should_propagate_trace(Some("db.internal.corp")));
433        assert!(!config.should_propagate_trace(Some("badexample.com")));
434        assert!(!config.should_propagate_trace(Some("example.com.evil.net")));
435        assert!(!config.should_propagate_trace(Some("other.net")));
436        assert!(!config.should_propagate_trace(None));
437
438        config.trace_propagation_targets = Some(vec![]);
439        assert!(!config.should_propagate_trace(Some("example.com")));
440    }
441
442    #[test]
443    fn should_propagate_trace_searches_regex_targets_in_the_lowercased_host() {
444        let mut config = Configuration::new();
445        config.trace_propagation_targets =
446            Some(vec![regex::Regex::new(r"^svc-\d+\.local$").unwrap().into()]);
447        assert!(config.should_propagate_trace(Some("svc-12.local")));
448        assert!(config.should_propagate_trace(Some("SVC-12.LOCAL")));
449        assert!(!config.should_propagate_trace(Some("svc-x.local")));
450    }
451}