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