Skip to main content

modkit/telemetry/
init.rs

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