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