Skip to main content

telemetry_init/
telemetry.rs

1//! Subscriber construction, global installation, and the [`Telemetry`]
2//! handle.
3
4use std::sync::atomic::{AtomicBool, Ordering};
5#[cfg(feature = "metrics")]
6use std::sync::Arc;
7
8use tracing::Subscriber as _SubscriberTrait;
9use tracing_subscriber::layer::{Layer, SubscriberExt};
10use tracing_subscriber::{fmt, EnvFilter};
11
12use crate::config::{LogFormat, TelemetryConfig};
13use crate::error::TelemetryError;
14
15/// The `OTel` tracer provider retained for flush/shutdown. Compiled out when
16/// the `otlp` feature is off; the alias keeps one signature for both
17/// builds.
18#[cfg(feature = "otlp")]
19type OtelProvider = opentelemetry_sdk::trace::SdkTracerProvider;
20#[cfg(not(feature = "otlp"))]
21type OtelProvider = ();
22
23/// The installed stack handed to callers: an installed global subscriber
24/// is a process-wide singleton, so the handle carries only what callers
25/// need afterwards.
26///
27/// `Telemetry` is [`Send`] + [`Sync`]; clone-free sharing is by design —
28/// hand the [`Telemetry::metrics`] registry `Arc` to workers instead.
29pub struct Telemetry {
30    #[cfg(feature = "metrics")]
31    metrics: Arc<metrics_kit::Registry>,
32    #[cfg(feature = "otlp")]
33    tracer_provider: Option<OtelProvider>,
34    /// Set once shutdown has run (explicitly or via [`Drop`]) so the
35    /// operation is idempotent.
36    shutdown: AtomicBool,
37}
38
39impl std::fmt::Debug for Telemetry {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        // Internal handles are noise for the Debug output; the shape is
42        // what matters (and `unwrap_err` on `init` needs any Debug).
43        f.debug_struct("Telemetry").finish_non_exhaustive()
44    }
45}
46
47impl Telemetry {
48    /// Bootstrap the estate telemetry stack and install it as the global
49    /// tracing subscriber.
50    ///
51    /// Installs, in one call:
52    ///
53    /// 1. a `tracing_subscriber` `EnvFilter` — built from the configured
54    ///    `log_level`, with `RUST_LOG` winning when set and non-empty —
55    ///    and a `fmt` layer ([`LogFormat::Json`] via the `json` feature,
56    ///    [`LogFormat::Pretty`] otherwise);
57    /// 2. a metrics-kit [`Registry`](metrics_kit::Registry) under the
58    ///    `metrics` feature, sized by
59    ///    [`metrics_budget`](TelemetryConfig::metrics_budget) and handed
60    ///    to the caller through [`Telemetry::metrics`];
61    /// 3. with the `otlp` feature and an endpoint configured: an
62    ///    `opentelemetry-otlp` span exporter (HTTP/protobuf, batch) wired
63    ///    as a `tracing-opentelemetry` layer, with `service.name` and
64    ///    `service.version` resource attributes and the configured sample
65    ///    rate.
66    ///
67    /// # Errors
68    ///
69    /// - [`TelemetryError::AlreadyInitialized`] — the global subscriber is
70    ///   already installed (this crate init'ed twice, or another crate
71    ///   owns the global). The process-wide subscriber can only be set
72    ///   once; this is a typed error, never a panic.
73    /// - [`TelemetryError::InitFailed`] — the `env-filter` directive does
74    ///   not parse, [`LogFormat::Json`] was requested without the `json`
75    ///   feature, or the OTLP exporter could not be built.
76    // By value per the estate API contract: configs flow out of a builder
77    // chain and are logically consumed by init even though the pipeline
78    // construction only borrows them.
79    #[allow(clippy::needless_pass_by_value)]
80    pub fn init(config: TelemetryConfig) -> Result<Self, TelemetryError> {
81        #[cfg(feature = "metrics")]
82        let metrics = Arc::new(metrics_kit::Registry::with_max_series(
83            config.metrics_budget,
84        ));
85
86        #[cfg(feature = "otlp")]
87        let (subscriber, provider) = build_pipeline(&config)?;
88        #[cfg(not(feature = "otlp"))]
89        let (subscriber, _) = build_pipeline(&config)?;
90
91        if tracing::subscriber::set_global_default(subscriber).is_err() {
92            // The provider was built before the install attempt; tear it
93            // down so the failed init leaks no exporter runtime.
94            #[cfg(feature = "otlp")]
95            if let Some(provider) = provider {
96                let _ = provider.shutdown();
97            }
98            return Err(TelemetryError::AlreadyInitialized);
99        }
100
101        Ok(Self {
102            #[cfg(feature = "metrics")]
103            metrics,
104            #[cfg(feature = "otlp")]
105            tracer_provider: provider,
106            shutdown: AtomicBool::new(false),
107        })
108    }
109
110    /// The metrics registry for hot-path handle registration.
111    ///
112    /// Handles register through this `Arc`; every clone of the returned
113    /// `Arc` renders identically. Under `no-default-features` (no
114    /// `metrics` feature) this method does not exist.
115    #[cfg(feature = "metrics")]
116    pub fn metrics(&self) -> Arc<metrics_kit::Registry> {
117        Arc::clone(&self.metrics)
118    }
119
120    /// Explicitly flush and tear down the export pipeline.
121    ///
122    /// Idempotent: the first call flushes and shuts down the OTLP tracer
123    /// provider (when one was built); every later call returns `Ok(())`
124    /// without touching anything. After `shutdown`, spans are no longer
125    /// exported; the log subscriber keeps working for the process
126    /// lifetime.
127    ///
128    /// # Errors
129    ///
130    /// [`TelemetryError::InitFailed`] if the first call's provider
131    /// shutdown fails; later calls never fail.
132    pub fn shutdown(&self) -> Result<(), TelemetryError> {
133        if self.shutdown.swap(true, Ordering::AcqRel) {
134            return Ok(());
135        }
136        #[cfg(feature = "otlp")]
137        if let Some(provider) = &self.tracer_provider {
138            provider
139                .shutdown()
140                .map_err(|e| TelemetryError::InitFailed(e.to_string()))?;
141        }
142        Ok(())
143    }
144}
145
146impl Drop for Telemetry {
147    /// Best-effort flush of in-flight spans. This is a safety net, not a
148    /// contract: batch export races process teardown, so call
149    /// [`Telemetry::shutdown`] explicitly on the way down.
150    fn drop(&mut self) {
151        if self.shutdown.swap(true, Ordering::AcqRel) {
152            return; // already shut down explicitly
153        }
154        #[cfg(feature = "otlp")]
155        if let Some(provider) = self.tracer_provider.take() {
156            // Best-effort: flush errors at teardown are not actionable.
157            let _ = provider.force_flush();
158        }
159    }
160}
161
162/// Construct the estate subscriber described by `config` *without*
163/// installing it.
164///
165/// This is the exact stack [`Telemetry::init`] installs — the seam used
166/// by the init-cost bench and by applications that must compose extra
167/// layers before installing. The OTLP tracer provider handle is **not**
168/// retained, so shutdown flushing is unavailable on this path; use
169/// [`Telemetry::init`] for the managed pipeline.
170///
171/// # Errors
172///
173/// Same conditions as [`Telemetry::init`] minus
174/// [`TelemetryError::AlreadyInitialized`] (nothing global is touched).
175pub fn build_subscriber(config: &TelemetryConfig) -> Result<Subscriber, TelemetryError> {
176    build_pipeline(config).map(|(subscriber, _)| Subscriber(subscriber))
177}
178
179/// A boxed [`Subscriber`] with a real `Debug` impl (the diagnostics
180/// matter; the subscriber's innards do not).
181///
182/// Exists because hosts that assert on [`build_subscriber`] results need
183/// `unwrap()`/`unwrap_err()` ergonomics, which require the ok/error types
184/// to be `Debug` — a bare `Box<dyn Subscriber>` is not.
185pub struct Subscriber(Box<dyn _SubscriberTrait + Send + Sync>);
186
187impl std::fmt::Debug for Subscriber {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        // The inner subscriber has no Debug bound; the honest diagnostic is
190        // that a subscriber is present.
191        f.debug_tuple("Subscriber").field(&"[subscriber]").finish()
192    }
193}
194
195impl _SubscriberTrait for Subscriber {
196    fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
197        self.0.enabled(metadata)
198    }
199
200    fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
201        self.0.new_span(span)
202    }
203
204    fn record(&self, span: &tracing::span::Id, values: &tracing::span::Record<'_>) {
205        self.0.record(span, values)
206    }
207
208    fn record_follows_from(&self, span: &tracing::span::Id, follows: &tracing::span::Id) {
209        self.0.record_follows_from(span, follows)
210    }
211
212    fn event(&self, event: &tracing::Event<'_>) {
213        self.0.event(event)
214    }
215
216    fn enter(&self, span: &tracing::span::Id) {
217        self.0.enter(span)
218    }
219
220    fn exit(&self, span: &tracing::span::Id) {
221        self.0.exit(span)
222    }
223
224    fn clone_span(&self, span: &tracing::span::Id) -> tracing::span::Id {
225        self.0.clone_span(span)
226    }
227
228    fn try_close(&self, id: tracing::span::Id) -> bool {
229        self.0.try_close(id)
230    }
231
232    fn current_span(&self) -> tracing_core::span::Current {
233        self.0.current_span()
234    }
235
236    // Forwarded explicitly: the trait default returns `None`, which would
237    // under-report the installed filter to any host introspecting it.
238    fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
239        self.0.max_level_hint()
240    }
241}
242
243/// The effective max verbosity [`Telemetry::init`] would install for
244/// `config`, honoring the `RUST_LOG` override. Round-4 estate feedback:
245/// lets hosts verify filter configuration without installing anything.
246///
247/// Returns `None` when the resolved filter declares no maximum level
248/// (e.g. a bare per-target directive with no global level).
249#[must_use]
250pub fn effective_max_level(
251    config: &TelemetryConfig,
252) -> Option<tracing::level_filters::LevelFilter> {
253    let filter = resolve_filter(config).ok()?;
254    filter.max_level_hint()
255}
256
257/// Build the effective `env-filter` from config: `RUST_LOG` wins when set
258/// and non-empty; otherwise the configured directive applies.
259pub(crate) fn resolve_filter(config: &TelemetryConfig) -> Result<EnvFilter, TelemetryError> {
260    match std::env::var("RUST_LOG") {
261        Ok(directive) if !directive.trim().is_empty() => build_filter(&directive),
262        _ => build_filter(&config.log_level),
263    }
264}
265
266/// Parse an `env-filter` directive string without consulting the
267/// environment.
268pub(crate) fn build_filter(directive: &str) -> Result<EnvFilter, TelemetryError> {
269    EnvFilter::try_new(directive).map_err(|e| {
270        TelemetryError::InitFailed(format!("invalid env-filter directive {directive:?}: {e}"))
271    })
272}
273
274/// Compose filter + fmt layer (+ `OTel` tracer layer under `otlp`) into the
275/// boxed subscriber, returning the tracer provider alongside for
276/// flush/shutdown ownership.
277fn build_pipeline(
278    config: &TelemetryConfig,
279) -> Result<
280    (
281        Box<dyn _SubscriberTrait + Send + Sync>,
282        Option<OtelProvider>,
283    ),
284    TelemetryError,
285> {
286    let filter = resolve_filter(config)?;
287
288    #[cfg(not(feature = "otlp"))]
289    {
290        let subscriber = tracing_subscriber::registry()
291            .with(filter)
292            .with(build_fmt_layer(config.log_format)?);
293        return Ok((Box::new(subscriber), None));
294    }
295
296    #[cfg(feature = "otlp")]
297    {
298        let provider = build_tracer_provider(config)?;
299        let otel_layer = provider.as_ref().map(|p| {
300            use opentelemetry::trace::TracerProvider as _;
301            let tracer = p.tracer(config.service_name.clone());
302            tracing_opentelemetry::layer().with_tracer(tracer)
303        });
304        let subscriber = tracing_subscriber::registry()
305            .with(filter)
306            .with(build_fmt_layer(config.log_format)?)
307            .with(otel_layer);
308        Ok((Box::new(subscriber), provider))
309    }
310}
311
312/// Build the `fmt` layer for the configured format, boxed over the
313/// subscriber `S` it will be applied to.
314// The Result carries the `json`-feature-disabled error path; with the
315// feature compiled in this fn is infallible, but the signature must not
316// change shape between feature combinations.
317#[allow(clippy::unnecessary_wraps)]
318fn build_fmt_layer<S>(format: LogFormat) -> Result<Box<dyn Layer<S> + Send + Sync>, TelemetryError>
319where
320    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
321{
322    match format {
323        LogFormat::Json => {
324            #[cfg(feature = "json")]
325            {
326                // JSON to stdout, ANSI escapes off: machine parsing must
327                // not meet terminal color codes.
328                Ok(fmt::layer().json().with_ansi(false).boxed())
329            }
330            #[cfg(not(feature = "json"))]
331            {
332                Err(TelemetryError::InitFailed(
333                    "LogFormat::Json requested but the `json` feature is not compiled in; \
334                     enable the default `json` feature or select LogFormat::Pretty"
335                        .to_owned(),
336                ))
337            }
338        }
339        LogFormat::Pretty => Ok(fmt::layer().boxed()),
340    }
341}
342
343/// Build the OTLP tracer provider when an endpoint is configured;
344/// `Ok(None)` (no traces) otherwise.
345///
346/// Exporter construction is offline — the HTTP client connects lazily at
347/// export time — so this path needs no network to test.
348#[cfg(feature = "otlp")]
349fn build_tracer_provider(config: &TelemetryConfig) -> Result<Option<OtelProvider>, TelemetryError> {
350    use opentelemetry_otlp::WithExportConfig;
351
352    let Some(endpoint) = config.otlp_endpoint.as_deref() else {
353        return Ok(None);
354    };
355
356    let resource = opentelemetry_sdk::Resource::builder()
357        .with_attributes([
358            opentelemetry::KeyValue::new("service.name", config.service_name.clone()),
359            opentelemetry::KeyValue::new("service.version", config.service_version.clone()),
360        ])
361        .build();
362
363    let exporter = opentelemetry_otlp::SpanExporter::builder()
364        .with_http()
365        .with_endpoint(endpoint)
366        .build()
367        .map_err(|e| TelemetryError::InitFailed(format!("OTLP exporter build failed: {e}")))?;
368
369    let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
370        .with_resource(resource)
371        .with_sampler(opentelemetry_sdk::trace::Sampler::ParentBased(Box::new(
372            opentelemetry_sdk::trace::Sampler::TraceIdRatioBased(f64::from(config.sample_rate)),
373        )))
374        .with_batch_exporter(exporter)
375        .build();
376
377    Ok(Some(provider))
378}
379
380#[cfg(test)]
381mod tests {
382    #![allow(clippy::unwrap_used, clippy::expect_used)]
383    use super::*;
384    use tracing::level_filters::LevelFilter;
385
386    /// Serializes every test that mutates `RUST_LOG`; env vars are
387    /// process-global and lib tests run multi-threaded.
388    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
389
390    /// Build a `Telemetry` without going through the global install, for
391    /// shutdown/drop-path tests. Feature-gated fields are handled here so
392    /// call sites stay readable.
393    #[cfg(feature = "otlp")]
394    fn raw_telemetry(provider: Option<OtelProvider>) -> Telemetry {
395        Telemetry {
396            #[cfg(feature = "metrics")]
397            metrics: Arc::new(metrics_kit::Registry::new()),
398            tracer_provider: provider,
399            shutdown: AtomicBool::new(false),
400        }
401    }
402
403    #[cfg(not(feature = "otlp"))]
404    fn raw_telemetry() -> Telemetry {
405        Telemetry {
406            #[cfg(feature = "metrics")]
407            metrics: Arc::new(metrics_kit::Registry::new()),
408            shutdown: AtomicBool::new(false),
409        }
410    }
411
412    /// Sets `RUST_LOG` for the test body and restores the previous state
413    /// afterwards.
414    fn with_rust_log<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
415        let guard = ENV_LOCK.lock().unwrap();
416        let saved = std::env::var("RUST_LOG").ok();
417        match value {
418            Some(v) => std::env::set_var("RUST_LOG", v),
419            None => std::env::remove_var("RUST_LOG"),
420        }
421        let out = f();
422        match saved {
423            Some(v) => std::env::set_var("RUST_LOG", v),
424            None => std::env::remove_var("RUST_LOG"),
425        }
426        drop(guard);
427        out
428    }
429
430    #[test]
431    fn build_filter_parses_directives_and_levels() {
432        assert_eq!(
433            build_filter("info").unwrap().max_level_hint(),
434            Some(LevelFilter::INFO)
435        );
436        assert_eq!(
437            build_filter("warn,hyper=debug").unwrap().max_level_hint(),
438            Some(LevelFilter::DEBUG)
439        );
440        assert!(matches!(
441            build_filter("hyper=notalevel"),
442            Err(TelemetryError::InitFailed(_))
443        ));
444        assert!(matches!(
445            build_filter("a=b=c"),
446            Err(TelemetryError::InitFailed(_))
447        ));
448    }
449
450    #[test]
451    fn resolve_filter_uses_config_when_rust_log_unset() {
452        with_rust_log(None, || {
453            let cfg = TelemetryConfig::new("svc").log_level("warn");
454            assert_eq!(
455                resolve_filter(&cfg).unwrap().max_level_hint(),
456                Some(LevelFilter::WARN)
457            );
458        });
459    }
460
461    #[test]
462    fn resolve_filter_rust_log_wins_over_config() {
463        with_rust_log(Some("debug"), || {
464            let cfg = TelemetryConfig::new("svc").log_level("info");
465            assert_eq!(
466                resolve_filter(&cfg).unwrap().max_level_hint(),
467                Some(LevelFilter::DEBUG)
468            );
469        });
470    }
471
472    #[test]
473    fn resolve_filter_treats_empty_rust_log_as_unset() {
474        with_rust_log(Some("   "), || {
475            let cfg = TelemetryConfig::new("svc").log_level("error");
476            assert_eq!(
477                resolve_filter(&cfg).unwrap().max_level_hint(),
478                Some(LevelFilter::ERROR)
479            );
480        });
481    }
482
483    #[test]
484    fn resolve_filter_propagates_invalid_rust_log() {
485        with_rust_log(Some("a=b=c"), || {
486            let cfg = TelemetryConfig::new("svc").log_level("info");
487            assert!(matches!(
488                resolve_filter(&cfg),
489                Err(TelemetryError::InitFailed(_))
490            ));
491        });
492    }
493
494    #[test]
495    fn build_subscriber_smoke_pretty() {
496        let cfg = TelemetryConfig::new("svc").log_format(LogFormat::Pretty);
497        let subscriber = build_subscriber(&cfg).unwrap();
498        assert_eq!(subscriber.max_level_hint(), Some(LevelFilter::INFO));
499    }
500
501    #[cfg(feature = "json")]
502    #[test]
503    fn build_subscriber_smoke_json() {
504        let cfg = TelemetryConfig::new("svc");
505        let subscriber = build_subscriber(&cfg).unwrap();
506        assert_eq!(subscriber.max_level_hint(), Some(LevelFilter::INFO));
507    }
508
509    #[test]
510    fn init_reports_already_initialized_when_global_is_taken() {
511        // Idempotent regardless of test order: first caller wins the
512        // global; everyone else's install attempt is ignored.
513        let _ = tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::new());
514        let err =
515            Telemetry::init(TelemetryConfig::new("svc").log_format(LogFormat::Pretty)).unwrap_err();
516        assert!(matches!(err, TelemetryError::AlreadyInitialized));
517    }
518
519    #[cfg(feature = "otlp")]
520    #[test]
521    fn init_with_otlp_reports_already_initialized_without_leaking_provider() {
522        // Same global-first pattern; the otlp endpoint forces the
523        // provider-build + cleanup branch (exporter build is offline).
524        let _ = tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::new());
525        let cfg = TelemetryConfig::new("svc").otlp_endpoint("http://127.0.0.1:4317");
526        let err = Telemetry::init(cfg).unwrap_err();
527        assert!(matches!(err, TelemetryError::AlreadyInitialized));
528    }
529
530    #[cfg(feature = "otlp")]
531    #[test]
532    fn shutdown_is_idempotent() {
533        let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
534        let t = raw_telemetry(Some(provider));
535        t.shutdown().unwrap();
536        t.shutdown().unwrap();
537        // Idempotent across the Drop path too.
538        drop(t);
539    }
540
541    #[cfg(feature = "otlp")]
542    #[test]
543    fn shutdown_maps_provider_error_to_init_failed_then_stays_ok() {
544        let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
545        provider.shutdown().unwrap(); // externally torn down already
546        let t = raw_telemetry(Some(provider));
547        assert!(matches!(
548            t.shutdown().unwrap_err(),
549            TelemetryError::InitFailed(_)
550        ));
551        // Second call hits the idempotency flag: Ok, never a repeat error.
552        t.shutdown().unwrap();
553    }
554
555    #[cfg(feature = "otlp")]
556    #[test]
557    fn drop_is_a_best_effort_flush_short_circuit() {
558        let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
559        let t = raw_telemetry(Some(provider));
560        t.shutdown().unwrap(); // flag set; Drop must take the early return
561        drop(t);
562        // Fresh handle dropped without explicit shutdown: best-effort
563        // flush path, no panic.
564        drop(raw_telemetry(Some(
565            opentelemetry_sdk::trace::SdkTracerProvider::builder().build(),
566        )));
567    }
568
569    #[cfg(not(feature = "otlp"))]
570    #[test]
571    fn shutdown_without_otlp_is_trivially_idempotent() {
572        let t = raw_telemetry();
573        t.shutdown().unwrap();
574        t.shutdown().unwrap();
575        drop(t);
576    }
577}