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/// - OTLP export (feature `otel`) needs a tokio runtime; called outside one it
92///   is skipped with a warning.
93#[cfg(feature = "observability-install")]
94pub fn install_observability(cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
95    let mut report = InstallReport::default();
96
97    // The OTLP exporters (and the fanout's `/metrics` task) need a tokio
98    // runtime; outside one, skip OTLP rather than panic.
99    #[cfg(feature = "otel")]
100    let without_otel;
101    #[cfg(feature = "otel")]
102    let cfg = if cfg.otel.is_some() && tokio::runtime::Handle::try_current().is_err() {
103        tracing::warn!("OTLP export needs a tokio runtime; install_observability skips it");
104        without_otel = ObservabilityConfig {
105            otel: None,
106            ..cfg.clone()
107        };
108        &without_otel
109    } else {
110        cfg
111    };
112
113    // Provider holders moved into the guard after both arms run; only populated
114    // (and only referenced) when the `otel` feature is enabled.
115    #[cfg(feature = "otel")]
116    let mut otel_tracer: Option<opentelemetry_sdk::trace::SdkTracerProvider> = None;
117
118    // --- Metrics ---
119    #[cfg(feature = "otel")]
120    let otel_metrics = cfg
121        .otel
122        .as_ref()
123        .map(|o| o.exports(crate::observability::otel::OtelSignal::Metrics))
124        .unwrap_or(false);
125    #[cfg(not(feature = "otel"))]
126    let otel_metrics = false;
127
128    let mode = MetricsMode::select(cfg.prometheus.is_some(), otel_metrics);
129
130    // Declared so the trace arm + guard step can move them; only used under otel.
131    #[cfg(feature = "otel")]
132    let mut otel_meter: Option<opentelemetry_sdk::metrics::SdkMeterProvider> = None;
133
134    match mode {
135        MetricsMode::None => {}
136        MetricsMode::PrometheusOnly => {
137            install_prometheus_only(cfg.prometheus.as_ref().unwrap(), &mut report)?;
138        }
139        #[cfg(feature = "otel")]
140        MetricsMode::OtelOnly => {
141            if let Some(otel) = cfg.otel.as_ref() {
142                match crate::observability::otel::build_meter_provider(otel) {
143                    Ok((mp, recorder)) => {
144                        if metrics::set_global_recorder(recorder).is_err() {
145                            tracing::warn!(
146                                "metrics recorder already installed; OTLP metrics are not exported"
147                            );
148                        } else {
149                            otel_meter = Some(mp);
150                            report.otel_signals.push("metrics");
151                        }
152                    }
153                    Err(e) => tracing::warn!("OTLP metrics exporter init failed; skipping: {e}"),
154                }
155            }
156        }
157        #[cfg(feature = "otel")]
158        MetricsMode::Fanout => {
159            install_fanout(
160                cfg.prometheus.as_ref().unwrap(),
161                cfg.otel.as_ref().unwrap(),
162                &mut report,
163                &mut otel_meter,
164            )?;
165        }
166        #[cfg(not(feature = "otel"))]
167        MetricsMode::OtelOnly | MetricsMode::Fanout => {
168            unreachable!("otel metrics mode selected without the otel feature")
169        }
170    }
171
172    // --- Traces ---
173    #[cfg(feature = "otel")]
174    let otel_traces = cfg
175        .otel
176        .as_ref()
177        .is_some_and(|o| o.exports(crate::observability::otel::OtelSignal::Traces));
178    #[cfg(not(feature = "otel"))]
179    let otel_traces = false;
180    if cfg.tracing.is_some() || otel_traces {
181        use tracing_subscriber::EnvFilter;
182        use tracing_subscriber::layer::SubscriberExt;
183        use tracing_subscriber::util::SubscriberInitExt;
184
185        let level = cfg.tracing.as_ref().map_or("info", |t| t.level.as_str());
186        let make_filter = || EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
187
188        // Reassigned only in the `otel` branch below; without that feature the
189        // `mut` is genuinely unused.
190        #[cfg_attr(not(feature = "otel"), allow(unused_mut))]
191        let mut installed = false;
192
193        #[cfg(feature = "otel")]
194        if let (true, Some(otel)) = (otel_traces, cfg.otel.as_ref()) {
195            if crate::observability::otel::tracer_installed() {
196                installed = true;
197            } else {
198                match crate::observability::otel::build_trace_provider(otel) {
199                    Ok(tp) => {
200                        use opentelemetry::trace::TracerProvider as _;
201                        use tracing_subscriber::Layer as _;
202                        let tracer = tp.tracer("faucet");
203                        crate::observability::otel::install_propagator();
204                        let layer: crate::observability::otel::TraceLayer = Box::new(
205                            tracing_opentelemetry::layer()
206                                .with_tracer(tracer)
207                                .and_then(crate::observability::otel::OtelErrorCountLayer),
208                        );
209                        // A host that installed its own subscriber first (the CLI)
210                        // registered a slot for exactly this layer.
211                        match crate::observability::otel::fill_trace_slot(layer) {
212                            Ok(()) => {
213                                report.otel_signals.push("traces");
214                                otel_tracer = Some(tp);
215                            }
216                            Err(layer) => {
217                                let reg = tracing_subscriber::registry()
218                                    .with(layer)
219                                    .with(make_filter())
220                                    .with(tracing_subscriber::fmt::layer());
221                                if reg.try_init().is_err() {
222                                    tracing::warn!(
223                                        "tracing subscriber already installed and no trace \
224                                         layer slot registered; OTLP traces are not exported"
225                                    );
226                                    report.tracing_already_installed = true;
227                                    // No otel layer was installed, so DON'T store the
228                                    // provider — let `tp` drop to shut its exporter down.
229                                } else {
230                                    report.otel_signals.push("traces");
231                                    otel_tracer = Some(tp);
232                                }
233                            }
234                        }
235                        installed = true;
236                    }
237                    Err(e) => tracing::warn!("OTLP trace exporter init failed; logs-only: {e}"),
238                }
239            }
240        }
241
242        if !installed {
243            // Install the OTLP export-error counter layer whenever ANY otel
244            // signal is exported — not only when a trace layer was installed —
245            // so `faucet_otel_export_failures_total` still fires under
246            // `otel.export: [metrics]` (audit #321 L7). `Option<Layer>` is a
247            // no-op when `None`, so this composes for the non-otel build too.
248            #[cfg(feature = "otel")]
249            let otel_error_layer = cfg
250                .otel
251                .as_ref()
252                .map(|o| {
253                    o.exports(crate::observability::otel::OtelSignal::Traces)
254                        || o.exports(crate::observability::otel::OtelSignal::Metrics)
255                })
256                .unwrap_or(false)
257                .then_some(crate::observability::otel::OtelErrorCountLayer);
258            #[cfg(not(feature = "otel"))]
259            let otel_error_layer: Option<tracing_subscriber::layer::Identity> = None;
260
261            let reg = tracing_subscriber::registry()
262                .with(make_filter())
263                .with(tracing_subscriber::fmt::layer())
264                .with(otel_error_layer);
265            if reg.try_init().is_err() {
266                // Some other code path has already set a global default. Log and
267                // continue — observability still works through the previously-
268                // installed subscriber.
269                tracing::warn!("tracing subscriber already installed; continuing");
270                report.tracing_already_installed = true;
271            }
272        }
273    }
274
275    // --- OTel guard + describe ---
276    #[cfg(feature = "otel")]
277    {
278        if otel_tracer.is_some() || otel_meter.is_some() {
279            crate::observability::otel::describe();
280            let _ = crate::observability::otel::set_guard(crate::observability::otel::OtelGuard {
281                tracer: otel_tracer,
282                meter: otel_meter,
283            });
284            report.otel_installed = true;
285        }
286    }
287
288    // Register metric HELP text + build_info after any Prometheus install
289    // attempt — describe!()/set!() into a not-yet-installed recorder is a no-op,
290    // so we order them last.
291    crate::observability::resilience::describe();
292    crate::observability::cleanup::describe();
293    crate::observability::drift::describe();
294    crate::observability::describe_roundtrip_metrics();
295    register_build_info();
296
297    Ok(report)
298}
299
300/// Install the Prometheus recorder + `/metrics` HTTP endpoint as the sole
301/// global recorder. Extracted verbatim from the original metrics arm so the
302/// fanout / otel-only paths can choose a different recorder.
303#[cfg(feature = "observability-install")]
304fn install_prometheus_only(
305    p: &PrometheusConfig,
306    report: &mut InstallReport,
307) -> Result<(), InstallError> {
308    use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
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
318    const DEFAULT_BUCKETS: &[f64] = &[
319        0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
320    ];
321    let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
322
323    let builder = PrometheusBuilder::new()
324        .with_http_listener(listen)
325        .set_buckets(buckets)
326        .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?;
327
328    match builder.install() {
329        Ok(()) => report.prometheus_listen = Some(p.listen.clone()),
330        // Match the TYPED `BuildError` variant rather than scraping its
331        // Display string — the latter breaks silently if the upstream
332        // wording changes.
333        Err(e) => match e {
334            // Recorder already installed (e.g. a prior `install` call or a
335            // test harness). Idempotent: warn and continue.
336            BuildError::FailedToSetGlobalRecorder(_) => {
337                tracing::warn!("Prometheus recorder already installed; continuing");
338                report.prometheus_already_installed = true;
339            }
340            // The HTTP `/metrics` listener could not bind. This is where a
341            // genuine bind failure (e.g. EADDRINUSE / port-in-use) lands,
342            // since the real `TcpListener::bind` happens inside `install()`,
343            // not in the address parse above. Surface it as the dedicated
344            // bind error so port-in-use is reported correctly.
345            BuildError::FailedToCreateHTTPListener(msg) => {
346                return Err(InstallError::PrometheusBind {
347                    listen: p.listen.clone(),
348                    source: std::io::Error::other(msg),
349                });
350            }
351            other => return Err(InstallError::PrometheusInstall(other.to_string())),
352        },
353    }
354    Ok(())
355}
356
357/// Install a `metrics-util` fanout recorder dispatching to BOTH a Prometheus
358/// recorder (with its `/metrics` HTTP endpoint) and an OTLP metrics recorder,
359/// so both coexist. If the OTLP exporter fails to build, falls back to a
360/// Prometheus-only recorder so `/metrics` still works — export never fails the
361/// process.
362#[cfg(all(feature = "observability-install", feature = "otel"))]
363fn install_fanout(
364    p: &PrometheusConfig,
365    otel: &crate::observability::otel::OtelConfig,
366    report: &mut InstallReport,
367    otel_meter: &mut Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
368) -> Result<(), InstallError> {
369    use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
370    use metrics_util::layers::FanoutBuilder;
371
372    let listen: std::net::SocketAddr =
373        p.listen
374            .parse()
375            .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
376                listen: p.listen.clone(),
377                source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
378            })?;
379    const DEFAULT_BUCKETS: &[f64] = &[
380        0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
381    ];
382    let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
383
384    // build() returns (recorder, /metrics exporter future) WITHOUT setting the
385    // global recorder, so we can fan it out.
386    let (prom_recorder, prom_exporter) = PrometheusBuilder::new()
387        .with_http_listener(listen)
388        .set_buckets(buckets)
389        .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?
390        .build()
391        .map_err(|e| match e {
392            BuildError::FailedToCreateHTTPListener(msg) => InstallError::PrometheusBind {
393                listen: p.listen.clone(),
394                source: std::io::Error::other(msg),
395            },
396            other => InstallError::PrometheusInstall(other.to_string()),
397        })?;
398
399    match crate::observability::otel::build_meter_provider(otel) {
400        Ok((mp, otel_recorder)) => {
401            let fanout = FanoutBuilder::default()
402                .add_recorder(prom_recorder)
403                .add_recorder(otel_recorder)
404                .build();
405            if metrics::set_global_recorder(fanout).is_err() {
406                tracing::warn!("metrics recorder already installed; continuing");
407                report.prometheus_already_installed = true;
408            } else {
409                report.prometheus_listen = Some(p.listen.clone());
410                report.otel_signals.push("metrics");
411                *otel_meter = Some(mp);
412                tokio::spawn(prom_exporter);
413            }
414        }
415        Err(e) => {
416            // OTLP metrics init failed — fall back to Prometheus-only so /metrics
417            // still works; export never fails the process.
418            tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
419            if metrics::set_global_recorder(prom_recorder).is_err() {
420                report.prometheus_already_installed = true;
421            } else {
422                report.prometheus_listen = Some(p.listen.clone());
423                tokio::spawn(prom_exporter);
424            }
425        }
426    }
427    Ok(())
428}
429
430/// Install, as the global metrics recorder, a fanout to `prometheus` — a
431/// recorder the host built (e.g. `PrometheusBuilder::build_recorder`) and
432/// renders through its own handle — and an OTLP metrics exporter built from
433/// `otel`. For a host such as an HTTP server that serves `/metrics` itself and
434/// so cannot use [`install_observability`]'s listener.
435///
436/// Returns `Ok(true)` when OTLP metrics are exported, `Ok(false)` when the
437/// exporter could not be built (the Prometheus recorder is installed alone, so
438/// `/metrics` keeps working), and an error when a global recorder is already
439/// installed. The meter provider lives for the process and is flushed by
440/// [`shutdown_otel`](crate::observability::otel::shutdown_otel).
441#[cfg(all(feature = "observability-install", feature = "otel"))]
442pub fn install_prometheus_with_otel_metrics(
443    prometheus: metrics_exporter_prometheus::PrometheusRecorder,
444    otel: &crate::observability::otel::OtelConfig,
445) -> Result<bool, InstallError> {
446    use metrics_util::layers::FanoutBuilder;
447    let already =
448        || InstallError::PrometheusInstall("a metrics recorder is already installed".into());
449    match crate::observability::otel::build_meter_provider(otel) {
450        Ok((mp, otel_recorder)) => {
451            let fanout = FanoutBuilder::default()
452                .add_recorder(prometheus)
453                .add_recorder(otel_recorder)
454                .build();
455            metrics::set_global_recorder(fanout).map_err(|_| already())?;
456            crate::observability::otel::keep_meter_provider(mp);
457            crate::observability::otel::describe();
458            Ok(true)
459        }
460        Err(e) => {
461            tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
462            metrics::set_global_recorder(prometheus).map_err(|_| already())?;
463            Ok(false)
464        }
465    }
466}
467
468/// Non-`observability-install` stub. Returns an empty report, never panics.
469#[cfg(not(feature = "observability-install"))]
470pub fn install_observability(_cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
471    crate::observability::resilience::describe();
472    crate::observability::cleanup::describe();
473    crate::observability::drift::describe();
474    crate::observability::describe_roundtrip_metrics();
475    crate::observability::otel::describe();
476    register_build_info();
477    Ok(InstallReport::default())
478}
479
480/// The version [`register_build_info`] reports, when an application set one.
481static BUILD_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
482
483/// Set the version the `faucet_build_info{version}` gauge reports — call it
484/// with the **binary's** own `env!("CARGO_PKG_VERSION")` before installing
485/// observability. Without it the label is `faucet-core`'s version, which
486/// differs from the binary's whenever only the binary was released (#832).
487///
488/// First call wins (a process has one build); returns `false` when a version
489/// was already set. Re-registers the gauge so a recorder installed before
490/// this call reports the new version too.
491pub fn set_build_version(version: &'static str) -> bool {
492    let first = BUILD_VERSION.set(version).is_ok();
493    if first {
494        register_build_info();
495    }
496    first
497}
498
499/// The version `faucet_build_info` reports: the one passed to
500/// [`set_build_version`], else `faucet-core`'s `CARGO_PKG_VERSION`.
501pub fn build_version() -> &'static str {
502    BUILD_VERSION
503        .get()
504        .copied()
505        .unwrap_or(env!("CARGO_PKG_VERSION"))
506}
507
508/// Register the `faucet_build_info{version}` gauge (set to 1) under the
509/// currently-installed `metrics` recorder. Safe to call from any code path
510/// that wants to ensure the gauge is set; `install_observability` invokes
511/// this automatically. Gauges are naturally idempotent under the `metrics`
512/// model — repeat calls just re-set the same value.
513///
514/// The version label is [`build_version`]: the application's version when it
515/// called [`set_build_version`] (the `faucet` CLI does), else `faucet-core`'s.
516/// Dashboards `group_left` the gauge onto every other metric to annotate
517/// panels with the running version.
518pub fn register_build_info() {
519    metrics::gauge!(
520        "faucet_build_info",
521        "version" => build_version(),
522    )
523    .set(1.0);
524}
525
526#[cfg(all(test, feature = "observability-install"))]
527mod tests {
528    use super::*;
529    use std::sync::Mutex;
530
531    static LOCK: Mutex<()> = Mutex::new(());
532
533    #[cfg(feature = "otel")]
534    #[test]
535    fn otlp_outside_a_runtime_is_skipped_not_a_panic() {
536        let cfg = ObservabilityConfig {
537            otel: Some(crate::observability::otel::OtelConfig::default()),
538            ..Default::default()
539        };
540        let report = install_observability(&cfg).expect("no runtime is not an error");
541        assert!(report.otel_signals.is_empty());
542        assert!(!report.prometheus_already_installed);
543    }
544
545    #[test]
546    fn metrics_mode_selection() {
547        use super::MetricsMode;
548        assert_eq!(MetricsMode::select(true, true), MetricsMode::Fanout);
549        assert_eq!(
550            MetricsMode::select(true, false),
551            MetricsMode::PrometheusOnly
552        );
553        assert_eq!(MetricsMode::select(false, true), MetricsMode::OtelOnly);
554        assert_eq!(MetricsMode::select(false, false), MetricsMode::None);
555    }
556
557    #[test]
558    fn no_config_returns_empty_report() {
559        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
560        let r = install_observability(&ObservabilityConfig::default()).unwrap();
561        assert!(r.prometheus_listen.is_none());
562        assert!(!r.prometheus_already_installed);
563        assert!(!r.tracing_already_installed);
564    }
565
566    #[test]
567    fn malformed_listen_returns_bind_error() {
568        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
569        let cfg = ObservabilityConfig {
570            prometheus: Some(PrometheusConfig {
571                listen: "not-a-socket".into(),
572                buckets: None,
573            }),
574            tracing: None,
575            otel: None,
576        };
577        match install_observability(&cfg) {
578            Err(InstallError::PrometheusBind { .. }) => {}
579            other => panic!("expected PrometheusBind error, got {other:?}"),
580        }
581    }
582
583    #[test]
584    fn register_build_info_is_callable_and_idempotent() {
585        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
586        // Gauges are idempotent under the metrics model; repeat calls must not
587        // panic regardless of which recorder (if any) is installed.
588        register_build_info();
589        register_build_info();
590    }
591
592    #[test]
593    fn install_prometheus_and_tracing_returns_ok() {
594        // Drive the full prometheus + tracing install path on an ephemeral
595        // port. The recorder + subscriber are process-global: depending on
596        // test ordering, the recorder either installs fresh (Ok path) or is
597        // already installed (idempotent warn path). Either way the call must
598        // return Ok and never panic, and the report must reflect what happened.
599        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
600        let cfg = ObservabilityConfig {
601            prometheus: Some(PrometheusConfig {
602                listen: "127.0.0.1:0".into(),
603                // Exercise the explicit-buckets branch.
604                buckets: Some(vec![0.01, 0.1, 1.0]),
605            }),
606            tracing: Some(TracingConfig {
607                level: "info".into(),
608            }),
609            otel: None,
610        };
611        let report = install_observability(&cfg).expect("install must return Ok");
612        // Exactly one of: recorder installed (listen set) OR already installed.
613        assert!(
614            report.prometheus_listen.is_some() || report.prometheus_already_installed,
615            "prometheus install must either bind or report already-installed"
616        );
617    }
618
619    #[test]
620    fn install_tracing_with_invalid_directive_falls_back_to_info() {
621        // An unparseable EnvFilter directive must not error — it silently falls
622        // back to "info". The call returns Ok regardless of subscriber state.
623        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
624        let cfg = ObservabilityConfig {
625            prometheus: None,
626            tracing: Some(TracingConfig {
627                // Garbage directive → try_new errors → fallback to info.
628                level: "this is !!! not a valid filter".into(),
629            }),
630            otel: None,
631        };
632        install_observability(&cfg).expect("invalid tracing directive must not fail install");
633    }
634}