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}