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
35pub mod span_enrichment;
36
37use opentelemetry::KeyValue;
38use opentelemetry::propagation::TextMapCompositePropagator;
39use opentelemetry_otlp::WithExportConfig;
40use opentelemetry_sdk::{
41    Resource,
42    logs::SdkLoggerProvider,
43    metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider},
44    propagation::{BaggagePropagator, TraceContextPropagator},
45    trace::{BatchConfigBuilder, BatchSpanProcessor, Sampler, SdkTracerProvider},
46};
47use opentelemetry_semantic_conventions::attribute::{
48    DEPLOYMENT_ENVIRONMENT_NAME, HOST_NAME, PROCESS_PID, SERVICE_VERSION,
49};
50use std::error::Error;
51use std::time::Duration;
52use tracing_subscriber::layer::SubscriberExt;
53use tracing_subscriber::util::SubscriberInitExt;
54
55/// Trace sampler configuration.
56///
57/// Controls how many traces are sampled. When no explicit sampler is passed to
58/// [`init_telemetry_with_sampler`], the library falls back to the
59/// `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` environment variables,
60/// and finally to [`TraceSampler::AlwaysOn`] for backward compatibility.
61///
62/// # Example
63/// ```
64/// use otel_bootstrap::TraceSampler;
65///
66/// // Sample 10 % of root spans; inherit parent decision for child spans.
67/// let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.1)));
68/// ```
69#[derive(Debug, Clone)]
70pub enum TraceSampler {
71    /// Record every trace (the default).
72    AlwaysOn,
73    /// Never record any trace.
74    AlwaysOff,
75    /// Sample a fraction of traces. `ratio` must be between 0.0 and 1.0.
76    TraceIdRatio(f64),
77    /// Respect the parent span's sampling decision; use the given sampler for
78    /// root spans (spans without a remote parent).
79    ParentBased(Box<TraceSampler>),
80}
81
82impl TraceSampler {
83    /// Convert to the SDK [`Sampler`].
84    fn into_sdk_sampler(self) -> Sampler {
85        match self {
86            TraceSampler::AlwaysOn => Sampler::AlwaysOn,
87            TraceSampler::AlwaysOff => Sampler::AlwaysOff,
88            TraceSampler::TraceIdRatio(r) => Sampler::TraceIdRatioBased(r),
89            TraceSampler::ParentBased(inner) => {
90                Sampler::ParentBased(Box::new(inner.into_sdk_sampler()))
91            }
92        }
93    }
94}
95
96/// Resolve the sampler from `OTEL_TRACES_SAMPLER` and `OTEL_TRACES_SAMPLER_ARG`
97/// environment variables.
98///
99/// Returns:
100/// - `Ok(None)` when `OTEL_TRACES_SAMPLER` is unset.
101/// - `Ok(Some(_))` for a recognised sampler name.
102/// - `Err(_)` for an unrecognised sampler name (clear error at init time).
103fn sampler_from_env() -> Result<Option<TraceSampler>, Box<dyn Error>> {
104    let name = match std::env::var("OTEL_TRACES_SAMPLER") {
105        Ok(v) => v,
106        Err(_) => return Ok(None),
107    };
108    let arg = std::env::var("OTEL_TRACES_SAMPLER_ARG").ok();
109    let sampler = match name.as_str() {
110        "always_on" => TraceSampler::AlwaysOn,
111        "always_off" => TraceSampler::AlwaysOff,
112        "traceidratio" => {
113            let ratio = arg
114                .as_deref()
115                .unwrap_or("1.0")
116                .parse::<f64>()
117                .unwrap_or(1.0);
118            TraceSampler::TraceIdRatio(ratio)
119        }
120        "parentbased_always_on" => TraceSampler::ParentBased(Box::new(TraceSampler::AlwaysOn)),
121        "parentbased_always_off" => TraceSampler::ParentBased(Box::new(TraceSampler::AlwaysOff)),
122        "parentbased_traceidratio" => {
123            let ratio = arg
124                .as_deref()
125                .unwrap_or("1.0")
126                .parse::<f64>()
127                .unwrap_or(1.0);
128            TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(ratio)))
129        }
130        unknown => {
131            return Err(format!(
132                "OTEL_TRACES_SAMPLER: unrecognised sampler name '{unknown}'. \
133                 Valid values: always_on, always_off, traceidratio, \
134                 parentbased_always_on, parentbased_always_off, parentbased_traceidratio"
135            )
136            .into());
137        }
138    };
139    Ok(Some(sampler))
140}
141
142/// Default timeout for provider shutdown in [`Drop`].
143const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
144
145/// Handles returned by [`init_telemetry`] or [`TelemetryBuilder::init`].
146///
147/// Keep alive for the duration of the process. Call [`shutdown`](TelemetryHandles::shutdown)
148/// before exiting to flush pending spans, metrics, and logs.
149///
150/// When dropped, shutdown is attempted with a bounded timeout (default: 5 s).
151/// If the timeout expires a warning is logged but the process continues normally.
152///
153/// # Example
154/// ```no_run
155/// #[tokio::main]
156/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
157///     let handles = otel_bootstrap::init_telemetry("my-service")?;
158///
159///     // run your application here …
160///
161///     handles.shutdown()?;
162///     Ok(())
163/// }
164/// ```
165pub struct TelemetryHandles {
166    pub tracer_provider: SdkTracerProvider,
167    pub meter_provider: Option<SdkMeterProvider>,
168    pub logger_provider: Option<SdkLoggerProvider>,
169    shutdown_timeout: Duration,
170}
171
172impl TelemetryHandles {
173    /// Flush pending data and shut down all providers.
174    ///
175    /// Must be called before the tokio runtime shuts down so the batch
176    /// exporter can send remaining spans over gRPC. Safe to call multiple
177    /// times — subsequent calls are no-ops.
178    ///
179    /// # Example
180    /// ```no_run
181    /// let handles = otel_bootstrap::init_telemetry("my-service").unwrap();
182    /// // … application logic …
183    /// handles.shutdown().expect("telemetry shutdown failed");
184    /// ```
185    pub fn shutdown(&self) -> Result<(), Box<dyn Error>> {
186        self.tracer_provider.shutdown()?;
187        if let Some(mp) = &self.meter_provider {
188            mp.shutdown()?;
189        }
190        if let Some(lp) = &self.logger_provider {
191            lp.shutdown()?;
192        }
193        Ok(())
194    }
195}
196
197impl Drop for TelemetryHandles {
198    fn drop(&mut self) {
199        let tracer_provider = self.tracer_provider.clone();
200        let meter_provider = self.meter_provider.clone();
201        let logger_provider = self.logger_provider.clone();
202        let timeout = self.shutdown_timeout;
203
204        let (tx, rx) = std::sync::mpsc::channel();
205        std::thread::spawn(move || {
206            if let Err(e) = tracer_provider.shutdown() {
207                tracing::warn!("tracer provider shutdown error: {e}");
208            }
209            if let Some(mp) = meter_provider
210                && let Err(e) = mp.shutdown()
211            {
212                tracing::warn!("meter provider shutdown error: {e}");
213            }
214            if let Some(lp) = logger_provider
215                && let Err(e) = lp.shutdown()
216            {
217                tracing::warn!("logger provider shutdown error: {e}");
218            }
219            let _ = tx.send(());
220        });
221
222        if rx.recv_timeout(timeout).is_err() {
223            tracing::warn!(
224                "telemetry shutdown did not complete within {timeout:?}; \
225                 some spans/metrics may not have been exported"
226            );
227        }
228    }
229}
230
231/// OTLP export protocol.
232///
233/// Selects between gRPC/tonic and HTTP/protobuf transports. When not set
234/// explicitly, the builder reads `OTEL_EXPORTER_OTLP_PROTOCOL`. If both the
235/// `grpc` and `http` features are compiled in and neither the builder nor the
236/// env var specifies a protocol, `grpc` is used.
237///
238/// Each variant is only present when its corresponding feature is enabled, so
239/// match expressions are always exhaustive without a fallback arm.
240///
241/// # Example
242/// ```no_run
243/// # #[cfg(feature = "grpc")]
244/// # {
245/// use otel_bootstrap::{ExportProtocol, Telemetry};
246///
247/// let _handles = Telemetry::builder("my-service")
248///     .with_protocol(ExportProtocol::Grpc)
249///     .init()
250///     .unwrap();
251/// # }
252/// ```
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum ExportProtocol {
255    /// gRPC via tonic (requires the `grpc` feature).
256    #[cfg(feature = "grpc")]
257    Grpc,
258    /// HTTP/protobuf (requires the `http` feature).
259    #[cfg(feature = "http")]
260    HttpProtobuf,
261}
262
263/// Resolve the export protocol from `OTEL_EXPORTER_OTLP_PROTOCOL`.
264fn protocol_from_env() -> Option<ExportProtocol> {
265    let val = std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL").ok()?;
266    match val.trim() {
267        #[cfg(feature = "grpc")]
268        "grpc" => Some(ExportProtocol::Grpc),
269        #[cfg(feature = "http")]
270        "http/protobuf" => Some(ExportProtocol::HttpProtobuf),
271        _ => None,
272    }
273}
274
275/// Entry point for configuring telemetry via a builder pattern.
276///
277/// # Example
278/// ```no_run
279/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
280/// let _handles = otel_bootstrap::Telemetry::builder("my-service")
281///     .with_version("1.0.0")
282///     .with_environment("production")
283///     .with_sampler(otel_bootstrap::TraceSampler::TraceIdRatio(0.1))
284///     .with_metrics(true)
285///     .with_logs(true)
286///     .init()?;
287/// # Ok(())
288/// # }
289/// ```
290pub struct Telemetry;
291
292impl Telemetry {
293    /// Create a new [`TelemetryBuilder`] with the given service name.
294    ///
295    /// The explicit `service_name` takes precedence over `OTEL_SERVICE_NAME`.
296    pub fn builder(service_name: &str) -> TelemetryBuilder {
297        TelemetryBuilder {
298            service_name: Some(service_name.to_string()),
299            service_version: None,
300            deployment_environment: None,
301            sampler: None,
302            metrics: true,
303            logs: false,
304            protocol: None,
305            max_export_batch_size: None,
306            metric_export_interval: None,
307            export_timeout: None,
308            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
309            extra_layers: Vec::new(),
310            extra_metric_readers: Vec::new(),
311        }
312    }
313
314    /// Create a new [`TelemetryBuilder`] that reads the service name from
315    /// `OTEL_SERVICE_NAME`. Falls back to `"unknown_service"` when the env var
316    /// is not set, following the OpenTelemetry default resource specification.
317    ///
318    /// # Example
319    /// ```no_run
320    /// // Set OTEL_SERVICE_NAME=my-service in the environment before calling this.
321    /// let _handles = otel_bootstrap::Telemetry::from_env().init().unwrap();
322    /// ```
323    pub fn from_env() -> TelemetryBuilder {
324        TelemetryBuilder {
325            service_name: None,
326            service_version: None,
327            deployment_environment: None,
328            sampler: None,
329            metrics: true,
330            logs: false,
331            protocol: None,
332            max_export_batch_size: None,
333            metric_export_interval: None,
334            export_timeout: None,
335            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
336            extra_layers: Vec::new(),
337            extra_metric_readers: Vec::new(),
338        }
339    }
340}
341
342/// Builder for configuring telemetry options incrementally.
343///
344/// Created via [`Telemetry::builder`] or [`Telemetry::from_env`]. Call
345/// [`.init()`](TelemetryBuilder::init) to consume the builder and start telemetry.
346///
347/// # Example
348/// ```no_run
349/// use std::time::Duration;
350///
351/// let _handles = otel_bootstrap::Telemetry::builder("my-service")
352///     .with_version("1.2.3")
353///     .with_environment("staging")
354///     .with_metrics(true)
355///     .with_shutdown_timeout(Duration::from_secs(10))
356///     .init()
357///     .unwrap();
358/// ```
359#[must_use = "a TelemetryBuilder does nothing until .init() is called"]
360pub struct TelemetryBuilder {
361    service_name: Option<String>,
362    service_version: Option<String>,
363    deployment_environment: Option<String>,
364    sampler: Option<TraceSampler>,
365    metrics: bool,
366    logs: bool,
367    protocol: Option<ExportProtocol>,
368    max_export_batch_size: Option<usize>,
369    metric_export_interval: Option<Duration>,
370    export_timeout: Option<Duration>,
371    shutdown_timeout: Duration,
372    extra_layers: Vec<
373        Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync + 'static>,
374    >,
375    extra_metric_readers: Vec<MeterProviderInstaller>,
376}
377
378/// Type-erased adapter that applies an extra `MetricReader` to the
379/// in-progress [`MeterProviderBuilder`]. Stored as a closure so the trait
380/// (which is generic, not object-safe in a useful way here) can be ranged
381/// over uniformly inside [`TelemetryBuilder`].
382type MeterProviderInstaller =
383    Box<dyn FnOnce(MeterProviderBuilder) -> MeterProviderBuilder + Send + Sync>;
384
385impl TelemetryBuilder {
386    /// Set the service version (maps to `service.version` resource attribute).
387    pub fn with_version(mut self, version: &str) -> Self {
388        self.service_version = Some(version.to_string());
389        self
390    }
391
392    /// Set the deployment environment (maps to `deployment.environment.name`).
393    pub fn with_environment(mut self, environment: &str) -> Self {
394        self.deployment_environment = Some(environment.to_string());
395        self
396    }
397
398    /// Set an explicit trace sampler. If not set, falls back to
399    /// `OTEL_TRACES_SAMPLER` env var, then always-on.
400    pub fn with_sampler(mut self, sampler: TraceSampler) -> Self {
401        self.sampler = Some(sampler);
402        self
403    }
404
405    /// Enable or disable metrics export (default: `true`).
406    pub fn with_metrics(mut self, enabled: bool) -> Self {
407        self.metrics = enabled;
408        self
409    }
410
411    /// Set the export protocol explicitly. If not set, falls back to
412    /// `OTEL_EXPORTER_OTLP_PROTOCOL`, then the compiled-in default (`grpc`
413    /// when the `grpc` feature is enabled, `http/protobuf` otherwise).
414    pub fn with_protocol(mut self, protocol: ExportProtocol) -> Self {
415        self.protocol = Some(protocol);
416        self
417    }
418
419    /// Set the maximum number of spans exported in a single batch (default: 512).
420    ///
421    /// Overrides `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` when set programmatically.
422    /// The env var is still read as a fallback when this method is not called.
423    pub fn with_max_export_batch_size(mut self, size: usize) -> Self {
424        self.max_export_batch_size = Some(size);
425        self
426    }
427
428    /// Set the interval between metric exports (default: 60 s).
429    ///
430    /// Returns an error at build time if `interval` is zero.
431    /// Overrides `OTEL_METRIC_EXPORT_INTERVAL` when set programmatically.
432    pub fn with_metric_export_interval(mut self, interval: Duration) -> Self {
433        self.metric_export_interval = Some(interval);
434        self
435    }
436
437    /// Enable or disable log export via the OTLP log bridge (default: `false`).
438    ///
439    /// When enabled, `tracing` events are forwarded to an OTLP `LogExporter`
440    /// in addition to the existing stdout fmt layer. This allows structured
441    /// logs to be correlated with traces in backends like Grafana Loki or
442    /// Datadog.
443    pub fn with_logs(mut self, enabled: bool) -> Self {
444        self.logs = enabled;
445        self
446    }
447
448    /// Set the OTLP export timeout explicitly. If not set, falls back to
449    /// `OTEL_EXPORTER_OTLP_TIMEOUT` (in milliseconds), then the SDK default
450    /// of 10 000 ms.
451    pub fn with_export_timeout(mut self, timeout: Duration) -> Self {
452        self.export_timeout = Some(timeout);
453        self
454    }
455
456    /// Set the maximum time to wait for provider shutdown when the
457    /// [`TelemetryHandles`] is dropped (default: 5 s).
458    ///
459    /// If the timeout expires a warning is logged and the drop completes
460    /// without panicking. The background shutdown thread is abandoned and
461    /// the providers may not have flushed all pending data.
462    pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
463        self.shutdown_timeout = timeout;
464        self
465    }
466
467    /// Add a custom [`tracing_subscriber::Layer`] to the subscriber stack.
468    ///
469    /// Multiple layers can be added by chaining calls. Each layer is composed
470    /// with the built-in `EnvFilter`, `fmt`, and OpenTelemetry layers.
471    ///
472    /// Insertion order in the subscriber stack (inner → outer, i.e. first-added
473    /// to last-added):
474    /// ```text
475    /// registry → custom layers → EnvFilter → fmt → OTel
476    /// ```
477    /// Because `EnvFilter` is outer, it can suppress events before they reach
478    /// the `fmt` and OTel layers; custom layers receive events independently
479    /// according to their own `enabled()` implementation.
480    ///
481    /// # Example
482    /// ```no_run
483    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
484    /// let _handles = otel_bootstrap::Telemetry::builder("my-service")
485    ///     .with_layer(tracing_subscriber::fmt::layer().with_target(false))
486    ///     .init()?;
487    /// # Ok(())
488    /// # }
489    /// ```
490    /// Customise the [`MeterProviderBuilder`] before it is built.
491    ///
492    /// Runs after the built-in OTLP `PeriodicReader` is attached (when
493    /// [`with_metrics`](Self::with_metrics) is enabled) and before
494    /// `.build()` is called. The closure is the escape hatch for everything
495    /// the explicit builder methods do not cover — most importantly,
496    /// installing **additional `MetricReader`s** like
497    /// [`opentelemetry-prometheus`](https://crates.io/crates/opentelemetry-prometheus)
498    /// alongside the OTLP push, so the same instruments fan out to multiple
499    /// transports without double-counting.
500    ///
501    /// May be called multiple times; closures run in registration order.
502    /// Has no effect when `with_metrics(false)` is also set on the builder —
503    /// when metrics are disabled, no `MeterProvider` is created at all.
504    ///
505    /// `MetricReader` is intentionally not nameable from outside
506    /// `opentelemetry_sdk`, so the closure form is the only way to attach
507    /// readers without leaking unstable trait names through this crate's
508    /// public API.
509    ///
510    /// # Example
511    ///
512    /// ```ignore
513    /// // With `opentelemetry-prometheus` in scope:
514    /// let registry = prometheus::Registry::new();
515    /// let exporter = opentelemetry_prometheus::exporter()
516    ///     .with_registry(registry.clone())
517    ///     .build()?;
518    /// let _handles = otel_bootstrap::Telemetry::builder("my-service")
519    ///     .with_meter_provider_setup(move |b| b.with_reader(exporter))
520    ///     .init()?;
521    /// // ...mount `registry` at GET /metrics in your HTTP layer.
522    /// ```
523    pub fn with_meter_provider_setup<F>(mut self, setup: F) -> Self
524    where
525        F: FnOnce(MeterProviderBuilder) -> MeterProviderBuilder + Send + Sync + 'static,
526    {
527        self.extra_metric_readers.push(Box::new(setup));
528        self
529    }
530
531    pub fn with_layer<L>(mut self, layer: L) -> Self
532    where
533        L: tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync + 'static,
534    {
535        self.extra_layers.push(Box::new(layer));
536        self
537    }
538
539    /// Consume the builder and initialise OpenTelemetry.
540    ///
541    /// Installs a global tracer provider, meter provider (if enabled), and
542    /// a `tracing` subscriber. Returns an error if any provider fails to
543    /// build (e.g. unknown sampler name, zero metric interval).
544    ///
545    /// # Example
546    /// ```no_run
547    /// let handles = otel_bootstrap::Telemetry::builder("my-service")
548    ///     .with_metrics(false)
549    ///     .init()
550    ///     .expect("telemetry init failed");
551    /// handles.shutdown().ok();
552    /// ```
553    pub fn init(self) -> Result<TelemetryHandles, Box<dyn Error>> {
554        if let Some(interval) = self.metric_export_interval
555            && interval.is_zero()
556        {
557            return Err("metric_export_interval must be greater than zero".into());
558        }
559
560        let protocol = self.protocol.or_else(protocol_from_env).unwrap_or({
561            #[cfg(feature = "grpc")]
562            {
563                ExportProtocol::Grpc
564            }
565            #[cfg(all(not(feature = "grpc"), feature = "http"))]
566            {
567                ExportProtocol::HttpProtobuf
568            }
569        });
570
571        let default_endpoint = match protocol {
572            #[cfg(feature = "grpc")]
573            ExportProtocol::Grpc => "http://localhost:4317",
574            #[cfg(feature = "http")]
575            ExportProtocol::HttpProtobuf => "http://localhost:4318",
576        };
577        let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
578            .unwrap_or_else(|_| default_endpoint.to_string());
579
580        // Resolve export timeout: explicit builder > OTEL_EXPORTER_OTLP_TIMEOUT > SDK default (10 s)
581        let export_timeout = self.export_timeout.or_else(timeout_from_env);
582
583        // Resolve service name: explicit builder > OTEL_SERVICE_NAME > "unknown_service"
584        let service_name = self.service_name.unwrap_or_else(|| {
585            std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "unknown_service".to_string())
586        });
587
588        let resource = build_resource(
589            &service_name,
590            self.service_version.as_deref(),
591            self.deployment_environment.as_deref(),
592        );
593
594        let sampler = match self.sampler {
595            Some(s) => s,
596            None => sampler_from_env()?.unwrap_or(TraceSampler::AlwaysOn),
597        };
598
599        // Tracer
600        let trace_exporter = build_span_exporter(protocol, &endpoint, export_timeout)?;
601
602        let batch_processor = if let Some(size) = self.max_export_batch_size {
603            BatchSpanProcessor::builder(trace_exporter)
604                .with_batch_config(
605                    BatchConfigBuilder::default()
606                        .with_max_export_batch_size(size)
607                        .build(),
608                )
609                .build()
610        } else {
611            BatchSpanProcessor::builder(trace_exporter).build()
612        };
613
614        let tracer_provider = SdkTracerProvider::builder()
615            .with_resource(resource.clone())
616            .with_sampler(sampler.into_sdk_sampler())
617            .with_span_processor(batch_processor)
618            .build();
619
620        opentelemetry::global::set_tracer_provider(tracer_provider.clone());
621
622        // Register W3C TraceContext + Baggage propagators
623        let propagator = TextMapCompositePropagator::new(vec![
624            Box::new(TraceContextPropagator::new()),
625            Box::new(BaggagePropagator::new()),
626        ]);
627        opentelemetry::global::set_text_map_propagator(propagator);
628
629        // Meter (optional)
630        let meter_provider = if self.metrics {
631            let metric_exporter = build_metric_exporter(protocol, &endpoint, export_timeout)?;
632
633            let periodic_reader = if let Some(interval) = self.metric_export_interval {
634                PeriodicReader::builder(metric_exporter)
635                    .with_interval(interval)
636                    .build()
637            } else {
638                PeriodicReader::builder(metric_exporter).build()
639            };
640
641            let mut mp_builder = SdkMeterProvider::builder()
642                .with_resource(resource.clone())
643                .with_reader(periodic_reader);
644            for installer in self.extra_metric_readers {
645                mp_builder = installer(mp_builder);
646            }
647            let mp = mp_builder.build();
648
649            opentelemetry::global::set_meter_provider(mp.clone());
650
651            Some(mp)
652        } else {
653            None
654        };
655
656        // Logger (optional) — bridges tracing events to the OTLP log pipeline
657        let logger_provider = if self.logs {
658            let log_exporter = build_log_exporter(protocol, &endpoint, export_timeout)?;
659
660            let lp = SdkLoggerProvider::builder()
661                .with_resource(resource)
662                .with_batch_exporter(log_exporter)
663                .build();
664
665            Some(lp)
666        } else {
667            None
668        };
669
670        // Wire into tracing
671        let otel_layer = tracing_opentelemetry::layer();
672
673        let registry = tracing_subscriber::registry()
674            .with(self.extra_layers)
675            .with(tracing_subscriber::EnvFilter::from_default_env())
676            .with(tracing_subscriber::fmt::layer())
677            .with(otel_layer);
678
679        if let Some(lp) = &logger_provider {
680            registry
681                .with(opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(lp))
682                .try_init()
683                .ok();
684        } else {
685            registry.try_init().ok();
686        }
687
688        Ok(TelemetryHandles {
689            tracer_provider,
690            meter_provider,
691            logger_provider,
692            shutdown_timeout: self.shutdown_timeout,
693        })
694    }
695}
696
697/// Initialise OpenTelemetry traces + metrics with OTLP gRPC export.
698///
699/// Convenience wrapper around [`Telemetry::builder`] with all defaults.
700/// For fine-grained control, use the builder directly.
701///
702/// # Example
703/// ```no_run
704/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
705/// let _tel = otel_bootstrap::init_telemetry("my-service")?;
706/// // start axum server...
707/// # Ok(())
708/// # }
709/// ```
710pub fn init_telemetry(service_name: &str) -> Result<TelemetryHandles, Box<dyn Error>> {
711    Telemetry::builder(service_name).init()
712}
713
714/// Initialise OpenTelemetry traces + metrics with OTLP gRPC export and an
715/// explicit trace sampler.
716///
717/// Convenience wrapper around [`Telemetry::builder`]. When `sampler` is
718/// `None`, falls back to `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG`,
719/// then always-on.
720///
721/// # Example
722/// ```no_run
723/// use otel_bootstrap::TraceSampler;
724/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
725/// let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.1)));
726/// let _tel = otel_bootstrap::init_telemetry_with_sampler("my-service", Some(sampler))?;
727/// # Ok(())
728/// # }
729/// ```
730pub fn init_telemetry_with_sampler(
731    service_name: &str,
732    sampler: Option<TraceSampler>,
733) -> Result<TelemetryHandles, Box<dyn Error>> {
734    let builder = Telemetry::builder(service_name);
735    match sampler {
736        Some(s) => builder.with_sampler(s),
737        None => builder, // no-op: identical to calling init_telemetry(); not covered by tests (see Makefile ci-coverage note)
738    }
739    .init()
740}
741
742/// Read `OTEL_EXPORTER_OTLP_TIMEOUT` (milliseconds). Returns `None` when unset or invalid.
743fn timeout_from_env() -> Option<Duration> {
744    let ms = std::env::var("OTEL_EXPORTER_OTLP_TIMEOUT").ok()?;
745    let ms: u64 = ms.trim().parse().ok()?;
746    Some(Duration::from_millis(ms))
747}
748
749fn build_span_exporter(
750    protocol: ExportProtocol,
751    endpoint: &str,
752    timeout: Option<Duration>,
753) -> Result<opentelemetry_otlp::SpanExporter, Box<dyn Error>> {
754    match protocol {
755        #[cfg(feature = "grpc")]
756        ExportProtocol::Grpc => {
757            let mut b = opentelemetry_otlp::SpanExporter::builder()
758                .with_tonic()
759                .with_endpoint(endpoint);
760            if let Some(t) = timeout {
761                b = b.with_timeout(t);
762            }
763            Ok(b.build()?)
764        }
765        #[cfg(feature = "http")]
766        ExportProtocol::HttpProtobuf => {
767            let mut b = opentelemetry_otlp::SpanExporter::builder()
768                .with_http()
769                .with_endpoint(endpoint);
770            if let Some(t) = timeout {
771                b = b.with_timeout(t);
772            }
773            Ok(b.build()?)
774        }
775    }
776}
777
778fn build_metric_exporter(
779    protocol: ExportProtocol,
780    endpoint: &str,
781    timeout: Option<Duration>,
782) -> Result<opentelemetry_otlp::MetricExporter, Box<dyn Error>> {
783    match protocol {
784        #[cfg(feature = "grpc")]
785        ExportProtocol::Grpc => {
786            let mut b = opentelemetry_otlp::MetricExporter::builder()
787                .with_tonic()
788                .with_endpoint(endpoint);
789            if let Some(t) = timeout {
790                b = b.with_timeout(t);
791            }
792            Ok(b.build()?)
793        }
794        #[cfg(feature = "http")]
795        ExportProtocol::HttpProtobuf => {
796            let mut b = opentelemetry_otlp::MetricExporter::builder()
797                .with_http()
798                .with_endpoint(endpoint);
799            if let Some(t) = timeout {
800                b = b.with_timeout(t);
801            }
802            Ok(b.build()?)
803        }
804    }
805}
806
807fn build_log_exporter(
808    protocol: ExportProtocol,
809    endpoint: &str,
810    timeout: Option<Duration>,
811) -> Result<opentelemetry_otlp::LogExporter, Box<dyn Error>> {
812    match protocol {
813        #[cfg(feature = "grpc")]
814        ExportProtocol::Grpc => {
815            let mut b = opentelemetry_otlp::LogExporter::builder()
816                .with_tonic()
817                .with_endpoint(endpoint);
818            if let Some(t) = timeout {
819                b = b.with_timeout(t);
820            }
821            Ok(b.build()?)
822        }
823        #[cfg(feature = "http")]
824        ExportProtocol::HttpProtobuf => {
825            let mut b = opentelemetry_otlp::LogExporter::builder()
826                .with_http()
827                .with_endpoint(endpoint);
828            if let Some(t) = timeout {
829                b = b.with_timeout(t);
830            }
831            Ok(b.build()?)
832        }
833    }
834}
835
836/// Build a [`Resource`] enriched with semantic-convention attributes.
837///
838/// Auto-detects `host.name` and `process.pid`. Optionally sets
839/// `service.version` and `deployment.environment` when provided.
840///
841/// # Example
842/// ```
843/// let resource = otel_bootstrap::build_resource(
844///     "my-service",
845///     Some("1.0.0"),
846///     Some("production"),
847/// );
848/// // `resource` can be passed to SdkTracerProvider::builder().with_resource(resource)
849/// ```
850pub fn build_resource(
851    service_name: &str,
852    service_version: Option<&str>,
853    deployment_environment: Option<&str>,
854) -> Resource {
855    let hostname = hostname::get()
856        .ok()
857        .and_then(|h| h.into_string().ok())
858        .unwrap_or_default();
859
860    let mut builder = Resource::builder()
861        .with_service_name(service_name.to_string())
862        .with_attributes([
863            KeyValue::new(HOST_NAME, hostname),
864            KeyValue::new(PROCESS_PID, std::process::id() as i64),
865        ]);
866
867    if let Some(version) = service_version {
868        builder = builder.with_attribute(KeyValue::new(SERVICE_VERSION, version.to_string()));
869    }
870
871    if let Some(env) = deployment_environment {
872        builder =
873            builder.with_attribute(KeyValue::new(DEPLOYMENT_ENVIRONMENT_NAME, env.to_string()));
874    }
875
876    builder.build()
877}
878
879/// Returns a ready-to-use [`tower::Layer`] that extracts W3C trace context from
880/// incoming HTTP requests, creates a span with standard HTTP semantic-convention
881/// attributes, and injects trace context into response headers.
882///
883/// Requires the `axum` feature flag.
884///
885/// # Example
886/// ```no_run
887/// # #[cfg(feature = "axum")]
888/// # {
889/// use axum::Router;
890///
891/// let app: Router = Router::new()
892///     // ... add routes ...
893///     .layer(otel_bootstrap::axum_layer());
894/// # }
895/// ```
896#[cfg(feature = "axum")]
897pub fn axum_layer() -> axum_middleware::OtelTraceLayer {
898    axum_middleware::OtelTraceLayer
899}
900
901/// Construct the tower [`Layer`](tower::Layer) that calls [`span_enrichment::EnrichSpan::enrich_span`]
902/// on every request that carries a `T` extension.
903///
904/// Requires the `axum` feature flag. Place this layer inside the
905/// [`axum::Extension`] layer that injects `T`, so the context is populated
906/// before this service inspects the extensions.
907///
908/// # Example
909/// ```no_run
910/// # #[cfg(feature = "axum")] {
911/// use axum::{Router, Extension, routing::get};
912/// use otel_bootstrap::span_enrichment::EnrichSpan;
913/// use tracing_opentelemetry::OpenTelemetrySpanExt as _;
914///
915/// #[derive(Clone)]
916/// struct MyCtx { user_id: String }
917///
918/// impl EnrichSpan for MyCtx {
919///     fn enrich_span(&self, span: &tracing::Span) {
920///         span.set_attribute("enduser.id", self.user_id.clone());
921///     }
922/// }
923///
924/// let app: Router = Router::new()
925///     .route("/", get(|| async { "ok" }))
926///     .layer(otel_bootstrap::span_enricher_layer::<MyCtx>())
927///     .layer(Extension(MyCtx { user_id: "u1".into() }))
928///     .layer(otel_bootstrap::axum_layer());
929/// # }
930/// ```
931#[cfg(feature = "axum")]
932pub fn span_enricher_layer<T>() -> axum_middleware::SpanEnricherLayer<T>
933where
934    T: span_enrichment::EnrichSpan + Clone + Send + Sync + 'static,
935{
936    axum_middleware::SpanEnricherLayer::default()
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use std::sync::Mutex;
943
944    static ENV_LOCK: Mutex<()> = Mutex::new(());
945
946    #[test]
947    fn resource_contains_all_attributes_when_provided() {
948        let resource = build_resource("test-svc", Some("1.2.3"), Some("staging"));
949
950        assert_eq!(
951            resource.get(&opentelemetry::Key::new("service.name")),
952            Some(opentelemetry::Value::from("test-svc")),
953        );
954        assert_eq!(
955            resource.get(&opentelemetry::Key::new(SERVICE_VERSION)),
956            Some(opentelemetry::Value::from("1.2.3")),
957        );
958        assert_eq!(
959            resource.get(&opentelemetry::Key::new(DEPLOYMENT_ENVIRONMENT_NAME)),
960            Some(opentelemetry::Value::from("staging")),
961        );
962        assert!(resource.get(&opentelemetry::Key::new(HOST_NAME)).is_some());
963        assert!(
964            resource
965                .get(&opentelemetry::Key::new(PROCESS_PID))
966                .is_some()
967        );
968    }
969
970    #[test]
971    fn resource_graceful_when_optional_values_omitted() {
972        let resource = build_resource("test-svc", None, None);
973
974        assert_eq!(
975            resource.get(&opentelemetry::Key::new("service.name")),
976            Some(opentelemetry::Value::from("test-svc")),
977        );
978        assert!(
979            resource
980                .get(&opentelemetry::Key::new(SERVICE_VERSION))
981                .is_none()
982        );
983        assert!(
984            resource
985                .get(&opentelemetry::Key::new(DEPLOYMENT_ENVIRONMENT_NAME))
986                .is_none()
987        );
988        // Auto-detected attributes still present
989        assert!(resource.get(&opentelemetry::Key::new(HOST_NAME)).is_some());
990        assert!(
991            resource
992                .get(&opentelemetry::Key::new(PROCESS_PID))
993                .is_some()
994        );
995    }
996
997    #[test]
998    fn trace_sampler_ratio_converts_to_sdk() {
999        let sampler = TraceSampler::TraceIdRatio(0.5);
1000        let sdk = sampler.into_sdk_sampler();
1001        assert_eq!(format!("{sdk:?}"), "TraceIdRatioBased(0.5)");
1002    }
1003
1004    #[test]
1005    fn trace_sampler_parent_based_converts_to_sdk() {
1006        let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.25)));
1007        let sdk = sampler.into_sdk_sampler();
1008        let debug = format!("{sdk:?}");
1009        assert!(debug.contains("ParentBased"));
1010        assert!(debug.contains("0.25"));
1011    }
1012
1013    /// # Safety helper — env var manipulation is unsafe in Rust 2024 edition.
1014    unsafe fn set_env(key: &str, val: &str) {
1015        unsafe {
1016            std::env::set_var(key, val);
1017        }
1018    }
1019
1020    unsafe fn remove_env(key: &str) {
1021        unsafe {
1022            std::env::remove_var(key);
1023        }
1024    }
1025
1026    #[test]
1027    fn sampler_from_env_reads_traceidratio() {
1028        let _lock = ENV_LOCK.lock().unwrap();
1029        unsafe {
1030            set_env("OTEL_TRACES_SAMPLER", "traceidratio");
1031            set_env("OTEL_TRACES_SAMPLER_ARG", "0.42");
1032        }
1033
1034        let sampler = sampler_from_env()
1035            .expect("should not error")
1036            .expect("should return Some");
1037        assert!(
1038            matches!(sampler, TraceSampler::TraceIdRatio(r) if (r - 0.42).abs() < f64::EPSILON)
1039        );
1040
1041        unsafe {
1042            remove_env("OTEL_TRACES_SAMPLER");
1043            remove_env("OTEL_TRACES_SAMPLER_ARG");
1044        }
1045    }
1046
1047    #[test]
1048    fn sampler_from_env_returns_none_when_unset() {
1049        let _lock = ENV_LOCK.lock().unwrap();
1050        unsafe {
1051            remove_env("OTEL_TRACES_SAMPLER");
1052        }
1053        assert!(sampler_from_env().expect("should not error").is_none());
1054    }
1055
1056    #[test]
1057    fn sampler_from_env_reads_parentbased_traceidratio() {
1058        let _lock = ENV_LOCK.lock().unwrap();
1059        unsafe {
1060            set_env("OTEL_TRACES_SAMPLER", "parentbased_traceidratio");
1061            set_env("OTEL_TRACES_SAMPLER_ARG", "0.1");
1062        }
1063
1064        let sampler = sampler_from_env()
1065            .expect("should not error")
1066            .expect("should return Some");
1067        assert!(
1068            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::TraceIdRatio(r) if (r - 0.1).abs() < f64::EPSILON))
1069        );
1070
1071        unsafe {
1072            remove_env("OTEL_TRACES_SAMPLER");
1073            remove_env("OTEL_TRACES_SAMPLER_ARG");
1074        }
1075    }
1076
1077    #[test]
1078    fn sampler_from_env_parentbased_always_on() {
1079        let _lock = ENV_LOCK.lock().unwrap();
1080        unsafe {
1081            set_env("OTEL_TRACES_SAMPLER", "parentbased_always_on");
1082        }
1083        let sampler = sampler_from_env()
1084            .expect("should not error")
1085            .expect("should return Some");
1086        assert!(
1087            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::AlwaysOn))
1088        );
1089        unsafe {
1090            remove_env("OTEL_TRACES_SAMPLER");
1091        }
1092    }
1093
1094    #[test]
1095    fn sampler_from_env_parentbased_always_off() {
1096        let _lock = ENV_LOCK.lock().unwrap();
1097        unsafe {
1098            set_env("OTEL_TRACES_SAMPLER", "parentbased_always_off");
1099        }
1100        let sampler = sampler_from_env()
1101            .expect("should not error")
1102            .expect("should return Some");
1103        assert!(
1104            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::AlwaysOff))
1105        );
1106        unsafe {
1107            remove_env("OTEL_TRACES_SAMPLER");
1108        }
1109    }
1110
1111    #[test]
1112    fn sampler_from_env_always_on() {
1113        let _lock = ENV_LOCK.lock().unwrap();
1114        unsafe {
1115            set_env("OTEL_TRACES_SAMPLER", "always_on");
1116        }
1117        let sampler = sampler_from_env()
1118            .expect("should not error")
1119            .expect("should return Some");
1120        assert!(matches!(sampler, TraceSampler::AlwaysOn));
1121        unsafe {
1122            remove_env("OTEL_TRACES_SAMPLER");
1123        }
1124    }
1125
1126    #[test]
1127    fn sampler_from_env_always_off() {
1128        let _lock = ENV_LOCK.lock().unwrap();
1129        unsafe {
1130            set_env("OTEL_TRACES_SAMPLER", "always_off");
1131        }
1132        let sampler = sampler_from_env()
1133            .expect("should not error")
1134            .expect("should return Some");
1135        assert!(matches!(sampler, TraceSampler::AlwaysOff));
1136        unsafe {
1137            remove_env("OTEL_TRACES_SAMPLER");
1138        }
1139    }
1140
1141    #[test]
1142    fn sampler_from_env_unknown_returns_error() {
1143        let _lock = ENV_LOCK.lock().unwrap();
1144        unsafe {
1145            set_env("OTEL_TRACES_SAMPLER", "unknown_sampler");
1146        }
1147        let err = sampler_from_env().expect_err("unknown sampler should produce an error");
1148        assert!(
1149            err.to_string().contains("unknown_sampler"),
1150            "error message should include the unknown name, got: {err}"
1151        );
1152        unsafe {
1153            remove_env("OTEL_TRACES_SAMPLER");
1154        }
1155    }
1156
1157    #[test]
1158    fn trace_sampler_always_on_converts_to_sdk() {
1159        let sdk = TraceSampler::AlwaysOn.into_sdk_sampler();
1160        assert_eq!(format!("{sdk:?}"), "AlwaysOn");
1161    }
1162
1163    #[test]
1164    fn trace_sampler_always_off_converts_to_sdk() {
1165        let sdk = TraceSampler::AlwaysOff.into_sdk_sampler();
1166        assert_eq!(format!("{sdk:?}"), "AlwaysOff");
1167    }
1168
1169    #[test]
1170    fn builder_has_sensible_defaults() {
1171        let builder = Telemetry::builder("test-svc");
1172        assert_eq!(builder.service_name.as_deref(), Some("test-svc"));
1173        assert!(builder.service_version.is_none());
1174        assert!(builder.deployment_environment.is_none());
1175        assert!(builder.sampler.is_none());
1176        assert!(builder.metrics);
1177        assert!(!builder.logs);
1178        assert!(builder.protocol.is_none());
1179        assert!(builder.max_export_batch_size.is_none());
1180        assert!(builder.metric_export_interval.is_none());
1181        assert!(builder.export_timeout.is_none());
1182    }
1183
1184    #[test]
1185    fn from_env_builder_has_no_service_name() {
1186        let builder = Telemetry::from_env();
1187        assert!(builder.service_name.is_none());
1188    }
1189
1190    #[test]
1191    fn with_export_timeout_stores_value() {
1192        let timeout = Duration::from_secs(5);
1193        let builder = Telemetry::builder("test-svc").with_export_timeout(timeout);
1194        assert_eq!(builder.export_timeout, Some(timeout));
1195    }
1196
1197    #[test]
1198    fn timeout_from_env_reads_milliseconds() {
1199        let _lock = ENV_LOCK.lock().unwrap();
1200        unsafe {
1201            set_env("OTEL_EXPORTER_OTLP_TIMEOUT", "5000");
1202        }
1203        let t = timeout_from_env();
1204        assert_eq!(t, Some(Duration::from_millis(5000)));
1205        unsafe {
1206            remove_env("OTEL_EXPORTER_OTLP_TIMEOUT");
1207        }
1208    }
1209
1210    #[test]
1211    fn timeout_from_env_returns_none_when_unset() {
1212        let _lock = ENV_LOCK.lock().unwrap();
1213        unsafe {
1214            remove_env("OTEL_EXPORTER_OTLP_TIMEOUT");
1215        }
1216        assert_eq!(timeout_from_env(), None);
1217    }
1218
1219    #[test]
1220    fn service_name_from_env_used_when_none_given() {
1221        let builder = Telemetry::from_env();
1222        assert!(builder.service_name.is_none());
1223    }
1224
1225    #[test]
1226    fn explicit_service_name_overrides_env_var() {
1227        let builder = Telemetry::builder("explicit-svc");
1228        assert_eq!(builder.service_name.as_deref(), Some("explicit-svc"));
1229    }
1230
1231    #[test]
1232    fn from_env_builder_service_name_is_none() {
1233        let builder = Telemetry::from_env();
1234        assert!(builder.service_name.is_none());
1235    }
1236
1237    #[test]
1238    fn init_returns_error_for_unknown_otel_traces_sampler() {
1239        let _lock = ENV_LOCK.lock().unwrap();
1240        unsafe {
1241            set_env("OTEL_TRACES_SAMPLER", "not_a_real_sampler");
1242        }
1243        let result = Telemetry::builder("test-svc").with_metrics(false).init();
1244        let err = result
1245            .err()
1246            .expect("unknown sampler env var should cause init to fail");
1247        assert!(
1248            err.to_string().contains("not_a_real_sampler"),
1249            "error should name the unknown sampler, got: {err}"
1250        );
1251        unsafe {
1252            remove_env("OTEL_TRACES_SAMPLER");
1253        }
1254    }
1255
1256    #[test]
1257    fn with_max_export_batch_size_stores_value() {
1258        let builder = Telemetry::builder("test-svc").with_max_export_batch_size(1024);
1259        assert_eq!(builder.max_export_batch_size, Some(1024));
1260    }
1261
1262    #[test]
1263    fn with_metric_export_interval_stores_value() {
1264        let interval = Duration::from_secs(30);
1265        let builder = Telemetry::builder("test-svc").with_metric_export_interval(interval);
1266        assert_eq!(builder.metric_export_interval, Some(interval));
1267    }
1268
1269    #[test]
1270    fn init_rejects_zero_metric_export_interval() {
1271        let err = Telemetry::builder("test-svc")
1272            .with_metric_export_interval(Duration::ZERO)
1273            .with_metrics(false)
1274            .init()
1275            .err()
1276            .expect("expected error for zero interval");
1277        assert!(
1278            err.to_string().contains("metric_export_interval"),
1279            "error message should mention metric_export_interval, got: {err}"
1280        );
1281    }
1282
1283    #[test]
1284    fn builder_with_custom_values() {
1285        let builder = Telemetry::builder("test-svc")
1286            .with_version("2.0.0")
1287            .with_environment("production")
1288            .with_sampler(TraceSampler::TraceIdRatio(0.5))
1289            .with_metrics(false);
1290
1291        assert_eq!(builder.service_name.as_deref(), Some("test-svc"));
1292        assert_eq!(builder.service_version.as_deref(), Some("2.0.0"));
1293        assert_eq!(
1294            builder.deployment_environment.as_deref(),
1295            Some("production")
1296        );
1297        assert!(
1298            matches!(builder.sampler, Some(TraceSampler::TraceIdRatio(r)) if (r - 0.5).abs() < f64::EPSILON)
1299        );
1300        assert!(!builder.metrics);
1301    }
1302
1303    #[test]
1304    #[cfg(feature = "grpc")]
1305    fn builder_with_protocol_grpc() {
1306        let builder = Telemetry::builder("test-svc").with_protocol(ExportProtocol::Grpc);
1307        assert_eq!(builder.protocol, Some(ExportProtocol::Grpc));
1308    }
1309
1310    #[test]
1311    #[cfg(feature = "http")]
1312    fn builder_with_protocol_http() {
1313        let builder = Telemetry::builder("test-svc").with_protocol(ExportProtocol::HttpProtobuf);
1314        assert_eq!(builder.protocol, Some(ExportProtocol::HttpProtobuf));
1315    }
1316
1317    #[test]
1318    #[cfg(feature = "grpc")]
1319    fn protocol_from_env_reads_grpc() {
1320        let _lock = ENV_LOCK.lock().unwrap();
1321        unsafe {
1322            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc");
1323        }
1324        assert_eq!(protocol_from_env(), Some(ExportProtocol::Grpc));
1325        unsafe {
1326            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1327        }
1328    }
1329
1330    #[test]
1331    #[cfg(feature = "http")]
1332    fn protocol_from_env_reads_http_protobuf() {
1333        let _lock = ENV_LOCK.lock().unwrap();
1334        unsafe {
1335            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf");
1336        }
1337        assert_eq!(protocol_from_env(), Some(ExportProtocol::HttpProtobuf));
1338        unsafe {
1339            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1340        }
1341    }
1342
1343    #[test]
1344    fn protocol_from_env_returns_none_when_unset() {
1345        let _lock = ENV_LOCK.lock().unwrap();
1346        unsafe {
1347            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1348        }
1349        assert_eq!(protocol_from_env(), None);
1350    }
1351
1352    #[test]
1353    fn protocol_from_env_returns_none_for_unknown() {
1354        let _lock = ENV_LOCK.lock().unwrap();
1355        unsafe {
1356            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "websocket");
1357        }
1358        assert_eq!(protocol_from_env(), None);
1359        unsafe {
1360            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1361        }
1362    }
1363
1364    #[test]
1365    fn builder_is_send_and_sync() {
1366        fn assert_send_sync<T: Send + Sync>() {}
1367        assert_send_sync::<TelemetryBuilder>();
1368    }
1369
1370    #[test]
1371    fn with_shutdown_timeout_stores_value() {
1372        let timeout = Duration::from_secs(10);
1373        let builder = Telemetry::builder("test-svc").with_shutdown_timeout(timeout);
1374        assert_eq!(builder.shutdown_timeout, timeout);
1375    }
1376
1377    #[test]
1378    fn default_shutdown_timeout_is_five_seconds() {
1379        let builder = Telemetry::builder("test-svc");
1380        assert_eq!(builder.shutdown_timeout, Duration::from_secs(5));
1381    }
1382
1383    /// Verify that drop completes within the configured timeout even when the
1384    /// shutdown thread is blocked (simulated by using a very short timeout so
1385    /// the test itself runs quickly).
1386    ///
1387    /// We construct `TelemetryHandles` with an artificially short timeout and
1388    /// a real (but disconnected) provider.  Drop must return before the test
1389    /// times out.
1390    #[cfg(feature = "testing")]
1391    #[test]
1392    fn drop_completes_within_shutdown_timeout() {
1393        // Use the testing helper so we don't need a running OTLP collector.
1394        let mut handles = crate::Telemetry::testing("drop-timeout-test");
1395        // Override the timeout to something very short so the test is fast.
1396        handles.shutdown_timeout = Duration::from_millis(100);
1397
1398        let start = std::time::Instant::now();
1399        drop(handles);
1400        let elapsed = start.elapsed();
1401
1402        // Drop should complete within 2× the timeout (generous margin for CI).
1403        assert!(
1404            elapsed < Duration::from_millis(500),
1405            "drop took {elapsed:?}, expected < 500 ms"
1406        );
1407    }
1408}