Skip to main content

otel_bootstrap/
lib.rs

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