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