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        // `Vec::register_callsite()` on an empty Vec returns `Interest::never()`, which
760        // propagates through the entire layer chain via `pick_interest()` and silently
761        // disables ALL tracing callsites for the process.  Guard against this by wrapping
762        // the Vec in `Option`: `None` returns `Interest::always()` and is a no-op.
763        let extra = if self.extra_layers.is_empty() {
764            None
765        } else {
766            Some(self.extra_layers)
767        };
768
769        let registry = tracing_subscriber::registry()
770            .with(extra)
771            .with(tracing_subscriber::EnvFilter::from_default_env())
772            .with(tracing_subscriber::fmt::layer())
773            .with(otel_layer);
774
775        if let Some(lp) = &logger_provider {
776            if let Err(e) = registry
777                .with(opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(lp))
778                .try_init()
779            {
780                eprintln!(
781                    "otel-bootstrap: global tracing subscriber already installed — \
782                     OTLP log records will NOT be exported to the collector: {e}"
783                );
784            }
785        } else if let Err(e) = registry.try_init() {
786            eprintln!(
787                "otel-bootstrap: global tracing subscriber already installed — \
788                 OTLP telemetry will NOT be exported to the collector: {e}"
789            );
790        }
791
792        Ok(TelemetryHandles {
793            tracer_provider,
794            meter_provider,
795            logger_provider,
796            shutdown_timeout: self.shutdown_timeout,
797        })
798    }
799}
800
801/// Initialise OpenTelemetry traces + metrics with OTLP gRPC export.
802///
803/// Convenience wrapper around [`Telemetry::builder`] with all defaults.
804/// For fine-grained control, use the builder directly.
805///
806/// # Example
807/// ```no_run
808/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
809/// let _tel = otel_bootstrap::init_telemetry("my-service")?;
810/// // start axum server...
811/// # Ok(())
812/// # }
813/// ```
814pub fn init_telemetry(service_name: &str) -> Result<TelemetryHandles, Box<dyn Error>> {
815    Telemetry::builder(service_name).init()
816}
817
818/// Initialise OpenTelemetry traces + metrics with OTLP gRPC export and an
819/// explicit trace sampler.
820///
821/// Convenience wrapper around [`Telemetry::builder`]. When `sampler` is
822/// `None`, falls back to `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG`,
823/// then always-on.
824///
825/// # Example
826/// ```no_run
827/// use otel_bootstrap::TraceSampler;
828/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
829/// let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.1)));
830/// let _tel = otel_bootstrap::init_telemetry_with_sampler("my-service", Some(sampler))?;
831/// # Ok(())
832/// # }
833/// ```
834pub fn init_telemetry_with_sampler(
835    service_name: &str,
836    sampler: Option<TraceSampler>,
837) -> Result<TelemetryHandles, Box<dyn Error>> {
838    let builder = Telemetry::builder(service_name);
839    match sampler {
840        Some(s) => builder.with_sampler(s),
841        None => builder, // no-op: identical to calling init_telemetry(); not covered by tests (see Makefile ci-coverage note)
842    }
843    .init()
844}
845
846/// Read `OTEL_EXPORTER_OTLP_TIMEOUT` (milliseconds). Returns `None` when unset or invalid.
847fn timeout_from_env() -> Option<Duration> {
848    let ms = std::env::var("OTEL_EXPORTER_OTLP_TIMEOUT").ok()?;
849    let ms: u64 = ms.trim().parse().ok()?;
850    Some(Duration::from_millis(ms))
851}
852
853/// Build a `tonic::transport::ClientTlsConfig` from PEM material.
854/// Centralised so the three exporter builders apply identical TLS config.
855///
856/// Note: `with_tls_config` is provided by the `WithTonicConfig` trait on
857/// `opentelemetry-otlp`'s tonic exporter builders — imported at each call
858/// site below.
859#[cfg(feature = "grpc-mtls")]
860fn build_tls_config(material: &MtlsMaterial) -> tonic::transport::ClientTlsConfig {
861    use tonic::transport::{Certificate, ClientTlsConfig, Identity};
862    ClientTlsConfig::new()
863        .ca_certificate(Certificate::from_pem(&material.trust_bundle_pem))
864        .identity(Identity::from_pem(
865            &material.client_cert_chain_pem,
866            &material.client_key_pem,
867        ))
868}
869
870fn build_span_exporter(
871    protocol: ExportProtocol,
872    endpoint: &str,
873    timeout: Option<Duration>,
874    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
875) -> Result<opentelemetry_otlp::SpanExporter, Box<dyn Error>> {
876    match protocol {
877        #[cfg(feature = "grpc")]
878        ExportProtocol::Grpc => {
879            let mut b = opentelemetry_otlp::SpanExporter::builder()
880                .with_tonic()
881                .with_endpoint(endpoint);
882            if let Some(t) = timeout {
883                b = b.with_timeout(t);
884            }
885            #[cfg(feature = "grpc-mtls")]
886            if let Some(m) = mtls {
887                use opentelemetry_otlp::WithTonicConfig as _;
888                b = b.with_tls_config(build_tls_config(m));
889            }
890            Ok(b.build()?)
891        }
892        #[cfg(feature = "http")]
893        ExportProtocol::HttpProtobuf => {
894            let mut b = opentelemetry_otlp::SpanExporter::builder()
895                .with_http()
896                .with_endpoint(endpoint);
897            if let Some(t) = timeout {
898                b = b.with_timeout(t);
899            }
900            Ok(b.build()?)
901        }
902    }
903}
904
905fn build_metric_exporter(
906    protocol: ExportProtocol,
907    endpoint: &str,
908    timeout: Option<Duration>,
909    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
910) -> Result<opentelemetry_otlp::MetricExporter, Box<dyn Error>> {
911    match protocol {
912        #[cfg(feature = "grpc")]
913        ExportProtocol::Grpc => {
914            let mut b = opentelemetry_otlp::MetricExporter::builder()
915                .with_tonic()
916                .with_endpoint(endpoint);
917            if let Some(t) = timeout {
918                b = b.with_timeout(t);
919            }
920            #[cfg(feature = "grpc-mtls")]
921            if let Some(m) = mtls {
922                use opentelemetry_otlp::WithTonicConfig as _;
923                b = b.with_tls_config(build_tls_config(m));
924            }
925            Ok(b.build()?)
926        }
927        #[cfg(feature = "http")]
928        ExportProtocol::HttpProtobuf => {
929            let mut b = opentelemetry_otlp::MetricExporter::builder()
930                .with_http()
931                .with_endpoint(endpoint);
932            if let Some(t) = timeout {
933                b = b.with_timeout(t);
934            }
935            Ok(b.build()?)
936        }
937    }
938}
939
940fn build_log_exporter(
941    protocol: ExportProtocol,
942    endpoint: &str,
943    timeout: Option<Duration>,
944    #[cfg(feature = "grpc-mtls")] mtls: Option<&MtlsMaterial>,
945) -> Result<opentelemetry_otlp::LogExporter, Box<dyn Error>> {
946    match protocol {
947        #[cfg(feature = "grpc")]
948        ExportProtocol::Grpc => {
949            let mut b = opentelemetry_otlp::LogExporter::builder()
950                .with_tonic()
951                .with_endpoint(endpoint);
952            if let Some(t) = timeout {
953                b = b.with_timeout(t);
954            }
955            #[cfg(feature = "grpc-mtls")]
956            if let Some(m) = mtls {
957                use opentelemetry_otlp::WithTonicConfig as _;
958                b = b.with_tls_config(build_tls_config(m));
959            }
960            Ok(b.build()?)
961        }
962        #[cfg(feature = "http")]
963        ExportProtocol::HttpProtobuf => {
964            let mut b = opentelemetry_otlp::LogExporter::builder()
965                .with_http()
966                .with_endpoint(endpoint);
967            if let Some(t) = timeout {
968                b = b.with_timeout(t);
969            }
970            Ok(b.build()?)
971        }
972    }
973}
974
975/// Build a [`Resource`] enriched with semantic-convention attributes.
976///
977/// Auto-detects `host.name` and `process.pid`. Optionally sets
978/// `service.version` and `deployment.environment` when provided.
979///
980/// # Example
981/// ```
982/// let resource = otel_bootstrap::build_resource(
983///     "my-service",
984///     Some("1.0.0"),
985///     Some("production"),
986/// );
987/// // `resource` can be passed to SdkTracerProvider::builder().with_resource(resource)
988/// ```
989pub fn build_resource(
990    service_name: &str,
991    service_version: Option<&str>,
992    deployment_environment: Option<&str>,
993) -> Resource {
994    let hostname = hostname::get()
995        .ok()
996        .and_then(|h| h.into_string().ok())
997        .unwrap_or_default();
998
999    let mut builder = Resource::builder()
1000        .with_service_name(service_name.to_string())
1001        .with_attributes([
1002            KeyValue::new(HOST_NAME, hostname),
1003            KeyValue::new(PROCESS_PID, std::process::id() as i64),
1004        ]);
1005
1006    if let Some(version) = service_version {
1007        builder = builder.with_attribute(KeyValue::new(SERVICE_VERSION, version.to_string()));
1008    }
1009
1010    if let Some(env) = deployment_environment {
1011        builder =
1012            builder.with_attribute(KeyValue::new(DEPLOYMENT_ENVIRONMENT_NAME, env.to_string()));
1013    }
1014
1015    builder.build()
1016}
1017
1018/// Returns a ready-to-use [`tower::Layer`] that extracts W3C trace context from
1019/// incoming HTTP requests, creates a span with standard HTTP semantic-convention
1020/// attributes, and injects trace context into response headers.
1021///
1022/// Requires the `axum` feature flag.
1023///
1024/// # Example
1025/// ```no_run
1026/// # #[cfg(feature = "axum")]
1027/// # {
1028/// use axum::Router;
1029///
1030/// let app: Router = Router::new()
1031///     // ... add routes ...
1032///     .layer(otel_bootstrap::axum_layer());
1033/// # }
1034/// ```
1035#[cfg(feature = "axum")]
1036pub fn axum_layer() -> axum_middleware::OtelTraceLayer {
1037    axum_middleware::OtelTraceLayer
1038}
1039
1040/// Construct the tower [`Layer`](tower::Layer) that calls [`span_enrichment::EnrichSpan::enrich_span`]
1041/// on every request that carries a `T` extension.
1042///
1043/// Requires the `axum` feature flag. Place this layer inside the
1044/// [`axum::Extension`] layer that injects `T`, so the context is populated
1045/// before this service inspects the extensions.
1046///
1047/// # Example
1048/// ```no_run
1049/// # #[cfg(feature = "axum")] {
1050/// use axum::{Router, Extension, routing::get};
1051/// use otel_bootstrap::span_enrichment::EnrichSpan;
1052/// use tracing_opentelemetry::OpenTelemetrySpanExt as _;
1053///
1054/// #[derive(Clone)]
1055/// struct MyCtx { user_id: String }
1056///
1057/// impl EnrichSpan for MyCtx {
1058///     fn enrich_span(&self, span: &tracing::Span) {
1059///         span.set_attribute("enduser.id", self.user_id.clone());
1060///     }
1061/// }
1062///
1063/// let app: Router = Router::new()
1064///     .route("/", get(|| async { "ok" }))
1065///     .layer(otel_bootstrap::span_enricher_layer::<MyCtx>())
1066///     .layer(Extension(MyCtx { user_id: "u1".into() }))
1067///     .layer(otel_bootstrap::axum_layer());
1068/// # }
1069/// ```
1070#[cfg(feature = "axum")]
1071pub fn span_enricher_layer<T>() -> axum_middleware::SpanEnricherLayer<T>
1072where
1073    T: span_enrichment::EnrichSpan + Clone + Send + Sync + 'static,
1074{
1075    axum_middleware::SpanEnricherLayer::default()
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081    use std::sync::Mutex;
1082
1083    static ENV_LOCK: Mutex<()> = Mutex::new(());
1084
1085    #[test]
1086    fn resource_contains_all_attributes_when_provided() {
1087        let resource = build_resource("test-svc", Some("1.2.3"), Some("staging"));
1088
1089        assert_eq!(
1090            resource.get(&opentelemetry::Key::new("service.name")),
1091            Some(opentelemetry::Value::from("test-svc")),
1092        );
1093        assert_eq!(
1094            resource.get(&opentelemetry::Key::new(SERVICE_VERSION)),
1095            Some(opentelemetry::Value::from("1.2.3")),
1096        );
1097        assert_eq!(
1098            resource.get(&opentelemetry::Key::new(DEPLOYMENT_ENVIRONMENT_NAME)),
1099            Some(opentelemetry::Value::from("staging")),
1100        );
1101        assert!(resource.get(&opentelemetry::Key::new(HOST_NAME)).is_some());
1102        assert!(
1103            resource
1104                .get(&opentelemetry::Key::new(PROCESS_PID))
1105                .is_some()
1106        );
1107    }
1108
1109    #[test]
1110    fn resource_graceful_when_optional_values_omitted() {
1111        let resource = build_resource("test-svc", None, None);
1112
1113        assert_eq!(
1114            resource.get(&opentelemetry::Key::new("service.name")),
1115            Some(opentelemetry::Value::from("test-svc")),
1116        );
1117        assert!(
1118            resource
1119                .get(&opentelemetry::Key::new(SERVICE_VERSION))
1120                .is_none()
1121        );
1122        assert!(
1123            resource
1124                .get(&opentelemetry::Key::new(DEPLOYMENT_ENVIRONMENT_NAME))
1125                .is_none()
1126        );
1127        // Auto-detected attributes still present
1128        assert!(resource.get(&opentelemetry::Key::new(HOST_NAME)).is_some());
1129        assert!(
1130            resource
1131                .get(&opentelemetry::Key::new(PROCESS_PID))
1132                .is_some()
1133        );
1134    }
1135
1136    #[test]
1137    fn trace_sampler_ratio_converts_to_sdk() {
1138        let sampler = TraceSampler::TraceIdRatio(0.5);
1139        let sdk = sampler.into_sdk_sampler();
1140        assert_eq!(format!("{sdk:?}"), "TraceIdRatioBased(0.5)");
1141    }
1142
1143    #[test]
1144    fn trace_sampler_parent_based_converts_to_sdk() {
1145        let sampler = TraceSampler::ParentBased(Box::new(TraceSampler::TraceIdRatio(0.25)));
1146        let sdk = sampler.into_sdk_sampler();
1147        let debug = format!("{sdk:?}");
1148        assert!(debug.contains("ParentBased"));
1149        assert!(debug.contains("0.25"));
1150    }
1151
1152    /// # Safety helper — env var manipulation is unsafe in Rust 2024 edition.
1153    unsafe fn set_env(key: &str, val: &str) {
1154        unsafe {
1155            std::env::set_var(key, val);
1156        }
1157    }
1158
1159    unsafe fn remove_env(key: &str) {
1160        unsafe {
1161            std::env::remove_var(key);
1162        }
1163    }
1164
1165    #[test]
1166    fn sampler_from_env_reads_traceidratio() {
1167        let _lock = ENV_LOCK.lock().unwrap();
1168        unsafe {
1169            set_env("OTEL_TRACES_SAMPLER", "traceidratio");
1170            set_env("OTEL_TRACES_SAMPLER_ARG", "0.42");
1171        }
1172
1173        let sampler = sampler_from_env()
1174            .expect("should not error")
1175            .expect("should return Some");
1176        assert!(
1177            matches!(sampler, TraceSampler::TraceIdRatio(r) if (r - 0.42).abs() < f64::EPSILON)
1178        );
1179
1180        unsafe {
1181            remove_env("OTEL_TRACES_SAMPLER");
1182            remove_env("OTEL_TRACES_SAMPLER_ARG");
1183        }
1184    }
1185
1186    #[test]
1187    fn sampler_from_env_returns_none_when_unset() {
1188        let _lock = ENV_LOCK.lock().unwrap();
1189        unsafe {
1190            remove_env("OTEL_TRACES_SAMPLER");
1191        }
1192        assert!(sampler_from_env().expect("should not error").is_none());
1193    }
1194
1195    #[test]
1196    fn sampler_from_env_reads_parentbased_traceidratio() {
1197        let _lock = ENV_LOCK.lock().unwrap();
1198        unsafe {
1199            set_env("OTEL_TRACES_SAMPLER", "parentbased_traceidratio");
1200            set_env("OTEL_TRACES_SAMPLER_ARG", "0.1");
1201        }
1202
1203        let sampler = sampler_from_env()
1204            .expect("should not error")
1205            .expect("should return Some");
1206        assert!(
1207            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::TraceIdRatio(r) if (r - 0.1).abs() < f64::EPSILON))
1208        );
1209
1210        unsafe {
1211            remove_env("OTEL_TRACES_SAMPLER");
1212            remove_env("OTEL_TRACES_SAMPLER_ARG");
1213        }
1214    }
1215
1216    #[test]
1217    fn sampler_from_env_parentbased_always_on() {
1218        let _lock = ENV_LOCK.lock().unwrap();
1219        unsafe {
1220            set_env("OTEL_TRACES_SAMPLER", "parentbased_always_on");
1221        }
1222        let sampler = sampler_from_env()
1223            .expect("should not error")
1224            .expect("should return Some");
1225        assert!(
1226            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::AlwaysOn))
1227        );
1228        unsafe {
1229            remove_env("OTEL_TRACES_SAMPLER");
1230        }
1231    }
1232
1233    #[test]
1234    fn sampler_from_env_parentbased_always_off() {
1235        let _lock = ENV_LOCK.lock().unwrap();
1236        unsafe {
1237            set_env("OTEL_TRACES_SAMPLER", "parentbased_always_off");
1238        }
1239        let sampler = sampler_from_env()
1240            .expect("should not error")
1241            .expect("should return Some");
1242        assert!(
1243            matches!(sampler, TraceSampler::ParentBased(inner) if matches!(*inner, TraceSampler::AlwaysOff))
1244        );
1245        unsafe {
1246            remove_env("OTEL_TRACES_SAMPLER");
1247        }
1248    }
1249
1250    #[test]
1251    fn sampler_from_env_always_on() {
1252        let _lock = ENV_LOCK.lock().unwrap();
1253        unsafe {
1254            set_env("OTEL_TRACES_SAMPLER", "always_on");
1255        }
1256        let sampler = sampler_from_env()
1257            .expect("should not error")
1258            .expect("should return Some");
1259        assert!(matches!(sampler, TraceSampler::AlwaysOn));
1260        unsafe {
1261            remove_env("OTEL_TRACES_SAMPLER");
1262        }
1263    }
1264
1265    #[test]
1266    fn sampler_from_env_always_off() {
1267        let _lock = ENV_LOCK.lock().unwrap();
1268        unsafe {
1269            set_env("OTEL_TRACES_SAMPLER", "always_off");
1270        }
1271        let sampler = sampler_from_env()
1272            .expect("should not error")
1273            .expect("should return Some");
1274        assert!(matches!(sampler, TraceSampler::AlwaysOff));
1275        unsafe {
1276            remove_env("OTEL_TRACES_SAMPLER");
1277        }
1278    }
1279
1280    #[test]
1281    fn sampler_from_env_unknown_returns_error() {
1282        let _lock = ENV_LOCK.lock().unwrap();
1283        unsafe {
1284            set_env("OTEL_TRACES_SAMPLER", "unknown_sampler");
1285        }
1286        let err = sampler_from_env().expect_err("unknown sampler should produce an error");
1287        assert!(
1288            err.to_string().contains("unknown_sampler"),
1289            "error message should include the unknown name, got: {err}"
1290        );
1291        unsafe {
1292            remove_env("OTEL_TRACES_SAMPLER");
1293        }
1294    }
1295
1296    #[test]
1297    fn trace_sampler_always_on_converts_to_sdk() {
1298        let sdk = TraceSampler::AlwaysOn.into_sdk_sampler();
1299        assert_eq!(format!("{sdk:?}"), "AlwaysOn");
1300    }
1301
1302    #[test]
1303    fn trace_sampler_always_off_converts_to_sdk() {
1304        let sdk = TraceSampler::AlwaysOff.into_sdk_sampler();
1305        assert_eq!(format!("{sdk:?}"), "AlwaysOff");
1306    }
1307
1308    #[test]
1309    fn builder_has_sensible_defaults() {
1310        let builder = Telemetry::builder("test-svc");
1311        assert_eq!(builder.service_name.as_deref(), Some("test-svc"));
1312        assert!(builder.service_version.is_none());
1313        assert!(builder.deployment_environment.is_none());
1314        assert!(builder.sampler.is_none());
1315        assert!(builder.metrics);
1316        assert!(!builder.logs);
1317        assert!(builder.protocol.is_none());
1318        assert!(builder.max_export_batch_size.is_none());
1319        assert!(builder.metric_export_interval.is_none());
1320        assert!(builder.export_timeout.is_none());
1321    }
1322
1323    #[test]
1324    fn from_env_builder_has_no_service_name() {
1325        let builder = Telemetry::from_env();
1326        assert!(builder.service_name.is_none());
1327    }
1328
1329    #[test]
1330    fn with_export_timeout_stores_value() {
1331        let timeout = Duration::from_secs(5);
1332        let builder = Telemetry::builder("test-svc").with_export_timeout(timeout);
1333        assert_eq!(builder.export_timeout, Some(timeout));
1334    }
1335
1336    #[test]
1337    fn timeout_from_env_reads_milliseconds() {
1338        let _lock = ENV_LOCK.lock().unwrap();
1339        unsafe {
1340            set_env("OTEL_EXPORTER_OTLP_TIMEOUT", "5000");
1341        }
1342        let t = timeout_from_env();
1343        assert_eq!(t, Some(Duration::from_millis(5000)));
1344        unsafe {
1345            remove_env("OTEL_EXPORTER_OTLP_TIMEOUT");
1346        }
1347    }
1348
1349    #[test]
1350    fn timeout_from_env_returns_none_when_unset() {
1351        let _lock = ENV_LOCK.lock().unwrap();
1352        unsafe {
1353            remove_env("OTEL_EXPORTER_OTLP_TIMEOUT");
1354        }
1355        assert_eq!(timeout_from_env(), None);
1356    }
1357
1358    #[test]
1359    fn service_name_from_env_used_when_none_given() {
1360        let builder = Telemetry::from_env();
1361        assert!(builder.service_name.is_none());
1362    }
1363
1364    #[test]
1365    fn explicit_service_name_overrides_env_var() {
1366        let builder = Telemetry::builder("explicit-svc");
1367        assert_eq!(builder.service_name.as_deref(), Some("explicit-svc"));
1368    }
1369
1370    #[test]
1371    fn from_env_builder_service_name_is_none() {
1372        let builder = Telemetry::from_env();
1373        assert!(builder.service_name.is_none());
1374    }
1375
1376    #[test]
1377    fn init_returns_error_for_unknown_otel_traces_sampler() {
1378        let _lock = ENV_LOCK.lock().unwrap();
1379        unsafe {
1380            set_env("OTEL_TRACES_SAMPLER", "not_a_real_sampler");
1381        }
1382        let result = Telemetry::builder("test-svc").with_metrics(false).init();
1383        let err = result
1384            .err()
1385            .expect("unknown sampler env var should cause init to fail");
1386        assert!(
1387            err.to_string().contains("not_a_real_sampler"),
1388            "error should name the unknown sampler, got: {err}"
1389        );
1390        unsafe {
1391            remove_env("OTEL_TRACES_SAMPLER");
1392        }
1393    }
1394
1395    #[test]
1396    fn with_max_export_batch_size_stores_value() {
1397        let builder = Telemetry::builder("test-svc").with_max_export_batch_size(1024);
1398        assert_eq!(builder.max_export_batch_size, Some(1024));
1399    }
1400
1401    #[test]
1402    fn with_metric_export_interval_stores_value() {
1403        let interval = Duration::from_secs(30);
1404        let builder = Telemetry::builder("test-svc").with_metric_export_interval(interval);
1405        assert_eq!(builder.metric_export_interval, Some(interval));
1406    }
1407
1408    #[test]
1409    fn init_rejects_zero_metric_export_interval() {
1410        let err = Telemetry::builder("test-svc")
1411            .with_metric_export_interval(Duration::ZERO)
1412            .with_metrics(false)
1413            .init()
1414            .err()
1415            .expect("expected error for zero interval");
1416        assert!(
1417            err.to_string().contains("metric_export_interval"),
1418            "error message should mention metric_export_interval, got: {err}"
1419        );
1420    }
1421
1422    #[test]
1423    fn builder_with_custom_values() {
1424        let builder = Telemetry::builder("test-svc")
1425            .with_version("2.0.0")
1426            .with_environment("production")
1427            .with_sampler(TraceSampler::TraceIdRatio(0.5))
1428            .with_metrics(false);
1429
1430        assert_eq!(builder.service_name.as_deref(), Some("test-svc"));
1431        assert_eq!(builder.service_version.as_deref(), Some("2.0.0"));
1432        assert_eq!(
1433            builder.deployment_environment.as_deref(),
1434            Some("production")
1435        );
1436        assert!(
1437            matches!(builder.sampler, Some(TraceSampler::TraceIdRatio(r)) if (r - 0.5).abs() < f64::EPSILON)
1438        );
1439        assert!(!builder.metrics);
1440    }
1441
1442    #[test]
1443    #[cfg(feature = "grpc")]
1444    fn builder_with_protocol_grpc() {
1445        let builder = Telemetry::builder("test-svc").with_protocol(ExportProtocol::Grpc);
1446        assert_eq!(builder.protocol, Some(ExportProtocol::Grpc));
1447    }
1448
1449    #[test]
1450    #[cfg(feature = "http")]
1451    fn builder_with_protocol_http() {
1452        let builder = Telemetry::builder("test-svc").with_protocol(ExportProtocol::HttpProtobuf);
1453        assert_eq!(builder.protocol, Some(ExportProtocol::HttpProtobuf));
1454    }
1455
1456    #[test]
1457    #[cfg(feature = "grpc")]
1458    fn protocol_from_env_reads_grpc() {
1459        let _lock = ENV_LOCK.lock().unwrap();
1460        unsafe {
1461            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc");
1462        }
1463        assert_eq!(protocol_from_env(), Some(ExportProtocol::Grpc));
1464        unsafe {
1465            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1466        }
1467    }
1468
1469    #[test]
1470    #[cfg(feature = "http")]
1471    fn protocol_from_env_reads_http_protobuf() {
1472        let _lock = ENV_LOCK.lock().unwrap();
1473        unsafe {
1474            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf");
1475        }
1476        assert_eq!(protocol_from_env(), Some(ExportProtocol::HttpProtobuf));
1477        unsafe {
1478            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1479        }
1480    }
1481
1482    #[test]
1483    fn protocol_from_env_returns_none_when_unset() {
1484        let _lock = ENV_LOCK.lock().unwrap();
1485        unsafe {
1486            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1487        }
1488        assert_eq!(protocol_from_env(), None);
1489    }
1490
1491    #[test]
1492    fn protocol_from_env_returns_none_for_unknown() {
1493        let _lock = ENV_LOCK.lock().unwrap();
1494        unsafe {
1495            set_env("OTEL_EXPORTER_OTLP_PROTOCOL", "websocket");
1496        }
1497        assert_eq!(protocol_from_env(), None);
1498        unsafe {
1499            remove_env("OTEL_EXPORTER_OTLP_PROTOCOL");
1500        }
1501    }
1502
1503    #[test]
1504    fn builder_is_send_and_sync() {
1505        fn assert_send_sync<T: Send + Sync>() {}
1506        assert_send_sync::<TelemetryBuilder>();
1507    }
1508
1509    #[test]
1510    fn with_shutdown_timeout_stores_value() {
1511        let timeout = Duration::from_secs(10);
1512        let builder = Telemetry::builder("test-svc").with_shutdown_timeout(timeout);
1513        assert_eq!(builder.shutdown_timeout, timeout);
1514    }
1515
1516    #[test]
1517    fn default_shutdown_timeout_is_five_seconds() {
1518        let builder = Telemetry::builder("test-svc");
1519        assert_eq!(builder.shutdown_timeout, Duration::from_secs(5));
1520    }
1521
1522    /// Verify that drop completes within the configured timeout even when the
1523    /// shutdown thread is blocked (simulated by using a very short timeout so
1524    /// the test itself runs quickly).
1525    ///
1526    /// We construct `TelemetryHandles` with an artificially short timeout and
1527    /// a real (but disconnected) provider.  Drop must return before the test
1528    /// times out.
1529    #[cfg(feature = "testing")]
1530    #[test]
1531    fn drop_completes_within_shutdown_timeout() {
1532        // Use the testing helper so we don't need a running OTLP collector.
1533        let mut handles = crate::Telemetry::testing("drop-timeout-test");
1534        // Override the timeout to something very short so the test is fast.
1535        handles.shutdown_timeout = Duration::from_millis(100);
1536
1537        let start = std::time::Instant::now();
1538        drop(handles);
1539        let elapsed = start.elapsed();
1540
1541        // Drop should complete within 2× the timeout (generous margin for CI).
1542        assert!(
1543            elapsed < Duration::from_millis(500),
1544            "drop took {elapsed:?}, expected < 500 ms"
1545        );
1546    }
1547}