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