Skip to main content

faucet_core/observability/
install.rs

1//! Idempotent global installer for the Prometheus recorder and a
2//! `tracing-subscriber`. Safe to call more than once; subsequent calls warn
3//! and continue rather than panicking. Port-in-use becomes a typed error.
4
5use thiserror::Error;
6
7/// Configuration for `install_observability`. Either or both sections may be
8/// `None`; unset sections install nothing.
9#[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/// Which metrics recorder to install given which exporters are requested.
17/// Only consulted by `install_observability`, which itself requires the recorder
18/// machinery, so it's gated on the same feature.
19#[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    /// `host:port` to bind a `/metrics` HTTP endpoint. Recommended:
43    /// `127.0.0.1:9464`.
44    pub listen: String,
45    /// Histogram bucket overrides (in seconds). When `None`, sensible defaults
46    /// apply (0.001..300s spanning sub-ms through five-minute durations).
47    pub buckets: Option<Vec<f64>>,
48}
49
50#[derive(Debug, Clone)]
51pub struct TracingConfig {
52    /// `EnvFilter`-style directive, e.g. `"info"` or `"faucet_core=debug,info"`.
53    pub level: String,
54}
55
56/// Report from `install_observability` so callers can log what actually
57/// happened (recorder installed vs. already-installed vs. disabled).
58#[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/// Install observability if requested. Always returns; never panics.
80///
81/// Behavior:
82/// - If `prometheus` is set, builds a `PrometheusBuilder` and installs the
83///   recorder + HTTP `/metrics` endpoint at the configured listen address.
84///   Already-installed recorder (typed `BuildError::FailedToSetGlobalRecorder`)
85///   is logged via `tracing::warn!` and continues. Listen-address parse failures
86///   and HTTP-listener bind failures (e.g. port-in-use, typed
87///   `BuildError::FailedToCreateHTTPListener`) return `InstallError::PrometheusBind`.
88/// - If `tracing` is set, installs a `tracing-subscriber` registry with the
89///   given env-filter directive as the default subscriber. Already-set-default
90///   is logged via `tracing::warn!` and continues.
91#[cfg(feature = "observability-install")]
92pub fn install_observability(cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
93    let mut report = InstallReport::default();
94
95    // Provider holders moved into the guard after both arms run; only populated
96    // (and only referenced) when the `otel` feature is enabled.
97    #[cfg(feature = "otel")]
98    let mut otel_tracer: Option<opentelemetry_sdk::trace::SdkTracerProvider> = None;
99
100    // --- Metrics ---
101    #[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    // Declared so the trace arm + guard step can move them; only used under otel.
113    #[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    // --- Traces ---
154    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        // Reassigned only in the `otel` branch below; without that feature the
163        // `mut` is genuinely unused.
164        #[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                            // try_init failed: no otel layer was installed, so DON'T store the
189                            // provider — let `tp` drop here to shut its exporter down.
190                        } 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            // Install the OTLP export-error counter layer whenever ANY otel
203            // signal is exported — not only when a trace layer was installed —
204            // so `faucet_otel_export_failures_total` still fires under
205            // `otel.export: [metrics]` (audit #321 L7). `Option<Layer>` is a
206            // no-op when `None`, so this composes for the non-otel build too.
207            #[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                // Some other code path has already set a global default. Log and
226                // continue — observability still works through the previously-
227                // installed subscriber.
228                tracing::warn!("tracing subscriber already installed; continuing");
229                report.tracing_already_installed = true;
230            }
231        }
232    }
233
234    // --- OTel guard + describe ---
235    #[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    // Register metric HELP text + build_info after any Prometheus install
248    // attempt — describe!()/set!() into a not-yet-installed recorder is a no-op,
249    // so we order them last.
250    crate::observability::resilience::describe();
251    crate::observability::drift::describe();
252    register_build_info();
253
254    Ok(report)
255}
256
257/// Install the Prometheus recorder + `/metrics` HTTP endpoint as the sole
258/// global recorder. Extracted verbatim from the original metrics arm so the
259/// fanout / otel-only paths can choose a different recorder.
260#[cfg(feature = "observability-install")]
261fn install_prometheus_only(
262    p: &PrometheusConfig,
263    report: &mut InstallReport,
264) -> Result<(), InstallError> {
265    use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
266
267    let listen: std::net::SocketAddr =
268        p.listen
269            .parse()
270            .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
271                listen: p.listen.clone(),
272                source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
273            })?;
274
275    const DEFAULT_BUCKETS: &[f64] = &[
276        0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
277    ];
278    let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
279
280    let builder = PrometheusBuilder::new()
281        .with_http_listener(listen)
282        .set_buckets(buckets)
283        .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?;
284
285    match builder.install() {
286        Ok(()) => report.prometheus_listen = Some(p.listen.clone()),
287        // Match the TYPED `BuildError` variant rather than scraping its
288        // Display string — the latter breaks silently if the upstream
289        // wording changes.
290        Err(e) => match e {
291            // Recorder already installed (e.g. a prior `install` call or a
292            // test harness). Idempotent: warn and continue.
293            BuildError::FailedToSetGlobalRecorder(_) => {
294                tracing::warn!("Prometheus recorder already installed; continuing");
295                report.prometheus_already_installed = true;
296            }
297            // The HTTP `/metrics` listener could not bind. This is where a
298            // genuine bind failure (e.g. EADDRINUSE / port-in-use) lands,
299            // since the real `TcpListener::bind` happens inside `install()`,
300            // not in the address parse above. Surface it as the dedicated
301            // bind error so port-in-use is reported correctly.
302            BuildError::FailedToCreateHTTPListener(msg) => {
303                return Err(InstallError::PrometheusBind {
304                    listen: p.listen.clone(),
305                    source: std::io::Error::other(msg),
306                });
307            }
308            other => return Err(InstallError::PrometheusInstall(other.to_string())),
309        },
310    }
311    Ok(())
312}
313
314/// Install a `metrics-util` fanout recorder dispatching to BOTH a Prometheus
315/// recorder (with its `/metrics` HTTP endpoint) and an OTLP metrics recorder,
316/// so both coexist. If the OTLP exporter fails to build, falls back to a
317/// Prometheus-only recorder so `/metrics` still works — export never fails the
318/// process.
319#[cfg(all(feature = "observability-install", feature = "otel"))]
320fn install_fanout(
321    p: &PrometheusConfig,
322    otel: &crate::observability::otel::OtelConfig,
323    report: &mut InstallReport,
324    otel_meter: &mut Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
325) -> Result<(), InstallError> {
326    use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
327    use metrics_util::layers::FanoutBuilder;
328
329    let listen: std::net::SocketAddr =
330        p.listen
331            .parse()
332            .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
333                listen: p.listen.clone(),
334                source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
335            })?;
336    const DEFAULT_BUCKETS: &[f64] = &[
337        0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
338    ];
339    let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
340
341    // build() returns (recorder, /metrics exporter future) WITHOUT setting the
342    // global recorder, so we can fan it out.
343    let (prom_recorder, prom_exporter) = PrometheusBuilder::new()
344        .with_http_listener(listen)
345        .set_buckets(buckets)
346        .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?
347        .build()
348        .map_err(|e| match e {
349            BuildError::FailedToCreateHTTPListener(msg) => InstallError::PrometheusBind {
350                listen: p.listen.clone(),
351                source: std::io::Error::other(msg),
352            },
353            other => InstallError::PrometheusInstall(other.to_string()),
354        })?;
355
356    match crate::observability::otel::build_meter_provider(otel) {
357        Ok((mp, otel_recorder)) => {
358            let fanout = FanoutBuilder::default()
359                .add_recorder(prom_recorder)
360                .add_recorder(otel_recorder)
361                .build();
362            if metrics::set_global_recorder(fanout).is_err() {
363                tracing::warn!("metrics recorder already installed; continuing");
364                report.prometheus_already_installed = true;
365            } else {
366                report.prometheus_listen = Some(p.listen.clone());
367                report.otel_signals.push("metrics");
368                *otel_meter = Some(mp);
369                tokio::spawn(prom_exporter);
370            }
371        }
372        Err(e) => {
373            // OTLP metrics init failed — fall back to Prometheus-only so /metrics
374            // still works; export never fails the process.
375            tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
376            if metrics::set_global_recorder(prom_recorder).is_err() {
377                report.prometheus_already_installed = true;
378            } else {
379                report.prometheus_listen = Some(p.listen.clone());
380                tokio::spawn(prom_exporter);
381            }
382        }
383    }
384    Ok(())
385}
386
387/// Non-`observability-install` stub. Returns an empty report, never panics.
388#[cfg(not(feature = "observability-install"))]
389pub fn install_observability(_cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
390    crate::observability::resilience::describe();
391    crate::observability::drift::describe();
392    crate::observability::otel::describe();
393    register_build_info();
394    Ok(InstallReport::default())
395}
396
397/// Register the `faucet_build_info{version}` gauge (set to 1) under the
398/// currently-installed `metrics` recorder. Safe to call from any code path
399/// that wants to ensure the gauge is set; `install_observability` invokes
400/// this automatically. Gauges are naturally idempotent under the `metrics`
401/// model — repeat calls just re-set the same value.
402///
403/// The version label is `CARGO_PKG_VERSION` of `faucet-core` — matches the
404/// crate that owns the observability layer. Dashboards `group_left` the gauge
405/// onto every other metric to annotate panels with the running version.
406pub fn register_build_info() {
407    metrics::gauge!(
408        "faucet_build_info",
409        "version" => env!("CARGO_PKG_VERSION"),
410    )
411    .set(1.0);
412}
413
414#[cfg(all(test, feature = "observability-install"))]
415mod tests {
416    use super::*;
417    use std::sync::Mutex;
418
419    static LOCK: Mutex<()> = Mutex::new(());
420
421    #[test]
422    fn metrics_mode_selection() {
423        use super::MetricsMode;
424        assert_eq!(MetricsMode::select(true, true), MetricsMode::Fanout);
425        assert_eq!(
426            MetricsMode::select(true, false),
427            MetricsMode::PrometheusOnly
428        );
429        assert_eq!(MetricsMode::select(false, true), MetricsMode::OtelOnly);
430        assert_eq!(MetricsMode::select(false, false), MetricsMode::None);
431    }
432
433    #[test]
434    fn no_config_returns_empty_report() {
435        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
436        let r = install_observability(&ObservabilityConfig::default()).unwrap();
437        assert!(r.prometheus_listen.is_none());
438        assert!(!r.prometheus_already_installed);
439        assert!(!r.tracing_already_installed);
440    }
441
442    #[test]
443    fn malformed_listen_returns_bind_error() {
444        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
445        let cfg = ObservabilityConfig {
446            prometheus: Some(PrometheusConfig {
447                listen: "not-a-socket".into(),
448                buckets: None,
449            }),
450            tracing: None,
451            otel: None,
452        };
453        match install_observability(&cfg) {
454            Err(InstallError::PrometheusBind { .. }) => {}
455            other => panic!("expected PrometheusBind error, got {other:?}"),
456        }
457    }
458
459    #[test]
460    fn register_build_info_is_callable_and_idempotent() {
461        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
462        // Gauges are idempotent under the metrics model; repeat calls must not
463        // panic regardless of which recorder (if any) is installed.
464        register_build_info();
465        register_build_info();
466    }
467
468    #[test]
469    fn install_prometheus_and_tracing_returns_ok() {
470        // Drive the full prometheus + tracing install path on an ephemeral
471        // port. The recorder + subscriber are process-global: depending on
472        // test ordering, the recorder either installs fresh (Ok path) or is
473        // already installed (idempotent warn path). Either way the call must
474        // return Ok and never panic, and the report must reflect what happened.
475        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
476        let cfg = ObservabilityConfig {
477            prometheus: Some(PrometheusConfig {
478                listen: "127.0.0.1:0".into(),
479                // Exercise the explicit-buckets branch.
480                buckets: Some(vec![0.01, 0.1, 1.0]),
481            }),
482            tracing: Some(TracingConfig {
483                level: "info".into(),
484            }),
485            otel: None,
486        };
487        let report = install_observability(&cfg).expect("install must return Ok");
488        // Exactly one of: recorder installed (listen set) OR already installed.
489        assert!(
490            report.prometheus_listen.is_some() || report.prometheus_already_installed,
491            "prometheus install must either bind or report already-installed"
492        );
493    }
494
495    #[test]
496    fn install_tracing_with_invalid_directive_falls_back_to_info() {
497        // An unparseable EnvFilter directive must not error — it silently falls
498        // back to "info". The call returns Ok regardless of subscriber state.
499        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
500        let cfg = ObservabilityConfig {
501            prometheus: None,
502            tracing: Some(TracingConfig {
503                // Garbage directive → try_new errors → fallback to info.
504                level: "this is !!! not a valid filter".into(),
505            }),
506            otel: None,
507        };
508        install_observability(&cfg).expect("invalid tracing directive must not fail install");
509    }
510}