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::cleanup::describe();
252    crate::observability::drift::describe();
253    register_build_info();
254
255    Ok(report)
256}
257
258/// Install the Prometheus recorder + `/metrics` HTTP endpoint as the sole
259/// global recorder. Extracted verbatim from the original metrics arm so the
260/// fanout / otel-only paths can choose a different recorder.
261#[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        // Match the TYPED `BuildError` variant rather than scraping its
289        // Display string — the latter breaks silently if the upstream
290        // wording changes.
291        Err(e) => match e {
292            // Recorder already installed (e.g. a prior `install` call or a
293            // test harness). Idempotent: warn and continue.
294            BuildError::FailedToSetGlobalRecorder(_) => {
295                tracing::warn!("Prometheus recorder already installed; continuing");
296                report.prometheus_already_installed = true;
297            }
298            // The HTTP `/metrics` listener could not bind. This is where a
299            // genuine bind failure (e.g. EADDRINUSE / port-in-use) lands,
300            // since the real `TcpListener::bind` happens inside `install()`,
301            // not in the address parse above. Surface it as the dedicated
302            // bind error so port-in-use is reported correctly.
303            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/// Install a `metrics-util` fanout recorder dispatching to BOTH a Prometheus
316/// recorder (with its `/metrics` HTTP endpoint) and an OTLP metrics recorder,
317/// so both coexist. If the OTLP exporter fails to build, falls back to a
318/// Prometheus-only recorder so `/metrics` still works — export never fails the
319/// process.
320#[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    // build() returns (recorder, /metrics exporter future) WITHOUT setting the
343    // global recorder, so we can fan it out.
344    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            // OTLP metrics init failed — fall back to Prometheus-only so /metrics
375            // still works; export never fails the process.
376            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/// Non-`observability-install` stub. Returns an empty report, never panics.
389#[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
399/// Register the `faucet_build_info{version}` gauge (set to 1) under the
400/// currently-installed `metrics` recorder. Safe to call from any code path
401/// that wants to ensure the gauge is set; `install_observability` invokes
402/// this automatically. Gauges are naturally idempotent under the `metrics`
403/// model — repeat calls just re-set the same value.
404///
405/// The version label is `CARGO_PKG_VERSION` of `faucet-core` — matches the
406/// crate that owns the observability layer. Dashboards `group_left` the gauge
407/// onto every other metric to annotate panels with the running version.
408pub 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        // Gauges are idempotent under the metrics model; repeat calls must not
465        // panic regardless of which recorder (if any) is installed.
466        register_build_info();
467        register_build_info();
468    }
469
470    #[test]
471    fn install_prometheus_and_tracing_returns_ok() {
472        // Drive the full prometheus + tracing install path on an ephemeral
473        // port. The recorder + subscriber are process-global: depending on
474        // test ordering, the recorder either installs fresh (Ok path) or is
475        // already installed (idempotent warn path). Either way the call must
476        // return Ok and never panic, and the report must reflect what happened.
477        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                // Exercise the explicit-buckets branch.
482                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        // Exactly one of: recorder installed (listen set) OR already installed.
491        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        // An unparseable EnvFilter directive must not error — it silently falls
500        // back to "info". The call returns Ok regardless of subscriber state.
501        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
502        let cfg = ObservabilityConfig {
503            prometheus: None,
504            tracing: Some(TracingConfig {
505                // Garbage directive → try_new errors → fallback to info.
506                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}