use std::collections::HashSet;
use std::env;
use std::time::Duration;
pub struct Configuration {
pub dsn: Option<String>,
pub environment: String,
pub release: Option<String>,
pub server_name: Option<String>,
pub app_root: Option<String>,
pub enabled_environments: HashSet<String>,
pub queue_size: usize,
pub timeout: Duration,
pub scrub_pii: bool,
pub install_panic_hook: bool,
pub capture_source_context: bool,
pub capture_sql_objects: bool,
pub capture_sql_statement: bool,
pub track_breadcrumbs: bool,
pub max_breadcrumbs: usize,
pub track_performance: bool,
pub performance_flush_interval: Duration,
pub track_tracing: bool,
pub metric_flush_interval: Duration,
pub infrastructure_metric_flush_interval: Duration,
pub trace_capture_threshold: Duration,
pub propagate_traces: bool,
pub trace_propagation_targets: Option<Vec<TracePropagationTarget>>,
}
#[derive(Debug, Clone)]
pub enum TracePropagationTarget {
Host(String),
Pattern(regex::Regex),
}
impl From<&str> for TracePropagationTarget {
fn from(host: &str) -> Self {
TracePropagationTarget::Host(host.to_string())
}
}
impl From<String> for TracePropagationTarget {
fn from(host: String) -> Self {
TracePropagationTarget::Host(host)
}
}
impl From<regex::Regex> for TracePropagationTarget {
fn from(pattern: regex::Regex) -> Self {
TracePropagationTarget::Pattern(pattern)
}
}
impl TracePropagationTarget {
fn matches(&self, host: &str) -> bool {
match self {
TracePropagationTarget::Host(target) => {
let domain = target.to_ascii_lowercase();
let domain = domain.strip_prefix('.').unwrap_or(&domain);
!domain.is_empty()
&& (host == domain
|| host
.strip_suffix(domain)
.is_some_and(|prefix| prefix.ends_with('.')))
}
TracePropagationTarget::Pattern(pattern) => pattern.is_match(host),
}
}
}
impl Configuration {
pub fn new() -> Self {
let mut enabled_environments = HashSet::new();
enabled_environments.insert("production".to_string());
enabled_environments.insert("staging".to_string());
Configuration {
dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
environment: env::var("FORGE_OPS_ENVIRONMENT")
.unwrap_or_else(|_| "development".to_string()),
release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
server_name: safe_hostname(),
app_root: env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned()),
enabled_environments,
queue_size: 1000,
timeout: Duration::from_secs(2),
scrub_pii: true,
install_panic_hook: true,
capture_source_context: true,
capture_sql_objects: true,
capture_sql_statement: false,
track_breadcrumbs: true,
max_breadcrumbs: 30,
track_performance: true,
performance_flush_interval: Duration::from_secs(60),
track_tracing: true,
metric_flush_interval: Duration::from_secs(60),
infrastructure_metric_flush_interval: Duration::from_secs(60),
trace_capture_threshold: Duration::from_secs(1),
propagate_traces: true,
trace_propagation_targets: None,
}
}
pub fn should_propagate_trace(&self, host: Option<&str>) -> bool {
if !self.propagate_traces {
return false;
}
let Some(targets) = &self.trace_propagation_targets else {
return true;
};
let host = host.unwrap_or("").to_ascii_lowercase();
!host.is_empty() && targets.iter().any(|target| target.matches(&host))
}
pub fn api_key(&self) -> Option<String> {
self.parsed_dsn().and_then(|d| d.api_key)
}
pub fn ingestion_uri(&self) -> Option<String> {
self.parsed_dsn().map(|d| d.ingestion_uri)
}
pub fn performance_samples_uri(&self) -> Option<String> {
self.ingestion_uri()
.map(|uri| match uri.strip_suffix("/events") {
Some(base) => format!("{base}/performance_samples"),
None => uri,
})
}
pub fn custom_metrics_uri(&self) -> Option<String> {
self.swap_events_suffix("/custom_metrics")
}
pub fn infrastructure_metrics_uri(&self) -> Option<String> {
self.swap_events_suffix("/infrastructure_metrics")
}
fn swap_events_suffix(&self, replacement: &str) -> Option<String> {
self.ingestion_uri()
.map(|uri| match uri.strip_suffix("/events") {
Some(base) => format!("{base}{replacement}"),
None => uri,
})
}
pub fn spans_uri(&self) -> Option<String> {
self.ingestion_uri()
.map(|uri| match uri.strip_suffix("/events") {
Some(base) => format!("{base}/spans"),
None => uri,
})
}
pub fn is_enabled(&self) -> bool {
self.dsn.is_some()
&& self.api_key().is_some()
&& self.enabled_environments.contains(&self.environment)
}
fn parsed_dsn(&self) -> Option<ParsedDsn> {
self.dsn.as_deref().and_then(parse_dsn)
}
}
impl Default for Configuration {
fn default() -> Self {
Self::new()
}
}
struct ParsedDsn {
api_key: Option<String>,
ingestion_uri: String,
}
fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
let (scheme, rest) = dsn.split_once("://")?;
if scheme.is_empty() {
return None;
}
let (userinfo, host_and_path) = rest.split_once('@')?;
if userinfo.is_empty() || host_and_path.is_empty() {
return None;
}
let api_key = percent_decode(userinfo);
Some(ParsedDsn {
api_key: if api_key.is_empty() {
None
} else {
Some(api_key)
},
ingestion_uri: format!("{scheme}://{host_and_path}"),
})
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn safe_hostname() -> Option<String> {
std::process::Command::new("hostname")
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_key_and_ingestion_uri() {
let config = Configuration {
dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
..Configuration::new()
};
assert_eq!(config.api_key(), Some("abc123".to_string()));
assert_eq!(
config.ingestion_uri(),
Some("https://forgeops.example/api/v1/events".to_string())
);
}
#[test]
fn api_key_percent_decodes() {
let config = Configuration {
dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
..Configuration::new()
};
assert_eq!(config.api_key(), Some("ab/c".to_string()));
}
#[test]
fn empty_or_malformed_dsn() {
for dsn in [
"",
"not-a-url",
"://broken",
"https://forgeops.example/no-userinfo",
] {
let config = Configuration {
dsn: Some(dsn.to_string()),
..Configuration::new()
};
assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
}
}
#[test]
fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
let mut config = Configuration::new();
config.dsn = Some("https://key@host/path".to_string());
config.environment = "production".to_string();
assert!(config.is_enabled());
config.environment = "development".to_string();
assert!(!config.is_enabled());
config.environment = "production".to_string();
config.dsn = None;
assert!(!config.is_enabled());
}
#[test]
fn defaults() {
let config = Configuration::new();
assert_eq!(config.queue_size, 1000);
assert_eq!(config.timeout, Duration::from_secs(2));
assert!(config.scrub_pii);
assert!(config.capture_source_context);
assert!(config.enabled_environments.contains("production"));
assert!(config.enabled_environments.contains("staging"));
assert!(config.propagate_traces);
assert!(config.trace_propagation_targets.is_none());
}
#[test]
fn should_propagate_trace_to_every_host_by_default_and_never_when_off() {
let mut config = Configuration::new();
assert!(config.should_propagate_trace(Some("anything.example")));
assert!(config.should_propagate_trace(None));
config.propagate_traces = false;
assert!(!config.should_propagate_trace(Some("anything.example")));
}
#[test]
fn should_propagate_trace_matches_hosts_on_a_dot_boundary_ignoring_case_and_a_leading_dot() {
let mut config = Configuration::new();
config.trace_propagation_targets = Some(vec![
"Example.com".into(),
".internal.corp".to_string().into(),
"".into(),
]);
assert!(config.should_propagate_trace(Some("example.com")));
assert!(config.should_propagate_trace(Some("API.example.COM")));
assert!(config.should_propagate_trace(Some("internal.corp")));
assert!(config.should_propagate_trace(Some("db.internal.corp")));
assert!(!config.should_propagate_trace(Some("badexample.com")));
assert!(!config.should_propagate_trace(Some("example.com.evil.net")));
assert!(!config.should_propagate_trace(Some("other.net")));
assert!(!config.should_propagate_trace(None));
config.trace_propagation_targets = Some(vec![]);
assert!(!config.should_propagate_trace(Some("example.com")));
}
#[test]
fn should_propagate_trace_searches_regex_targets_in_the_lowercased_host() {
let mut config = Configuration::new();
config.trace_propagation_targets =
Some(vec![regex::Regex::new(r"^svc-\d+\.local$").unwrap().into()]);
assert!(config.should_propagate_trace(Some("svc-12.local")));
assert!(config.should_propagate_trace(Some("SVC-12.LOCAL")));
assert!(!config.should_propagate_trace(Some("svc-x.local")));
}
}