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}