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` starts a trace and reports it (when slow) to `/spans`. `span` and
72 /// `record_span` only record inside a trace, so this gates the whole feature. This crate has no
73 /// web framework integration, so nothing starts a trace automatically.
74 pub track_tracing: bool,
75 /// How often the buffered `capture_metric` entries are flushed as one batch. There is no
76 /// `track_metrics` flag the way `track_performance` has one: these are explicit calls the host
77 /// app's own code makes, not automatic instrumentation, so there is nothing to turn off that
78 /// simply not calling them doesn't already do.
79 pub metric_flush_interval: Duration,
80 /// The same for `capture_infrastructure_metric`.
81 pub infrastructure_metric_flush_interval: Duration,
82 /// A trace is only sent when its root span took at least this long.
83 pub trace_capture_threshold: Duration,
84}
85
86impl Configuration {
87 /// Seeds a Configuration from FORGE_OPS_DSN/FORGE_OPS_ENVIRONMENT/FORGE_OPS_RELEASE and
88 /// sensible defaults for everything else: the same env vars and defaults every other client
89 /// in this repo reads.
90 pub fn new() -> Self {
91 let mut enabled_environments = HashSet::new();
92 enabled_environments.insert("production".to_string());
93 enabled_environments.insert("staging".to_string());
94
95 Configuration {
96 dsn: env::var("FORGE_OPS_DSN").ok().filter(|s| !s.is_empty()),
97 environment: env::var("FORGE_OPS_ENVIRONMENT")
98 .unwrap_or_else(|_| "development".to_string()),
99 release: env::var("FORGE_OPS_RELEASE").ok().filter(|s| !s.is_empty()),
100 server_name: safe_hostname(),
101 app_root: env::current_dir()
102 .ok()
103 .map(|p| p.to_string_lossy().into_owned()),
104 enabled_environments,
105 queue_size: 1000,
106 timeout: Duration::from_secs(2),
107 scrub_pii: true,
108 install_panic_hook: true,
109 capture_source_context: true,
110 capture_sql_objects: true,
111 capture_sql_statement: false,
112 track_breadcrumbs: true,
113 max_breadcrumbs: 30,
114 track_performance: true,
115 performance_flush_interval: Duration::from_secs(60),
116 track_tracing: true,
117 metric_flush_interval: Duration::from_secs(60),
118 infrastructure_metric_flush_interval: Duration::from_secs(60),
119 trace_capture_threshold: Duration::from_secs(1),
120 }
121 }
122
123 /// The DSN's userinfo component, percent-decoded: None if the DSN is unset or malformed.
124 pub fn api_key(&self) -> Option<String> {
125 self.parsed_dsn().and_then(|d| d.api_key)
126 }
127
128 /// The ingestion URL with credentials stripped out: they travel as the Authorization header
129 /// instead, never embedded in the request URI.
130 pub fn ingestion_uri(&self) -> Option<String> {
131 self.parsed_dsn().map(|d| d.ingestion_uri)
132 }
133
134 /// Same derivation as `ingestion_uri`, with the trailing "/events" swapped for
135 /// "/performance_samples": one DSN, two endpoints, matching the Ruby gem's own
136 /// `Configuration#performance_samples_uri`.
137 pub fn performance_samples_uri(&self) -> Option<String> {
138 self.ingestion_uri()
139 .map(|uri| match uri.strip_suffix("/events") {
140 Some(base) => format!("{base}/performance_samples"),
141 None => uri,
142 })
143 }
144
145 /// Same derivation again, swapping the trailing "/events" for "/custom_metrics".
146 pub fn custom_metrics_uri(&self) -> Option<String> {
147 self.swap_events_suffix("/custom_metrics")
148 }
149
150 /// Same derivation again, swapping the trailing "/events" for "/infrastructure_metrics".
151 pub fn infrastructure_metrics_uri(&self) -> Option<String> {
152 self.swap_events_suffix("/infrastructure_metrics")
153 }
154
155 fn swap_events_suffix(&self, replacement: &str) -> Option<String> {
156 self.ingestion_uri()
157 .map(|uri| match uri.strip_suffix("/events") {
158 Some(base) => format!("{base}{replacement}"),
159 None => uri,
160 })
161 }
162
163 /// Same derivation again, swapping the trailing "/events" for "/spans".
164 pub fn spans_uri(&self) -> Option<String> {
165 self.ingestion_uri()
166 .map(|uri| match uri.strip_suffix("/events") {
167 Some(base) => format!("{base}/spans"),
168 None => uri,
169 })
170 }
171
172 pub fn is_enabled(&self) -> bool {
173 self.dsn.is_some()
174 && self.api_key().is_some()
175 && self.enabled_environments.contains(&self.environment)
176 }
177
178 fn parsed_dsn(&self) -> Option<ParsedDsn> {
179 self.dsn.as_deref().and_then(parse_dsn)
180 }
181}
182
183impl Default for Configuration {
184 fn default() -> Self {
185 Self::new()
186 }
187}
188
189struct ParsedDsn {
190 api_key: Option<String>,
191 ingestion_uri: String,
192}
193
194/// Hand-parses a DSN of the form "scheme://api_key@host[:port]/path[?query]" rather than pulling
195/// in a URL-parsing crate: the shape is fixed and simple enough that a small dependency-free
196/// parser is clearer here than a general-purpose one, the same spirit as the Perl client's own
197/// dependency-free design.
198fn parse_dsn(dsn: &str) -> Option<ParsedDsn> {
199 let (scheme, rest) = dsn.split_once("://")?;
200 if scheme.is_empty() {
201 return None;
202 }
203 let (userinfo, host_and_path) = rest.split_once('@')?;
204 if userinfo.is_empty() || host_and_path.is_empty() {
205 return None;
206 }
207
208 let api_key = percent_decode(userinfo);
209 Some(ParsedDsn {
210 api_key: if api_key.is_empty() {
211 None
212 } else {
213 Some(api_key)
214 },
215 ingestion_uri: format!("{scheme}://{host_and_path}"),
216 })
217}
218
219/// Minimal percent-decoding for a DSN's userinfo component: the only place this client ever
220/// needs it, not a general-purpose URL decoder.
221fn percent_decode(s: &str) -> String {
222 let bytes = s.as_bytes();
223 let mut out = Vec::with_capacity(bytes.len());
224 let mut i = 0;
225 while i < bytes.len() {
226 if bytes[i] == b'%' && i + 2 < bytes.len() {
227 if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
228 out.push(byte);
229 i += 3;
230 continue;
231 }
232 }
233 out.push(bytes[i]);
234 i += 1;
235 }
236 String::from_utf8_lossy(&out).into_owned()
237}
238
239/// std has no cross-platform hostname lookup: shells out to the `hostname` command (present on
240/// macOS, Linux, and Windows alike) rather than add a crate for one lookup done once at startup.
241/// Mirrors the Ruby gem's own Socket.gethostname wrapped in a rescue: any failure here yields
242/// None rather than being able to crash the host app.
243fn safe_hostname() -> Option<String> {
244 std::process::Command::new("hostname")
245 .output()
246 .ok()
247 .filter(|o| o.status.success())
248 .and_then(|o| String::from_utf8(o.stdout).ok())
249 .map(|s| s.trim().to_string())
250 .filter(|s| !s.is_empty())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn api_key_and_ingestion_uri() {
259 let config = Configuration {
260 dsn: Some("https://abc123@forgeops.example/api/v1/events".to_string()),
261 ..Configuration::new()
262 };
263
264 assert_eq!(config.api_key(), Some("abc123".to_string()));
265 assert_eq!(
266 config.ingestion_uri(),
267 Some("https://forgeops.example/api/v1/events".to_string())
268 );
269 }
270
271 #[test]
272 fn api_key_percent_decodes() {
273 let config = Configuration {
274 dsn: Some("https://ab%2Fc@forgeops.example/api/v1/events".to_string()),
275 ..Configuration::new()
276 };
277
278 assert_eq!(config.api_key(), Some("ab/c".to_string()));
279 }
280
281 #[test]
282 fn empty_or_malformed_dsn() {
283 for dsn in [
284 "",
285 "not-a-url",
286 "://broken",
287 "https://forgeops.example/no-userinfo",
288 ] {
289 let config = Configuration {
290 dsn: Some(dsn.to_string()),
291 ..Configuration::new()
292 };
293 assert_eq!(config.api_key(), None, "dsn = {dsn:?}");
294 assert_eq!(config.ingestion_uri(), None, "dsn = {dsn:?}");
295 }
296 }
297
298 #[test]
299 fn is_enabled_requires_dsn_api_key_and_enabled_environment() {
300 let mut config = Configuration::new();
301 config.dsn = Some("https://key@host/path".to_string());
302
303 config.environment = "production".to_string();
304 assert!(config.is_enabled());
305
306 config.environment = "development".to_string();
307 assert!(!config.is_enabled());
308
309 config.environment = "production".to_string();
310 config.dsn = None;
311 assert!(!config.is_enabled());
312 }
313
314 #[test]
315 fn defaults() {
316 let config = Configuration::new();
317 assert_eq!(config.queue_size, 1000);
318 assert_eq!(config.timeout, Duration::from_secs(2));
319 assert!(config.scrub_pii);
320 assert!(config.capture_source_context);
321 assert!(config.enabled_environments.contains("production"));
322 assert!(config.enabled_environments.contains("staging"));
323 }
324}