Skip to main content

toolkit/telemetry/
init.rs

1//! OpenTelemetry tracing initialization utilities
2//!
3//! This gear sets up OpenTelemetry tracing and exports spans via OTLP
4//! (gRPC or HTTP) to collectors such as Jaeger, Uptrace, or the `OTel` Collector.
5
6#[cfg(feature = "otel")]
7use anyhow::Context;
8#[cfg(feature = "otel")]
9use opentelemetry::{KeyValue, global, trace::TracerProvider as _};
10#[cfg(feature = "otel")]
11use std::sync::{Once, OnceLock};
12
13#[cfg(feature = "otel")]
14use opentelemetry_otlp::{Protocol, WithExportConfig};
15// Bring extension traits into scope for builder methods like `.with_headers()` and `.with_metadata()`.
16#[cfg(feature = "otel")]
17use opentelemetry_otlp::{WithHttpConfig, WithTonicConfig};
18
19#[cfg(feature = "otel")]
20use opentelemetry_sdk::{
21    Resource,
22    propagation::TraceContextPropagator,
23    trace::{Sampler, SdkTracerProvider},
24};
25
26#[cfg(feature = "otel")]
27use super::config::{OpenTelemetryConfig, OpenTelemetryResource, TracingConfig};
28#[cfg(feature = "otel")]
29use crate::telemetry::config::ExporterKind;
30#[cfg(feature = "otel")]
31use tonic::metadata::{MetadataKey, MetadataMap, MetadataValue};
32
33// ===== init_tracing (feature = "otel") ========================================
34
35/// Build resource with service name and custom attributes
36#[cfg(feature = "otel")]
37pub(crate) fn build_resource(cfg: &OpenTelemetryResource) -> Resource {
38    tracing::debug!(
39        "Building OpenTelemetry resource for service: {}",
40        cfg.service_name
41    );
42    let mut attrs = vec![KeyValue::new("service.name", cfg.service_name.clone())];
43
44    for (k, v) in &cfg.attributes {
45        // Skip any caller-supplied "service.name" entry: the dedicated field
46        // cfg.service_name already seeds attrs above and a duplicate key would
47        // create ambiguity in the resource attributes.
48        if k == "service.name" {
49            continue;
50        }
51        attrs.push(KeyValue::new(k.clone(), v.clone()));
52    }
53
54    Resource::builder_empty().with_attributes(attrs).build()
55}
56
57/// Build sampler from configuration
58#[cfg(feature = "otel")]
59fn build_sampler(cfg: &TracingConfig) -> Sampler {
60    match cfg.sampler.as_ref() {
61        Some(crate::telemetry::config::Sampler::AlwaysOff { .. }) => Sampler::AlwaysOff,
62        Some(crate::telemetry::config::Sampler::AlwaysOn { .. }) => Sampler::AlwaysOn,
63        Some(crate::telemetry::config::Sampler::ParentBasedAlwaysOn { .. }) => {
64            Sampler::ParentBased(Box::new(Sampler::AlwaysOn))
65        }
66        Some(crate::telemetry::config::Sampler::ParentBasedRatio { ratio }) => {
67            let ratio = ratio.unwrap_or(0.1);
68            Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(ratio)))
69        }
70        None => Sampler::ParentBased(Box::new(Sampler::AlwaysOn)),
71    }
72}
73
74/// Extract exporter kind and endpoint from the resolved exporter.
75#[cfg(feature = "otel")]
76pub(crate) fn extract_exporter_config(
77    exporter: Option<&crate::telemetry::config::Exporter>,
78) -> (ExporterKind, String, Option<std::time::Duration>) {
79    let kind = exporter.map_or(ExporterKind::OtlpGrpc, |e| e.kind);
80    let default_endpoint = match kind {
81        ExporterKind::OtlpHttp => "http://127.0.0.1:4318",
82        ExporterKind::OtlpGrpc => "http://127.0.0.1:4317",
83    };
84    let endpoint = exporter
85        .and_then(|e| e.endpoint.clone())
86        .unwrap_or_else(|| default_endpoint.into());
87
88    let timeout = exporter
89        .and_then(|e| e.timeout_ms)
90        .map(std::time::Duration::from_millis);
91
92    (kind, endpoint, timeout)
93}
94
95/// Build HTTP OTLP exporter
96#[cfg(feature = "otel")]
97fn build_http_exporter(
98    exporter: Option<&crate::telemetry::config::Exporter>,
99    endpoint: String,
100    timeout: Option<std::time::Duration>,
101) -> anyhow::Result<opentelemetry_otlp::SpanExporter> {
102    let mut b = opentelemetry_otlp::SpanExporter::builder()
103        .with_http()
104        .with_protocol(Protocol::HttpBinary)
105        .with_endpoint(endpoint);
106    if let Some(t) = timeout {
107        b = b.with_timeout(t);
108    }
109    if let Some(hmap) = build_headers_from_cfg_and_env(exporter) {
110        b = b.with_headers(hmap);
111    }
112    b.build().context("build OTLP HTTP exporter")
113}
114
115/// Build gRPC OTLP exporter
116#[cfg(feature = "otel")]
117fn build_grpc_exporter(
118    exporter: Option<&crate::telemetry::config::Exporter>,
119    endpoint: String,
120    timeout: Option<std::time::Duration>,
121) -> anyhow::Result<opentelemetry_otlp::SpanExporter> {
122    let mut b = opentelemetry_otlp::SpanExporter::builder()
123        .with_tonic()
124        .with_endpoint(endpoint);
125    if let Some(t) = timeout {
126        b = b.with_timeout(t);
127    }
128    if let Some(md) = build_metadata_from_cfg_and_env(exporter) {
129        b = b.with_metadata(md);
130    }
131    b.build().context("build OTLP gRPC exporter")
132}
133
134#[cfg(feature = "otel")]
135static INIT_TRACING: Once = Once::new();
136
137/// Handle to the installed tracer provider, kept so `shutdown_tracing()` can
138/// flush the batch processor on graceful shutdown. `global::set_tracer_provider`
139/// consumes the provider and exposes no way to get it back, so without this
140/// handle the last batch of spans is lost on every restart.
141#[cfg(feature = "otel")]
142static TRACER_PROVIDER: OnceLock<SdkTracerProvider> = OnceLock::new();
143
144/// Handle to the installed meter provider — same rationale as
145/// [`TRACER_PROVIDER`], for the final metrics collection interval.
146#[cfg(feature = "otel")]
147static METER_PROVIDER: OnceLock<opentelemetry_sdk::metrics::SdkMeterProvider> = OnceLock::new();
148
149/// Initialize OpenTelemetry tracing from configuration and return a layer
150/// to be attached to `tracing_subscriber`.
151///
152/// # Errors
153/// Returns an error if the configuration is invalid or if the exporter fails to build.
154#[cfg(feature = "otel")]
155pub fn init_tracing(
156    otel_cfg: &OpenTelemetryConfig,
157) -> anyhow::Result<
158    tracing_opentelemetry::OpenTelemetryLayer<
159        tracing_subscriber::Registry,
160        opentelemetry_sdk::trace::Tracer,
161    >,
162> {
163    let cfg = &otel_cfg.tracing;
164    if !cfg.enabled {
165        return Err(anyhow::anyhow!("tracing is disabled"));
166    }
167
168    // Set W3C propagator for trace-context propagation
169    global::set_text_map_propagator(TraceContextPropagator::new());
170
171    // Build resource, sampler, and extract exporter config
172    let resource = build_resource(&otel_cfg.resource);
173    let sampler = build_sampler(cfg);
174    let resolved_exporter = otel_cfg.tracing_exporter();
175    let (kind, endpoint, timeout) = extract_exporter_config(resolved_exporter);
176
177    tracing::info!(kind = ?kind, %endpoint, "OTLP exporter config");
178
179    // Build span exporter based on kind
180    let exporter = if matches!(kind, ExporterKind::OtlpHttp) {
181        build_http_exporter(resolved_exporter, endpoint, timeout)
182    } else {
183        build_grpc_exporter(resolved_exporter, endpoint, timeout)
184    }?;
185
186    // Build tracer provider with batch processor
187    let provider = SdkTracerProvider::builder()
188        .with_batch_exporter(exporter)
189        .with_sampler(sampler)
190        .with_resource(resource)
191        .build();
192
193    // Create tracer and layer
194    let service_name = otel_cfg.resource.service_name.clone();
195    let tracer = provider.tracer(service_name);
196    let otel_layer = tracing_opentelemetry::OpenTelemetryLayer::new(tracer);
197
198    // Make it global. Keep a clone first: `set_tracer_provider` takes ownership
199    // and the global registry offers no accessor, so this is the only chance to
200    // retain a handle for the shutdown flush.
201    INIT_TRACING.call_once(|| {
202        if TRACER_PROVIDER.set(provider.clone()).is_err() {
203            tracing::debug!("tracer provider handle already stored");
204        }
205        global::set_tracer_provider(provider);
206    });
207
208    tracing::info!("OpenTelemetry layer created successfully");
209    Ok(otel_layer)
210}
211
212#[cfg(feature = "otel")]
213pub(crate) fn build_headers_from_cfg_and_env(
214    exporter: Option<&crate::telemetry::config::Exporter>,
215) -> Option<std::collections::HashMap<String, String>> {
216    use std::collections::HashMap;
217    let mut out: HashMap<String, String> = HashMap::new();
218
219    // From config file
220    if let Some(exp) = exporter
221        && let Some(hdrs) = &exp.headers
222    {
223        for (k, v) in hdrs {
224            out.insert(k.clone(), v.clone());
225        }
226    }
227
228    // From ENV OTEL_EXPORTER_OTLP_HEADERS (format: k=v,k2=v2)
229    if let Ok(env_hdrs) = std::env::var("OTEL_EXPORTER_OTLP_HEADERS") {
230        for part in env_hdrs.split(',').map(str::trim).filter(|s| !s.is_empty()) {
231            if let Some((k, v)) = part.split_once('=') {
232                out.insert(k.trim().to_owned(), v.trim().to_owned());
233            }
234        }
235    }
236
237    if out.is_empty() { None } else { Some(out) }
238}
239
240#[cfg(feature = "otel")]
241pub(crate) fn extend_metadata_from_source<'a, I>(
242    md: &mut MetadataMap,
243    source: I,
244    context: &'static str,
245) where
246    I: Iterator<Item = (&'a str, &'a str)>,
247{
248    for (k, v) in source {
249        match MetadataKey::from_bytes(k.as_bytes()) {
250            Ok(key) => match MetadataValue::try_from(v) {
251                Ok(val) => {
252                    md.insert(key, val);
253                }
254                Err(_) => {
255                    tracing::warn!(header = %k, context, "Skipping invalid gRPC metadata value");
256                }
257            },
258            Err(_) => {
259                tracing::warn!(header = %k, context, "Skipping invalid gRPC metadata header name");
260            }
261        }
262    }
263}
264
265#[cfg(feature = "otel")]
266pub(crate) fn build_metadata_from_cfg_and_env(
267    exporter: Option<&crate::telemetry::config::Exporter>,
268) -> Option<MetadataMap> {
269    let mut md = MetadataMap::new();
270
271    // From config file
272    if let Some(exp) = exporter
273        && let Some(hdrs) = &exp.headers
274    {
275        let iter = hdrs.iter().map(|(k, v)| (k.as_str(), v.as_str()));
276        extend_metadata_from_source(&mut md, iter, "config");
277    }
278
279    // From ENV OTEL_EXPORTER_OTLP_HEADERS (format: k=v,k2=v2)
280    if let Ok(env_hdrs) = std::env::var("OTEL_EXPORTER_OTLP_HEADERS") {
281        let iter = env_hdrs.split(',').filter_map(|part| {
282            let part = part.trim();
283            if part.is_empty() {
284                None
285            } else {
286                part.split_once('=').map(|(k, v)| (k.trim(), v.trim()))
287            }
288        });
289        extend_metadata_from_source(&mut md, iter, "env");
290    }
291
292    if md.is_empty() { None } else { Some(md) }
293}
294
295// ===== shutdown_tracing =======================================================
296
297/// Gracefully shut down OpenTelemetry tracing, flushing the batch processor.
298///
299/// There is no global `shutdown_tracer_provider()` in opentelemetry 0.32, so
300/// this drains the handle retained by [`init_tracing`]. Without it the spans
301/// still sitting in the batch processor are dropped when the process exits.
302///
303/// Safe to call when tracing was never initialised (no handle — no-op) and
304/// idempotent: a second call hits the SDK's already-shut-down state and is
305/// logged at debug level rather than treated as a failure.
306#[cfg(feature = "otel")]
307pub fn shutdown_tracing() {
308    let Some(provider) = TRACER_PROVIDER.get() else {
309        tracing::debug!("Tracing shutdown: no provider installed, nothing to flush");
310        return;
311    };
312    match provider.shutdown() {
313        Ok(()) => tracing::info!("OpenTelemetry tracing flushed and shut down"),
314        Err(e) => tracing::warn!(error = %e, "OpenTelemetry tracing shutdown failed"),
315    }
316}
317
318#[cfg(not(feature = "otel"))]
319pub fn shutdown_tracing() {
320    tracing::info!("Tracing shutdown (no-op)");
321}
322
323/// Gracefully shut down OpenTelemetry metrics, exporting the final interval.
324///
325/// Drains the handle retained by [`init_metrics_provider`]; see
326/// [`shutdown_tracing`] for the rationale and the idempotency contract.
327#[cfg(feature = "otel")]
328pub fn shutdown_metrics() {
329    let Some(provider) = METER_PROVIDER.get() else {
330        tracing::debug!("Metrics shutdown: no provider installed, nothing to flush");
331        return;
332    };
333    match provider.shutdown() {
334        Ok(()) => tracing::info!("OpenTelemetry metrics flushed and shut down"),
335        Err(e) => tracing::warn!(error = %e, "OpenTelemetry metrics shutdown failed"),
336    }
337}
338
339#[cfg(not(feature = "otel"))]
340pub fn shutdown_metrics() {
341    tracing::info!("Metrics shutdown (no-op)");
342}
343
344// ===== init_metrics_provider ==================================================
345
346#[cfg(feature = "otel")]
347static METRICS_INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
348
349/// Build a [`SdkMeterProvider`] from the resolved metrics exporter settings and
350/// register it as the global meter provider.
351///
352/// When `metrics.enabled` is `false` the function is a no-op: the global meter
353/// provider stays as the built-in [`NoopMeterProvider`] (zero overhead — all
354/// instruments obtained via `global::meter_with_scope()` become no-op).
355///
356/// Exporter resolution: `opentelemetry.metrics.exporter` overrides
357/// `opentelemetry.exporter` when present.
358///
359/// Initialisation runs at most once on success and is cached. Errors are
360/// **not** cached — a transient failure (e.g. OTLP endpoint briefly
361/// unreachable) is returned to the caller, and a subsequent call may retry.
362///
363/// # Errors
364///
365/// The OTLP metric exporter cannot be constructed.
366#[cfg(feature = "otel")]
367pub fn init_metrics_provider(otel_cfg: &OpenTelemetryConfig) -> anyhow::Result<()> {
368    if !otel_cfg.metrics.enabled {
369        // Do NOT cache the disabled path in METRICS_INIT — a later call with
370        // metrics enabled must still be able to initialise the real provider.
371        tracing::info!(
372            "OpenTelemetry metrics disabled - global meter provider is \
373             the built-in NoopMeterProvider"
374        );
375        return Ok(());
376    }
377
378    if METRICS_INIT.get().is_some() {
379        return Ok(());
380    }
381
382    do_init_metrics_provider(otel_cfg)?;
383    // Race between concurrent first-callers is benign: OTel allows
384    // re-registering the global meter provider, so at worst we initialise
385    // twice. The `set` returns Err for the loser of the race; ignored.
386    let _set_result = METRICS_INIT.set(());
387    Ok(())
388}
389
390#[cfg(feature = "otel")]
391fn do_init_metrics_provider(otel_cfg: &OpenTelemetryConfig) -> anyhow::Result<()> {
392    let resolved_exporter = otel_cfg.metrics_exporter();
393
394    let (kind, endpoint, timeout) = extract_exporter_config(resolved_exporter);
395
396    // Build OTLP metric exporter matching the configured transport
397    let exporter = if matches!(kind, ExporterKind::OtlpHttp) {
398        let mut b = opentelemetry_otlp::MetricExporter::builder()
399            .with_http()
400            .with_protocol(Protocol::HttpBinary)
401            .with_endpoint(&endpoint);
402        if let Some(t) = timeout {
403            b = b.with_timeout(t);
404        }
405        if let Some(headers) = build_headers_from_cfg_and_env(resolved_exporter) {
406            b = b.with_headers(headers);
407        }
408        b.build().context("build OTLP HTTP metric exporter")?
409    } else {
410        let mut b = opentelemetry_otlp::MetricExporter::builder()
411            .with_tonic()
412            .with_endpoint(&endpoint);
413        if let Some(t) = timeout {
414            b = b.with_timeout(t);
415        }
416        if let Some(md) = build_metadata_from_cfg_and_env(resolved_exporter) {
417            b = b.with_metadata(md);
418        }
419        b.build().context("build OTLP gRPC metric exporter")?
420    };
421
422    // Build resource with service name and attributes
423    let resource = build_resource(&otel_cfg.resource);
424
425    // Build the SdkMeterProvider with periodic exporter
426    let mut builder = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
427        .with_periodic_exporter(exporter)
428        .with_resource(resource);
429
430    // Apply a global cardinality limit when configured
431    if let Some(limit) = otel_cfg.metrics.cardinality_limit {
432        builder = builder.with_view(move |_: &opentelemetry_sdk::metrics::Instrument| {
433            opentelemetry_sdk::metrics::Stream::builder()
434                .with_cardinality_limit(limit)
435                .build()
436                .ok()
437        });
438    }
439
440    let provider = builder.build();
441
442    // Retain a handle for the shutdown flush, and register *that* handle
443    // globally. `METRICS_INIT` is checked before this function and set after
444    // it, so two concurrent first-callers both reach this point; storing one
445    // provider while registering the other would leave `shutdown_metrics`
446    // flushing an instrument-less provider and losing the live one's final
447    // interval. `get_or_init` makes the winner's provider the only one that is
448    // ever retained or registered; the loser's is dropped unused.
449    let installed = METER_PROVIDER.get_or_init(|| provider);
450    global::set_meter_provider(installed.clone());
451    tracing::info!("OpenTelemetry metrics initialized successfully");
452
453    Ok(())
454}
455
456/// No-op when the `otel` feature is disabled.
457///
458/// # Errors
459/// Always returns an error indicating the feature is disabled.
460#[cfg(not(feature = "otel"))]
461pub fn init_metrics_provider(_otel_cfg: &super::config::OpenTelemetryConfig) -> anyhow::Result<()> {
462    Err(anyhow::anyhow!("otel feature is disabled"))
463}
464
465// ===== connectivity probe =====================================================
466
467/// Build a tiny, separate OTLP pipeline and export a single span to verify connectivity.
468/// This does *not* depend on `tracing_subscriber`; it uses SDK directly.
469///
470/// # Errors
471/// Returns an error if the OTLP exporter cannot be built or the probe fails.
472#[cfg(feature = "otel")]
473pub fn otel_connectivity_probe(otel_cfg: &OpenTelemetryConfig) -> anyhow::Result<()> {
474    use opentelemetry::trace::{Span, Tracer as _};
475
476    let resolved_exporter = otel_cfg.tracing_exporter();
477    let (kind, endpoint, timeout) = extract_exporter_config(resolved_exporter);
478
479    // Resource (reuse shared builder)
480    let resource = build_resource(&otel_cfg.resource);
481
482    // Exporter (type-state branches again)
483    let exporter = if matches!(kind, ExporterKind::OtlpHttp) {
484        let mut b = opentelemetry_otlp::SpanExporter::builder()
485            .with_http()
486            .with_protocol(Protocol::HttpBinary)
487            .with_endpoint(endpoint);
488        if let Some(t) = timeout {
489            b = b.with_timeout(t);
490        }
491        if let Some(h) = build_headers_from_cfg_and_env(resolved_exporter) {
492            b = b.with_headers(h);
493        }
494        b.build()
495            .map_err(|e| anyhow::anyhow!("otlp http exporter build failed: {e}"))?
496    } else {
497        let mut b = opentelemetry_otlp::SpanExporter::builder()
498            .with_tonic()
499            .with_endpoint(endpoint);
500        if let Some(t) = timeout {
501            b = b.with_timeout(t);
502        }
503        if let Some(md) = build_metadata_from_cfg_and_env(resolved_exporter) {
504            b = b.with_metadata(md);
505        }
506        b.build()
507            .map_err(|e| anyhow::anyhow!("otlp grpc exporter build failed: {e}"))?
508    };
509
510    // Provider (simple processor is fine for a probe)
511    let provider = SdkTracerProvider::builder()
512        .with_simple_exporter(exporter)
513        .with_resource(resource)
514        .build();
515
516    // Emit a single span
517    let tracer = provider.tracer("connectivity_probe");
518    let mut span = tracer.start("otel_connectivity_probe");
519    span.end();
520
521    // Ensure delivery
522    if let Err(e) = provider.force_flush() {
523        tracing::warn!(error = %e, "force_flush failed during OTLP connectivity probe");
524    }
525
526    provider
527        .shutdown()
528        .map_err(|e| anyhow::anyhow!("shutdown failed: {e}"))?;
529
530    tracing::info!(kind = ?kind, "OTLP connectivity probe exported a test span");
531    Ok(())
532}
533
534/// OTLP connectivity probe (no-op when otel feature is disabled).
535///
536/// # Errors
537/// This function always succeeds when the otel feature is disabled.
538#[cfg(not(feature = "otel"))]
539pub fn otel_connectivity_probe(_cfg: &super::config::OpenTelemetryConfig) -> anyhow::Result<()> {
540    tracing::info!("OTLP connectivity probe skipped (otel feature disabled)");
541    Ok(())
542}
543
544// ===== tests ==================================================================
545
546#[cfg(test)]
547#[cfg_attr(coverage_nightly, coverage(off))]
548mod tests {
549    use super::*;
550    #[cfg(feature = "otel")]
551    use crate::telemetry::config::{
552        Exporter, ExporterKind, OpenTelemetryConfig, OpenTelemetryResource, Sampler, TracingConfig,
553    };
554    #[cfg(feature = "otel")]
555    use std::collections::{BTreeMap, HashMap};
556
557    /// Helper to build an `OpenTelemetryConfig` with the given tracing config.
558    #[cfg(feature = "otel")]
559    fn otel_with_tracing(tracing: TracingConfig) -> OpenTelemetryConfig {
560        OpenTelemetryConfig {
561            tracing,
562            ..Default::default()
563        }
564    }
565
566    #[test]
567    #[cfg(feature = "otel")]
568    fn test_init_tracing_disabled() {
569        let otel = otel_with_tracing(TracingConfig {
570            enabled: false,
571            ..Default::default()
572        });
573
574        let result = init_tracing(&otel);
575        assert!(result.is_err());
576    }
577
578    #[tokio::test]
579    #[cfg(feature = "otel")]
580    async fn test_init_tracing_enabled() {
581        let otel = otel_with_tracing(TracingConfig {
582            enabled: true,
583            ..Default::default()
584        });
585
586        let result = init_tracing(&otel);
587        assert!(result.is_ok());
588    }
589
590    #[test]
591    #[cfg(feature = "otel")]
592    fn test_init_tracing_with_resource_attributes() {
593        let rt = tokio::runtime::Runtime::new().unwrap();
594        let _guard = rt.enter();
595
596        let mut attrs = BTreeMap::new();
597        attrs.insert("service.version".to_owned(), "1.0.0".to_owned());
598        attrs.insert("deployment.environment".to_owned(), "test".to_owned());
599
600        let otel = OpenTelemetryConfig {
601            resource: OpenTelemetryResource {
602                service_name: "test-service".to_owned(),
603                attributes: attrs,
604            },
605            tracing: TracingConfig {
606                enabled: true,
607                ..Default::default()
608            },
609            ..Default::default()
610        };
611
612        let result = init_tracing(&otel);
613        assert!(result.is_ok());
614    }
615
616    #[test]
617    #[cfg(feature = "otel")]
618    fn test_init_tracing_with_always_on_sampler() {
619        let rt = tokio::runtime::Runtime::new().unwrap();
620        let _guard = rt.enter();
621
622        let otel = otel_with_tracing(TracingConfig {
623            enabled: true,
624            sampler: Some(Sampler::AlwaysOn {}),
625            ..Default::default()
626        });
627
628        let result = init_tracing(&otel);
629        assert!(result.is_ok());
630    }
631
632    #[test]
633    #[cfg(feature = "otel")]
634    fn test_init_tracing_with_always_off_sampler() {
635        let rt = tokio::runtime::Runtime::new().unwrap();
636        let _guard = rt.enter();
637
638        let otel = otel_with_tracing(TracingConfig {
639            enabled: true,
640            sampler: Some(Sampler::AlwaysOff {}),
641            ..Default::default()
642        });
643
644        let result = init_tracing(&otel);
645        assert!(result.is_ok());
646    }
647
648    #[test]
649    #[cfg(feature = "otel")]
650    fn test_init_tracing_with_ratio_sampler() {
651        let rt = tokio::runtime::Runtime::new().unwrap();
652        let _guard = rt.enter();
653
654        let otel = otel_with_tracing(TracingConfig {
655            enabled: true,
656            sampler: Some(Sampler::ParentBasedRatio { ratio: Some(0.5) }),
657            ..Default::default()
658        });
659
660        let result = init_tracing(&otel);
661        assert!(result.is_ok());
662    }
663
664    #[test]
665    #[cfg(feature = "otel")]
666    fn test_init_tracing_with_http_exporter() {
667        let _rt = tokio::runtime::Runtime::new().unwrap();
668
669        let otel = otel_with_tracing(TracingConfig {
670            enabled: true,
671            exporter: Some(Exporter {
672                kind: ExporterKind::OtlpHttp,
673                endpoint: Some("http://localhost:4318".to_owned()),
674                headers: None,
675                timeout_ms: Some(5000),
676            }),
677            ..Default::default()
678        });
679
680        let result = init_tracing(&otel);
681        assert!(result.is_ok());
682    }
683
684    #[test]
685    #[cfg(feature = "otel")]
686    fn test_init_tracing_with_grpc_exporter() {
687        let rt = tokio::runtime::Runtime::new().unwrap();
688        let _guard = rt.enter();
689
690        let otel = otel_with_tracing(TracingConfig {
691            enabled: true,
692            exporter: Some(Exporter {
693                kind: ExporterKind::OtlpGrpc,
694                endpoint: Some("http://localhost:4317".to_owned()),
695                headers: None,
696                timeout_ms: Some(5000),
697            }),
698            ..Default::default()
699        });
700
701        let result = init_tracing(&otel);
702        assert!(result.is_ok());
703    }
704
705    #[test]
706    #[cfg(feature = "otel")]
707    fn test_build_headers_from_cfg_empty() {
708        temp_env::with_var_unset("OTEL_EXPORTER_OTLP_HEADERS", || {
709            let cfg = TracingConfig {
710                enabled: true,
711                ..Default::default()
712            };
713
714            let result = build_headers_from_cfg_and_env(cfg.exporter.as_ref());
715            assert!(
716                result.is_none(),
717                "expected None when no headers configured and no env"
718            );
719        });
720    }
721
722    #[test]
723    #[cfg(feature = "otel")]
724    fn test_build_headers_from_cfg_with_headers() {
725        let mut headers = HashMap::new();
726        headers.insert("authorization".to_owned(), "Bearer token".to_owned());
727
728        let cfg = TracingConfig {
729            enabled: true,
730            exporter: Some(Exporter {
731                kind: ExporterKind::OtlpHttp,
732                endpoint: Some("http://localhost:4318".to_owned()),
733                headers: Some(headers.clone()),
734                timeout_ms: None,
735            }),
736            ..Default::default()
737        };
738
739        let result = build_headers_from_cfg_and_env(cfg.exporter.as_ref());
740        assert!(result.is_some());
741        let result_headers = result.unwrap();
742        assert_eq!(
743            result_headers.get("authorization"),
744            Some(&"Bearer token".to_owned())
745        );
746    }
747
748    #[test]
749    #[cfg(feature = "otel")]
750    fn test_build_metadata_from_cfg_empty() {
751        temp_env::with_var_unset("OTEL_EXPORTER_OTLP_HEADERS", || {
752            let cfg = TracingConfig {
753                enabled: true,
754                ..Default::default()
755            };
756
757            let result = build_metadata_from_cfg_and_env(cfg.exporter.as_ref());
758            assert!(
759                result.is_none(),
760                "expected None when no headers configured and no env"
761            );
762        });
763    }
764
765    #[test]
766    #[cfg(feature = "otel")]
767    fn test_build_metadata_from_cfg_with_headers() {
768        let mut headers = HashMap::new();
769        headers.insert("authorization".to_owned(), "Bearer token".to_owned());
770
771        let cfg = TracingConfig {
772            enabled: true,
773            exporter: Some(Exporter {
774                kind: ExporterKind::OtlpGrpc,
775                endpoint: Some("http://localhost:4317".to_owned()),
776                headers: Some(headers.clone()),
777                timeout_ms: None,
778            }),
779            ..Default::default()
780        };
781
782        let result = build_metadata_from_cfg_and_env(cfg.exporter.as_ref());
783        assert!(result.is_some());
784        let metadata = result.unwrap();
785        assert!(!metadata.is_empty());
786    }
787
788    #[test]
789    #[cfg(feature = "otel")]
790    fn test_build_metadata_multiple_headers() {
791        let mut headers = HashMap::new();
792        headers.insert("authorization".to_owned(), "Bearer token".to_owned());
793        headers.insert("x-custom-header".to_owned(), "custom-value".to_owned());
794
795        let cfg = TracingConfig {
796            enabled: true,
797            exporter: Some(Exporter {
798                kind: ExporterKind::OtlpGrpc,
799                endpoint: Some("http://localhost:4317".to_owned()),
800                headers: Some(headers.clone()),
801                timeout_ms: None,
802            }),
803            ..Default::default()
804        };
805
806        let result = build_metadata_from_cfg_and_env(cfg.exporter.as_ref());
807        assert!(result.is_some());
808        let metadata = result.unwrap();
809        assert_eq!(metadata.len(), 2);
810    }
811
812    #[test]
813    #[cfg(feature = "otel")]
814    fn test_build_metadata_invalid_header_name_skipped() {
815        let mut headers = HashMap::new();
816        headers.insert("valid-header".to_owned(), "value1".to_owned());
817        headers.insert("invalid header with spaces".to_owned(), "value2".to_owned());
818
819        let cfg = TracingConfig {
820            enabled: true,
821            exporter: Some(Exporter {
822                kind: ExporterKind::OtlpGrpc,
823                endpoint: Some("http://localhost:4317".to_owned()),
824                headers: Some(headers.clone()),
825                timeout_ms: None,
826            }),
827            ..Default::default()
828        };
829
830        let result = build_metadata_from_cfg_and_env(cfg.exporter.as_ref());
831        assert!(result.is_some());
832        let metadata = result.unwrap();
833        // Should only have the valid header
834        assert_eq!(metadata.len(), 1);
835    }
836
837    #[test]
838    fn test_shutdown_tracing_does_not_panic() {
839        // Should not panic regardless of feature state
840        shutdown_tracing();
841    }
842
843    #[test]
844    #[cfg(feature = "otel")]
845    fn test_init_metrics_provider_disabled() {
846        let otel = OpenTelemetryConfig {
847            metrics: crate::telemetry::config::MetricsConfig {
848                enabled: false,
849                ..Default::default()
850            },
851            ..Default::default()
852        };
853        // Disabled path returns Ok (noop — global provider stays NoopMeterProvider)
854        let result = init_metrics_provider(&otel);
855        assert!(result.is_ok());
856    }
857}