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;
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(
176    config: &TelemetryConfig,
177) -> Result<Box<dyn Subscriber + Send + Sync>, TelemetryError> {
178    build_pipeline(config).map(|(subscriber, _)| subscriber)
179}
180
181/// Build the effective `env-filter` from config: `RUST_LOG` wins when set
182/// and non-empty; otherwise the configured directive applies.
183pub(crate) fn resolve_filter(config: &TelemetryConfig) -> Result<EnvFilter, TelemetryError> {
184    match std::env::var("RUST_LOG") {
185        Ok(directive) if !directive.trim().is_empty() => build_filter(&directive),
186        _ => build_filter(&config.log_level),
187    }
188}
189
190/// Parse an `env-filter` directive string without consulting the
191/// environment.
192pub(crate) fn build_filter(directive: &str) -> Result<EnvFilter, TelemetryError> {
193    EnvFilter::try_new(directive).map_err(|e| {
194        TelemetryError::InitFailed(format!("invalid env-filter directive {directive:?}: {e}"))
195    })
196}
197
198/// Compose filter + fmt layer (+ `OTel` tracer layer under `otlp`) into the
199/// boxed subscriber, returning the tracer provider alongside for
200/// flush/shutdown ownership.
201fn build_pipeline(
202    config: &TelemetryConfig,
203) -> Result<(Box<dyn Subscriber + Send + Sync>, Option<OtelProvider>), TelemetryError> {
204    let filter = resolve_filter(config)?;
205
206    #[cfg(not(feature = "otlp"))]
207    {
208        let subscriber = tracing_subscriber::registry()
209            .with(filter)
210            .with(build_fmt_layer(config.log_format)?);
211        return Ok((Box::new(subscriber), None));
212    }
213
214    #[cfg(feature = "otlp")]
215    {
216        let provider = build_tracer_provider(config)?;
217        let otel_layer = provider.as_ref().map(|p| {
218            use opentelemetry::trace::TracerProvider as _;
219            let tracer = p.tracer(config.service_name.clone());
220            tracing_opentelemetry::layer().with_tracer(tracer)
221        });
222        let subscriber = tracing_subscriber::registry()
223            .with(filter)
224            .with(build_fmt_layer(config.log_format)?)
225            .with(otel_layer);
226        Ok((Box::new(subscriber), provider))
227    }
228}
229
230/// Build the `fmt` layer for the configured format, boxed over the
231/// subscriber `S` it will be applied to.
232// The Result carries the `json`-feature-disabled error path; with the
233// feature compiled in this fn is infallible, but the signature must not
234// change shape between feature combinations.
235#[allow(clippy::unnecessary_wraps)]
236fn build_fmt_layer<S>(format: LogFormat) -> Result<Box<dyn Layer<S> + Send + Sync>, TelemetryError>
237where
238    S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
239{
240    match format {
241        LogFormat::Json => {
242            #[cfg(feature = "json")]
243            {
244                // JSON to stdout, ANSI escapes off: machine parsing must
245                // not meet terminal color codes.
246                Ok(fmt::layer().json().with_ansi(false).boxed())
247            }
248            #[cfg(not(feature = "json"))]
249            {
250                Err(TelemetryError::InitFailed(
251                    "LogFormat::Json requested but the `json` feature is not compiled in; \
252                     enable the default `json` feature or select LogFormat::Pretty"
253                        .to_owned(),
254                ))
255            }
256        }
257        LogFormat::Pretty => Ok(fmt::layer().boxed()),
258    }
259}
260
261/// Build the OTLP tracer provider when an endpoint is configured;
262/// `Ok(None)` (no traces) otherwise.
263///
264/// Exporter construction is offline — the HTTP client connects lazily at
265/// export time — so this path needs no network to test.
266#[cfg(feature = "otlp")]
267fn build_tracer_provider(config: &TelemetryConfig) -> Result<Option<OtelProvider>, TelemetryError> {
268    use opentelemetry_otlp::WithExportConfig;
269
270    let Some(endpoint) = config.otlp_endpoint.as_deref() else {
271        return Ok(None);
272    };
273
274    let resource = opentelemetry_sdk::Resource::builder()
275        .with_attributes([
276            opentelemetry::KeyValue::new("service.name", config.service_name.clone()),
277            opentelemetry::KeyValue::new("service.version", config.service_version.clone()),
278        ])
279        .build();
280
281    let exporter = opentelemetry_otlp::SpanExporter::builder()
282        .with_http()
283        .with_endpoint(endpoint)
284        .build()
285        .map_err(|e| TelemetryError::InitFailed(format!("OTLP exporter build failed: {e}")))?;
286
287    let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
288        .with_resource(resource)
289        .with_sampler(opentelemetry_sdk::trace::Sampler::ParentBased(Box::new(
290            opentelemetry_sdk::trace::Sampler::TraceIdRatioBased(f64::from(config.sample_rate)),
291        )))
292        .with_batch_exporter(exporter)
293        .build();
294
295    Ok(Some(provider))
296}
297
298#[cfg(test)]
299mod tests {
300    #![allow(clippy::unwrap_used, clippy::expect_used)]
301    use super::*;
302    use tracing::level_filters::LevelFilter;
303
304    /// Serializes every test that mutates `RUST_LOG`; env vars are
305    /// process-global and lib tests run multi-threaded.
306    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
307
308    /// Build a `Telemetry` without going through the global install, for
309    /// shutdown/drop-path tests. Feature-gated fields are handled here so
310    /// call sites stay readable.
311    #[cfg(feature = "otlp")]
312    fn raw_telemetry(provider: Option<OtelProvider>) -> Telemetry {
313        Telemetry {
314            #[cfg(feature = "metrics")]
315            metrics: Arc::new(metrics_kit::Registry::new()),
316            tracer_provider: provider,
317            shutdown: AtomicBool::new(false),
318        }
319    }
320
321    #[cfg(not(feature = "otlp"))]
322    fn raw_telemetry() -> Telemetry {
323        Telemetry {
324            shutdown: AtomicBool::new(false),
325        }
326    }
327
328    /// Sets `RUST_LOG` for the test body and restores the previous state
329    /// afterwards.
330    fn with_rust_log<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
331        let guard = ENV_LOCK.lock().unwrap();
332        let saved = std::env::var("RUST_LOG").ok();
333        match value {
334            Some(v) => std::env::set_var("RUST_LOG", v),
335            None => std::env::remove_var("RUST_LOG"),
336        }
337        let out = f();
338        match saved {
339            Some(v) => std::env::set_var("RUST_LOG", v),
340            None => std::env::remove_var("RUST_LOG"),
341        }
342        drop(guard);
343        out
344    }
345
346    #[test]
347    fn build_filter_parses_directives_and_levels() {
348        assert_eq!(
349            build_filter("info").unwrap().max_level_hint(),
350            Some(LevelFilter::INFO)
351        );
352        assert_eq!(
353            build_filter("warn,hyper=debug").unwrap().max_level_hint(),
354            Some(LevelFilter::DEBUG)
355        );
356        assert!(matches!(
357            build_filter("hyper=notalevel"),
358            Err(TelemetryError::InitFailed(_))
359        ));
360        assert!(matches!(
361            build_filter("a=b=c"),
362            Err(TelemetryError::InitFailed(_))
363        ));
364    }
365
366    #[test]
367    fn resolve_filter_uses_config_when_rust_log_unset() {
368        with_rust_log(None, || {
369            let cfg = TelemetryConfig::new("svc").log_level("warn");
370            assert_eq!(
371                resolve_filter(&cfg).unwrap().max_level_hint(),
372                Some(LevelFilter::WARN)
373            );
374        });
375    }
376
377    #[test]
378    fn resolve_filter_rust_log_wins_over_config() {
379        with_rust_log(Some("debug"), || {
380            let cfg = TelemetryConfig::new("svc").log_level("info");
381            assert_eq!(
382                resolve_filter(&cfg).unwrap().max_level_hint(),
383                Some(LevelFilter::DEBUG)
384            );
385        });
386    }
387
388    #[test]
389    fn resolve_filter_treats_empty_rust_log_as_unset() {
390        with_rust_log(Some("   "), || {
391            let cfg = TelemetryConfig::new("svc").log_level("error");
392            assert_eq!(
393                resolve_filter(&cfg).unwrap().max_level_hint(),
394                Some(LevelFilter::ERROR)
395            );
396        });
397    }
398
399    #[test]
400    fn resolve_filter_propagates_invalid_rust_log() {
401        with_rust_log(Some("a=b=c"), || {
402            let cfg = TelemetryConfig::new("svc").log_level("info");
403            assert!(matches!(
404                resolve_filter(&cfg),
405                Err(TelemetryError::InitFailed(_))
406            ));
407        });
408    }
409
410    #[test]
411    fn build_subscriber_smoke_pretty() {
412        let cfg = TelemetryConfig::new("svc").log_format(LogFormat::Pretty);
413        let subscriber = build_subscriber(&cfg).unwrap();
414        assert_eq!(subscriber.max_level_hint(), Some(LevelFilter::INFO));
415    }
416
417    #[cfg(feature = "json")]
418    #[test]
419    fn build_subscriber_smoke_json() {
420        let cfg = TelemetryConfig::new("svc");
421        let subscriber = build_subscriber(&cfg).unwrap();
422        assert_eq!(subscriber.max_level_hint(), Some(LevelFilter::INFO));
423    }
424
425    #[test]
426    fn init_reports_already_initialized_when_global_is_taken() {
427        // Idempotent regardless of test order: first caller wins the
428        // global; everyone else's install attempt is ignored.
429        let _ = tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::new());
430        let err =
431            Telemetry::init(TelemetryConfig::new("svc").log_format(LogFormat::Pretty)).unwrap_err();
432        assert!(matches!(err, TelemetryError::AlreadyInitialized));
433    }
434
435    #[cfg(feature = "otlp")]
436    #[test]
437    fn init_with_otlp_reports_already_initialized_without_leaking_provider() {
438        // Same global-first pattern; the otlp endpoint forces the
439        // provider-build + cleanup branch (exporter build is offline).
440        let _ = tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::new());
441        let cfg = TelemetryConfig::new("svc").otlp_endpoint("http://127.0.0.1:4317");
442        let err = Telemetry::init(cfg).unwrap_err();
443        assert!(matches!(err, TelemetryError::AlreadyInitialized));
444    }
445
446    #[cfg(feature = "otlp")]
447    #[test]
448    fn shutdown_is_idempotent() {
449        let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
450        let t = raw_telemetry(Some(provider));
451        t.shutdown().unwrap();
452        t.shutdown().unwrap();
453        // Idempotent across the Drop path too.
454        drop(t);
455    }
456
457    #[cfg(feature = "otlp")]
458    #[test]
459    fn shutdown_maps_provider_error_to_init_failed_then_stays_ok() {
460        let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
461        provider.shutdown().unwrap(); // externally torn down already
462        let t = raw_telemetry(Some(provider));
463        assert!(matches!(
464            t.shutdown().unwrap_err(),
465            TelemetryError::InitFailed(_)
466        ));
467        // Second call hits the idempotency flag: Ok, never a repeat error.
468        t.shutdown().unwrap();
469    }
470
471    #[cfg(feature = "otlp")]
472    #[test]
473    fn drop_is_a_best_effort_flush_short_circuit() {
474        let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
475        let t = raw_telemetry(Some(provider));
476        t.shutdown().unwrap(); // flag set; Drop must take the early return
477        drop(t);
478        // Fresh handle dropped without explicit shutdown: best-effort
479        // flush path, no panic.
480        drop(raw_telemetry(Some(
481            opentelemetry_sdk::trace::SdkTracerProvider::builder().build(),
482        )));
483    }
484
485    #[cfg(not(feature = "otlp"))]
486    #[test]
487    fn shutdown_without_otlp_is_trivially_idempotent() {
488        let t = raw_telemetry();
489        t.shutdown().unwrap();
490        t.shutdown().unwrap();
491        drop(t);
492    }
493}