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