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