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