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