Skip to main content

hyperi_rustlib/otel_tracing/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/otel_tracing/mod.rs
3// Purpose:   OpenTelemetry trace span exporter (OTLP) + tracing-subscriber bridge
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! OpenTelemetry distributed tracing -- span export via OTLP.
10//!
11//! Bridges the `tracing` ecosystem (`tracing::span!`, `#[instrument]`,
12//! `tracing::info_span!`) to OpenTelemetry spans that get exported via
13//! OTLP to a collector or backend (Tempo, Jaeger, Honeycomb, etc.).
14//!
15//! Closes the loop on the framework's W3C traceparent propagation:
16//! [`crate::transport::propagation`] reads the current OTel context
17//! (set externally) and propagates it across transport boundaries.
18//! Without this module wired up, internal `tracing::span!`s never become
19//! OTel spans, leaving distributed traces with broken segments.
20//!
21//! # Quick start
22//!
23//! ```rust,no_run
24//! use hyperi_rustlib::otel_tracing::{OtelTracingConfig, build_tracer_layer};
25//! use tracing_subscriber::layer::SubscriberExt;
26//! use tracing_subscriber::util::SubscriberInitExt;
27//!
28//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
29//! let config = OtelTracingConfig {
30//!     service_name: "dfe-loader".into(),
31//!     endpoint: "http://otel-collector:4317".into(),
32//!     ..Default::default()
33//! };
34//! let (otel_layer, _provider) = build_tracer_layer(&config)?;
35//!
36//! tracing_subscriber::registry()
37//!     .with(tracing_subscriber::fmt::layer())
38//!     .with(otel_layer)
39//!     .init();
40//!
41//! tracing::info_span!("startup").in_scope(|| {
42//!     tracing::info!("application booted");
43//! });
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! # Why a separate module from `otel-metrics`
49//!
50//! Metrics and traces have independent lifecycles, samplers, and exporters.
51//! Mixing them under one feature gate forced consumers who only want one
52//! to pull in the other. They share `OtelProtocol` and the OTLP endpoint
53//! discipline but otherwise operate independently.
54
55use opentelemetry::trace::TracerProvider as _;
56use opentelemetry_otlp::WithExportConfig;
57use opentelemetry_sdk::Resource;
58use opentelemetry_sdk::trace::SdkTracerProvider;
59use serde::{Deserialize, Serialize};
60use tracing_opentelemetry::OpenTelemetryLayer;
61
62/// OTLP transport protocol (mirrors [`crate::metrics::OtelProtocol`]).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
64pub enum OtelTracingProtocol {
65    /// gRPC with tonic (default; OTLP-native, lowest overhead).
66    #[default]
67    Grpc,
68    /// HTTP with protobuf body.
69    Http,
70}
71
72/// OpenTelemetry tracing configuration.
73///
74/// Resolves env-var overrides at build time:
75/// - `OTEL_EXPORTER_OTLP_ENDPOINT` overrides `endpoint`
76/// - `OTEL_EXPORTER_OTLP_PROTOCOL` (`grpc` | `http/protobuf` | `http`) overrides `protocol`
77/// - `OTEL_SERVICE_NAME` overrides `service_name`
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct OtelTracingConfig {
80    /// OTLP endpoint (default `http://localhost:4317` for gRPC).
81    pub endpoint: String,
82    /// Wire protocol.
83    pub protocol: OtelTracingProtocol,
84    /// `service.name` resource attribute.
85    pub service_name: String,
86    /// Batch exporter scheduled-delay (milliseconds).
87    pub batch_scheduled_delay_ms: u64,
88    /// Batch exporter max queue size.
89    pub batch_max_queue_size: usize,
90}
91
92impl Default for OtelTracingConfig {
93    fn default() -> Self {
94        Self {
95            endpoint: "http://localhost:4317".into(),
96            protocol: OtelTracingProtocol::Grpc,
97            service_name: env!("CARGO_PKG_NAME").into(),
98            batch_scheduled_delay_ms: 5_000,
99            batch_max_queue_size: 2_048,
100        }
101    }
102}
103
104/// Errors when building the OTel tracer.
105#[derive(Debug, thiserror::Error)]
106pub enum OtelTracingError {
107    /// OTLP exporter construction failed.
108    #[error("OTLP {protocol:?} span exporter: {source}")]
109    ExporterBuild {
110        /// The protocol attempted.
111        protocol: OtelTracingProtocol,
112        /// Underlying error.
113        source: opentelemetry_otlp::ExporterBuildError,
114    },
115}
116
117fn resolve(config: &OtelTracingConfig) -> OtelTracingConfig {
118    let mut resolved = config.clone();
119    if let Ok(v) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
120        resolved.endpoint = v;
121    }
122    if let Ok(v) = std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL") {
123        resolved.protocol = match v.as_str() {
124            "http/protobuf" | "http" => OtelTracingProtocol::Http,
125            _ => OtelTracingProtocol::Grpc,
126        };
127    }
128    if let Ok(v) = std::env::var("OTEL_SERVICE_NAME") {
129        resolved.service_name = v;
130    }
131    resolved
132}
133
134fn build_span_exporter(
135    protocol: OtelTracingProtocol,
136    endpoint: &str,
137) -> Result<opentelemetry_otlp::SpanExporter, OtelTracingError> {
138    let result = match protocol {
139        OtelTracingProtocol::Grpc => opentelemetry_otlp::SpanExporter::builder()
140            .with_tonic()
141            .with_endpoint(endpoint)
142            .build(),
143        OtelTracingProtocol::Http => opentelemetry_otlp::SpanExporter::builder()
144            .with_http()
145            .with_endpoint(endpoint)
146            .build(),
147    };
148    result.map_err(|source| OtelTracingError::ExporterBuild { protocol, source })
149}
150
151/// Build an OTel tracer + tracing-subscriber layer ready for composition.
152///
153/// Sets the resulting [`SdkTracerProvider`] as the **global** tracer
154/// provider (so [`crate::transport::propagation`] picks it up). The
155/// returned layer should be added to a `tracing_subscriber::Registry`.
156///
157/// The provider is also returned so callers can `.shutdown()` it on
158/// graceful exit (otherwise the batch exporter loses queued spans).
159///
160/// # Errors
161///
162/// Returns [`OtelTracingError::ExporterBuild`] if the OTLP exporter
163/// cannot be initialised (typically endpoint format / TLS setup issues).
164pub fn build_tracer_layer<S>(
165    config: &OtelTracingConfig,
166) -> Result<
167    (
168        OpenTelemetryLayer<S, opentelemetry_sdk::trace::Tracer>,
169        SdkTracerProvider,
170    ),
171    OtelTracingError,
172>
173where
174    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
175{
176    let resolved = resolve(config);
177
178    let exporter = build_span_exporter(resolved.protocol, &resolved.endpoint)?;
179
180    let resource = Resource::builder()
181        .with_service_name(resolved.service_name.clone())
182        .build();
183
184    let provider = SdkTracerProvider::builder()
185        .with_batch_exporter(exporter)
186        .with_resource(resource)
187        .build();
188
189    let tracer = provider.tracer("hyperi-rustlib");
190
191    // Install as global so propagation.rs picks up the active context.
192    opentelemetry::global::set_tracer_provider(provider.clone());
193
194    let layer = tracing_opentelemetry::layer().with_tracer(tracer);
195
196    tracing::info!(
197        endpoint = %resolved.endpoint,
198        protocol = ?resolved.protocol,
199        service_name = %resolved.service_name,
200        scheduled_delay_ms = resolved.batch_scheduled_delay_ms,
201        "OTel tracing layer built"
202    );
203
204    Ok((layer, provider))
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn config_default_round_trip() {
213        let cfg = OtelTracingConfig::default();
214        assert_eq!(cfg.protocol, OtelTracingProtocol::Grpc);
215        assert!(!cfg.endpoint.is_empty());
216        assert!(!cfg.service_name.is_empty());
217    }
218
219    #[test]
220    fn resolve_picks_up_env_overrides() {
221        // SAFETY: temp_env handles cleanup; env mutations are scoped.
222        temp_env::with_vars(
223            [
224                (
225                    "OTEL_EXPORTER_OTLP_ENDPOINT",
226                    Some("http://my-collector:4317"),
227                ),
228                ("OTEL_EXPORTER_OTLP_PROTOCOL", Some("http/protobuf")),
229                ("OTEL_SERVICE_NAME", Some("test-service")),
230            ],
231            || {
232                let r = resolve(&OtelTracingConfig::default());
233                assert_eq!(r.endpoint, "http://my-collector:4317");
234                assert_eq!(r.protocol, OtelTracingProtocol::Http);
235                assert_eq!(r.service_name, "test-service");
236            },
237        );
238    }
239}