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