faucet_core/observability/
install.rs1use thiserror::Error;
6
7#[derive(Debug, Clone, Default)]
10pub struct ObservabilityConfig {
11 pub prometheus: Option<PrometheusConfig>,
12 pub tracing: Option<TracingConfig>,
13 pub otel: Option<crate::observability::otel::OtelConfig>,
14}
15
16#[cfg(feature = "observability-install")]
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub(crate) enum MetricsMode {
22 None,
23 PrometheusOnly,
24 OtelOnly,
25 Fanout,
26}
27
28#[cfg(feature = "observability-install")]
29impl MetricsMode {
30 pub(crate) fn select(prometheus: bool, otel_metrics: bool) -> Self {
31 match (prometheus, otel_metrics) {
32 (true, true) => MetricsMode::Fanout,
33 (true, false) => MetricsMode::PrometheusOnly,
34 (false, true) => MetricsMode::OtelOnly,
35 (false, false) => MetricsMode::None,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
41pub struct PrometheusConfig {
42 pub listen: String,
45 pub buckets: Option<Vec<f64>>,
48}
49
50#[derive(Debug, Clone)]
51pub struct TracingConfig {
52 pub level: String,
54}
55
56#[derive(Debug, Clone, Default)]
59pub struct InstallReport {
60 pub prometheus_listen: Option<String>,
61 pub prometheus_already_installed: bool,
62 pub tracing_already_installed: bool,
63 pub otel_installed: bool,
64 pub otel_signals: Vec<&'static str>,
65}
66
67#[derive(Debug, Error)]
68pub enum InstallError {
69 #[error("failed to bind Prometheus listener at {listen}: {source}")]
70 PrometheusBind {
71 listen: String,
72 #[source]
73 source: std::io::Error,
74 },
75 #[error("failed to install Prometheus recorder: {0}")]
76 PrometheusInstall(String),
77}
78
79#[cfg(feature = "observability-install")]
92pub fn install_observability(cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
93 let mut report = InstallReport::default();
94
95 #[cfg(feature = "otel")]
98 let mut otel_tracer: Option<opentelemetry_sdk::trace::SdkTracerProvider> = None;
99
100 #[cfg(feature = "otel")]
102 let otel_metrics = cfg
103 .otel
104 .as_ref()
105 .map(|o| o.exports(crate::observability::otel::OtelSignal::Metrics))
106 .unwrap_or(false);
107 #[cfg(not(feature = "otel"))]
108 let otel_metrics = false;
109
110 let mode = MetricsMode::select(cfg.prometheus.is_some(), otel_metrics);
111
112 #[cfg(feature = "otel")]
114 let mut otel_meter: Option<opentelemetry_sdk::metrics::SdkMeterProvider> = None;
115
116 match mode {
117 MetricsMode::None => {}
118 MetricsMode::PrometheusOnly => {
119 install_prometheus_only(cfg.prometheus.as_ref().unwrap(), &mut report)?;
120 }
121 #[cfg(feature = "otel")]
122 MetricsMode::OtelOnly => {
123 if let Some(otel) = cfg.otel.as_ref() {
124 match crate::observability::otel::build_meter_provider(otel) {
125 Ok((mp, recorder)) => {
126 if metrics::set_global_recorder(recorder).is_err() {
127 tracing::warn!("metrics recorder already installed; continuing");
128 report.prometheus_already_installed = true;
129 } else {
130 otel_meter = Some(mp);
131 report.otel_signals.push("metrics");
132 }
133 }
134 Err(e) => tracing::warn!("OTLP metrics exporter init failed; skipping: {e}"),
135 }
136 }
137 }
138 #[cfg(feature = "otel")]
139 MetricsMode::Fanout => {
140 install_fanout(
141 cfg.prometheus.as_ref().unwrap(),
142 cfg.otel.as_ref().unwrap(),
143 &mut report,
144 &mut otel_meter,
145 )?;
146 }
147 #[cfg(not(feature = "otel"))]
148 MetricsMode::OtelOnly | MetricsMode::Fanout => {
149 unreachable!("otel metrics mode selected without the otel feature")
150 }
151 }
152
153 if let Some(t) = cfg.tracing.as_ref() {
155 use tracing_subscriber::EnvFilter;
156 use tracing_subscriber::layer::SubscriberExt;
157 use tracing_subscriber::util::SubscriberInitExt;
158
159 let make_filter =
160 || EnvFilter::try_new(&t.level).unwrap_or_else(|_| EnvFilter::new("info"));
161
162 #[cfg_attr(not(feature = "otel"), allow(unused_mut))]
165 let mut installed = false;
166
167 #[cfg(feature = "otel")]
168 {
169 let otel_traces = cfg
170 .otel
171 .as_ref()
172 .map(|o| o.exports(crate::observability::otel::OtelSignal::Traces))
173 .unwrap_or(false);
174 if let (true, Some(otel)) = (otel_traces, cfg.otel.as_ref()) {
175 match crate::observability::otel::build_trace_provider(otel) {
176 Ok(tp) => {
177 use opentelemetry::trace::TracerProvider as _;
178 let tracer = tp.tracer("faucet");
179 crate::observability::otel::install_propagator();
180 let reg = tracing_subscriber::registry()
181 .with(make_filter())
182 .with(tracing_subscriber::fmt::layer())
183 .with(tracing_opentelemetry::layer().with_tracer(tracer))
184 .with(crate::observability::otel::OtelErrorCountLayer);
185 if reg.try_init().is_err() {
186 tracing::warn!("tracing subscriber already installed; continuing");
187 report.tracing_already_installed = true;
188 } else {
191 report.otel_signals.push("traces");
192 otel_tracer = Some(tp);
193 }
194 installed = true;
195 }
196 Err(e) => tracing::warn!("OTLP trace exporter init failed; logs-only: {e}"),
197 }
198 }
199 }
200
201 if !installed {
202 #[cfg(feature = "otel")]
208 let otel_error_layer = cfg
209 .otel
210 .as_ref()
211 .map(|o| {
212 o.exports(crate::observability::otel::OtelSignal::Traces)
213 || o.exports(crate::observability::otel::OtelSignal::Metrics)
214 })
215 .unwrap_or(false)
216 .then_some(crate::observability::otel::OtelErrorCountLayer);
217 #[cfg(not(feature = "otel"))]
218 let otel_error_layer: Option<tracing_subscriber::layer::Identity> = None;
219
220 let reg = tracing_subscriber::registry()
221 .with(make_filter())
222 .with(tracing_subscriber::fmt::layer())
223 .with(otel_error_layer);
224 if reg.try_init().is_err() {
225 tracing::warn!("tracing subscriber already installed; continuing");
229 report.tracing_already_installed = true;
230 }
231 }
232 }
233
234 #[cfg(feature = "otel")]
236 {
237 if otel_tracer.is_some() || otel_meter.is_some() {
238 crate::observability::otel::describe();
239 let _ = crate::observability::otel::set_guard(crate::observability::otel::OtelGuard {
240 tracer: otel_tracer,
241 meter: otel_meter,
242 });
243 report.otel_installed = true;
244 }
245 }
246
247 crate::observability::resilience::describe();
251 crate::observability::cleanup::describe();
252 crate::observability::drift::describe();
253 register_build_info();
254
255 Ok(report)
256}
257
258#[cfg(feature = "observability-install")]
262fn install_prometheus_only(
263 p: &PrometheusConfig,
264 report: &mut InstallReport,
265) -> Result<(), InstallError> {
266 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
267
268 let listen: std::net::SocketAddr =
269 p.listen
270 .parse()
271 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
272 listen: p.listen.clone(),
273 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
274 })?;
275
276 const DEFAULT_BUCKETS: &[f64] = &[
277 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
278 ];
279 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
280
281 let builder = PrometheusBuilder::new()
282 .with_http_listener(listen)
283 .set_buckets(buckets)
284 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?;
285
286 match builder.install() {
287 Ok(()) => report.prometheus_listen = Some(p.listen.clone()),
288 Err(e) => match e {
292 BuildError::FailedToSetGlobalRecorder(_) => {
295 tracing::warn!("Prometheus recorder already installed; continuing");
296 report.prometheus_already_installed = true;
297 }
298 BuildError::FailedToCreateHTTPListener(msg) => {
304 return Err(InstallError::PrometheusBind {
305 listen: p.listen.clone(),
306 source: std::io::Error::other(msg),
307 });
308 }
309 other => return Err(InstallError::PrometheusInstall(other.to_string())),
310 },
311 }
312 Ok(())
313}
314
315#[cfg(all(feature = "observability-install", feature = "otel"))]
321fn install_fanout(
322 p: &PrometheusConfig,
323 otel: &crate::observability::otel::OtelConfig,
324 report: &mut InstallReport,
325 otel_meter: &mut Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
326) -> Result<(), InstallError> {
327 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
328 use metrics_util::layers::FanoutBuilder;
329
330 let listen: std::net::SocketAddr =
331 p.listen
332 .parse()
333 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
334 listen: p.listen.clone(),
335 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
336 })?;
337 const DEFAULT_BUCKETS: &[f64] = &[
338 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
339 ];
340 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
341
342 let (prom_recorder, prom_exporter) = PrometheusBuilder::new()
345 .with_http_listener(listen)
346 .set_buckets(buckets)
347 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?
348 .build()
349 .map_err(|e| match e {
350 BuildError::FailedToCreateHTTPListener(msg) => InstallError::PrometheusBind {
351 listen: p.listen.clone(),
352 source: std::io::Error::other(msg),
353 },
354 other => InstallError::PrometheusInstall(other.to_string()),
355 })?;
356
357 match crate::observability::otel::build_meter_provider(otel) {
358 Ok((mp, otel_recorder)) => {
359 let fanout = FanoutBuilder::default()
360 .add_recorder(prom_recorder)
361 .add_recorder(otel_recorder)
362 .build();
363 if metrics::set_global_recorder(fanout).is_err() {
364 tracing::warn!("metrics recorder already installed; continuing");
365 report.prometheus_already_installed = true;
366 } else {
367 report.prometheus_listen = Some(p.listen.clone());
368 report.otel_signals.push("metrics");
369 *otel_meter = Some(mp);
370 tokio::spawn(prom_exporter);
371 }
372 }
373 Err(e) => {
374 tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
377 if metrics::set_global_recorder(prom_recorder).is_err() {
378 report.prometheus_already_installed = true;
379 } else {
380 report.prometheus_listen = Some(p.listen.clone());
381 tokio::spawn(prom_exporter);
382 }
383 }
384 }
385 Ok(())
386}
387
388#[cfg(not(feature = "observability-install"))]
390pub fn install_observability(_cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
391 crate::observability::resilience::describe();
392 crate::observability::cleanup::describe();
393 crate::observability::drift::describe();
394 crate::observability::otel::describe();
395 register_build_info();
396 Ok(InstallReport::default())
397}
398
399pub fn register_build_info() {
409 metrics::gauge!(
410 "faucet_build_info",
411 "version" => env!("CARGO_PKG_VERSION"),
412 )
413 .set(1.0);
414}
415
416#[cfg(all(test, feature = "observability-install"))]
417mod tests {
418 use super::*;
419 use std::sync::Mutex;
420
421 static LOCK: Mutex<()> = Mutex::new(());
422
423 #[test]
424 fn metrics_mode_selection() {
425 use super::MetricsMode;
426 assert_eq!(MetricsMode::select(true, true), MetricsMode::Fanout);
427 assert_eq!(
428 MetricsMode::select(true, false),
429 MetricsMode::PrometheusOnly
430 );
431 assert_eq!(MetricsMode::select(false, true), MetricsMode::OtelOnly);
432 assert_eq!(MetricsMode::select(false, false), MetricsMode::None);
433 }
434
435 #[test]
436 fn no_config_returns_empty_report() {
437 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
438 let r = install_observability(&ObservabilityConfig::default()).unwrap();
439 assert!(r.prometheus_listen.is_none());
440 assert!(!r.prometheus_already_installed);
441 assert!(!r.tracing_already_installed);
442 }
443
444 #[test]
445 fn malformed_listen_returns_bind_error() {
446 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
447 let cfg = ObservabilityConfig {
448 prometheus: Some(PrometheusConfig {
449 listen: "not-a-socket".into(),
450 buckets: None,
451 }),
452 tracing: None,
453 otel: None,
454 };
455 match install_observability(&cfg) {
456 Err(InstallError::PrometheusBind { .. }) => {}
457 other => panic!("expected PrometheusBind error, got {other:?}"),
458 }
459 }
460
461 #[test]
462 fn register_build_info_is_callable_and_idempotent() {
463 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
464 register_build_info();
467 register_build_info();
468 }
469
470 #[test]
471 fn install_prometheus_and_tracing_returns_ok() {
472 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
478 let cfg = ObservabilityConfig {
479 prometheus: Some(PrometheusConfig {
480 listen: "127.0.0.1:0".into(),
481 buckets: Some(vec![0.01, 0.1, 1.0]),
483 }),
484 tracing: Some(TracingConfig {
485 level: "info".into(),
486 }),
487 otel: None,
488 };
489 let report = install_observability(&cfg).expect("install must return Ok");
490 assert!(
492 report.prometheus_listen.is_some() || report.prometheus_already_installed,
493 "prometheus install must either bind or report already-installed"
494 );
495 }
496
497 #[test]
498 fn install_tracing_with_invalid_directive_falls_back_to_info() {
499 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
502 let cfg = ObservabilityConfig {
503 prometheus: None,
504 tracing: Some(TracingConfig {
505 level: "this is !!! not a valid filter".into(),
507 }),
508 otel: None,
509 };
510 install_observability(&cfg).expect("invalid tracing directive must not fail install");
511 }
512}