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    /// Whether `init()` sends one snapshot of what this process sees (the Rust version it was
102    /// built with, plus environment variable names if `track_env_var_names` is on) so ForgeOps can
103    /// show what changed since the last deploy. Sent once per process, on the delivery thread, so
104    /// it never delays startup. On by default; set false to never send it. `record_change` is
105    /// unaffected either way.
106    pub detect_changes: bool,
107    /// Whether that snapshot includes the *names* of this process's environment variables, so an
108    /// added or removed variable shows up as a change. Values are never read or sent. Names that
109    /// differ from host to host (HOSTNAME, PATH, LC_*, KUBERNETES_*, this crate's own
110    /// FORGE_OPS_*, and the like) are left out. Off by default.
111    pub track_env_var_names: bool,
112}
113
114/// One entry in `Configuration.trace_propagation_targets`. Build one with `.into()` from a `&str`
115/// or `String` (a host) or a `regex::Regex`:
116///
117/// ```no_run
118/// forge_ops_tracker::init(|c| {
119///     c.trace_propagation_targets = Some(vec![
120///         "example.com".into(),
121///         regex::Regex::new(r"^internal-\d+\.corp$").unwrap().into(),
122///     ]);
123/// });
124/// ```
125#[derive(Debug, Clone)]
126pub enum TracePropagationTarget {
127    /// Matches this host and its subdomains on a dot boundary, ignoring case and a leading dot.
128    Host(String),
129    /// Searched for anywhere in the lowercased host.
130    Pattern(regex::Regex),
131}
132
133impl From<&str> for TracePropagationTarget {
134    fn from(host: &str) -> Self {
135        TracePropagationTarget::Host(host.to_string())
136    }
137}
138
139impl From<String> for TracePropagationTarget {
140    fn from(host: String) -> Self {
141        TracePropagationTarget::Host(host)
142    }
143}
144
145impl From<regex::Regex> for TracePropagationTarget {
146    fn from(pattern: regex::Regex) -> Self {
147        TracePropagationTarget::Pattern(pattern)
148    }
149}
150
151impl TracePropagationTarget {
152    fn matches(&self, host: &str) -> bool {
153        match self {
154            TracePropagationTarget::Host(target) => {
155                let domain = target.to_ascii_lowercase();
156                let domain = domain.strip_prefix('.').unwrap_or(&domain);
157                !domain.is_empty()
158                    && (host == domain
159                        || host
160                            .strip_suffix(domain)
161                            .is_some_and(|prefix| prefix.ends_with('.')))
162            }
163            TracePropagationTarget::Pattern(pattern) => pattern.is_match(host),
164        }
165    }
166}
167
168impl Configuration {
169    /// Seeds a Configuration from FORGE_OPS_DSN/FORGE_OPS_ENVIRONMENT/FORGE_OPS_RELEASE and
170    /// sensible defaults for everything else: the same env vars and defaults every other client
171    /// in this repo reads.
172    pub fn new() -> Self {
173        let mut enabled_environments = HashSet::new();
174        enabled_environments.insert("production".to_string());
175        enabled_environments.insert("staging".to_string());
176
177        Configuration {
178            dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
179            environment: env::var("FORGE_OPS_ENVIRONMENT")
180                .unwrap_or_else(|_| "development".to_string()),
181            release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
182            server_name: safe_hostname(),
183            app_root: env::current_dir()
184                .ok()
185                .map(|p| p.to_string_lossy().into_owned()),
186            enabled_environments,
187            queue_size: 1000,
188            timeout: Duration::from_secs(2),
189            scrub_pii: true,
190            install_panic_hook: true,
191            capture_source_context: true,
192            capture_sql_objects: true,
193            capture_sql_statement: false,
194            track_breadcrumbs: true,
195            max_breadcrumbs: 30,
196            track_performance: true,
197            performance_flush_interval: Duration::from_secs(60),
198            track_tracing: true,
199            metric_flush_interval: Duration::from_secs(60),
200            infrastructure_metric_flush_interval: Duration::from_secs(60),
201            trace_capture_threshold: Duration::from_secs(1),
202            propagate_traces: true,
203            trace_propagation_targets: None,
204            detect_changes: true,
205            track_env_var_names: false,
206        }
207    }
208
209    /// Whether an outgoing call to `host` should carry a `traceparent` header; case-insensitive,
210    /// since hostnames are. A call with no host only matches when there is no target list.
211    pub fn should_propagate_trace(&self, host: Option<&str>) -> bool {
212        if !self.propagate_traces {
213            return false;
214        }
215        let Some(targets) = &self.trace_propagation_targets else {
216            return true;
217        };
218        let host = host.unwrap_or("").to_ascii_lowercase();
219        !host.is_empty() && targets.iter().any(|target| target.matches(&host))
220    }
221
222    /// The DSN's userinfo component, percent-decoded: None if the DSN is unset or malformed.
223    pub fn api_key(&self) -> Option<String> {
224        self.parsed_dsn().and_then(|d| d.api_key)
225    }
226
227    /// The ingestion URL with credentials stripped out: they travel as the Authorization header
228    /// instead, never embedded in the request URI.
229    pub fn ingestion_uri(&self) -> Option<String> {
230        self.parsed_dsn().map(|d| d.ingestion_uri)
231    }
232
233    /// Same derivation as `ingestion_uri`, with the trailing "/events" swapped for
234    /// "/performance_samples": one DSN, two endpoints, matching the Ruby gem's own
235    /// `Configuration#performance_samples_uri`.
236    pub fn performance_samples_uri(&self) -> Option<String> {
237        self.ingestion_uri()
238            .map(|uri| match uri.strip_suffix("/events") {
239                Some(base) => format!("{base}/performance_samples"),
240                None => uri,
241            })
242    }
243
244    /// Same derivation again, swapping the trailing "/events" for "/custom_metrics".
245    pub fn custom_metrics_uri(&self) -> Option<String> {
246        self.swap_events_suffix("/custom_metrics")
247    }
248
249    /// Same derivation again, swapping the trailing "/events" for "/infrastructure_metrics".
250    pub fn infrastructure_metrics_uri(&self) -> Option<String> {
251        self.swap_events_suffix("/infrastructure_metrics")
252    }
253
254    fn swap_events_suffix(&self, replacement: &str) -> Option<String> {
255        self.ingestion_uri()
256            .map(|uri| match uri.strip_suffix("/events") {
257                Some(base) => format!("{base}{replacement}"),
258                None => uri,
259            })
260    }
261
262    /// Same derivation again, swapping the trailing "/events" for "/changes".
263    pub fn changes_uri(&self) -> Option<String> {
264        self.swap_events_suffix("/changes")
265    }
266
267    /// Same derivation again, swapping the trailing "/events" for "/change_snapshots".
268    pub fn change_snapshots_uri(&self) -> Option<String> {
269        self.swap_events_suffix("/change_snapshots")
270    }
271
272    /// Same derivation again, swapping the trailing "/events" for "/spans".
273    pub fn spans_uri(&self) -> Option<String> {
274        self.ingestion_uri()
275            .map(|uri| match uri.strip_suffix("/events") {
276                Some(base) => format!("{base}/spans"),
277                None => uri,
278            })
279    }
280
281    pub fn is_enabled(&self) -> bool {
282        self.dsn.is_some()
283            && self.api_key().is_some()
284            && self.enabled_environments.contains(&self.environment)
285    }
286
287    fn parsed_dsn(&self) -> Option<ParsedDsn> {
288        self.dsn.as_deref().and_then(parse_dsn)
289    }
290}
291
292impl Default for Configuration {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298struct ParsedDsn {
299    api_key: Option<String>,
300    ingestion_uri: String,
301}
302
303/// Hand-parses a DSN of the form "scheme://api_key@host[:port]/path[?query]" rather than pulling
304/// in a URL-parsing crate: the shape is fixed and simple enough that a small dependency-free
305/// parser is clearer here than a general-purpose one, the same spirit as the Perl client's own
306/// dependency-free design.
307fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
308    let (scheme, rest) = dsn.split_once("://")?;
309    if scheme.is_empty() {
310        return None;
311    }
312    let (userinfo, host_and_path) = rest.split_once('@')?;
313    if userinfo.is_empty() || host_and_path.is_empty() {
314        return None;
315    }
316
317    let api_key = percent_decode(userinfo);
318    Some(ParsedDsn {
319        api_key: if api_key.is_empty() {
320            None
321        } else {
322            Some(api_key)
323        },
324        ingestion_uri: format!("{scheme}://{host_and_path}"),
325    })
326}
327
328/// Minimal percent-decoding for a DSN's userinfo component: the only place this client ever
329/// needs it, not a general-purpose URL decoder.
330fn percent_decode(s: &str) -> String {
331    let bytes = s.as_bytes();
332    let mut out = Vec::with_capacity(bytes.len());
333    let mut i = 0;
334    while i < bytes.len() {
335        if bytes[i] == b'%' && i + 2 < bytes.len() {
336            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
337                out.push(byte);
338                i += 3;
339                continue;
340            }
341        }
342        out.push(bytes[i]);
343        i += 1;
344    }
345    String::from_utf8_lossy(&out).into_owned()
346}
347
348/// std has no cross-platform hostname lookup: shells out to the `hostname` command (present on
349/// macOS, Linux, and Windows alike) rather than add a crate for one lookup done once at startup.
350/// Mirrors the Ruby gem's own Socket.gethostname wrapped in a rescue: any failure here yields
351/// None rather than being able to crash the host app.
352fn safe_hostname() -> Option<String> {
353    std::process::Command::new("hostname")
354        .output()
355        .ok()
356        .filter(|o| o.status.success())
357        .and_then(|o| String::from_utf8(o.stdout).ok())
358        .map(|s| s.trim().to_string())
359        .filter(|s| !s.is_empty())
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn api_key_and_ingestion_uri() {
368        let config = Configuration {
369            dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
370            ..Configuration::new()
371        };
372
373        assert_eq!(config.api_key(), Some("abc123".to_string()));
374        assert_eq!(
375            config.ingestion_uri(),
376            Some("https://forgeops.example/api/v1/events".to_string())
377        );
378    }
379
380    #[test]
381    fn api_key_percent_decodes() {
382        let config = Configuration {
383            dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
384            ..Configuration::new()
385        };
386
387        assert_eq!(config.api_key(), Some("ab/c".to_string()));
388    }
389
390    #[test]
391    fn empty_or_malformed_dsn() {
392        for dsn in [
393            "",
394            "not-a-url",
395            "://broken",
396            "https://forgeops.example/no-userinfo",
397        ] {
398            let config = Configuration {
399                dsn: Some(dsn.to_string()),
400                ..Configuration::new()
401            };
402            assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
403            assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
404        }
405    }
406
407    #[test]
408    fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
409        let mut config = Configuration::new();
410        config.dsn = Some("https://key@host/path".to_string());
411
412        config.environment = "production".to_string();
413        assert!(config.is_enabled());
414
415        config.environment = "development".to_string();
416        assert!(!config.is_enabled());
417
418        config.environment = "production".to_string();
419        config.dsn = None;
420        assert!(!config.is_enabled());
421    }
422
423    #[test]
424    fn defaults() {
425        let config = Configuration::new();
426        assert_eq!(config.queue_size, 1000);
427        assert_eq!(config.timeout, Duration::from_secs(2));
428        assert!(config.scrub_pii);
429        assert!(config.capture_source_context);
430        assert!(config.enabled_environments.contains("production"));
431        assert!(config.enabled_environments.contains("staging"));
432        assert!(config.propagate_traces);
433        assert!(config.trace_propagation_targets.is_none());
434        assert!(config.detect_changes);
435        assert!(!config.track_env_var_names);
436    }
437
438    #[test]
439    fn change_endpoints_share_the_dsn() {
440        let config = Configuration {
441            dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
442            ..Configuration::new()
443        };
444        assert_eq!(
445            config.changes_uri(),
446            Some("https://forgeops.example/api/v1/changes".to_string())
447        );
448        assert_eq!(
449            config.change_snapshots_uri(),
450            Some("https://forgeops.example/api/v1/change_snapshots".to_string())
451        );
452    }
453
454    #[test]
455    fn should_propagate_trace_to_every_host_by_default_and_never_when_off() {
456        let mut config = Configuration::new();
457        assert!(config.should_propagate_trace(Some("anything.example")));
458        assert!(config.should_propagate_trace(None));
459        config.propagate_traces = false;
460        assert!(!config.should_propagate_trace(Some("anything.example")));
461    }
462
463    #[test]
464    fn should_propagate_trace_matches_hosts_on_a_dot_boundary_ignoring_case_and_a_leading_dot() {
465        let mut config = Configuration::new();
466        config.trace_propagation_targets = Some(vec![
467            "Example.com".into(),
468            ".internal.corp".to_string().into(),
469            "".into(),
470        ]);
471        assert!(config.should_propagate_trace(Some("example.com")));
472        assert!(config.should_propagate_trace(Some("API.example.COM")));
473        assert!(config.should_propagate_trace(Some("internal.corp")));
474        assert!(config.should_propagate_trace(Some("db.internal.corp")));
475        assert!(!config.should_propagate_trace(Some("badexample.com")));
476        assert!(!config.should_propagate_trace(Some("example.com.evil.net")));
477        assert!(!config.should_propagate_trace(Some("other.net")));
478        assert!(!config.should_propagate_trace(None));
479
480        config.trace_propagation_targets = Some(vec![]);
481        assert!(!config.should_propagate_trace(Some("example.com")));
482    }
483
484    #[test]
485    fn should_propagate_trace_searches_regex_targets_in_the_lowercased_host() {
486        let mut config = Configuration::new();
487        config.trace_propagation_targets =
488            Some(vec![regex::Regex::new(r"^svc-\d+\.local$").unwrap().into()]);
489        assert!(config.should_propagate_trace(Some("svc-12.local")));
490        assert!(config.should_propagate_trace(Some("SVC-12.LOCAL")));
491        assert!(!config.should_propagate_trace(Some("svc-x.local")));
492    }
493}