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/// mTLS material for the gRPC transport. Requires the `grpc-mtls` feature.
264///
265/// PEM-encoded. The CA is used to verify the collector's server cert; the
266/// client cert + key authenticate this workload to the collector.
267///
268/// To use a static (no-rotation) source, wrap in [`StaticCertSource`] and
269/// pass to [`TelemetryBuilder::with_mtls`]. For SVID-style rotation, plug
270/// in your own [`CertSource`] implementation (e.g. service-kit's
271/// `SpiffeCertSource`).
272#[cfg(feature = "grpc-mtls")]
273#[derive(Clone)]
274pub struct MtlsMaterial {
275    /// PEM-encoded client certificate chain (leaf + intermediates).
276    pub client_cert_chain_pem: Vec<u8>,
277    /// PEM-encoded client private key matching `client_cert_chain_pem`.
278    pub client_key_pem: Vec<u8>,
279    /// PEM-encoded trust bundle — collector cert must chain to one of these.
280    pub trust_bundle_pem: Vec<u8>,
281}
282
283#[cfg(feature = "grpc-mtls")]
284impl std::fmt::Debug for MtlsMaterial {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("MtlsMaterial")
287            .field("client_cert_chain_pem", &"<redacted>")
288            .field("client_key_pem", &"<redacted>")
289            .field("trust_bundle_pem", &"<redacted>")
290            .finish()
291    }
292}
293
294/// Resolve the export protocol from `OTEL_EXPORTER_OTLP_PROTOCOL`.
295fn protocol_from_env() -> Option<ExportProtocol> {
296    let val = std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL").ok()?;
297    match val.trim() {
298        #[cfg(feature = "grpc")]
299        "grpc" => Some(ExportProtocol::Grpc),
300        #[cfg(feature = "http")]
301        "http/protobuf" => Some(ExportProtocol::HttpProtobuf),
302        _ => None,
303    }
304}
305
306/// Entry point for configuring telemetry via a builder pattern.
307///
308/// # Example
309/// ```no_run
310/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
311/// let _handles = otel_bootstrap::Telemetry::builder("my-service")
312///     .with_version("1.0.0")
313///     .with_environment("production")
314///     .with_sampler(otel_bootstrap::TraceSampler::TraceIdRatio(0.1))
315///     .with_metrics(true)
316///     .with_logs(true)
317///     .init()?;
318/// # Ok(())
319/// # }
320/// ```
321pub struct Telemetry;
322
323impl Telemetry {
324    /// Create a new [`TelemetryBuilder`] with the given service name.
325    ///
326    /// The explicit `service_name` takes precedence over `OTEL_SERVICE_NAME`.
327    pub fn builder(service_name: &str) -> TelemetryBuilder {
328        TelemetryBuilder {
329            service_name: Some(service_name.to_string()),
330            service_version: None,
331            deployment_environment: None,
332            sampler: None,
333            metrics: true,
334            logs: false,
335            protocol: None,
336            max_export_batch_size: None,
337            metric_export_interval: None,
338            export_timeout: None,
339            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
340            extra_layers: Vec::new(),
341            extra_metric_readers: Vec::new(),
342            #[cfg(feature = "grpc-mtls")]
343            mtls: None,
344        }
345    }
346
347    /// Create a new [`TelemetryBuilder`] that reads the service name from
348    /// `OTEL_SERVICE_NAME`. Falls back to `"unknown_service"` when the env var
349    /// is not set, following the OpenTelemetry default resource specification.
350    ///
351    /// # Example
352    /// ```no_run
353    /// // Set OTEL_SERVICE_NAME=my-service in the environment before calling this.
354    /// let _handles = otel_bootstrap::Telemetry::from_env().init().unwrap();
355    /// ```
356    pub fn from_env() -> TelemetryBuilder {
357        TelemetryBuilder {
358            service_name: None,
359            service_version: None,
360            deployment_environment: None,
361            sampler: None,
362            metrics: true,
363            logs: false,
364            protocol: None,
365            max_export_batch_size: None,
366            metric_export_interval: None,
367            export_timeout: None,
368            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
369            extra_layers: Vec::new(),
370            extra_metric_readers: Vec::new(),
371            #[cfg(feature = "grpc-mtls")]
372            mtls: None,
373        }
374    }
375}
376
377/// Builder for configuring telemetry options incrementally.
378///
379/// Created via [`Telemetry::builder`] or [`Telemetry::from_env`]. Call
380/// [`.init()`](TelemetryBuilder::init) to consume the builder and start telemetry.
381///
382/// # Example
383/// ```no_run
384/// use std::time::Duration;
385///
386/// let _handles = otel_bootstrap::Telemetry::builder("my-service")
387///     .with_version("1.2.3")
388///     .with_environment("staging")
389///     .with_metrics(true)
390///     .with_shutdown_timeout(Duration::from_secs(10))
391///     .init()
392///     .unwrap();
393/// ```
394#[must_use = "a TelemetryBuilder does nothing until .init() is called"]
395pub struct TelemetryBuilder {
396    service_name: Option<String>,
397    service_version: Option<String>,
398    deployment_environment: Option<String>,
399    sampler: Option<TraceSampler>,
400    metrics: bool,
401    logs: bool,
402    protocol: Option<ExportProtocol>,
403    max_export_batch_size: Option<usize>,
404    metric_export_interval: Option<Duration>,
405    export_timeout: Option<Duration>,
406    shutdown_timeout: Duration,
407    extra_layers: Vec<
408        Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync + 'static>,
409    >,
410    extra_metric_readers: Vec<MeterProviderInstaller>,
411    #[cfg(feature = "grpc-mtls")]
412    mtls: Option<MtlsMaterial>,
413}
414
415/// Type-erased adapter that applies an extra `MetricReader` to the
416/// in-progress [`MeterProviderBuilder`]. Stored as a closure so the trait
417/// (which is generic, not object-safe in a useful way here) can be ranged
418/// over uniformly inside [`TelemetryBuilder`].
419type MeterProviderInstaller =
420    Box<dyn FnOnce(MeterProviderBuilder) -> MeterProviderBuilder + Send + Sync>;
421
422impl TelemetryBuilder {
423    /// Set the service version (maps to `service.version` resource attribute).
424    pub fn with_version(mut self, version: &str) -> Self {
425        self.service_version = Some(version.to_string());
426        self
427    }
428
429    /// Set the deployment environment (maps to `deployment.environment.name`).
430    pub fn with_environment(mut self, environment: &str) -> Self {
431        self.deployment_environment = Some(environment.to_string());
432        self
433    }
434
435    /// Enable mTLS on the gRPC OTLP exporter (requires the `grpc-mtls` feature).
436    ///
437    /// The material is read once at [`init`](TelemetryBuilder::init) time;
438    /// the resulting tonic Channel is built once and reused for the lifetime
439    /// of the process.
440    ///
441    /// Forces the protocol to [`ExportProtocol::Grpc`] regardless of
442    /// `OTEL_EXPORTER_OTLP_PROTOCOL` or any prior `with_protocol(...)` call.
443    /// Pairs with a collector configured with `client_ca_file`.
444    ///
445    /// # Rotation
446    ///
447    /// In-process auto-rotation is **not yet implemented** — when the
448    /// underlying SVID rotates (typically every 1h), the existing tonic
449    /// Channel keeps presenting the old cert and exports start failing.
450    /// Two-part mitigation until a proper rotation watcher lands:
451    ///
452    /// 1. Issue long-lived client certs (≥365 days) so manual rotation is
453    ///    infrequent.
454    /// 2. Rely on natural pod restarts (deploys, reschedules) to pick up
455    ///    fresh material — every restart re-reads the SVID at this call.
456    ///
457    /// Rotation as a first-class feature is tracked as an immediate
458    /// follow-up (see CHANGELOG).
459    #[cfg(feature = "grpc-mtls")]
460    pub fn with_mtls(mut self, material: MtlsMaterial) -> Self {
461        self.mtls = Some(material);
462        self.protocol = Some(ExportProtocol::Grpc);
463        self
464    }
465
466    /// Set an explicit trace sampler. If not set, falls back to
467    /// `OTEL_TRACES_SAMPLER` env var, then always-on.
468    pub fn with_sampler(mut self, sampler: TraceSampler) -> Self {
469        self.sampler = Some(sampler);
470        self
471    }
472
473    /// Enable or disable metrics export (default: `true`).
474    pub fn with_metrics(mut self, enabled: bool) -> Self {
475        self.metrics = enabled;
476        self
477    }
478
479    /// Set the export protocol explicitly. If not set, falls back to
480    /// `OTEL_EXPORTER_OTLP_PROTOCOL`, then the compiled-in default (`grpc`
481    /// when the `grpc` feature is enabled, `http/protobuf` otherwise).
482    pub fn with_protocol(mut self, protocol: ExportProtocol) -> Self {
483        self.protocol = Some(protocol);
484        self
485    }
486
487    /// Set the maximum number of spans exported in a single batch (default: 512).
488    ///
489    /// Overrides `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` when set programmatically.
490    /// The env var is still read as a fallback when this method is not called.
491    pub fn with_max_export_batch_size(mut self, size: usize) -> Self {
492        self.max_export_batch_size = Some(size);
493        self
494    }
495
496    /// Set the interval between metric exports (default: 60 s).
497    ///
498    /// Returns an error at build time if `interval` is zero.
499    /// Overrides `OTEL_METRIC_EXPORT_INTERVAL` when set programmatically.
500    pub fn with_metric_export_interval(mut self, interval: Duration) -> Self {
501        self.metric_export_interval = Some(interval);
502        self
503    }
504
505    /// Enable or disable log export via the OTLP log bridge (default: `false`).
506    ///
507    /// When enabled, `tracing` events are forwarded to an OTLP `LogExporter`
508    /// in addition to the existing stdout fmt layer. This allows structured
509    /// logs to be correlated with traces in backends like Grafana Loki or
510    /// Datadog.
511    pub fn with_logs(mut self, enabled: bool) -> Self {
512        self.logs = enabled;
513        self
514    }
515
516    /// Set the OTLP export timeout explicitly. If not set, falls back to
517    /// `OTEL_EXPORTER_OTLP_TIMEOUT` (in milliseconds), then the SDK default
518    /// of 10 000 ms.
519    pub fn with_export_timeout(mut self, timeout: Duration) -> Self {
520        self.export_timeout = Some(timeout);
521        self
522    }
523
524    /// Set the maximum time to wait for provider shutdown when the
525    /// [`TelemetryHandles`] is dropped (default: 5 s).
526    ///
527    /// If the timeout expires a warning is logged and the drop completes
528    /// without panicking. The background shutdown thread is abandoned and
529    /// the providers may not have flushed all pending data.
530    pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
531        self.shutdown_timeout = timeout;
532        self
533    }
534
535    /// Add a custom [`tracing_subscriber::Layer`] to the subscriber stack.
536    ///
537    /// Multiple layers can be added by chaining calls. Each layer is composed
538    /// with the built-in `EnvFilter`, `fmt`, and OpenTelemetry layers.
539    ///
540    /// Insertion order in the subscriber stack (inner → outer, i.e. first-added
541    /// to last-added):
542    /// ```text
543    /// registry → custom layers → EnvFilter → fmt → OTel
544    /// ```
545    /// Because `EnvFilter` is outer, it can suppress events before they reach
546    /// the `fmt` and OTel layers; custom layers receive events independently
547    /// according to their own `enabled()` implementation.
548    ///
549    /// # Example
550    /// ```no_run
551    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
552    /// let _handles = otel_bootstrap::Telemetry::builder("my-service")
553    ///     .with_layer(tracing_subscriber::fmt::layer().with_target(false))
554    ///     .init()?;
555    /// # Ok(())
556    /// # }
557    /// ```
558    /// Customise the [`MeterProviderBuilder`] before it is built.
559    ///
560    /// Runs after the built-in OTLP `PeriodicReader` is attached (when
561    /// [`with_metrics`](Self::with_metrics) is enabled) and before
562    /// `.build()` is called. The closure is the escape hatch for everything
563    /// the explicit builder methods do not cover — most importantly,
564    /// installing **additional `MetricReader`s** like
565    /// [`opentelemetry-prometheus`](https://crates.io/crates/opentelemetry-prometheus)
566    /// alongside the OTLP push, so the same instruments fan out to multiple
567    /// transports without double-counting.
568    ///
569    /// May be called multiple times; closures run in registration order.
570    /// Has no effect when `with_metrics(false)` is also set on the builder —
571    /// when metrics are disabled, no `MeterProvider` is created at all.
572    ///
573    /// `MetricReader` is intentionally not nameable from outside
574    /// `opentelemetry_sdk`, so the closure form is the only way to attach
575    /// readers without leaking unstable trait names through this crate's
576    /// public API.
577    ///
578    /// # Example
579    ///
580    /// ```ignore
581    /// // With `opentelemetry-prometheus` in scope:
582    /// let registry = prometheus::Registry::new();
583    /// let exporter = opentelemetry_prometheus::exporter()
584    ///     .with_registry(registry.clone())
585    ///     .build()?;
586    /// let _handles = otel_bootstrap::Telemetry::builder("my-service")
587    ///     .with_meter_provider_setup(move |b| b.with_reader(exporter))
588    ///     .init()?;
589    /// // ...mount `registry` at GET /metrics in your HTTP layer.
590    /// ```
591    pub fn with_meter_provider_setup<F>(mut self, setup: F) -> Self
592    where
593        F: FnOnce(MeterProviderBuilder) -> MeterProviderBuilder + Send + Sync + 'static,
594    {
595        self.extra_metric_readers.push(Box::new(setup));
596        self
597    }
598
599    pub fn with_layer<L>(mut self, layer: L) -> Self
600    where
601        L: tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync + 'static,
602    {
603        self.extra_layers.push(Box::new(layer));
604        self
605    }
606
607    /// Consume the builder and initialise OpenTelemetry.
608    ///
609    /// Installs a global tracer provider, meter provider (if enabled), and
610    /// a `tracing` subscriber. Returns an error if any provider fails to
611    /// build (e.g. unknown sampler name, zero metric interval).
612    ///
613    /// # Example
614    /// ```no_run
615    /// let handles = otel_bootstrap::Telemetry::builder("my-service")
616    ///     .with_metrics(false)
617    ///     .init()
618    ///     .expect("telemetry init failed");
619    /// handles.shutdown().ok();
620    /// ```
621    pub fn init(self) -> Result<TelemetryHandles, Box<dyn Error>> {
622        if let Some(interval) = self.metric_export_interval
623            && interval.is_zero()
624        {
625            return Err("metric_export_interval must be greater than zero".into());
626        }
627
628        let protocol = self.protocol.or_else(protocol_from_env).unwrap_or({
629            #[cfg(feature = "grpc")]
630            {
631                ExportProtocol::Grpc
632            }
633            #[cfg(all(not(feature = "grpc"), feature = "http"))]
634            {
635                ExportProtocol::HttpProtobuf
636            }
637        });
638
639        let default_endpoint = match protocol {
640            #[cfg(feature = "grpc")]
641            ExportProtocol::Grpc => "http://localhost:4317",
642            #[cfg(feature = "http")]
643            ExportProtocol::HttpProtobuf => "http://localhost:4318",
644        };
645        let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
646            .unwrap_or_else(|_| default_endpoint.to_string());
647
648        // Resolve export timeout: explicit builder > OTEL_EXPORTER_OTLP_TIMEOUT > SDK default (10 s)
649        let export_timeout = self.export_timeout.or_else(timeout_from_env);
650
651        // Resolve service name: explicit builder > OTEL_SERVICE_NAME > "unknown_service"
652        let service_name = self.service_name.unwrap_or_else(|| {
653            std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "unknown_service".to_string())
654        });
655
656        let resource = build_resource(
657            &service_name,
658            self.service_version.as_deref(),
659            self.deployment_environment.as_deref(),
660        );
661
662        let sampler = match self.sampler {
663            Some(s) => s,
664            None => sampler_from_env()?.unwrap_or(TraceSampler::AlwaysOn),
665        };
666
667        // Tracer
668        let trace_exporter = build_span_exporter(
669            protocol,
670            &endpoint,
671            export_timeout,
672            #[cfg(feature = "grpc-mtls")]
673            self.mtls.as_ref(),
674        )?;
675
676        let batch_processor = if let Some(size) = self.max_export_batch_size {
677            BatchSpanProcessor::builder(trace_exporter)
678                .with_batch_config(
679                    BatchConfigBuilder::default()
680                        .with_max_export_batch_size(size)
681                        .build(),
682                )
683                .build()
684        } else {
685            BatchSpanProcessor::builder(trace_exporter).build()
686        };
687
688        let tracer_provider = SdkTracerProvider::builder()
689            .with_resource(resource.clone())
690            .with_sampler(sampler.into_sdk_sampler())
691            .with_span_processor(batch_processor)
692            .build();
693
694        opentelemetry::global::set_tracer_provider(tracer_provider.clone());
695
696        // Register W3C TraceContext + Baggage propagators
697        let propagator = TextMapCompositePropagator::new(vec![
698            Box::new(TraceContextPropagator::new()),
699            Box::new(BaggagePropagator::new()),
700        ]);
701        opentelemetry::global::set_text_map_propagator(propagator);
702
703        // Meter (optional)
704        let meter_provider = if self.metrics {
705            let metric_exporter = build_metric_exporter(
706                protocol,
707                &endpoint,
708                export_timeout,
709                #[cfg(feature = "grpc-mtls")]
710                self.mtls.as_ref(),
711            )?;
712
713            let periodic_reader = if let Some(interval) = self.metric_export_interval {
714                PeriodicReader::builder(metric_exporter)
715                    .with_interval(interval)
716                    .build()
717            } else {
718                PeriodicReader::builder(metric_exporter).build()
719            };
720
721            let mut mp_builder = SdkMeterProvider::builder()
722                .with_resource(resource.clone())
723                .with_reader(periodic_reader);
724            for installer in self.extra_metric_readers {
725                mp_builder = installer(mp_builder);
726            }
727            let mp = mp_builder.build();
728
729            opentelemetry::global::set_meter_provider(mp.clone());
730
731            Some(mp)
732        } else {
733            None
734        };
735
736        // Logger (optional) — bridges tracing events to the OTLP log pipeline
737        let logger_provider = if self.logs {
738            let log_exporter = build_log_exporter(
739                protocol,
740                &endpoint,
741                export_timeout,
742                #[cfg(feature = "grpc-mtls")]
743                self.mtls.as_ref(),
744            )?;
745
746            let lp = SdkLoggerProvider::builder()
747                .with_resource(resource)
748                .with_batch_exporter(log_exporter)
749                .build();
750
751            Some(lp)
752        } else {
753            None
754        };
755
756        // Wire into tracing
757        let otel_layer = tracing_opentelemetry::layer();
758
759        let registry = tracing_subscriber::registry()
760            .with(self.extra_layers)
761            .with(tracing_subscriber::EnvFilter::from_default_env())
762            .with(tracing_subscriber::fmt::layer())
763            .with(otel_layer);
764
765        if let Some(lp) = &logger_provider {
766            registry
767                .with(opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(lp))
768                .try_init()
769                .ok();
770        } else {
771            registry.try_init().ok();
772        }
773
774        Ok(TelemetryHandles {
775            tracer_provider,
776            meter_provider,
777            logger_provider,
778            shutdown_timeout: self.shutdown_timeout,
779        })
780    }
781}
782
783/// Initialise OpenTelemetry traces + metrics with OTLP gRPC export.
784///
785/// Convenience wrapper around [`Telemetry::builder`] with all defaults.
786/// For fine-grained control, use the builder directly.
787///
788/// # Example
789/// ```no_run
790/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
791/// let _tel = otel_bootstrap::init_telemetry("my-service")?;
792/// // start axum server...
793/// # Ok(())
794/// # }
795/// ```
796pub fn init_telemetry(service_name: &str) -> Result<TelemetryHandles, Box<dyn Error>> {
797    Telemetry::builder(service_name).init()
798}
799
800/// Initialise OpenTelemetry traces + metrics with OTLP gRPC export and an
801/// explicit trace sampler.
802///
803/// Convenience wrapper around [`Telemetry::builder`]. When `sampler` is
804/// `None`, falls back to `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG`,
805/// then always-on.
806///
807/// # Example
808/// ```no_run
809/// use otel_bootstrap::TraceSampler;
810/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
811/// let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.1)));
812/// let _tel = otel_bootstrap::init_telemetry_with_sampler("my-service", Some(sampler))?;
813/// # Ok(())
814/// # }
815/// ```
816pub fn init_telemetry_with_sampler(
817    service_name: &str,
818    sampler: Option<TraceSampler>,
819) -> Result<TelemetryHandles, Box<dyn Error>> {
820    let builder = Telemetry::builder(service_name);
821    match sampler {
822        Some(s) => builder.with_sampler(s),
823        None => builder, // no-op: identical to calling init_telemetry(); not covered by tests (see Makefile ci-coverage note)
824    }
825    .init()
826}
827
828/// Read `OTEL_EXPORTER_OTLP_TIMEOUT` (milliseconds). Returns `None` when unset or invalid.
829fn timeout_from_env() -> Option<Duration> {
830    let ms = std::env::var("OTEL_EXPORTER_OTLP_TIMEOUT").ok()?;
831    let ms: u64 = ms.trim().parse().ok()?;
832    Some(Duration::from_millis(ms))
833}
834
835/// Build a `tonic::transport::ClientTlsConfig` from PEM material.
836/// Centralised so the three exporter builders apply identical TLS config.
837///
838/// Note: `with_tls_config` is provided by the `WithTonicConfig` trait on
839/// `opentelemetry-otlp`'s tonic exporter builders — imported at each call
840/// site below.
841#[cfg(feature = "grpc-mtls")]
842fn build_tls_config(material: &MtlsMaterial) -> tonic::transport::ClientTlsConfig {
843    use tonic::transport::{Certificate, ClientTlsConfig, Identity};
844    ClientTlsConfig::new()
845        .ca_certificate(Certificate::from_pem(&material.trust_bundle_pem))
846        .identity(Identity::from_pem(
847            &material.client_cert_chain_pem,
848            &material.client_key_pem,
849        ))
850}
851
852fn build_span_exporter(
853    protocol: ExportProtocol,
854    endpoint: &str,
855    timeout: Option<Duration>,
856    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
857) -> Result<opentelemetry_otlp::SpanExporter, Box<dyn Error>> {
858    match protocol {
859        #[cfg(feature = "grpc")]
860        ExportProtocol::Grpc => {
861            let mut b = opentelemetry_otlp::SpanExporter::builder()
862                .with_tonic()
863                .with_endpoint(endpoint);
864            if let Some(t) = timeout {
865                b = b.with_timeout(t);
866            }
867            #[cfg(feature = "grpc-mtls")]
868            if let Some(m) = mtls {
869                use opentelemetry_otlp::WithTonicConfig as _;
870                b = b.with_tls_config(build_tls_config(m));
871            }
872            Ok(b.build()?)
873        }
874        #[cfg(feature = "http")]
875        ExportProtocol::HttpProtobuf => {
876            let mut b = opentelemetry_otlp::SpanExporter::builder()
877                .with_http()
878                .with_endpoint(endpoint);
879            if let Some(t) = timeout {
880                b = b.with_timeout(t);
881            }
882            Ok(b.build()?)
883        }
884    }
885}
886
887fn build_metric_exporter(
888    protocol: ExportProtocol,
889    endpoint: &str,
890    timeout: Option<Duration>,
891    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
892) -> Result<opentelemetry_otlp::MetricExporter, Box<dyn Error>> {
893    match protocol {
894        #[cfg(feature = "grpc")]
895        ExportProtocol::Grpc => {
896            let mut b = opentelemetry_otlp::MetricExporter::builder()
897                .with_tonic()
898                .with_endpoint(endpoint);
899            if let Some(t) = timeout {
900                b = b.with_timeout(t);
901            }
902            #[cfg(feature = "grpc-mtls")]
903            if let Some(m) = mtls {
904                use opentelemetry_otlp::WithTonicConfig as _;
905                b = b.with_tls_config(build_tls_config(m));
906            }
907            Ok(b.build()?)
908        }
909        #[cfg(feature = "http")]
910        ExportProtocol::HttpProtobuf => {
911            let mut b = opentelemetry_otlp::MetricExporter::builder()
912                .with_http()
913                .with_endpoint(endpoint);
914            if let Some(t) = timeout {
915                b = b.with_timeout(t);
916            }
917            Ok(b.build()?)
918        }
919    }
920}
921
922fn build_log_exporter(
923    protocol: ExportProtocol,
924    endpoint: &str,
925    timeout: Option<Duration>,
926    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
927) -> Result<opentelemetry_otlp::LogExporter, Box<dyn Error>> {
928    match protocol {
929        #[cfg(feature = "grpc")]
930        ExportProtocol::Grpc => {
931            let mut b = opentelemetry_otlp::LogExporter::builder()
932                .with_tonic()
933                .with_endpoint(endpoint);
934            if let Some(t) = timeout {
935                b = b.with_timeout(t);
936            }
937            #[cfg(feature = "grpc-mtls")]
938            if let Some(m) = mtls {
939                use opentelemetry_otlp::WithTonicConfig as _;
940                b = b.with_tls_config(build_tls_config(m));
941            }
942            Ok(b.build()?)
943        }
944        #[cfg(feature = "http")]
945        ExportProtocol::HttpProtobuf => {
946            let mut b = opentelemetry_otlp::LogExporter::builder()
947                .with_http()
948                .with_endpoint(endpoint);
949            if let Some(t) = timeout {
950                b = b.with_timeout(t);
951            }
952            Ok(b.build()?)
953        }
954    }
955}
956
957/// Build a [`Resource`] enriched with semantic-convention attributes.
958///
959/// Auto-detects `host.name` and `process.pid`. Optionally sets
960/// `service.version` and `deployment.environment` when provided.
961///
962/// # Example
963/// ```
964/// let resource = otel_bootstrap::build_resource(
965///     "my-service",
966///     Some("1.0.0"),
967///     Some("production"),
968/// );
969/// // `resource` can be passed to SdkTracerProvider::builder().with_resource(resource)
970/// ```
971pub fn build_resource(
972    service_name: &str,
973    service_version: Option<&str>,
974    deployment_environment: Option<&str>,
975) -> Resource {
976    let hostname = hostname::get()
977        .ok()
978        .and_then(|h| h.into_string().ok())
979        .unwrap_or_default();
980
981    let mut builder = Resource::builder()
982        .with_service_name(service_name.to_string())
983        .with_attributes([
984            KeyValue::new(HOST_NAME, hostname),
985            KeyValue::new(PROCESS_PID, std::process::id() as i64),
986        ]);
987
988    if let Some(version) = service_version {
989        builder = builder.with_attribute(KeyValue::new(SERVICE_VERSION, version.to_string()));
990    }
991
992    if let Some(env) = deployment_environment {
993        builder =
994            builder.with_attribute(KeyValue::new(DEPLOYMENT_ENVIRONMENT_NAME, env.to_string()));
995    }
996
997    builder.build()
998}
999
1000/// Returns a ready-to-use [`tower::Layer`] that extracts W3C trace context from
1001/// incoming HTTP requests, creates a span with standard HTTP semantic-convention
1002/// attributes, and injects trace context into response headers.
1003///
1004/// Requires the `axum` feature flag.
1005///
1006/// # Example
1007/// ```no_run
1008/// # #[cfg(feature = "axum")]
1009/// # {
1010/// use axum::Router;
1011///
1012/// let app: Router = Router::new()
1013///     // ... add routes ...
1014///     .layer(otel_bootstrap::axum_layer());
1015/// # }
1016/// ```
1017#[cfg(feature = "axum")]
1018pub fn axum_layer() -> axum_middleware::OtelTraceLayer {
1019    axum_middleware::OtelTraceLayer
1020}
1021
1022/// Construct the tower [`Layer`](tower::Layer) that calls [`span_enrichment::EnrichSpan::enrich_span`]
1023/// on every request that carries a `T` extension.
1024///
1025/// Requires the `axum` feature flag. Place this layer inside the
1026/// [`axum::Extension`] layer that injects `T`, so the context is populated
1027/// before this service inspects the extensions.
1028///
1029/// # Example
1030/// ```no_run
1031/// # #[cfg(feature = "axum")] {
1032/// use axum::{Router, Extension, routing::get};
1033/// use otel_bootstrap::span_enrichment::EnrichSpan;
1034/// use tracing_opentelemetry::OpenTelemetrySpanExt as _;
1035///
1036/// #[derive(Clone)]
1037/// struct MyCtx { user_id: String }
1038///
1039/// impl EnrichSpan for MyCtx {
1040///     fn enrich_span(&self, span: &tracing::Span) {
1041///         span.set_attribute("enduser.id", self.user_id.clone());
1042///     }
1043/// }
1044///
1045/// let app: Router = Router::new()
1046///     .route("/", get(|| async { "ok" }))
1047///     .layer(otel_bootstrap::span_enricher_layer::<MyCtx>())
1048///     .layer(Extension(MyCtx { user_id: "u1".into() }))
1049///     .layer(otel_bootstrap::axum_layer());
1050/// # }
1051/// ```
1052#[cfg(feature = "axum")]
1053pub fn span_enricher_layer<T>() -> axum_middleware::SpanEnricherLayer<T>
1054where
1055    T: span_enrichment::EnrichSpan + Clone + Send + Sync + 'static,
1056{
1057    axum_middleware::SpanEnricherLayer::default()
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::*;
1063    use std::sync::Mutex;
1064
1065    static ENV_LOCK: Mutex<()> = Mutex::new(());
1066
1067    #[test]
1068    fn resource_contains_all_attributes_when_provided() {
1069        let resource = build_resource("test-svc", Some("1.2.3"), Some("staging"));
1070
1071        assert_eq!(
1072            resource.get(&opentelemetry::Key::new("service.name")),
1073            Some(opentelemetry::Value::from("test-svc")),
1074        );
1075        assert_eq!(
1076            resource.get(&opentelemetry::Key::new(SERVICE_VERSION)),
1077            Some(opentelemetry::Value::from("1.2.3")),
1078        );
1079        assert_eq!(
1080            resource.get(&opentelemetry::Key::new(DEPLOYMENT_ENVIRONMENT_NAME)),
1081            Some(opentelemetry::Value::from("staging")),
1082        );
1083        assert!(resource.get(&opentelemetry::Key::new(HOST_NAME)).is_some());
1084        assert!(
1085            resource
1086                .get(&opentelemetry::Key::new(PROCESS_PID))
1087                .is_some()
1088        );
1089    }
1090
1091    #[test]
1092    fn resource_graceful_when_optional_values_omitted() {
1093        let resource = build_resource("test-svc", None, None);
1094
1095        assert_eq!(
1096            resource.get(&opentelemetry::Key::new("service.name")),
1097            Some(opentelemetry::Value::from("test-svc")),
1098        );
1099        assert!(
1100            resource
1101                .get(&opentelemetry::Key::new(SERVICE_VERSION))
1102                .is_none()
1103        );
1104        assert!(
1105            resource
1106                .get(&opentelemetry::Key::new(DEPLOYMENT_ENVIRONMENT_NAME))
1107                .is_none()
1108        );
1109        // Auto-detected attributes still present
1110        assert!(resource.get(&opentelemetry::Key::new(HOST_NAME)).is_some());
1111        assert!(
1112            resource
1113                .get(&opentelemetry::Key::new(PROCESS_PID))
1114                .is_some()
1115        );
1116    }
1117
1118    #[test]
1119    fn trace_sampler_ratio_converts_to_sdk() {
1120        let sampler = TraceSampler::TraceIdRatio(0.5);
1121        let sdk = sampler.into_sdk_sampler();
1122        assert_eq!(format!("{sdk:?}"), "TraceIdRatioBased(0.5)");
1123    }
1124
1125    #[test]
1126    fn trace_sampler_parent_based_converts_to_sdk() {
1127        let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.25)));
1128        let sdk = sampler.into_sdk_sampler();
1129        let debug = format!("{sdk:?}");
1130        assert!(debug.contains("ParentBased"));
1131        assert!(debug.contains("0.25"));
1132    }
1133
1134    /// # Safety helper — env var manipulation is unsafe in Rust 2024 edition.
1135    unsafe fn set_env(key: &str, val: &str) {
1136        unsafe {
1137            std::env::set_var(key, val);
1138        }
1139    }
1140
1141    unsafe fn remove_env(key: &str) {
1142        unsafe {
1143            std::env::remove_var(key);
1144        }
1145    }
1146
1147    #[test]
1148    fn sampler_from_env_reads_traceidratio() {
1149        let _lock = ENV_LOCK.lock().unwrap();
1150        unsafe {
1151            set_env("OTEL_TRACES_SAMPLER", "traceidratio");
1152            set_env("OTEL_TRACES_SAMPLER_ARG", "0.42");
1153        }
1154
1155        let sampler = sampler_from_env()
1156            .expect("should not error")
1157            .expect("should return Some");
1158        assert!(
1159            matches!(sampler, TraceSampler::TraceIdRatio(r) if (r - 0.42).abs() < f64::EPSILON)
1160        );
1161
1162        unsafe {
1163            remove_env("OTEL_TRACES_SAMPLER");
1164            remove_env("OTEL_TRACES_SAMPLER_ARG");
1165        }
1166    }
1167
1168    #[test]
1169    fn sampler_from_env_returns_none_when_unset() {
1170        let _lock = ENV_LOCK.lock().unwrap();
1171        unsafe {
1172            remove_env("OTEL_TRACES_SAMPLER");
1173        }
1174        assert!(sampler_from_env().expect("should not error").is_none());
1175    }
1176
1177    #[test]
1178    fn sampler_from_env_reads_parentbased_traceidratio() {
1179        let _lock = ENV_LOCK.lock().unwrap();
1180        unsafe {
1181            set_env("OTEL_TRACES_SAMPLER", "parentbased_traceidratio");
1182            set_env("OTEL_TRACES_SAMPLER_ARG", "0.1");
1183        }
1184
1185        let sampler = sampler_from_env()
1186            .expect("should not error")
1187            .expect("should return Some");
1188        assert!(
1189            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::TraceIdRatio(r) if (r - 0.1).abs() < f64::EPSILON))
1190        );
1191
1192        unsafe {
1193            remove_env("OTEL_TRACES_SAMPLER");
1194            remove_env("OTEL_TRACES_SAMPLER_ARG");
1195        }
1196    }
1197
1198    #[test]
1199    fn sampler_from_env_parentbased_always_on() {
1200        let _lock = ENV_LOCK.lock().unwrap();
1201        unsafe {
1202            set_env("OTEL_TRACES_SAMPLER", "parentbased_always_on");
1203        }
1204        let sampler = sampler_from_env()
1205            .expect("should not error")
1206            .expect("should return Some");
1207        assert!(
1208            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::AlwaysOn))
1209        );
1210        unsafe {
1211            remove_env("OTEL_TRACES_SAMPLER");
1212        }
1213    }
1214
1215    #[test]
1216    fn sampler_from_env_parentbased_always_off() {
1217        let _lock = ENV_LOCK.lock().unwrap();
1218        unsafe {
1219            set_env("OTEL_TRACES_SAMPLER", "parentbased_always_off");
1220        }
1221        let sampler = sampler_from_env()
1222            .expect("should not error")
1223            .expect("should return Some");
1224        assert!(
1225            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::AlwaysOff))
1226        );
1227        unsafe {
1228            remove_env("OTEL_TRACES_SAMPLER");
1229        }
1230    }
1231
1232    #[test]
1233    fn sampler_from_env_always_on() {
1234        let _lock = ENV_LOCK.lock().unwrap();
1235        unsafe {
1236            set_env("OTEL_TRACES_SAMPLER", "always_on");
1237        }
1238        let sampler = sampler_from_env()
1239            .expect("should not error")
1240            .expect("should return Some");
1241        assert!(matches!(sampler, TraceSampler::AlwaysOn));
1242        unsafe {
1243            remove_env("OTEL_TRACES_SAMPLER");
1244        }
1245    }
1246
1247    #[test]
1248    fn sampler_from_env_always_off() {
1249        let _lock = ENV_LOCK.lock().unwrap();
1250        unsafe {
1251            set_env("OTEL_TRACES_SAMPLER", "always_off");
1252        }
1253        let sampler = sampler_from_env()
1254            .expect("should not error")
1255            .expect("should return Some");
1256        assert!(matches!(sampler, TraceSampler::AlwaysOff));
1257        unsafe {
1258            remove_env("OTEL_TRACES_SAMPLER");
1259        }
1260    }
1261
1262    #[test]
1263    fn sampler_from_env_unknown_returns_error() {
1264        let _lock = ENV_LOCK.lock().unwrap();
1265        unsafe {
1266            set_env("OTEL_TRACES_SAMPLER", "unknown_sampler");
1267        }
1268        let err = sampler_from_env().expect_err("unknown sampler should produce an error");
1269        assert!(
1270            err.to_string().contains("unknown_sampler"),
1271            "error message should include the unknown name, got: {err}"
1272        );
1273        unsafe {
1274            remove_env("OTEL_TRACES_SAMPLER");
1275        }
1276    }
1277
1278    #[test]
1279    fn trace_sampler_always_on_converts_to_sdk() {
1280        let sdk = TraceSampler::AlwaysOn.into_sdk_sampler();
1281        assert_eq!(format!("{sdk:?}"), "AlwaysOn");
1282    }
1283
1284    #[test]
1285    fn trace_sampler_always_off_converts_to_sdk() {
1286        let sdk = TraceSampler::AlwaysOff.into_sdk_sampler();
1287        assert_eq!(format!("{sdk:?}"), "AlwaysOff");
1288    }
1289
1290    #[test]
1291    fn builder_has_sensible_defaults() {
1292        let builder = Telemetry::builder("test-svc");
1293        assert_eq!(builder.service_name.as_deref(), Some("test-svc"));
1294        assert!(builder.service_version.is_none());
1295        assert!(builder.deployment_environment.is_none());
1296        assert!(builder.sampler.is_none());
1297        assert!(builder.metrics);
1298        assert!(!builder.logs);
1299        assert!(builder.protocol.is_none());
1300        assert!(builder.max_export_batch_size.is_none());
1301        assert!(builder.metric_export_interval.is_none());
1302        assert!(builder.export_timeout.is_none());
1303    }
1304
1305    #[test]
1306    fn from_env_builder_has_no_service_name() {
1307        let builder = Telemetry::from_env();
1308        assert!(builder.service_name.is_none());
1309    }
1310
1311    #[test]
1312    fn with_export_timeout_stores_value() {
1313        let timeout = Duration::from_secs(5);
1314        let builder = Telemetry::builder("test-svc").with_export_timeout(timeout);
1315        assert_eq!(builder.export_timeout, Some(timeout));
1316    }
1317
1318    #[test]
1319    fn timeout_from_env_reads_milliseconds() {
1320        let _lock = ENV_LOCK.lock().unwrap();
1321        unsafe {
1322            set_env("OTEL_EXPORTER_OTLP_TIMEOUT", "5000");
1323        }
1324        let t = timeout_from_env();
1325        assert_eq!(t, Some(Duration::from_millis(5000)));
1326        unsafe {
1327            remove_env("OTEL_EXPORTER_OTLP_TIMEOUT");
1328        }
1329    }
1330
1331    #[test]
1332    fn timeout_from_env_returns_none_when_unset() {
1333        let _lock = ENV_LOCK.lock().unwrap();
1334        unsafe {
1335            remove_env("OTEL_EXPORTER_OTLP_TIMEOUT");
1336        }
1337        assert_eq!(timeout_from_env(), None);
1338    }
1339
1340    #[test]
1341    fn service_name_from_env_used_when_none_given() {
1342        let builder = Telemetry::from_env();
1343        assert!(builder.service_name.is_none());
1344    }
1345
1346    #[test]
1347    fn explicit_service_name_overrides_env_var() {
1348        let builder = Telemetry::builder("explicit-svc");
1349        assert_eq!(builder.service_name.as_deref(), Some("explicit-svc"));
1350    }
1351
1352    #[test]
1353    fn from_env_builder_service_name_is_none() {
1354        let builder = Telemetry::from_env();
1355        assert!(builder.service_name.is_none());
1356    }
1357
1358    #[test]
1359    fn init_returns_error_for_unknown_otel_traces_sampler() {
1360        let _lock = ENV_LOCK.lock().unwrap();
1361        unsafe {
1362            set_env("OTEL_TRACES_SAMPLER", "not_a_real_sampler");
1363        }
1364        let result = Telemetry::builder("test-svc").with_metrics(false).init();
1365        let err = result
1366            .err()
1367            .expect("unknown sampler env var should cause init to fail");
1368        assert!(
1369            err.to_string().contains("not_a_real_sampler"),
1370            "error should name the unknown sampler, got: {err}"
1371        );
1372        unsafe {
1373            remove_env("OTEL_TRACES_SAMPLER");
1374        }
1375    }
1376
1377    #[test]
1378    fn with_max_export_batch_size_stores_value() {
1379        let builder = Telemetry::builder("test-svc").with_max_export_batch_size(1024);
1380        assert_eq!(builder.max_export_batch_size, Some(1024));
1381    }
1382
1383    #[test]
1384    fn with_metric_export_interval_stores_value() {
1385        let interval = Duration::from_secs(30);
1386        let builder = Telemetry::builder("test-svc").with_metric_export_interval(interval);
1387        assert_eq!(builder.metric_export_interval, Some(interval));
1388    }
1389
1390    #[test]
1391    fn init_rejects_zero_metric_export_interval() {
1392        let err = Telemetry::builder("test-svc")
1393            .with_metric_export_interval(Duration::ZERO)
1394            .with_metrics(false)
1395            .init()
1396            .err()
1397            .expect("expected error for zero interval");
1398        assert!(
1399            err.to_string().contains("metric_export_interval"),
1400            "error message should mention metric_export_interval, got: {err}"
1401        );
1402    }
1403
1404    #[test]
1405    fn builder_with_custom_values() {
1406        let builder = Telemetry::builder("test-svc")
1407            .with_version("2.0.0")
1408            .with_environment("production")
1409            .with_sampler(TraceSampler::TraceIdRatio(0.5))
1410            .with_metrics(false);
1411
1412        assert_eq!(builder.service_name.as_deref(), Some("test-svc"));
1413        assert_eq!(builder.service_version.as_deref(), Some("2.0.0"));
1414        assert_eq!(
1415            builder.deployment_environment.as_deref(),
1416            Some("production")
1417        );
1418        assert!(
1419            matches!(builder.sampler, Some(TraceSampler::TraceIdRatio(r)) if (r - 0.5).abs() < f64::EPSILON)
1420        );
1421        assert!(!builder.metrics);
1422    }
1423
1424    #[test]
1425    #[cfg(feature = "grpc")]
1426    fn builder_with_protocol_grpc() {
1427        let builder = Telemetry::builder("test-svc").with_protocol(ExportProtocol::Grpc);
1428        assert_eq!(builder.protocol, Some(ExportProtocol::Grpc));
1429    }
1430
1431    #[test]
1432    #[cfg(feature = "http")]
1433    fn builder_with_protocol_http() {
1434        let builder = Telemetry::builder("test-svc").with_protocol(ExportProtocol::HttpProtobuf);
1435        assert_eq!(builder.protocol, Some(ExportProtocol::HttpProtobuf));
1436    }
1437
1438    #[test]
1439    #[cfg(feature = "grpc")]
1440    fn protocol_from_env_reads_grpc() {
1441        let _lock = ENV_LOCK.lock().unwrap();
1442        unsafe {
1443            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc");
1444        }
1445        assert_eq!(protocol_from_env(), Some(ExportProtocol::Grpc));
1446        unsafe {
1447            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1448        }
1449    }
1450
1451    #[test]
1452    #[cfg(feature = "http")]
1453    fn protocol_from_env_reads_http_protobuf() {
1454        let _lock = ENV_LOCK.lock().unwrap();
1455        unsafe {
1456            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf");
1457        }
1458        assert_eq!(protocol_from_env(), Some(ExportProtocol::HttpProtobuf));
1459        unsafe {
1460            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1461        }
1462    }
1463
1464    #[test]
1465    fn protocol_from_env_returns_none_when_unset() {
1466        let _lock = ENV_LOCK.lock().unwrap();
1467        unsafe {
1468            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1469        }
1470        assert_eq!(protocol_from_env(), None);
1471    }
1472
1473    #[test]
1474    fn protocol_from_env_returns_none_for_unknown() {
1475        let _lock = ENV_LOCK.lock().unwrap();
1476        unsafe {
1477            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "websocket");
1478        }
1479        assert_eq!(protocol_from_env(), None);
1480        unsafe {
1481            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1482        }
1483    }
1484
1485    #[test]
1486    fn builder_is_send_and_sync() {
1487        fn assert_send_sync<T: Send + Sync>() {}
1488        assert_send_sync::<TelemetryBuilder>();
1489    }
1490
1491    #[test]
1492    fn with_shutdown_timeout_stores_value() {
1493        let timeout = Duration::from_secs(10);
1494        let builder = Telemetry::builder("test-svc").with_shutdown_timeout(timeout);
1495        assert_eq!(builder.shutdown_timeout, timeout);
1496    }
1497
1498    #[test]
1499    fn default_shutdown_timeout_is_five_seconds() {
1500        let builder = Telemetry::builder("test-svc");
1501        assert_eq!(builder.shutdown_timeout, Duration::from_secs(5));
1502    }
1503
1504    /// Verify that drop completes within the configured timeout even when the
1505    /// shutdown thread is blocked (simulated by using a very short timeout so
1506    /// the test itself runs quickly).
1507    ///
1508    /// We construct `TelemetryHandles` with an artificially short timeout and
1509    /// a real (but disconnected) provider.  Drop must return before the test
1510    /// times out.
1511    #[cfg(feature = "testing")]
1512    #[test]
1513    fn drop_completes_within_shutdown_timeout() {
1514        // Use the testing helper so we don't need a running OTLP collector.
1515        let mut handles = crate::Telemetry::testing("drop-timeout-test");
1516        // Override the timeout to something very short so the test is fast.
1517        handles.shutdown_timeout = Duration::from_millis(100);
1518
1519        let start = std::time::Instant::now();
1520        drop(handles);
1521        let elapsed = start.elapsed();
1522
1523        // Drop should complete within 2× the timeout (generous margin for CI).
1524        assert!(
1525            elapsed < Duration::from_millis(500),
1526            "drop took {elapsed:?}, expected < 500 ms"
1527        );
1528    }
1529}