Skip to main content

reth_tracing_otlp/
lib.rs

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