Skip to main content

reth_tracing_otlp/
otlp.rs

1//! OTLP exporter layers and configuration.
2
3use crate::{file::FileSpanExporter, trace_file_path, OtlpProtocol};
4use eyre::ensure;
5use opentelemetry::{global, trace::TracerProvider, KeyValue, Value};
6use opentelemetry_otlp::{SpanExporter, WithExportConfig};
7use opentelemetry_sdk::{
8    propagation::TraceContextPropagator,
9    trace::{Sampler, SdkTracer},
10    Resource,
11};
12use opentelemetry_semantic_conventions::{attribute::SERVICE_VERSION, SCHEMA_URL};
13use tracing::Subscriber;
14use tracing_opentelemetry::OpenTelemetryLayer;
15use tracing_subscriber::registry::LookupSpan;
16use url::Url;
17
18use base64::{prelude::BASE64_STANDARD, Engine};
19
20pub use opentelemetry_sdk::trace::SdkTracerProvider;
21
22const OTEL_EXPORTER_OTLP_TRACES_HEADERS: &str = "OTEL_EXPORTER_OTLP_TRACES_HEADERS";
23const OTEL_EXPORTER_OTLP_LOGS_HEADERS: &str = "OTEL_EXPORTER_OTLP_LOGS_HEADERS";
24
25/// Creates a tracing [`OpenTelemetryLayer`] that exports spans to an OTLP endpoint.
26///
27/// This layer can be added to a [`tracing_subscriber::Registry`] to enable `OpenTelemetry` tracing
28/// with OTLP export to an url.
29pub fn span_layer<S>(otlp_config: OtlpConfig) -> eyre::Result<OpenTelemetryLayer<S, SdkTracer>>
30where
31    for<'span> S: Subscriber + LookupSpan<'span>,
32{
33    let (layer, provider) = span_layer_with_provider(otlp_config)?;
34    global::set_tracer_provider(provider);
35    Ok(layer)
36}
37
38/// Creates an OTLP span layer and returns its provider for explicit flush and shutdown.
39///
40/// Unlike [`span_layer`], this does not install the provider globally. Call
41/// [`SdkTracerProvider::force_flush`] to export completed spans and
42/// [`SdkTracerProvider::shutdown`] after dropping active spans and the subscriber.
43/// File export uses the SDK batch worker and does not require a Tokio runtime.
44/// Network transports retain their usual runtime requirements.
45pub fn span_layer_with_provider<S>(
46    otlp_config: OtlpConfig,
47) -> eyre::Result<(OpenTelemetryLayer<S, SdkTracer>, SdkTracerProvider)>
48where
49    for<'span> S: Subscriber + LookupSpan<'span>,
50{
51    global::set_text_map_propagator(TraceContextPropagator::new());
52
53    let resource =
54        build_resource(otlp_config.service_name.clone(), otlp_config.service_version.as_deref());
55    let builder = SdkTracerProvider::builder()
56        .with_resource(resource)
57        .with_sampler(build_sampler(otlp_config.sample_ratio)?);
58
59    let tracer_provider = if otlp_config.endpoint.scheme() == "file" {
60        builder.with_batch_exporter(FileSpanExporter::new(&otlp_config.endpoint)?).build()
61    } else {
62        let span_builder = SpanExporter::builder();
63        let span_exporter = match otlp_config.protocol {
64            OtlpProtocol::Http => {
65                span_builder.with_http().with_endpoint(otlp_config.endpoint.as_str()).build()?
66            }
67            OtlpProtocol::Grpc => {
68                span_builder.with_tonic().with_endpoint(otlp_config.endpoint.as_str()).build()?
69            }
70        };
71        builder.with_batch_exporter(span_exporter).build()
72    };
73
74    let tracer = tracer_provider.tracer(otlp_config.service_name);
75    let layer = tracing_opentelemetry::layer()
76        .with_tracer(tracer)
77        .with_location(false)
78        .with_tracked_inactivity(false)
79        .with_target(false)
80        .with_threads(false);
81    Ok((layer, tracer_provider))
82}
83
84/// Creates a tracing layer that exports logs to an OTLP endpoint.
85///
86/// This layer bridges logs emitted via the `tracing` crate to `OpenTelemetry` logs.
87#[cfg(feature = "otlp-logs")]
88pub fn log_layer(
89    otlp_config: OtlpLogsConfig,
90) -> eyre::Result<
91    opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge<
92        opentelemetry_sdk::logs::SdkLoggerProvider,
93        opentelemetry_sdk::logs::SdkLogger,
94    >,
95> {
96    use opentelemetry_appender_tracing::layer::TracingSpanAttributes;
97    use opentelemetry_otlp::LogExporter;
98    use opentelemetry_sdk::logs::SdkLoggerProvider;
99
100    let resource =
101        build_resource(otlp_config.service_name.clone(), otlp_config.service_version.as_deref());
102
103    let log_builder = LogExporter::builder();
104
105    let log_exporter = match otlp_config.protocol {
106        OtlpProtocol::Http => {
107            log_builder.with_http().with_endpoint(otlp_config.endpoint.as_str()).build()?
108        }
109        OtlpProtocol::Grpc => {
110            log_builder.with_tonic().with_endpoint(otlp_config.endpoint.as_str()).build()?
111        }
112    };
113
114    let logger_provider = SdkLoggerProvider::builder()
115        .with_resource(resource)
116        .with_batch_exporter(log_exporter)
117        .build();
118
119    Ok(opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::builder(&logger_provider)
120        .with_tracing_span_attributes(TracingSpanAttributes::all())
121        .build())
122}
123
124/// Configuration for OTLP trace export.
125#[derive(Debug, Clone)]
126pub struct OtlpConfig {
127    /// Service name for trace identification
128    service_name: String,
129    /// Optional service version override. Falls back to `CARGO_PKG_VERSION` if `None`.
130    service_version: Option<String>,
131    /// Otlp endpoint URL
132    endpoint: Url,
133    /// Transport protocol, HTTP or gRPC
134    protocol: OtlpProtocol,
135    /// Optional sampling ratio, from 0.0 to 1.0
136    sample_ratio: Option<f64>,
137}
138
139impl OtlpConfig {
140    /// Creates a new OTLP configuration.
141    pub fn new(
142        service_name: impl Into<String>,
143        endpoint: Url,
144        protocol: OtlpProtocol,
145        sample_ratio: Option<f64>,
146    ) -> eyre::Result<Self> {
147        if let Some(ratio) = sample_ratio {
148            ensure!(
149                (0.0..=1.0).contains(&ratio),
150                "Sample ratio must be between 0.0 and 1.0, got: {}",
151                ratio
152            );
153        }
154
155        if endpoint.scheme() == "file" {
156            trace_file_path(&endpoint)?;
157        } else {
158            set_otlp_auth_header_from_endpoint(&endpoint, OTEL_EXPORTER_OTLP_TRACES_HEADERS)?;
159        }
160        Ok(Self {
161            service_name: service_name.into(),
162            service_version: None,
163            endpoint: endpoint_without_credentials(endpoint),
164            protocol,
165            sample_ratio,
166        })
167    }
168
169    /// Sets the service version for OTLP resource identification.
170    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
171        self.service_version = Some(version.into());
172        self
173    }
174
175    /// Returns the service name.
176    pub fn service_name(&self) -> &str {
177        &self.service_name
178    }
179
180    /// Returns the OTLP endpoint URL.
181    pub const fn endpoint(&self) -> &Url {
182        &self.endpoint
183    }
184
185    /// Returns the transport protocol.
186    pub const fn protocol(&self) -> OtlpProtocol {
187        self.protocol
188    }
189
190    /// Returns the sampling ratio.
191    pub const fn sample_ratio(&self) -> Option<f64> {
192        self.sample_ratio
193    }
194}
195
196/// Configuration for OTLP logs export.
197#[derive(Debug, Clone)]
198pub struct OtlpLogsConfig {
199    /// Service name for log identification
200    service_name: String,
201    /// Optional service version override. Falls back to `CARGO_PKG_VERSION` if `None`.
202    service_version: Option<String>,
203    /// Otlp endpoint URL
204    endpoint: Url,
205    /// Transport protocol, HTTP or gRPC
206    protocol: OtlpProtocol,
207}
208
209impl OtlpLogsConfig {
210    /// Creates a new OTLP logs configuration.
211    pub fn new(
212        service_name: impl Into<String>,
213        endpoint: Url,
214        protocol: OtlpProtocol,
215    ) -> eyre::Result<Self> {
216        ensure!(endpoint.scheme() != "file", "OTLP logs do not support file endpoints");
217        set_otlp_auth_header_from_endpoint(&endpoint, OTEL_EXPORTER_OTLP_LOGS_HEADERS)?;
218        Ok(Self {
219            service_name: service_name.into(),
220            service_version: None,
221            endpoint: endpoint_without_credentials(endpoint),
222            protocol,
223        })
224    }
225
226    /// Sets the service version for OTLP resource identification.
227    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
228        self.service_version = Some(version.into());
229        self
230    }
231
232    /// Returns the service name.
233    pub fn service_name(&self) -> &str {
234        &self.service_name
235    }
236
237    /// Returns the OTLP endpoint URL.
238    pub const fn endpoint(&self) -> &Url {
239        &self.endpoint
240    }
241
242    /// Returns the transport protocol.
243    pub const fn protocol(&self) -> OtlpProtocol {
244        self.protocol
245    }
246}
247
248// Builds OTLP resource with service information.
249fn build_resource(service_name: impl Into<Value>, service_version: Option<&str>) -> Resource {
250    let version = service_version.unwrap_or(env!("CARGO_PKG_VERSION"));
251    Resource::builder()
252        .with_service_name(service_name)
253        .with_schema_url([KeyValue::new(SERVICE_VERSION, version.to_string())], SCHEMA_URL)
254        .build()
255}
256
257/// Returns an OTLP endpoint URL with username and password removed.
258fn endpoint_without_credentials(mut endpoint: Url) -> Url {
259    if !endpoint.username().is_empty() {
260        endpoint.set_username("").ok();
261    }
262    if endpoint.password().is_some() {
263        endpoint.set_password(None).ok();
264    }
265    endpoint
266}
267
268/// Builds an OTLP `Authorization` header from endpoint `username:password` credentials.
269fn otlp_auth_header_from_endpoint(endpoint: &Url) -> eyre::Result<Option<String>> {
270    let username = endpoint.username();
271    if username.is_empty() && endpoint.password().is_none() {
272        return Ok(None);
273    }
274
275    let Some(password) = endpoint.password() else {
276        eyre::bail!("OTLP endpoint credentials must include both username and password");
277    };
278    if username.is_empty() {
279        eyre::bail!("OTLP endpoint credentials must include both username and password");
280    }
281
282    let credentials = format!("{username}:{password}");
283    let encoded = BASE64_STANDARD.encode(credentials.as_bytes());
284    Ok(Some(format!("Authorization=Basic {encoded}")))
285}
286
287/// Sets the OTLP signal-specific header env var from endpoint credentials if it is unset.
288fn set_otlp_auth_header_from_endpoint(endpoint: &Url, header_env: &str) -> eyre::Result<()> {
289    if std::env::var_os(header_env).is_some() {
290        return Ok(());
291    }
292
293    let Some(auth_header) = otlp_auth_header_from_endpoint(endpoint)? else {
294        return Ok(());
295    };
296
297    // SAFETY: OTLP exporters are initialized during process startup before exporter worker threads
298    // are spawned.
299    unsafe {
300        std::env::set_var(header_env, auth_header);
301    }
302
303    Ok(())
304}
305
306/// Builds the appropriate sampler based on the sample ratio.
307fn build_sampler(sample_ratio: Option<f64>) -> eyre::Result<Sampler> {
308    match sample_ratio {
309        // Default behavior: sample all traces
310        None | Some(1.0) => Ok(Sampler::ParentBased(Box::new(Sampler::AlwaysOn))),
311        // Don't sample anything
312        Some(0.0) => Ok(Sampler::ParentBased(Box::new(Sampler::AlwaysOff))),
313        // Sample based on trace ID ratio
314        Some(ratio) => Ok(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(ratio)))),
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn strips_credentials_from_trace_endpoint_and_preserves_auth_header() {
324        let config = OtlpConfig::new(
325            "reth",
326            "https://user:pass@example.com/v1/traces".parse().unwrap(),
327            OtlpProtocol::Http,
328            None,
329        )
330        .unwrap();
331
332        assert_eq!(config.endpoint.as_str(), "https://example.com/v1/traces");
333        assert_eq!(
334            otlp_auth_header_from_endpoint(
335                &"https://user:pass@example.com/v1/traces".parse().unwrap()
336            )
337            .unwrap()
338            .as_deref(),
339            Some("Authorization=Basic dXNlcjpwYXNz")
340        );
341    }
342
343    #[test]
344    fn strips_credentials_from_logs_endpoint_and_preserves_auth_header() {
345        let config = OtlpLogsConfig::new(
346            "reth",
347            "https://logs:secret@example.com/v1/logs".parse().unwrap(),
348            OtlpProtocol::Http,
349        )
350        .unwrap();
351
352        assert_eq!(config.endpoint.as_str(), "https://example.com/v1/logs");
353        assert_eq!(
354            otlp_auth_header_from_endpoint(
355                &"https://logs:secret@example.com/v1/logs".parse().unwrap()
356            )
357            .unwrap()
358            .as_deref(),
359            Some("Authorization=Basic bG9nczpzZWNyZXQ=")
360        );
361    }
362
363    #[test]
364    fn leaves_endpoint_without_credentials_unchanged() {
365        let config = OtlpConfig::new(
366            "reth",
367            "https://example.com/v1/traces".parse().unwrap(),
368            OtlpProtocol::Http,
369            None,
370        )
371        .unwrap();
372
373        assert_eq!(config.endpoint.as_str(), "https://example.com/v1/traces");
374        assert_eq!(otlp_auth_header_from_endpoint(config.endpoint()).unwrap(), None);
375    }
376
377    #[test]
378    fn rejects_partial_credentials() {
379        assert!(OtlpConfig::new(
380            "reth",
381            "https://user@example.com/v1/traces".parse().unwrap(),
382            OtlpProtocol::Http,
383            None,
384        )
385        .is_err());
386    }
387}