forge_ops_tracker/
configuration.rs1use std::collections::HashSet;
2use std::env;
3use std::time::Duration;
4
5pub struct Configuration {
9 pub dsn: Option<String>,
10 pub environment: String,
11 pub release: Option<String>,
12 pub server_name: Option<String>,
13
14 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 pub install_panic_hook: bool,
33
34 pub capture_source_context: bool,
42
43 pub capture_sql_objects: bool,
51 pub capture_sql_statement: bool,
52
53 pub track_breadcrumbs: bool,
58 pub max_breadcrumbs: usize,
61
62 pub track_performance: bool,
67 pub performance_flush_interval: Duration,
70
71 pub track_tracing: bool,
78 pub metric_flush_interval: Duration,
83 pub infrastructure_metric_flush_interval: Duration,
85 pub trace_capture_threshold: Duration,
87
88 pub propagate_traces: bool,
93 pub trace_propagation_targets: Option<Vec<TracePropagationTarget>>,
100}
101
102#[derive(Debug, Clone)]
114pub enum TracePropagationTarget {
115 Host(String),
117 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 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 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 pub fn api_key(&self) -> Option<String> {
210 self.parsed_dsn().and_then(|d| d.api_key)
211 }
212
213 pub fn ingestion_uri(&self) -> Option<String> {
216 self.parsed_dsn().map(|d| d.ingestion_uri)
217 }
218
219 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 pub fn custom_metrics_uri(&self) -> Option<String> {
232 self.swap_events_suffix("/custom_metrics")
233 }
234
235 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 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
279fn 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
304fn 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
324fn 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}