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