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