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            let reg = tracing_subscriber::registry()
203                .with(make_filter())
204                .with(tracing_subscriber::fmt::layer());
205            if reg.try_init().is_err() {
206                // Some other code path has already set a global default. Log and
207                // continue — observability still works through the previously-
208                // installed subscriber.
209                tracing::warn!("tracing subscriber already installed; continuing");
210                report.tracing_already_installed = true;
211            }
212        }
213    }
214
215    // --- OTel guard + describe ---
216    #[cfg(feature = "otel")]
217    {
218        if otel_tracer.is_some() || otel_meter.is_some() {
219            crate::observability::otel::describe();
220            let _ = crate::observability::otel::set_guard(crate::observability::otel::OtelGuard {
221                tracer: otel_tracer,
222                meter: otel_meter,
223            });
224            report.otel_installed = true;
225        }
226    }
227
228    // Register metric HELP text + build_info after any Prometheus install
229    // attempt — describe!()/set!() into a not-yet-installed recorder is a no-op,
230    // so we order them last.
231    crate::observability::resilience::describe();
232    crate::observability::drift::describe();
233    register_build_info();
234
235    Ok(report)
236}
237
238/// Install the Prometheus recorder + `/metrics` HTTP endpoint as the sole
239/// global recorder. Extracted verbatim from the original metrics arm so the
240/// fanout / otel-only paths can choose a different recorder.
241#[cfg(feature = "observability-install")]
242fn install_prometheus_only(
243    p: &PrometheusConfig,
244    report: &mut InstallReport,
245) -> Result<(), InstallError> {
246    use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
247
248    let listen: std::net::SocketAddr =
249        p.listen
250            .parse()
251            .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
252                listen: p.listen.clone(),
253                source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
254            })?;
255
256    const DEFAULT_BUCKETS: &[f64] = &[
257        0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
258    ];
259    let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
260
261    let builder = PrometheusBuilder::new()
262        .with_http_listener(listen)
263        .set_buckets(buckets)
264        .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?;
265
266    match builder.install() {
267        Ok(()) => report.prometheus_listen = Some(p.listen.clone()),
268        // Match the TYPED `BuildError` variant rather than scraping its
269        // Display string — the latter breaks silently if the upstream
270        // wording changes.
271        Err(e) => match e {
272            // Recorder already installed (e.g. a prior `install` call or a
273            // test harness). Idempotent: warn and continue.
274            BuildError::FailedToSetGlobalRecorder(_) => {
275                tracing::warn!("Prometheus recorder already installed; continuing");
276                report.prometheus_already_installed = true;
277            }
278            // The HTTP `/metrics` listener could not bind. This is where a
279            // genuine bind failure (e.g. EADDRINUSE / port-in-use) lands,
280            // since the real `TcpListener::bind` happens inside `install()`,
281            // not in the address parse above. Surface it as the dedicated
282            // bind error so port-in-use is reported correctly.
283            BuildError::FailedToCreateHTTPListener(msg) => {
284                return Err(InstallError::PrometheusBind {
285                    listen: p.listen.clone(),
286                    source: std::io::Error::other(msg),
287                });
288            }
289            other => return Err(InstallError::PrometheusInstall(other.to_string())),
290        },
291    }
292    Ok(())
293}
294
295/// Install a `metrics-util` fanout recorder dispatching to BOTH a Prometheus
296/// recorder (with its `/metrics` HTTP endpoint) and an OTLP metrics recorder,
297/// so both coexist. If the OTLP exporter fails to build, falls back to a
298/// Prometheus-only recorder so `/metrics` still works — export never fails the
299/// process.
300#[cfg(all(feature = "observability-install", feature = "otel"))]
301fn install_fanout(
302    p: &PrometheusConfig,
303    otel: &crate::observability::otel::OtelConfig,
304    report: &mut InstallReport,
305    otel_meter: &mut Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
306) -> Result<(), InstallError> {
307    use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
308    use metrics_util::layers::FanoutBuilder;
309
310    let listen: std::net::SocketAddr =
311        p.listen
312            .parse()
313            .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
314                listen: p.listen.clone(),
315                source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
316            })?;
317    const DEFAULT_BUCKETS: &[f64] = &[
318        0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
319    ];
320    let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
321
322    // build() returns (recorder, /metrics exporter future) WITHOUT setting the
323    // global recorder, so we can fan it out.
324    let (prom_recorder, prom_exporter) = PrometheusBuilder::new()
325        .with_http_listener(listen)
326        .set_buckets(buckets)
327        .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?
328        .build()
329        .map_err(|e| match e {
330            BuildError::FailedToCreateHTTPListener(msg) => InstallError::PrometheusBind {
331                listen: p.listen.clone(),
332                source: std::io::Error::other(msg),
333            },
334            other => InstallError::PrometheusInstall(other.to_string()),
335        })?;
336
337    match crate::observability::otel::build_meter_provider(otel) {
338        Ok((mp, otel_recorder)) => {
339            let fanout = FanoutBuilder::default()
340                .add_recorder(prom_recorder)
341                .add_recorder(otel_recorder)
342                .build();
343            if metrics::set_global_recorder(fanout).is_err() {
344                tracing::warn!("metrics recorder already installed; continuing");
345                report.prometheus_already_installed = true;
346            } else {
347                report.prometheus_listen = Some(p.listen.clone());
348                report.otel_signals.push("metrics");
349                *otel_meter = Some(mp);
350                tokio::spawn(prom_exporter);
351            }
352        }
353        Err(e) => {
354            // OTLP metrics init failed — fall back to Prometheus-only so /metrics
355            // still works; export never fails the process.
356            tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
357            if metrics::set_global_recorder(prom_recorder).is_err() {
358                report.prometheus_already_installed = true;
359            } else {
360                report.prometheus_listen = Some(p.listen.clone());
361                tokio::spawn(prom_exporter);
362            }
363        }
364    }
365    Ok(())
366}
367
368/// Non-`observability-install` stub. Returns an empty report, never panics.
369#[cfg(not(feature = "observability-install"))]
370pub fn install_observability(_cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
371    crate::observability::resilience::describe();
372    crate::observability::drift::describe();
373    crate::observability::otel::describe();
374    register_build_info();
375    Ok(InstallReport::default())
376}
377
378/// Register the `faucet_build_info{version}` gauge (set to 1) under the
379/// currently-installed `metrics` recorder. Safe to call from any code path
380/// that wants to ensure the gauge is set; `install_observability` invokes
381/// this automatically. Gauges are naturally idempotent under the `metrics`
382/// model — repeat calls just re-set the same value.
383///
384/// The version label is `CARGO_PKG_VERSION` of `faucet-core` — matches the
385/// crate that owns the observability layer. Dashboards `group_left` the gauge
386/// onto every other metric to annotate panels with the running version.
387pub fn register_build_info() {
388    metrics::gauge!(
389        "faucet_build_info",
390        "version" => env!("CARGO_PKG_VERSION"),
391    )
392    .set(1.0);
393}
394
395#[cfg(all(test, feature = "observability-install"))]
396mod tests {
397    use super::*;
398    use std::sync::Mutex;
399
400    static LOCK: Mutex<()> = Mutex::new(());
401
402    #[test]
403    fn metrics_mode_selection() {
404        use super::MetricsMode;
405        assert_eq!(MetricsMode::select(true, true), MetricsMode::Fanout);
406        assert_eq!(
407            MetricsMode::select(true, false),
408            MetricsMode::PrometheusOnly
409        );
410        assert_eq!(MetricsMode::select(false, true), MetricsMode::OtelOnly);
411        assert_eq!(MetricsMode::select(false, false), MetricsMode::None);
412    }
413
414    #[test]
415    fn no_config_returns_empty_report() {
416        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
417        let r = install_observability(&ObservabilityConfig::default()).unwrap();
418        assert!(r.prometheus_listen.is_none());
419        assert!(!r.prometheus_already_installed);
420        assert!(!r.tracing_already_installed);
421    }
422
423    #[test]
424    fn malformed_listen_returns_bind_error() {
425        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
426        let cfg = ObservabilityConfig {
427            prometheus: Some(PrometheusConfig {
428                listen: "not-a-socket".into(),
429                buckets: None,
430            }),
431            tracing: None,
432            otel: None,
433        };
434        match install_observability(&cfg) {
435            Err(InstallError::PrometheusBind { .. }) => {}
436            other => panic!("expected PrometheusBind error, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn register_build_info_is_callable_and_idempotent() {
442        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
443        // Gauges are idempotent under the metrics model; repeat calls must not
444        // panic regardless of which recorder (if any) is installed.
445        register_build_info();
446        register_build_info();
447    }
448
449    #[test]
450    fn install_prometheus_and_tracing_returns_ok() {
451        // Drive the full prometheus + tracing install path on an ephemeral
452        // port. The recorder + subscriber are process-global: depending on
453        // test ordering, the recorder either installs fresh (Ok path) or is
454        // already installed (idempotent warn path). Either way the call must
455        // return Ok and never panic, and the report must reflect what happened.
456        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
457        let cfg = ObservabilityConfig {
458            prometheus: Some(PrometheusConfig {
459                listen: "127.0.0.1:0".into(),
460                // Exercise the explicit-buckets branch.
461                buckets: Some(vec![0.01, 0.1, 1.0]),
462            }),
463            tracing: Some(TracingConfig {
464                level: "info".into(),
465            }),
466            otel: None,
467        };
468        let report = install_observability(&cfg).expect("install must return Ok");
469        // Exactly one of: recorder installed (listen set) OR already installed.
470        assert!(
471            report.prometheus_listen.is_some() || report.prometheus_already_installed,
472            "prometheus install must either bind or report already-installed"
473        );
474    }
475
476    #[test]
477    fn install_tracing_with_invalid_directive_falls_back_to_info() {
478        // An unparseable EnvFilter directive must not error — it silently falls
479        // back to "info". The call returns Ok regardless of subscriber state.
480        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
481        let cfg = ObservabilityConfig {
482            prometheus: None,
483            tracing: Some(TracingConfig {
484                // Garbage directive → try_new errors → fallback to info.
485                level: "this is !!! not a valid filter".into(),
486            }),
487            otel: None,
488        };
489        install_observability(&cfg).expect("invalid tracing directive must not fail install");
490    }
491}