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}