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