Skip to main content

otel_bootstrap/
lib.rs

1//! One-call OpenTelemetry bootstrap — traces + metrics + logs with OTLP export.
2//!
3//! Call [`init_telemetry`] at `main()` before starting the server. Keep the returned
4//! [`TelemetryHandles`] alive for the duration of the process — dropping them flushes
5//! and shuts down both providers.
6//!
7//! Configuration is via environment variables per the OpenTelemetry spec:
8//! - `OTEL_EXPORTER_OTLP_ENDPOINT` (default: `http://localhost:4317` for gRPC, `http://localhost:4318` for HTTP)
9//! - `OTEL_EXPORTER_OTLP_PROTOCOL` (`grpc` or `http/protobuf`) — selects transport when both features are enabled
10//! - `OTEL_EXPORTER_OTLP_TIMEOUT` — export timeout in milliseconds (default: 10 000 ms)
11//! - `OTEL_SERVICE_NAME` (overridden by the `service_name` argument)
12//! - `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` (fallback when no explicit sampler is set)
13//!
14//! ## Env var handling: otel-bootstrap vs SDK
15//! | Env var | Handled by |
16//! |---------|-----------|
17//! | `OTEL_SERVICE_NAME` | otel-bootstrap (falls back to SDK default) |
18//! | `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` | otel-bootstrap |
19//! | `OTEL_EXPORTER_OTLP_PROTOCOL` | otel-bootstrap |
20//! | `OTEL_EXPORTER_OTLP_ENDPOINT` | otel-bootstrap |
21//! | `OTEL_EXPORTER_OTLP_TIMEOUT` | otel-bootstrap |
22//! | `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` | SDK (batch span processor) |
23//! | `OTEL_METRIC_EXPORT_INTERVAL` | SDK (periodic reader) |
24//! | Per-signal endpoints (`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` etc.) | SDK |
25
26#[cfg(not(any(feature = "grpc", feature = "http")))]
27compile_error!("at least one transport feature must be enabled: `grpc` or `http`");
28
29#[cfg(feature = "testing")]
30pub mod testing;
31
32#[cfg(feature = "axum")]
33pub mod axum_middleware;
34
35#[cfg(feature = "tonic-tracing")]
36pub mod grpc_middleware;
37
38#[cfg(feature = "profiling")]
39pub mod profiling;
40mod runtime_metrics;
41
42pub mod instrumented_port;
43pub mod log_bridge;
44pub mod span_enrichment;
45pub mod spanned;
46
47pub use instrumented_port::{Instrumented, InstrumentedArc};
48pub use log_bridge::{
49    PROPAGATED_SPAN_FIELDS, SpanLogAttrs, record_span_log_attr, record_span_log_attr_on,
50};
51pub use spanned::{Spanned, in_span};
52
53use opentelemetry::KeyValue;
54use opentelemetry::propagation::TextMapCompositePropagator;
55use opentelemetry_otlp::WithExportConfig;
56use opentelemetry_sdk::{
57    Resource,
58    logs::SdkLoggerProvider,
59    metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider},
60    propagation::{BaggagePropagator, TraceContextPropagator},
61    trace::{BatchConfigBuilder, BatchSpanProcessor, Sampler, SdkTracer, SdkTracerProvider},
62};
63use opentelemetry_semantic_conventions::attribute::{
64    DEPLOYMENT_ENVIRONMENT_NAME, HOST_NAME, PROCESS_PID, SERVICE_VERSION,
65};
66use std::error::Error;
67use std::time::Duration;
68use tracing_subscriber::layer::SubscriberExt;
69use tracing_subscriber::util::SubscriberInitExt;
70
71fn tracing_bridge_tracer(provider: &SdkTracerProvider) -> SdkTracer {
72    use opentelemetry::trace::TracerProvider as _;
73
74    provider.tracer(env!("CARGO_PKG_NAME"))
75}
76
77/// Trace sampler configuration.
78///
79/// Controls how many traces are sampled. When no explicit sampler is passed to
80/// [`init_telemetry_with_sampler`], the library falls back to the
81/// `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` environment variables,
82/// and finally to [`TraceSampler::AlwaysOn`] for backward compatibility.
83///
84/// # Example
85/// ```
86/// use otel_bootstrap::TraceSampler;
87///
88/// // Sample 10 % of root spans; inherit parent decision for child spans.
89/// let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.1)));
90/// ```
91#[derive(Debug, Clone)]
92pub enum TraceSampler {
93    /// Record every trace (the default).
94    AlwaysOn,
95    /// Never record any trace.
96    AlwaysOff,
97    /// Sample a fraction of traces. `ratio` must be between 0.0 and 1.0.
98    TraceIdRatio(f64),
99    /// Respect the parent span's sampling decision; use the given sampler for
100    /// root spans (spans without a remote parent).
101    ParentBased(Box<TraceSampler>),
102}
103
104/// Stdout log encoding installed by [`TelemetryBuilder`].
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
106pub enum LogFormat {
107    /// Human-readable log lines.
108    #[default]
109    Pretty,
110    /// One JSON object per line.
111    Json,
112}
113
114impl TraceSampler {
115    /// Convert to the SDK [`Sampler`].
116    fn into_sdk_sampler(self) -> Sampler {
117        match self {
118            TraceSampler::AlwaysOn => Sampler::AlwaysOn,
119            TraceSampler::AlwaysOff => Sampler::AlwaysOff,
120            TraceSampler::TraceIdRatio(r) => Sampler::TraceIdRatioBased(r),
121            TraceSampler::ParentBased(inner) => {
122                Sampler::ParentBased(Box::new(inner.into_sdk_sampler()))
123            }
124        }
125    }
126}
127
128/// Resolve the sampler from `OTEL_TRACES_SAMPLER` and `OTEL_TRACES_SAMPLER_ARG`
129/// environment variables.
130///
131/// Returns:
132/// - `Ok(None)` when `OTEL_TRACES_SAMPLER` is unset.
133/// - `Ok(Some(_))` for a recognised sampler name.
134/// - `Err(_)` for an unrecognised sampler name (clear error at init time).
135fn sampler_from_env() -> Result<Option<TraceSampler>, Box<dyn Error>> {
136    let name = match std::env::var("OTEL_TRACES_SAMPLER") {
137        Ok(v) => v,
138        Err(_) => return Ok(None),
139    };
140    let arg = std::env::var("OTEL_TRACES_SAMPLER_ARG").ok();
141    let sampler = match name.as_str() {
142        "always_on" => TraceSampler::AlwaysOn,
143        "always_off" => TraceSampler::AlwaysOff,
144        "traceidratio" => {
145            let ratio = arg
146                .as_deref()
147                .unwrap_or("1.0")
148                .parse::<f64>()
149                .unwrap_or(1.0);
150            TraceSampler::TraceIdRatio(ratio)
151        }
152        "parentbased_always_on" => TraceSampler::ParentBased(Box::new(TraceSampler::AlwaysOn)),
153        "parentbased_always_off" => TraceSampler::ParentBased(Box::new(TraceSampler::AlwaysOff)),
154        "parentbased_traceidratio" => {
155            let ratio = arg
156                .as_deref()
157                .unwrap_or("1.0")
158                .parse::<f64>()
159                .unwrap_or(1.0);
160            TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(ratio)))
161        }
162        unknown => {
163            return Err(format!(
164                "OTEL_TRACES_SAMPLER: unrecognised sampler name '{unknown}'. \
165                 Valid values: always_on, always_off, traceidratio, \
166                 parentbased_always_on, parentbased_always_off, parentbased_traceidratio"
167            )
168            .into());
169        }
170    };
171    Ok(Some(sampler))
172}
173
174/// Default timeout for provider shutdown in [`Drop`].
175const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
176
177/// Handles returned by [`init_telemetry`] or [`TelemetryBuilder::init`].
178///
179/// Keep alive for the duration of the process. Call [`shutdown`](TelemetryHandles::shutdown)
180/// before exiting to flush pending spans, metrics, and logs.
181///
182/// When dropped, shutdown is attempted with a bounded timeout (default: 5 s).
183/// If the timeout expires a warning is logged but the process continues normally.
184///
185/// # Example
186/// ```no_run
187/// #[tokio::main]
188/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
189///     let handles = otel_bootstrap::init_telemetry("my-service")?;
190///
191///     // run your application here …
192///
193///     handles.shutdown()?;
194///     Ok(())
195/// }
196/// ```
197pub struct TelemetryHandles {
198    pub tracer_provider: SdkTracerProvider,
199    pub meter_provider: Option<SdkMeterProvider>,
200    pub logger_provider: Option<SdkLoggerProvider>,
201    shutdown_timeout: Duration,
202    #[cfg(feature = "profiling")]
203    pub profiling_handle: Option<profiling::ProfilingHandle>,
204}
205
206impl TelemetryHandles {
207    /// Flush pending data and shut down all providers.
208    ///
209    /// Must be called before the tokio runtime shuts down so the batch
210    /// exporter can send remaining spans over gRPC. Safe to call multiple
211    /// times — subsequent calls are no-ops.
212    ///
213    /// **Best-effort.** A provider that cannot flush — collector unreachable,
214    /// export deadline exceeded — is logged at `warn` and shutdown continues
215    /// to the next one. Failing to deliver telemetry is not a failure of the
216    /// program that produced it, and a service must be able to exit cleanly
217    /// when its collector is down. This mirrors what [`Drop`] has always done;
218    /// the two paths previously disagreed, and `shutdown()` propagating was
219    /// the odd one out.
220    ///
221    /// The `Result` is retained for API compatibility and so a genuinely
222    /// fallible step could be surfaced later; today every provider error is
223    /// absorbed.
224    ///
225    /// Historically this returned `Ok` for metrics purely because nothing
226    /// registered instruments, so there was never anything to export. Once
227    /// real instruments exist, an unreachable collector turns every shutdown
228    /// into a 5-second timeout and an error — which is exactly the situation
229    /// this must not turn into a failure.
230    ///
231    /// # Example
232    /// ```no_run
233    /// let handles = otel_bootstrap::init_telemetry("my-service").unwrap();
234    /// // … application logic …
235    /// handles.shutdown().expect("telemetry shutdown failed");
236    /// ```
237    pub fn shutdown(&self) -> Result<(), Box<dyn Error>> {
238        if let Err(e) = self.tracer_provider.shutdown() {
239            tracing::warn!("tracer provider shutdown error: {e}");
240        }
241        if let Some(mp) = &self.meter_provider
242            && let Err(e) = mp.shutdown()
243        {
244            tracing::warn!("meter provider shutdown error: {e}");
245        }
246        if let Some(lp) = &self.logger_provider
247            && let Err(e) = lp.shutdown()
248        {
249            tracing::warn!("logger provider shutdown error: {e}");
250        }
251        Ok(())
252    }
253}
254
255impl Drop for TelemetryHandles {
256    fn drop(&mut self) {
257        let tracer_provider = self.tracer_provider.clone();
258        let meter_provider = self.meter_provider.clone();
259        let logger_provider = self.logger_provider.clone();
260        let timeout = self.shutdown_timeout;
261
262        let (tx, rx) = std::sync::mpsc::channel();
263        std::thread::spawn(move || {
264            if let Err(e) = tracer_provider.shutdown() {
265                tracing::warn!("tracer provider shutdown error: {e}");
266            }
267            if let Some(mp) = meter_provider
268                && let Err(e) = mp.shutdown()
269            {
270                tracing::warn!("meter provider shutdown error: {e}");
271            }
272            if let Some(lp) = logger_provider
273                && let Err(e) = lp.shutdown()
274            {
275                tracing::warn!("logger provider shutdown error: {e}");
276            }
277            let _ = tx.send(());
278        });
279
280        if rx.recv_timeout(timeout).is_err() {
281            tracing::warn!(
282                "telemetry shutdown did not complete within {timeout:?}; \
283                 some spans/metrics may not have been exported"
284            );
285        }
286    }
287}
288
289/// OTLP export protocol.
290///
291/// Selects between gRPC/tonic and HTTP/protobuf transports. When not set
292/// explicitly, the builder reads `OTEL_EXPORTER_OTLP_PROTOCOL`. If both the
293/// `grpc` and `http` features are compiled in and neither the builder nor the
294/// env var specifies a protocol, `grpc` is used.
295///
296/// Each variant is only present when its corresponding feature is enabled, so
297/// match expressions are always exhaustive without a fallback arm.
298///
299/// # Example
300/// ```no_run
301/// # #[cfg(feature = "grpc")]
302/// # {
303/// use otel_bootstrap::{ExportProtocol, Telemetry};
304///
305/// let _handles = Telemetry::builder("my-service")
306///     .with_protocol(ExportProtocol::Grpc)
307///     .init()
308///     .unwrap();
309/// # }
310/// ```
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312pub enum ExportProtocol {
313    /// gRPC via tonic (requires the `grpc` feature).
314    #[cfg(feature = "grpc")]
315    Grpc,
316    /// HTTP/protobuf (requires the `http` feature).
317    #[cfg(feature = "http")]
318    HttpProtobuf,
319}
320
321/// mTLS material for the gRPC transport. Requires the `grpc-mtls` feature.
322///
323/// PEM-encoded. The CA is used to verify the collector's server cert; the
324/// client cert + key authenticate this workload to the collector.
325///
326/// To use a static (no-rotation) source, wrap in [`StaticCertSource`] and
327/// pass to [`TelemetryBuilder::with_mtls`]. For SVID-style rotation, plug
328/// in your own [`CertSource`] implementation (e.g. service-kit's
329/// `SpiffeCertSource`).
330#[cfg(feature = "grpc-mtls")]
331#[derive(Clone)]
332pub struct MtlsMaterial {
333    /// PEM-encoded client certificate chain (leaf + intermediates).
334    pub client_cert_chain_pem: Vec<u8>,
335    /// PEM-encoded client private key matching `client_cert_chain_pem`.
336    pub client_key_pem: Vec<u8>,
337    /// PEM-encoded trust bundle — collector cert must chain to one of these.
338    pub trust_bundle_pem: Vec<u8>,
339}
340
341#[cfg(feature = "grpc-mtls")]
342impl std::fmt::Debug for MtlsMaterial {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        f.debug_struct("MtlsMaterial")
345            .field("client_cert_chain_pem", &"<redacted>")
346            .field("client_key_pem", &"<redacted>")
347            .field("trust_bundle_pem", &"<redacted>")
348            .finish()
349    }
350}
351
352/// Resolve the export protocol from `OTEL_EXPORTER_OTLP_PROTOCOL`.
353fn protocol_from_env() -> Option<ExportProtocol> {
354    let val = std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL").ok()?;
355    match val.trim() {
356        #[cfg(feature = "grpc")]
357        "grpc" => Some(ExportProtocol::Grpc),
358        #[cfg(feature = "http")]
359        "http/protobuf" => Some(ExportProtocol::HttpProtobuf),
360        _ => None,
361    }
362}
363
364/// Entry point for configuring telemetry via a builder pattern.
365///
366/// # Example
367/// ```no_run
368/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
369/// let _handles = otel_bootstrap::Telemetry::builder("my-service")
370///     .with_version("1.0.0")
371///     .with_environment("production")
372///     .with_sampler(otel_bootstrap::TraceSampler::TraceIdRatio(0.1))
373///     .with_metrics(true)
374///     .with_logs(true)
375///     .init()?;
376/// # Ok(())
377/// # }
378/// ```
379pub struct Telemetry;
380
381impl Telemetry {
382    /// Create a new [`TelemetryBuilder`] with the given service name.
383    ///
384    /// The explicit `service_name` takes precedence over `OTEL_SERVICE_NAME`.
385    pub fn builder(service_name: &str) -> TelemetryBuilder {
386        TelemetryBuilder {
387            service_name: Some(service_name.to_string()),
388            service_version: None,
389            deployment_environment: None,
390            sampler: None,
391            metrics: true,
392            logs: false,
393            protocol: None,
394            max_export_batch_size: None,
395            metric_export_interval: None,
396            export_timeout: None,
397            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
398            log_filter: None,
399            log_format: LogFormat::default(),
400            extra_layers: Vec::new(),
401            extra_metric_readers: Vec::new(),
402            runtime_metrics: true,
403            #[cfg(feature = "grpc-mtls")]
404            mtls: None,
405            propagated_span_fields: crate::log_bridge::PROPAGATED_SPAN_FIELDS,
406            #[cfg(feature = "profiling")]
407            pyroscope_endpoint: None,
408        }
409    }
410
411    /// Create a new [`TelemetryBuilder`] that reads the service name from
412    /// `OTEL_SERVICE_NAME`. Falls back to `"unknown_service"` when the env var
413    /// is not set, following the OpenTelemetry default resource specification.
414    ///
415    /// # Example
416    /// ```no_run
417    /// // Set OTEL_SERVICE_NAME=my-service in the environment before calling this.
418    /// let _handles = otel_bootstrap::Telemetry::from_env().init().unwrap();
419    /// ```
420    pub fn from_env() -> TelemetryBuilder {
421        TelemetryBuilder {
422            service_name: None,
423            service_version: None,
424            deployment_environment: None,
425            sampler: None,
426            metrics: true,
427            logs: false,
428            protocol: None,
429            max_export_batch_size: None,
430            metric_export_interval: None,
431            export_timeout: None,
432            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
433            log_filter: None,
434            log_format: LogFormat::default(),
435            extra_layers: Vec::new(),
436            extra_metric_readers: Vec::new(),
437            runtime_metrics: true,
438            #[cfg(feature = "grpc-mtls")]
439            mtls: None,
440            propagated_span_fields: crate::log_bridge::PROPAGATED_SPAN_FIELDS,
441            #[cfg(feature = "profiling")]
442            pyroscope_endpoint: None,
443        }
444    }
445}
446
447/// Builder for configuring telemetry options incrementally.
448///
449/// Created via [`Telemetry::builder`] or [`Telemetry::from_env`]. Call
450/// [`.init()`](TelemetryBuilder::init) to consume the builder and start telemetry.
451///
452/// # Example
453/// ```no_run
454/// use std::time::Duration;
455///
456/// let _handles = otel_bootstrap::Telemetry::builder("my-service")
457///     .with_version("1.2.3")
458///     .with_environment("staging")
459///     .with_metrics(true)
460///     .with_shutdown_timeout(Duration::from_secs(10))
461///     .init()
462///     .unwrap();
463/// ```
464#[must_use = "a TelemetryBuilder does nothing until .init() is called"]
465pub struct TelemetryBuilder {
466    service_name: Option<String>,
467    service_version: Option<String>,
468    deployment_environment: Option<String>,
469    sampler: Option<TraceSampler>,
470    metrics: bool,
471    logs: bool,
472    protocol: Option<ExportProtocol>,
473    max_export_batch_size: Option<usize>,
474    metric_export_interval: Option<Duration>,
475    export_timeout: Option<Duration>,
476    shutdown_timeout: Duration,
477    log_filter: Option<String>,
478    log_format: LogFormat,
479    extra_layers: Vec<
480        Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync + 'static>,
481    >,
482    extra_metric_readers: Vec<MeterProviderInstaller>,
483    runtime_metrics: bool,
484    #[cfg(feature = "grpc-mtls")]
485    mtls: Option<MtlsMaterial>,
486    propagated_span_fields: &'static [&'static str],
487    #[cfg(feature = "profiling")]
488    pyroscope_endpoint: Option<String>,
489}
490
491/// Type-erased adapter that applies an extra `MetricReader` to the
492/// in-progress [`MeterProviderBuilder`]. Stored as a closure so the trait
493/// (which is generic, not object-safe in a useful way here) can be ranged
494/// over uniformly inside [`TelemetryBuilder`].
495type MeterProviderInstaller =
496    Box<dyn FnOnce(MeterProviderBuilder) -> MeterProviderBuilder + Send + Sync>;
497
498impl TelemetryBuilder {
499    /// Set the tracing filter without mutating process-global environment.
500    ///
501    /// The directive is parsed during [`init`](Self::init). Invalid directives
502    /// fail initialization before exporters or the global subscriber are built.
503    pub fn with_log_filter(mut self, directive: impl Into<String>) -> Self {
504        self.log_filter = Some(directive.into());
505        self
506    }
507
508    /// Set stdout log encoding without mutating process-global environment.
509    pub fn with_log_format(mut self, format: LogFormat) -> Self {
510        self.log_format = format;
511        self
512    }
513
514    /// Set the service version (maps to `service.version` resource attribute).
515    pub fn with_version(mut self, version: &str) -> Self {
516        self.service_version = Some(version.to_string());
517        self
518    }
519
520    /// Set the deployment environment (maps to `deployment.environment.name`).
521    pub fn with_environment(mut self, environment: &str) -> Self {
522        self.deployment_environment = Some(environment.to_string());
523        self
524    }
525
526    /// Enable mTLS on the gRPC OTLP exporter (requires the `grpc-mtls` feature).
527    ///
528    /// The material is read once at [`init`](TelemetryBuilder::init) time;
529    /// the resulting tonic Channel is built once and reused for the lifetime
530    /// of the process.
531    ///
532    /// Forces the protocol to [`ExportProtocol::Grpc`] regardless of
533    /// `OTEL_EXPORTER_OTLP_PROTOCOL` or any prior `with_protocol(...)` call.
534    /// Pairs with a collector configured with `client_ca_file`.
535    ///
536    /// # Rotation
537    ///
538    /// In-process auto-rotation is **not yet implemented** — when the
539    /// underlying SVID rotates (typically every 1h), the existing tonic
540    /// Channel keeps presenting the old cert and exports start failing.
541    /// Two-part mitigation until a proper rotation watcher lands:
542    ///
543    /// 1. Issue long-lived client certs (≥365 days) so manual rotation is
544    ///    infrequent.
545    /// 2. Rely on natural pod restarts (deploys, reschedules) to pick up
546    ///    fresh material — every restart re-reads the SVID at this call.
547    ///
548    /// Rotation as a first-class feature is tracked as an immediate
549    /// follow-up (see CHANGELOG).
550    #[cfg(feature = "grpc-mtls")]
551    pub fn with_mtls(mut self, material: MtlsMaterial) -> Self {
552        self.mtls = Some(material);
553        self.protocol = Some(ExportProtocol::Grpc);
554        self
555    }
556
557    /// Set an explicit trace sampler. If not set, falls back to
558    /// `OTEL_TRACES_SAMPLER` env var, then always-on.
559    pub fn with_sampler(mut self, sampler: TraceSampler) -> Self {
560        self.sampler = Some(sampler);
561        self
562    }
563
564    /// Enable or disable metrics export (default: `true`).
565    pub fn with_metrics(mut self, enabled: bool) -> Self {
566        self.metrics = enabled;
567        self
568    }
569
570    /// Enable or disable the built-in process/runtime gauges (default: `true`).
571    ///
572    /// Covers process uptime and resident memory plus Tokio worker count, live
573    /// task count, global queue depth and scheduler delay — see
574    /// [`runtime_metrics`](crate::runtime_metrics) for what each answers.
575    ///
576    /// On by default because these are the instruments that distinguish "the
577    /// runtime never polled us" from "the thing we called was slow", and a
578    /// service that has to opt in generally has not, precisely when it matters.
579    /// They are registered on the `MeterProvider` this builder installs, so
580    /// they cost nothing when [`with_metrics(false)`](Self::with_metrics) is
581    /// set — no provider is created and this is never reached.
582    ///
583    /// Turn off for a process where the extra series are unwanted, e.g. a
584    /// short-lived CLI whose runtime state carries no operational meaning.
585    pub fn with_runtime_metrics(mut self, enabled: bool) -> Self {
586        self.runtime_metrics = enabled;
587        self
588    }
589
590    /// Set the export protocol explicitly. If not set, falls back to
591    /// `OTEL_EXPORTER_OTLP_PROTOCOL`, then the compiled-in default (`grpc`
592    /// when the `grpc` feature is enabled, `http/protobuf` otherwise).
593    pub fn with_protocol(mut self, protocol: ExportProtocol) -> Self {
594        self.protocol = Some(protocol);
595        self
596    }
597
598    /// Set the maximum number of spans exported in a single batch (default: 512).
599    ///
600    /// Overrides `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` when set programmatically.
601    /// The env var is still read as a fallback when this method is not called.
602    pub fn with_max_export_batch_size(mut self, size: usize) -> Self {
603        self.max_export_batch_size = Some(size);
604        self
605    }
606
607    /// Set the interval between metric exports (default: 60 s).
608    ///
609    /// Returns an error at build time if `interval` is zero.
610    /// Overrides `OTEL_METRIC_EXPORT_INTERVAL` when set programmatically.
611    pub fn with_metric_export_interval(mut self, interval: Duration) -> Self {
612        self.metric_export_interval = Some(interval);
613        self
614    }
615
616    /// Enable or disable log export via the OTLP log bridge (default: `false`).
617    ///
618    /// When enabled, `tracing` events are forwarded to an OTLP `LogExporter`
619    /// in addition to the existing stdout fmt layer. This allows structured
620    /// logs to be correlated with traces in backends like Grafana Loki or
621    /// Datadog.
622    pub fn with_logs(mut self, enabled: bool) -> Self {
623        self.logs = enabled;
624        self
625    }
626
627    /// Override the set of span field names propagated into OTLP log records.
628    ///
629    /// The default set is [`PROPAGATED_SPAN_FIELDS`]. Callers that add extra
630    /// tracing fields (e.g. `"request.id"`, `"enduser.id"`) can extend it:
631    ///
632    /// ```rust
633    /// const MY_FIELDS: &[&str] = &["request.id", "enduser.id", "tenant.id"];
634    /// let _handles = otel_bootstrap::Telemetry::builder("my-service")
635    ///     .with_logs(true)
636    ///     .with_propagated_span_fields(MY_FIELDS)
637    ///     .init();
638    /// ```
639    pub fn with_propagated_span_fields(mut self, fields: &'static [&'static str]) -> Self {
640        self.propagated_span_fields = fields;
641        self
642    }
643
644    /// Set the OTLP export timeout explicitly. If not set, falls back to
645    /// `OTEL_EXPORTER_OTLP_TIMEOUT` (in milliseconds), then the SDK default
646    /// of 10 000 ms.
647    pub fn with_export_timeout(mut self, timeout: Duration) -> Self {
648        self.export_timeout = Some(timeout);
649        self
650    }
651
652    /// Set the maximum time to wait for provider shutdown when the
653    /// [`TelemetryHandles`] is dropped (default: 5 s).
654    ///
655    /// If the timeout expires a warning is logged and the drop completes
656    /// without panicking. The background shutdown thread is abandoned and
657    /// the providers may not have flushed all pending data.
658    pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
659        self.shutdown_timeout = timeout;
660        self
661    }
662
663    /// Enable continuous profiling via pyroscope (requires the `profiling-bridge-pyroscope-rs` feature).
664    ///
665    /// The bridge pushes profiles over plain HTTP/loopback to a local SPIFFE-terminating
666    /// sidecar (or an already-mTLS'd endpoint reachable without client-side TLS material).
667    /// pyroscope-rs hardcodes its own HTTP client internally with no hook for custom
668    /// TLS/identity, so in-process mTLS is not possible; the sidecar carries the workload
669    /// identity upstream.
670    ///
671    /// The endpoint must target loopback only (127.0.0.1, ::1, localhost, or a unix socket)
672    /// per ADR platform/0203 AC1 — enforced at init time.
673    ///
674    /// # Example
675    /// ```ignore
676    /// let _handles = otel_bootstrap::Telemetry::builder("my-service")
677    ///     .with_profiling("http://localhost:4040")
678    ///     .init()?;
679    /// ```
680    #[cfg(feature = "profiling")]
681    pub fn with_profiling(mut self, endpoint: &str) -> Self {
682        self.pyroscope_endpoint = Some(endpoint.to_string());
683        self
684    }
685
686    /// Add a custom [`tracing_subscriber::Layer`] to the subscriber stack.
687    ///
688    /// Multiple layers can be added by chaining calls. Each layer is composed
689    /// with the built-in `EnvFilter`, `fmt`, and OpenTelemetry layers.
690    ///
691    /// Insertion order in the subscriber stack (inner → outer, i.e. first-added
692    /// to last-added):
693    /// ```text
694    /// registry → custom layers → EnvFilter → fmt → OTel
695    /// ```
696    /// Because `EnvFilter` is outer, it can suppress events before they reach
697    /// the `fmt` and OTel layers; custom layers receive events independently
698    /// according to their own `enabled()` implementation.
699    ///
700    /// # Example
701    /// ```no_run
702    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
703    /// let _handles = otel_bootstrap::Telemetry::builder("my-service")
704    ///     .with_layer(tracing_subscriber::fmt::layer().with_target(false))
705    ///     .init()?;
706    /// # Ok(())
707    /// # }
708    /// ```
709    /// Customise the [`MeterProviderBuilder`] before it is built.
710    ///
711    /// Runs after the built-in OTLP `PeriodicReader` is attached (when
712    /// [`with_metrics`](Self::with_metrics) is enabled) and before
713    /// `.build()` is called. The closure is the escape hatch for everything
714    /// the explicit builder methods do not cover — most importantly,
715    /// installing **additional `MetricReader`s** like
716    /// [`opentelemetry-prometheus`](https://crates.io/crates/opentelemetry-prometheus)
717    /// alongside the OTLP push, so the same instruments fan out to multiple
718    /// transports without double-counting.
719    ///
720    /// May be called multiple times; closures run in registration order.
721    /// Has no effect when `with_metrics(false)` is also set on the builder —
722    /// when metrics are disabled, no `MeterProvider` is created at all.
723    ///
724    /// `MetricReader` is intentionally not nameable from outside
725    /// `opentelemetry_sdk`, so the closure form is the only way to attach
726    /// readers without leaking unstable trait names through this crate's
727    /// public API.
728    ///
729    /// # Example
730    ///
731    /// ```ignore
732    /// // With `opentelemetry-prometheus` in scope:
733    /// let registry = prometheus::Registry::new();
734    /// let exporter = opentelemetry_prometheus::exporter()
735    ///     .with_registry(registry.clone())
736    ///     .build()?;
737    /// let _handles = otel_bootstrap::Telemetry::builder("my-service")
738    ///     .with_meter_provider_setup(move |b| b.with_reader(exporter))
739    ///     .init()?;
740    /// // ...mount `registry` at GET /metrics in your HTTP layer.
741    /// ```
742    pub fn with_meter_provider_setup<F>(mut self, setup: F) -> Self
743    where
744        F: FnOnce(MeterProviderBuilder) -> MeterProviderBuilder + Send + Sync + 'static,
745    {
746        self.extra_metric_readers.push(Box::new(setup));
747        self
748    }
749
750    pub fn with_layer<L>(mut self, layer: L) -> Self
751    where
752        L: tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync + 'static,
753    {
754        self.extra_layers.push(Box::new(layer));
755        self
756    }
757
758    /// Consume the builder and initialise OpenTelemetry.
759    ///
760    /// Installs a global tracer provider, meter provider (if enabled), and
761    /// a `tracing` subscriber. Returns an error if any provider fails to
762    /// build (e.g. unknown sampler name, zero metric interval).
763    ///
764    /// # Example
765    /// ```no_run
766    /// let handles = otel_bootstrap::Telemetry::builder("my-service")
767    ///     .with_metrics(false)
768    ///     .init()
769    ///     .expect("telemetry init failed");
770    /// handles.shutdown().ok();
771    /// ```
772    pub fn init(self) -> Result<TelemetryHandles, Box<dyn Error>> {
773        let log_filter = match self.log_filter.as_deref() {
774            Some(directive) => tracing_subscriber::EnvFilter::try_new(directive)?,
775            None => tracing_subscriber::EnvFilter::from_default_env(),
776        };
777
778        if let Some(interval) = self.metric_export_interval
779            && interval.is_zero()
780        {
781            return Err("metric_export_interval must be greater than zero".into());
782        }
783
784        let protocol = self.protocol.or_else(protocol_from_env).unwrap_or({
785            #[cfg(feature = "grpc")]
786            {
787                ExportProtocol::Grpc
788            }
789            #[cfg(all(not(feature = "grpc"), feature = "http"))]
790            {
791                ExportProtocol::HttpProtobuf
792            }
793        });
794
795        let default_endpoint = match protocol {
796            #[cfg(feature = "grpc")]
797            ExportProtocol::Grpc => "http://localhost:4317",
798            #[cfg(feature = "http")]
799            ExportProtocol::HttpProtobuf => "http://localhost:4318",
800        };
801        let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
802            .unwrap_or_else(|_| default_endpoint.to_string());
803
804        // Resolve export timeout: explicit builder > OTEL_EXPORTER_OTLP_TIMEOUT > SDK default (10 s)
805        let export_timeout = self.export_timeout.or_else(timeout_from_env);
806
807        // Resolve service name: explicit builder > OTEL_SERVICE_NAME > "unknown_service"
808        let service_name = self.service_name.unwrap_or_else(|| {
809            std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "unknown_service".to_string())
810        });
811
812        let resource = build_resource(
813            &service_name,
814            self.service_version.as_deref(),
815            self.deployment_environment.as_deref(),
816        );
817
818        let sampler = match self.sampler {
819            Some(s) => s,
820            None => sampler_from_env()?.unwrap_or(TraceSampler::AlwaysOn),
821        };
822
823        // Tracer
824        let trace_exporter = build_span_exporter(
825            protocol,
826            &endpoint,
827            export_timeout,
828            #[cfg(feature = "grpc-mtls")]
829            self.mtls.as_ref(),
830        )?;
831
832        let batch_processor = if let Some(size) = self.max_export_batch_size {
833            BatchSpanProcessor::builder(trace_exporter)
834                .with_batch_config(
835                    BatchConfigBuilder::default()
836                        .with_max_export_batch_size(size)
837                        .build(),
838                )
839                .build()
840        } else {
841            BatchSpanProcessor::builder(trace_exporter).build()
842        };
843
844        let tracer_provider = SdkTracerProvider::builder()
845            .with_resource(resource.clone())
846            .with_sampler(sampler.into_sdk_sampler())
847            .with_span_processor(batch_processor)
848            .build();
849
850        opentelemetry::global::set_tracer_provider(tracer_provider.clone());
851
852        // Register W3C TraceContext + Baggage propagators
853        let propagator = TextMapCompositePropagator::new(vec![
854            Box::new(TraceContextPropagator::new()),
855            Box::new(BaggagePropagator::new()),
856        ]);
857        opentelemetry::global::set_text_map_propagator(propagator);
858
859        // Meter (optional)
860        let meter_provider = if self.metrics {
861            let metric_exporter = build_metric_exporter(
862                protocol,
863                &endpoint,
864                export_timeout,
865                #[cfg(feature = "grpc-mtls")]
866                self.mtls.as_ref(),
867            )?;
868
869            let periodic_reader = if let Some(interval) = self.metric_export_interval {
870                PeriodicReader::builder(metric_exporter)
871                    .with_interval(interval)
872                    .build()
873            } else {
874                PeriodicReader::builder(metric_exporter).build()
875            };
876
877            let mut mp_builder = SdkMeterProvider::builder()
878                .with_resource(resource.clone())
879                .with_reader(periodic_reader);
880            for installer in self.extra_metric_readers {
881                mp_builder = installer(mp_builder);
882            }
883            let mp = mp_builder.build();
884
885            opentelemetry::global::set_meter_provider(mp.clone());
886
887            // Strictly after the provider is global: OpenTelemetry binds an
888            // instrument to whichever provider is installed when it is built,
889            // so registering any earlier would yield permanent no-ops.
890            if self.runtime_metrics {
891                crate::runtime_metrics::install();
892            }
893
894            Some(mp)
895        } else {
896            None
897        };
898
899        // Logger (optional) — bridges tracing events to the OTLP log pipeline
900        let logger_provider = if self.logs {
901            let log_exporter = build_log_exporter(
902                protocol,
903                &endpoint,
904                export_timeout,
905                #[cfg(feature = "grpc-mtls")]
906                self.mtls.as_ref(),
907            )?;
908
909            let lp = SdkLoggerProvider::builder()
910                .with_resource(resource)
911                .with_batch_exporter(log_exporter)
912                .build();
913
914            Some(lp)
915        } else {
916            None
917        };
918
919        // Profiling (optional)
920        #[cfg(feature = "profiling")]
921        let profiling_handle = if let Some(ref endpoint) = self.pyroscope_endpoint {
922            // Same identity the resource carries on logs and traces, so a
923            // profile can be joined to them by pod without translation.
924            // Derived here rather than asked of the caller: every value is
925            // already known to this builder.
926            let identity = profiling::ProfilingIdentity {
927                host_name: hostname::get()
928                    .ok()
929                    .and_then(|h| h.into_string().ok())
930                    .filter(|h| !h.is_empty()),
931                deployment_environment: self.deployment_environment.clone(),
932                service_version: self.service_version.clone(),
933            };
934            profiling::start_pyroscope_bridge(&service_name, endpoint, &identity)?
935        } else {
936            None
937        };
938        #[cfg(not(feature = "profiling"))]
939        let _profiling_handle: Option<()> = None;
940
941        // Wire into tracing
942        // `Vec::register_callsite()` on an empty Vec returns `Interest::never()`, which
943        // propagates through the entire layer chain via `pick_interest()` and silently
944        // disables ALL tracing callsites for the process.  Guard against this by wrapping
945        // the Vec in `Option`: `None` returns `Interest::always()` and is a no-op.
946        let extra = if self.extra_layers.is_empty() {
947            None
948        } else {
949            Some(self.extra_layers)
950        };
951
952        macro_rules! install_subscriber {
953            ($fmt_layer:expr) => {{
954                // `tracing_opentelemetry::layer()` defaults to a `NoopTracer`.
955                // Construct inside each format branch so its subscriber type
956                // is inferred against that branch's concrete fmt layer.
957                let otel_layer = tracing_opentelemetry::layer()
958                    .with_tracer(tracing_bridge_tracer(&tracer_provider));
959                let registry = tracing_subscriber::registry()
960                    .with(extra)
961                    .with(log_filter)
962                    .with($fmt_layer)
963                    .with(otel_layer);
964
965                // Inert since 2.15.0 — kept in the stack so the subscriber type
966                // is unchanged. See `profiling::ProfilingTagLayer`.
967                #[cfg(feature = "profiling-bridge-pyroscope-rs")]
968                #[allow(deprecated)]
969                let registry = registry.with(crate::profiling::ProfilingTagLayer);
970
971                if let Some(lp) = &logger_provider {
972                    if let Err(e) = registry
973                        .with(crate::log_bridge::SpanAwareLogBridge::new(
974                            lp,
975                            self.propagated_span_fields,
976                        ))
977                        .try_init()
978                    {
979                        eprintln!(
980                            "otel-bootstrap: global tracing subscriber already installed — \
981                             OTLP log records will NOT be exported to the collector: {e}"
982                        );
983                    }
984                } else if let Err(e) = registry.try_init() {
985                    eprintln!(
986                        "otel-bootstrap: global tracing subscriber already installed — \
987                         OTLP telemetry will NOT be exported to the collector: {e}"
988                    );
989                }
990            }};
991        }
992
993        match self.log_format {
994            LogFormat::Pretty => install_subscriber!(tracing_subscriber::fmt::layer()),
995            LogFormat::Json => install_subscriber!(tracing_subscriber::fmt::layer().json()),
996        }
997
998        Ok(TelemetryHandles {
999            tracer_provider,
1000            meter_provider,
1001            logger_provider,
1002            shutdown_timeout: self.shutdown_timeout,
1003            #[cfg(feature = "profiling")]
1004            profiling_handle,
1005        })
1006    }
1007}
1008
1009/// Initialise OpenTelemetry traces + metrics with OTLP gRPC export.
1010///
1011/// Convenience wrapper around [`Telemetry::builder`] with all defaults.
1012/// For fine-grained control, use the builder directly.
1013///
1014/// # Example
1015/// ```no_run
1016/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
1017/// let _tel = otel_bootstrap::init_telemetry("my-service")?;
1018/// // start axum server...
1019/// # Ok(())
1020/// # }
1021/// ```
1022pub fn init_telemetry(service_name: &str) -> Result<TelemetryHandles, Box<dyn Error>> {
1023    Telemetry::builder(service_name).init()
1024}
1025
1026/// Initialise OpenTelemetry traces + metrics with OTLP gRPC export and an
1027/// explicit trace sampler.
1028///
1029/// Convenience wrapper around [`Telemetry::builder`]. When `sampler` is
1030/// `None`, falls back to `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG`,
1031/// then always-on.
1032///
1033/// # Example
1034/// ```no_run
1035/// use otel_bootstrap::TraceSampler;
1036/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
1037/// let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.1)));
1038/// let _tel = otel_bootstrap::init_telemetry_with_sampler("my-service", Some(sampler))?;
1039/// # Ok(())
1040/// # }
1041/// ```
1042pub fn init_telemetry_with_sampler(
1043    service_name: &str,
1044    sampler: Option<TraceSampler>,
1045) -> Result<TelemetryHandles, Box<dyn Error>> {
1046    let builder = Telemetry::builder(service_name);
1047    match sampler {
1048        Some(s) => builder.with_sampler(s),
1049        None => builder, // no-op: identical to calling init_telemetry(); not covered by tests (see Makefile ci-coverage note)
1050    }
1051    .init()
1052}
1053
1054/// Read `OTEL_EXPORTER_OTLP_TIMEOUT` (milliseconds). Returns `None` when unset or invalid.
1055fn timeout_from_env() -> Option<Duration> {
1056    let ms = std::env::var("OTEL_EXPORTER_OTLP_TIMEOUT").ok()?;
1057    let ms: u64 = ms.trim().parse().ok()?;
1058    Some(Duration::from_millis(ms))
1059}
1060
1061/// Build a `tonic::transport::ClientTlsConfig` from PEM material.
1062/// Centralised so the three exporter builders apply identical TLS config.
1063///
1064/// Note: `with_tls_config` is provided by the `WithTonicConfig` trait on
1065/// `opentelemetry-otlp`'s tonic exporter builders — imported at each call
1066/// site below.
1067#[cfg(feature = "grpc-mtls")]
1068fn build_tls_config(material: &MtlsMaterial) -> tonic::transport::ClientTlsConfig {
1069    use tonic::transport::{Certificate, ClientTlsConfig, Identity};
1070    ClientTlsConfig::new()
1071        .ca_certificate(Certificate::from_pem(&material.trust_bundle_pem))
1072        .identity(Identity::from_pem(
1073            &material.client_cert_chain_pem,
1074            &material.client_key_pem,
1075        ))
1076}
1077
1078fn build_span_exporter(
1079    protocol: ExportProtocol,
1080    endpoint: &str,
1081    timeout: Option<Duration>,
1082    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
1083) -> Result<opentelemetry_otlp::SpanExporter, Box<dyn Error>> {
1084    match protocol {
1085        #[cfg(feature = "grpc")]
1086        ExportProtocol::Grpc => {
1087            let mut b = opentelemetry_otlp::SpanExporter::builder()
1088                .with_tonic()
1089                .with_endpoint(endpoint);
1090            if let Some(t) = timeout {
1091                b = b.with_timeout(t);
1092            }
1093            #[cfg(feature = "grpc-mtls")]
1094            if let Some(m) = mtls {
1095                use opentelemetry_otlp::WithTonicConfig as _;
1096                b = b.with_tls_config(build_tls_config(m));
1097            }
1098            Ok(b.build()?)
1099        }
1100        #[cfg(feature = "http")]
1101        ExportProtocol::HttpProtobuf => {
1102            let mut b = opentelemetry_otlp::SpanExporter::builder()
1103                .with_http()
1104                .with_endpoint(endpoint);
1105            if let Some(t) = timeout {
1106                b = b.with_timeout(t);
1107            }
1108            Ok(b.build()?)
1109        }
1110    }
1111}
1112
1113fn build_metric_exporter(
1114    protocol: ExportProtocol,
1115    endpoint: &str,
1116    timeout: Option<Duration>,
1117    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
1118) -> Result<opentelemetry_otlp::MetricExporter, Box<dyn Error>> {
1119    match protocol {
1120        #[cfg(feature = "grpc")]
1121        ExportProtocol::Grpc => {
1122            let mut b = opentelemetry_otlp::MetricExporter::builder()
1123                .with_tonic()
1124                .with_endpoint(endpoint);
1125            if let Some(t) = timeout {
1126                b = b.with_timeout(t);
1127            }
1128            #[cfg(feature = "grpc-mtls")]
1129            if let Some(m) = mtls {
1130                use opentelemetry_otlp::WithTonicConfig as _;
1131                b = b.with_tls_config(build_tls_config(m));
1132            }
1133            Ok(b.build()?)
1134        }
1135        #[cfg(feature = "http")]
1136        ExportProtocol::HttpProtobuf => {
1137            let mut b = opentelemetry_otlp::MetricExporter::builder()
1138                .with_http()
1139                .with_endpoint(endpoint);
1140            if let Some(t) = timeout {
1141                b = b.with_timeout(t);
1142            }
1143            Ok(b.build()?)
1144        }
1145    }
1146}
1147
1148fn build_log_exporter(
1149    protocol: ExportProtocol,
1150    endpoint: &str,
1151    timeout: Option<Duration>,
1152    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
1153) -> Result<opentelemetry_otlp::LogExporter, Box<dyn Error>> {
1154    match protocol {
1155        #[cfg(feature = "grpc")]
1156        ExportProtocol::Grpc => {
1157            let mut b = opentelemetry_otlp::LogExporter::builder()
1158                .with_tonic()
1159                .with_endpoint(endpoint);
1160            if let Some(t) = timeout {
1161                b = b.with_timeout(t);
1162            }
1163            #[cfg(feature = "grpc-mtls")]
1164            if let Some(m) = mtls {
1165                use opentelemetry_otlp::WithTonicConfig as _;
1166                b = b.with_tls_config(build_tls_config(m));
1167            }
1168            Ok(b.build()?)
1169        }
1170        #[cfg(feature = "http")]
1171        ExportProtocol::HttpProtobuf => {
1172            let mut b = opentelemetry_otlp::LogExporter::builder()
1173                .with_http()
1174                .with_endpoint(endpoint);
1175            if let Some(t) = timeout {
1176                b = b.with_timeout(t);
1177            }
1178            Ok(b.build()?)
1179        }
1180    }
1181}
1182
1183/// Build a [`Resource`] enriched with semantic-convention attributes.
1184///
1185/// Auto-detects `host.name` and `process.pid`. Optionally sets
1186/// `service.version` and `deployment.environment` when provided.
1187///
1188/// # Example
1189/// ```
1190/// let resource = otel_bootstrap::build_resource(
1191///     "my-service",
1192///     Some("1.0.0"),
1193///     Some("production"),
1194/// );
1195/// // `resource` can be passed to SdkTracerProvider::builder().with_resource(resource)
1196/// ```
1197pub fn build_resource(
1198    service_name: &str,
1199    service_version: Option<&str>,
1200    deployment_environment: Option<&str>,
1201) -> Resource {
1202    let hostname = hostname::get()
1203        .ok()
1204        .and_then(|h| h.into_string().ok())
1205        .unwrap_or_default();
1206
1207    let mut builder = Resource::builder()
1208        .with_service_name(service_name.to_string())
1209        .with_attributes([
1210            KeyValue::new(HOST_NAME, hostname),
1211            KeyValue::new(PROCESS_PID, std::process::id() as i64),
1212        ]);
1213
1214    if let Some(version) = service_version {
1215        builder = builder.with_attribute(KeyValue::new(SERVICE_VERSION, version.to_string()));
1216    }
1217
1218    if let Some(env) = deployment_environment {
1219        builder =
1220            builder.with_attribute(KeyValue::new(DEPLOYMENT_ENVIRONMENT_NAME, env.to_string()));
1221    }
1222
1223    builder.build()
1224}
1225
1226/// Returns a ready-to-use [`tower::Layer`] that extracts W3C trace context from
1227/// incoming HTTP requests, creates a span with standard HTTP semantic-convention
1228/// attributes, and injects trace context into response headers.
1229///
1230/// Requires the `axum` feature flag.
1231///
1232/// # Example
1233/// ```no_run
1234/// # #[cfg(feature = "axum")]
1235/// # {
1236/// use axum::Router;
1237///
1238/// let app: Router = Router::new()
1239///     // ... add routes ...
1240///     .layer(otel_bootstrap::axum_layer());
1241/// # }
1242/// ```
1243#[cfg(feature = "axum")]
1244pub fn axum_layer() -> axum_middleware::OtelTraceLayer {
1245    axum_middleware::OtelTraceLayer
1246}
1247
1248/// Construct the tower [`Layer`](tower::Layer) that calls [`span_enrichment::EnrichSpan::enrich_span`]
1249/// on every request that carries a `T` extension.
1250///
1251/// Requires the `axum` feature flag. Place this layer inside the
1252/// [`axum::Extension`] layer that injects `T`, so the context is populated
1253/// before this service inspects the extensions.
1254///
1255/// # Example
1256/// ```no_run
1257/// # #[cfg(feature = "axum")] {
1258/// use axum::{Router, Extension, routing::get};
1259/// use otel_bootstrap::span_enrichment::EnrichSpan;
1260/// use tracing_opentelemetry::OpenTelemetrySpanExt as _;
1261///
1262/// #[derive(Clone)]
1263/// struct MyCtx { user_id: String }
1264///
1265/// impl EnrichSpan for MyCtx {
1266///     fn enrich_span(&self, span: &tracing::Span) {
1267///         span.set_attribute("enduser.id", self.user_id.clone());
1268///     }
1269/// }
1270///
1271/// let app: Router = Router::new()
1272///     .route("/", get(|| async { "ok" }))
1273///     .layer(otel_bootstrap::span_enricher_layer::<MyCtx>())
1274///     .layer(Extension(MyCtx { user_id: "u1".into() }))
1275///     .layer(otel_bootstrap::axum_layer());
1276/// # }
1277/// ```
1278#[cfg(feature = "axum")]
1279pub fn span_enricher_layer<T>() -> axum_middleware::SpanEnricherLayer<T>
1280where
1281    T: span_enrichment::EnrichSpan + Clone + Send + Sync + 'static,
1282{
1283    axum_middleware::SpanEnricherLayer::default()
1284}
1285
1286/// Construct the tower [`Layer`](tower::Layer) that injects the current trace
1287/// context into outgoing gRPC request metadata.
1288///
1289/// Requires the `tonic-tracing` feature. Wrap a tonic
1290/// [`tonic::transport::Channel`] with this before constructing the generated
1291/// client stub, so calls make from this process propagate `traceparent` to
1292/// the callee.
1293///
1294/// # Example
1295/// ```no_run
1296/// # #[cfg(feature = "tonic-tracing")]
1297/// # async fn example() -> Result<(), tonic::transport::Error> {
1298/// let channel = tonic::transport::Channel::from_static("http://localhost:50051")
1299///     .connect()
1300///     .await?;
1301/// let channel = tower::ServiceBuilder::new()
1302///     .layer(otel_bootstrap::grpc_client_layer())
1303///     .service(channel);
1304/// # Ok(())
1305/// # }
1306/// ```
1307#[cfg(feature = "tonic-tracing")]
1308pub fn grpc_client_layer() -> grpc_middleware::GrpcClientTraceLayer {
1309    grpc_middleware::GrpcClientTraceLayer
1310}
1311
1312/// Construct the tower [`Layer`](tower::Layer) that extracts trace context
1313/// from incoming gRPC request metadata and opens a child span.
1314///
1315/// Requires the `tonic-tracing` feature. Attach to a tonic
1316/// [`tonic::transport::Server`] via `.layer(...)`, before `.add_service(...)`.
1317///
1318/// # Example
1319/// ```no_run
1320/// # #[cfg(feature = "tonic-tracing")]
1321/// # fn example() {
1322/// let _ = tonic::transport::Server::builder()
1323///     .layer(otel_bootstrap::grpc_server_layer());
1324/// # }
1325/// ```
1326#[cfg(feature = "tonic-tracing")]
1327pub fn grpc_server_layer() -> grpc_middleware::GrpcServerTraceLayer {
1328    grpc_middleware::GrpcServerTraceLayer
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334
1335    /// The opt-out flag and the branch it controls.
1336    #[test]
1337    fn runtime_metrics_can_be_disabled() {
1338        assert!(
1339            Telemetry::builder("rm-default").runtime_metrics,
1340            "runtime metrics are on by default"
1341        );
1342        assert!(
1343            !Telemetry::builder("rm-off")
1344                .with_runtime_metrics(false)
1345                .runtime_metrics
1346        );
1347    }
1348
1349    /// `shutdown()` must absorb provider errors rather than propagate them.
1350    ///
1351    /// Shutting a provider down twice is the cheapest way to make one fail
1352    /// deterministically — the second call reports that it is already shut
1353    /// down. Doing it with a real exporter would need an unreachable collector
1354    /// and a multi-second export deadline, and `force_flush` against a closed
1355    /// port blocks outright rather than failing.
1356    #[tokio::test]
1357    async fn shutdown_absorbs_provider_errors() {
1358        let handles = TelemetryHandles {
1359            tracer_provider: SdkTracerProvider::builder().build(),
1360            meter_provider: Some(SdkMeterProvider::builder().build()),
1361            logger_provider: Some(SdkLoggerProvider::builder().build()),
1362            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
1363            #[cfg(feature = "profiling")]
1364            profiling_handle: None,
1365        };
1366
1367        handles.shutdown().expect("first shutdown succeeds");
1368        handles
1369            .shutdown()
1370            .expect("second shutdown absorbs the already-shut-down errors");
1371    }
1372    use opentelemetry::trace::{Span as _, Tracer as _};
1373    use std::sync::Mutex;
1374
1375    static ENV_LOCK: Mutex<()> = Mutex::new(());
1376
1377    #[test]
1378    fn tracing_bridge_uses_sdk_tracer() {
1379        let provider = SdkTracerProvider::builder().build();
1380        let tracer = tracing_bridge_tracer(&provider);
1381        let span = tracer.start("bridge-regression");
1382
1383        assert!(span.span_context().is_valid());
1384
1385        provider.shutdown().expect("provider shutdown");
1386    }
1387
1388    #[test]
1389    fn resource_contains_all_attributes_when_provided() {
1390        let resource = build_resource("test-svc", Some("1.2.3"), Some("staging"));
1391
1392        assert_eq!(
1393            resource.get(&opentelemetry::Key::new("service.name")),
1394            Some(opentelemetry::Value::from("test-svc")),
1395        );
1396        assert_eq!(
1397            resource.get(&opentelemetry::Key::new(SERVICE_VERSION)),
1398            Some(opentelemetry::Value::from("1.2.3")),
1399        );
1400        assert_eq!(
1401            resource.get(&opentelemetry::Key::new(DEPLOYMENT_ENVIRONMENT_NAME)),
1402            Some(opentelemetry::Value::from("staging")),
1403        );
1404        assert!(resource.get(&opentelemetry::Key::new(HOST_NAME)).is_some());
1405        assert!(
1406            resource
1407                .get(&opentelemetry::Key::new(PROCESS_PID))
1408                .is_some()
1409        );
1410    }
1411
1412    #[test]
1413    fn resource_graceful_when_optional_values_omitted() {
1414        let resource = build_resource("test-svc", None, None);
1415
1416        assert_eq!(
1417            resource.get(&opentelemetry::Key::new("service.name")),
1418            Some(opentelemetry::Value::from("test-svc")),
1419        );
1420        assert!(
1421            resource
1422                .get(&opentelemetry::Key::new(SERVICE_VERSION))
1423                .is_none()
1424        );
1425        assert!(
1426            resource
1427                .get(&opentelemetry::Key::new(DEPLOYMENT_ENVIRONMENT_NAME))
1428                .is_none()
1429        );
1430        // Auto-detected attributes still present
1431        assert!(resource.get(&opentelemetry::Key::new(HOST_NAME)).is_some());
1432        assert!(
1433            resource
1434                .get(&opentelemetry::Key::new(PROCESS_PID))
1435                .is_some()
1436        );
1437    }
1438
1439    #[test]
1440    fn trace_sampler_ratio_converts_to_sdk() {
1441        let sampler = TraceSampler::TraceIdRatio(0.5);
1442        let sdk = sampler.into_sdk_sampler();
1443        assert_eq!(format!("{sdk:?}"), "TraceIdRatioBased(0.5)");
1444    }
1445
1446    #[test]
1447    fn trace_sampler_parent_based_converts_to_sdk() {
1448        let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.25)));
1449        let sdk = sampler.into_sdk_sampler();
1450        let debug = format!("{sdk:?}");
1451        assert!(debug.contains("ParentBased"));
1452        assert!(debug.contains("0.25"));
1453    }
1454
1455    /// # Safety helper — env var manipulation is unsafe in Rust 2024 edition.
1456    unsafe fn set_env(key: &str, val: &str) {
1457        unsafe {
1458            std::env::set_var(key, val);
1459        }
1460    }
1461
1462    unsafe fn remove_env(key: &str) {
1463        unsafe {
1464            std::env::remove_var(key);
1465        }
1466    }
1467
1468    #[test]
1469    fn sampler_from_env_reads_traceidratio() {
1470        let _lock = ENV_LOCK.lock().unwrap();
1471        unsafe {
1472            set_env("OTEL_TRACES_SAMPLER", "traceidratio");
1473            set_env("OTEL_TRACES_SAMPLER_ARG", "0.42");
1474        }
1475
1476        let sampler = sampler_from_env()
1477            .expect("should not error")
1478            .expect("should return Some");
1479        assert!(
1480            matches!(sampler, TraceSampler::TraceIdRatio(r) if (r - 0.42).abs() < f64::EPSILON)
1481        );
1482
1483        unsafe {
1484            remove_env("OTEL_TRACES_SAMPLER");
1485            remove_env("OTEL_TRACES_SAMPLER_ARG");
1486        }
1487    }
1488
1489    #[test]
1490    fn sampler_from_env_returns_none_when_unset() {
1491        let _lock = ENV_LOCK.lock().unwrap();
1492        unsafe {
1493            remove_env("OTEL_TRACES_SAMPLER");
1494        }
1495        assert!(sampler_from_env().expect("should not error").is_none());
1496    }
1497
1498    #[test]
1499    fn sampler_from_env_reads_parentbased_traceidratio() {
1500        let _lock = ENV_LOCK.lock().unwrap();
1501        unsafe {
1502            set_env("OTEL_TRACES_SAMPLER", "parentbased_traceidratio");
1503            set_env("OTEL_TRACES_SAMPLER_ARG", "0.1");
1504        }
1505
1506        let sampler = sampler_from_env()
1507            .expect("should not error")
1508            .expect("should return Some");
1509        assert!(
1510            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::TraceIdRatio(r) if (r - 0.1).abs() < f64::EPSILON))
1511        );
1512
1513        unsafe {
1514            remove_env("OTEL_TRACES_SAMPLER");
1515            remove_env("OTEL_TRACES_SAMPLER_ARG");
1516        }
1517    }
1518
1519    #[test]
1520    fn sampler_from_env_parentbased_always_on() {
1521        let _lock = ENV_LOCK.lock().unwrap();
1522        unsafe {
1523            set_env("OTEL_TRACES_SAMPLER", "parentbased_always_on");
1524        }
1525        let sampler = sampler_from_env()
1526            .expect("should not error")
1527            .expect("should return Some");
1528        assert!(
1529            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::AlwaysOn))
1530        );
1531        unsafe {
1532            remove_env("OTEL_TRACES_SAMPLER");
1533        }
1534    }
1535
1536    #[test]
1537    fn sampler_from_env_parentbased_always_off() {
1538        let _lock = ENV_LOCK.lock().unwrap();
1539        unsafe {
1540            set_env("OTEL_TRACES_SAMPLER", "parentbased_always_off");
1541        }
1542        let sampler = sampler_from_env()
1543            .expect("should not error")
1544            .expect("should return Some");
1545        assert!(
1546            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::AlwaysOff))
1547        );
1548        unsafe {
1549            remove_env("OTEL_TRACES_SAMPLER");
1550        }
1551    }
1552
1553    #[test]
1554    fn sampler_from_env_always_on() {
1555        let _lock = ENV_LOCK.lock().unwrap();
1556        unsafe {
1557            set_env("OTEL_TRACES_SAMPLER", "always_on");
1558        }
1559        let sampler = sampler_from_env()
1560            .expect("should not error")
1561            .expect("should return Some");
1562        assert!(matches!(sampler, TraceSampler::AlwaysOn));
1563        unsafe {
1564            remove_env("OTEL_TRACES_SAMPLER");
1565        }
1566    }
1567
1568    #[test]
1569    fn sampler_from_env_always_off() {
1570        let _lock = ENV_LOCK.lock().unwrap();
1571        unsafe {
1572            set_env("OTEL_TRACES_SAMPLER", "always_off");
1573        }
1574        let sampler = sampler_from_env()
1575            .expect("should not error")
1576            .expect("should return Some");
1577        assert!(matches!(sampler, TraceSampler::AlwaysOff));
1578        unsafe {
1579            remove_env("OTEL_TRACES_SAMPLER");
1580        }
1581    }
1582
1583    #[test]
1584    fn sampler_from_env_unknown_returns_error() {
1585        let _lock = ENV_LOCK.lock().unwrap();
1586        unsafe {
1587            set_env("OTEL_TRACES_SAMPLER", "unknown_sampler");
1588        }
1589        let err = sampler_from_env().expect_err("unknown sampler should produce an error");
1590        assert!(
1591            err.to_string().contains("unknown_sampler"),
1592            "error message should include the unknown name, got: {err}"
1593        );
1594        unsafe {
1595            remove_env("OTEL_TRACES_SAMPLER");
1596        }
1597    }
1598
1599    #[test]
1600    fn trace_sampler_always_on_converts_to_sdk() {
1601        let sdk = TraceSampler::AlwaysOn.into_sdk_sampler();
1602        assert_eq!(format!("{sdk:?}"), "AlwaysOn");
1603    }
1604
1605    #[test]
1606    fn trace_sampler_always_off_converts_to_sdk() {
1607        let sdk = TraceSampler::AlwaysOff.into_sdk_sampler();
1608        assert_eq!(format!("{sdk:?}"), "AlwaysOff");
1609    }
1610
1611    #[test]
1612    fn builder_has_sensible_defaults() {
1613        let builder = Telemetry::builder("test-svc");
1614        assert_eq!(builder.service_name.as_deref(), Some("test-svc"));
1615        assert!(builder.service_version.is_none());
1616        assert!(builder.deployment_environment.is_none());
1617        assert!(builder.sampler.is_none());
1618        assert!(builder.metrics);
1619        assert!(!builder.logs);
1620        assert!(builder.protocol.is_none());
1621        assert!(builder.max_export_batch_size.is_none());
1622        assert!(builder.metric_export_interval.is_none());
1623        assert!(builder.export_timeout.is_none());
1624    }
1625
1626    #[test]
1627    fn from_env_builder_has_no_service_name() {
1628        let builder = Telemetry::from_env();
1629        assert!(builder.service_name.is_none());
1630    }
1631
1632    #[test]
1633    fn with_export_timeout_stores_value() {
1634        let timeout = Duration::from_secs(5);
1635        let builder = Telemetry::builder("test-svc").with_export_timeout(timeout);
1636        assert_eq!(builder.export_timeout, Some(timeout));
1637    }
1638
1639    #[test]
1640    fn timeout_from_env_reads_milliseconds() {
1641        let _lock = ENV_LOCK.lock().unwrap();
1642        unsafe {
1643            set_env("OTEL_EXPORTER_OTLP_TIMEOUT", "5000");
1644        }
1645        let t = timeout_from_env();
1646        assert_eq!(t, Some(Duration::from_millis(5000)));
1647        unsafe {
1648            remove_env("OTEL_EXPORTER_OTLP_TIMEOUT");
1649        }
1650    }
1651
1652    #[test]
1653    fn timeout_from_env_returns_none_when_unset() {
1654        let _lock = ENV_LOCK.lock().unwrap();
1655        unsafe {
1656            remove_env("OTEL_EXPORTER_OTLP_TIMEOUT");
1657        }
1658        assert_eq!(timeout_from_env(), None);
1659    }
1660
1661    #[test]
1662    fn service_name_from_env_used_when_none_given() {
1663        let builder = Telemetry::from_env();
1664        assert!(builder.service_name.is_none());
1665    }
1666
1667    #[test]
1668    fn explicit_service_name_overrides_env_var() {
1669        let builder = Telemetry::builder("explicit-svc");
1670        assert_eq!(builder.service_name.as_deref(), Some("explicit-svc"));
1671    }
1672
1673    #[test]
1674    fn from_env_builder_service_name_is_none() {
1675        let builder = Telemetry::from_env();
1676        assert!(builder.service_name.is_none());
1677    }
1678
1679    #[test]
1680    fn init_returns_error_for_unknown_otel_traces_sampler() {
1681        let _lock = ENV_LOCK.lock().unwrap();
1682        unsafe {
1683            set_env("OTEL_TRACES_SAMPLER", "not_a_real_sampler");
1684        }
1685        let result = Telemetry::builder("test-svc").with_metrics(false).init();
1686        let err = result
1687            .err()
1688            .expect("unknown sampler env var should cause init to fail");
1689        assert!(
1690            err.to_string().contains("not_a_real_sampler"),
1691            "error should name the unknown sampler, got: {err}"
1692        );
1693        unsafe {
1694            remove_env("OTEL_TRACES_SAMPLER");
1695        }
1696    }
1697
1698    #[test]
1699    fn with_max_export_batch_size_stores_value() {
1700        let builder = Telemetry::builder("test-svc").with_max_export_batch_size(1024);
1701        assert_eq!(builder.max_export_batch_size, Some(1024));
1702    }
1703
1704    #[test]
1705    fn with_metric_export_interval_stores_value() {
1706        let interval = Duration::from_secs(30);
1707        let builder = Telemetry::builder("test-svc").with_metric_export_interval(interval);
1708        assert_eq!(builder.metric_export_interval, Some(interval));
1709    }
1710
1711    #[test]
1712    fn init_rejects_zero_metric_export_interval() {
1713        let err = Telemetry::builder("test-svc")
1714            .with_metric_export_interval(Duration::ZERO)
1715            .with_metrics(false)
1716            .init()
1717            .err()
1718            .expect("expected error for zero interval");
1719        assert!(
1720            err.to_string().contains("metric_export_interval"),
1721            "error message should mention metric_export_interval, got: {err}"
1722        );
1723    }
1724
1725    #[test]
1726    fn builder_with_custom_values() {
1727        let builder = Telemetry::builder("test-svc")
1728            .with_version("2.0.0")
1729            .with_environment("production")
1730            .with_sampler(TraceSampler::TraceIdRatio(0.5))
1731            .with_metrics(false);
1732
1733        assert_eq!(builder.service_name.as_deref(), Some("test-svc"));
1734        assert_eq!(builder.service_version.as_deref(), Some("2.0.0"));
1735        assert_eq!(
1736            builder.deployment_environment.as_deref(),
1737            Some("production")
1738        );
1739        assert!(
1740            matches!(builder.sampler, Some(TraceSampler::TraceIdRatio(r)) if (r - 0.5).abs() < f64::EPSILON)
1741        );
1742        assert!(!builder.metrics);
1743    }
1744
1745    #[test]
1746    fn builder_stores_programmatic_log_configuration() {
1747        let builder = Telemetry::builder("test-svc")
1748            .with_log_filter("info,opentelemetry_sdk=warn")
1749            .with_log_format(LogFormat::Json);
1750
1751        assert_eq!(
1752            builder.log_filter.as_deref(),
1753            Some("info,opentelemetry_sdk=warn")
1754        );
1755        assert_eq!(builder.log_format, LogFormat::Json);
1756    }
1757
1758    #[test]
1759    fn init_rejects_invalid_programmatic_log_filter_before_provider_setup() {
1760        let setup_ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1761        let setup_ran_in_closure = std::sync::Arc::clone(&setup_ran);
1762
1763        let error = Telemetry::builder("test-svc")
1764            .with_log_filter("[")
1765            .with_meter_provider_setup(move |builder| {
1766                setup_ran_in_closure.store(true, std::sync::atomic::Ordering::SeqCst);
1767                builder
1768            })
1769            .init()
1770            .err()
1771            .expect("invalid filter must fail initialization");
1772
1773        assert!(error.to_string().contains("invalid filter directive"));
1774        assert!(!setup_ran.load(std::sync::atomic::Ordering::SeqCst));
1775    }
1776
1777    #[test]
1778    #[cfg(feature = "grpc")]
1779    fn builder_with_protocol_grpc() {
1780        let builder = Telemetry::builder("test-svc").with_protocol(ExportProtocol::Grpc);
1781        assert_eq!(builder.protocol, Some(ExportProtocol::Grpc));
1782    }
1783
1784    #[test]
1785    #[cfg(feature = "http")]
1786    fn builder_with_protocol_http() {
1787        let builder = Telemetry::builder("test-svc").with_protocol(ExportProtocol::HttpProtobuf);
1788        assert_eq!(builder.protocol, Some(ExportProtocol::HttpProtobuf));
1789    }
1790
1791    #[test]
1792    #[cfg(feature = "grpc")]
1793    fn protocol_from_env_reads_grpc() {
1794        let _lock = ENV_LOCK.lock().unwrap();
1795        unsafe {
1796            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc");
1797        }
1798        assert_eq!(protocol_from_env(), Some(ExportProtocol::Grpc));
1799        unsafe {
1800            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1801        }
1802    }
1803
1804    #[test]
1805    #[cfg(feature = "http")]
1806    fn protocol_from_env_reads_http_protobuf() {
1807        let _lock = ENV_LOCK.lock().unwrap();
1808        unsafe {
1809            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf");
1810        }
1811        assert_eq!(protocol_from_env(), Some(ExportProtocol::HttpProtobuf));
1812        unsafe {
1813            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1814        }
1815    }
1816
1817    #[test]
1818    fn protocol_from_env_returns_none_when_unset() {
1819        let _lock = ENV_LOCK.lock().unwrap();
1820        unsafe {
1821            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1822        }
1823        assert_eq!(protocol_from_env(), None);
1824    }
1825
1826    #[test]
1827    fn protocol_from_env_returns_none_for_unknown() {
1828        let _lock = ENV_LOCK.lock().unwrap();
1829        unsafe {
1830            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "websocket");
1831        }
1832        assert_eq!(protocol_from_env(), None);
1833        unsafe {
1834            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1835        }
1836    }
1837
1838    #[test]
1839    fn builder_is_send_and_sync() {
1840        fn assert_send_sync<T: Send + Sync>() {}
1841        assert_send_sync::<TelemetryBuilder>();
1842    }
1843
1844    #[test]
1845    fn with_shutdown_timeout_stores_value() {
1846        let timeout = Duration::from_secs(10);
1847        let builder = Telemetry::builder("test-svc").with_shutdown_timeout(timeout);
1848        assert_eq!(builder.shutdown_timeout, timeout);
1849    }
1850
1851    #[test]
1852    fn default_shutdown_timeout_is_five_seconds() {
1853        let builder = Telemetry::builder("test-svc");
1854        assert_eq!(builder.shutdown_timeout, Duration::from_secs(5));
1855    }
1856
1857    /// Verify that drop completes within the configured timeout even when the
1858    /// shutdown thread is blocked (simulated by using a very short timeout so
1859    /// the test itself runs quickly).
1860    ///
1861    /// We construct `TelemetryHandles` with an artificially short timeout and
1862    /// a real (but disconnected) provider.  Drop must return before the test
1863    /// times out.
1864    #[cfg(feature = "testing")]
1865    #[test]
1866    fn drop_completes_within_shutdown_timeout() {
1867        // Use the testing helper so we don't need a running OTLP collector.
1868        let mut handles = crate::Telemetry::testing("drop-timeout-test");
1869        // Override the timeout to something very short so the test is fast.
1870        handles.shutdown_timeout = Duration::from_millis(100);
1871
1872        let start = std::time::Instant::now();
1873        drop(handles);
1874        let elapsed = start.elapsed();
1875
1876        // Drop should complete within 2× the timeout (generous margin for CI).
1877        assert!(
1878            elapsed < Duration::from_millis(500),
1879            "drop took {elapsed:?}, expected < 500 ms"
1880        );
1881    }
1882}