Skip to main content

reth_node_core/args/
trace.rs

1//! Opentelemetry tracing and logging configuration through CLI args.
2
3use clap::{builder::Resettable, Parser};
4use eyre::WrapErr;
5use reth_tracing::{tracing_subscriber::EnvFilter, Layers};
6use reth_tracing_otlp::OtlpProtocol;
7use std::sync::OnceLock;
8use url::Url;
9
10static TRACE_DEFAULTS: OnceLock<DefaultTraceValues> = OnceLock::new();
11
12/// Overridable defaults for OTLP trace configuration.
13///
14/// Downstream binaries that embed reth can call
15/// `DefaultTraceValues::default().with_service_name("myapp").try_init()` before CLI parsing to
16/// change the defaults that clap will use.
17#[derive(Debug, Clone)]
18pub struct DefaultTraceValues {
19    otlp: Option<Url>,
20    logs_otlp: Option<Url>,
21    otlp_default_endpoint: String,
22    logs_otlp_default_endpoint: String,
23    protocol: OtlpProtocol,
24    service_name: String,
25    service_version: Option<String>,
26    otlp_filter: String,
27    logs_otlp_filter: String,
28    sample_ratio: Option<f64>,
29}
30
31impl Default for DefaultTraceValues {
32    fn default() -> Self {
33        Self {
34            otlp: None,
35            logs_otlp: None,
36            otlp_default_endpoint: "http://localhost:4318/v1/traces".to_string(),
37            logs_otlp_default_endpoint: "http://localhost:4318/v1/logs".to_string(),
38            protocol: OtlpProtocol::Http,
39            service_name: "reth".to_string(),
40            service_version: None,
41            otlp_filter: "debug".to_string(),
42            logs_otlp_filter: "info".to_string(),
43            sample_ratio: None,
44        }
45    }
46}
47
48impl DefaultTraceValues {
49    /// Initialize the global trace defaults with this configuration.
50    pub fn try_init(self) -> Result<(), Self> {
51        TRACE_DEFAULTS.set(self)
52    }
53
54    /// Get a reference to the global trace defaults.
55    pub fn get_global() -> &'static Self {
56        TRACE_DEFAULTS.get_or_init(Self::default)
57    }
58
59    /// Set the default OTLP tracing endpoint.
60    pub fn with_otlp(mut self, otlp: Option<Url>) -> Self {
61        self.otlp = otlp;
62        self
63    }
64
65    /// Set the default OTLP logs endpoint.
66    pub fn with_logs_otlp(mut self, logs_otlp: Option<Url>) -> Self {
67        self.logs_otlp = logs_otlp;
68        self
69    }
70
71    /// Set the endpoint used when `--tracing-otlp` is provided without a value.
72    pub fn with_otlp_default_endpoint(mut self, endpoint: impl Into<String>) -> Self {
73        self.otlp_default_endpoint = endpoint.into();
74        self
75    }
76
77    /// Set the endpoint used when `--logs-otlp` is provided without a value.
78    pub fn with_logs_otlp_default_endpoint(mut self, endpoint: impl Into<String>) -> Self {
79        self.logs_otlp_default_endpoint = endpoint.into();
80        self
81    }
82
83    /// Set the default OTLP transport protocol.
84    pub const fn with_protocol(mut self, protocol: OtlpProtocol) -> Self {
85        self.protocol = protocol;
86        self
87    }
88
89    /// Set the default service name.
90    pub fn with_service_name(mut self, name: impl Into<String>) -> Self {
91        self.service_name = name.into();
92        self
93    }
94
95    /// Set the default service version.
96    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
97        self.service_version = Some(version.into());
98        self
99    }
100
101    /// Set the default OTLP tracing filter.
102    pub fn with_otlp_filter(mut self, filter: impl Into<String>) -> Self {
103        self.otlp_filter = filter.into();
104        self
105    }
106
107    /// Set the default OTLP logs filter.
108    pub fn with_logs_otlp_filter(mut self, filter: impl Into<String>) -> Self {
109        self.logs_otlp_filter = filter.into();
110        self
111    }
112
113    /// Set the default OTLP trace sampling ratio.
114    pub const fn with_sample_ratio(mut self, sample_ratio: Option<f64>) -> Self {
115        self.sample_ratio = sample_ratio;
116        self
117    }
118
119    const fn protocol_as_str(&self) -> &'static str {
120        match self.protocol {
121            OtlpProtocol::Http => "http",
122            OtlpProtocol::Grpc => "grpc",
123        }
124    }
125}
126
127/// CLI arguments for configuring `Opentelemetry` trace and logs export.
128#[derive(Debug, Clone, Parser)]
129pub struct TraceArgs {
130    /// Enable `Opentelemetry` tracing export to an OTLP endpoint or local file.
131    ///
132    /// If no value provided, defaults based on protocol:
133    /// - HTTP: `http://localhost:4318/v1/traces`
134    /// - gRPC: `http://localhost:4317`
135    ///
136    /// A `file:///absolute/path` endpoint appends length-delimited OTLP protobuf batches without
137    /// a collector. Use a separate file per process. The network protocol is ignored for files.
138    ///
139    /// Example: --tracing-otlp=http://collector:4318/v1/traces
140    #[arg(
141        long = "tracing-otlp",
142        // Per specification.
143        env = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
144        global = true,
145        value_name = "URL",
146        num_args = 0..=1,
147        default_value = Resettable::from(DefaultTraceValues::get_global().otlp.as_ref().map(|url| url.as_str().into())),
148        default_missing_value = DefaultTraceValues::get_global().otlp_default_endpoint.as_str(),
149        require_equals = true,
150        value_parser = parse_otlp_endpoint,
151        help_heading = "Tracing"
152    )]
153    pub otlp: Option<Url>,
154
155    /// Enable `Opentelemetry` logs export to an OTLP endpoint.
156    ///
157    /// If no value provided, defaults based on protocol:
158    /// - HTTP: `http://localhost:4318/v1/logs`
159    /// - gRPC: `http://localhost:4317`
160    ///
161    /// Example: --logs-otlp=http://collector:4318/v1/logs
162    #[arg(
163        long = "logs-otlp",
164        env = "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
165        global = true,
166        value_name = "URL",
167        num_args = 0..=1,
168        default_value = Resettable::from(DefaultTraceValues::get_global().logs_otlp.as_ref().map(|url| url.as_str().into())),
169        default_missing_value = DefaultTraceValues::get_global().logs_otlp_default_endpoint.as_str(),
170        require_equals = true,
171        value_parser = parse_otlp_endpoint,
172        help_heading = "Logging"
173    )]
174    pub logs_otlp: Option<Url>,
175
176    /// OTLP transport protocol to use for exporting traces and logs.
177    ///
178    /// - `http`: expects endpoint path to end with `/v1/traces` or `/v1/logs`
179    /// - `grpc`: expects endpoint without a path
180    ///
181    /// Defaults to HTTP if not specified.
182    #[arg(
183        long = "tracing-otlp-protocol",
184        env = "OTEL_EXPORTER_OTLP_PROTOCOL",
185        global = true,
186        value_name = "PROTOCOL",
187        default_value = DefaultTraceValues::get_global().protocol_as_str(),
188        help_heading = "Tracing"
189    )]
190    pub protocol: OtlpProtocol,
191
192    /// Set a filter directive for the OTLP tracer. This controls the verbosity
193    /// of spans and events sent to the OTLP endpoint. It follows the same
194    /// syntax as the `RUST_LOG` environment variable.
195    ///
196    /// Example: --tracing-otlp.filter=info,reth=debug,hyper_util=off
197    ///
198    /// Defaults to TRACE if not specified.
199    #[arg(
200        long = "tracing-otlp.filter",
201        global = true,
202        value_name = "FILTER",
203        default_value = DefaultTraceValues::get_global().otlp_filter.as_str(),
204        help_heading = "Tracing"
205    )]
206    pub otlp_filter: EnvFilter,
207
208    /// Set a filter directive for the OTLP logs exporter. This controls the verbosity
209    /// of logs sent to the OTLP endpoint. It follows the same syntax as the
210    /// `RUST_LOG` environment variable.
211    ///
212    /// Example: --logs-otlp.filter=info,reth=debug
213    ///
214    /// Defaults to INFO if not specified.
215    #[arg(
216        long = "logs-otlp.filter",
217        global = true,
218        value_name = "FILTER",
219        default_value = DefaultTraceValues::get_global().logs_otlp_filter.as_str(),
220        help_heading = "Logging"
221    )]
222    pub logs_otlp_filter: EnvFilter,
223
224    /// Service name to use for OTLP tracing export.
225    ///
226    /// This name will be used to identify the service in distributed tracing systems
227    /// like Jaeger or Zipkin. Useful for differentiating between multiple reth instances.
228    ///
229    /// Set via `OTEL_SERVICE_NAME` environment variable. Defaults to "reth" if not specified.
230    #[arg(
231        long = "tracing-otlp.service-name",
232        env = "OTEL_SERVICE_NAME",
233        global = true,
234        value_name = "NAME",
235        default_value = DefaultTraceValues::get_global().service_name.as_str(),
236        hide = true,
237        help_heading = "Tracing"
238    )]
239    pub service_name: String,
240
241    /// Service version to use for OTLP tracing export.
242    ///
243    /// Overrides the default version reported in the `service.version` OTLP resource attribute.
244    /// Falls back to the crate's `CARGO_PKG_VERSION` if not specified.
245    #[arg(
246        long = "tracing-otlp.service-version",
247        env = "OTEL_SERVICE_VERSION",
248        global = true,
249        value_name = "VERSION",
250        default_value = Resettable::from(DefaultTraceValues::get_global().service_version.as_ref().map(|version| version.as_str().into())),
251        hide = true,
252        help_heading = "Tracing"
253    )]
254    pub service_version: Option<String>,
255
256    /// Trace sampling ratio to control the percentage of traces to export.
257    ///
258    /// Valid range: 0.0 to 1.0
259    /// - 1.0, default: Sample all traces
260    /// - 0.01: Sample 1% of traces
261    /// - 0.0: Disable sampling
262    ///
263    /// Example: --tracing-otlp.sample-ratio=0.0.
264    #[arg(
265        long = "tracing-otlp.sample-ratio",
266        env = "OTEL_TRACES_SAMPLER_ARG",
267        global = true,
268        value_name = "RATIO",
269        default_value = Resettable::from(DefaultTraceValues::get_global().sample_ratio.map(|ratio| ratio.to_string().into())),
270        help_heading = "Tracing"
271    )]
272    pub sample_ratio: Option<f64>,
273}
274
275impl Default for TraceArgs {
276    fn default() -> Self {
277        let defaults = DefaultTraceValues::get_global();
278        Self {
279            otlp: defaults.otlp.clone(),
280            logs_otlp: defaults.logs_otlp.clone(),
281            protocol: defaults.protocol,
282            otlp_filter: defaults.otlp_filter.parse().expect("valid filter"),
283            logs_otlp_filter: defaults.logs_otlp_filter.parse().expect("valid filter"),
284            sample_ratio: defaults.sample_ratio,
285            service_name: defaults.service_name.clone(),
286            service_version: defaults.service_version.clone(),
287        }
288    }
289}
290
291impl TraceArgs {
292    /// Initialize OTLP tracing with the given layers and runner.
293    ///
294    /// This method handles OTLP tracing initialization based on the configured options,
295    /// including validation, protocol selection, and feature flag checking.
296    ///
297    /// Returns the initialization status to allow callers to log appropriate messages.
298    ///
299    /// Note: even though this function is async, it does not actually perform any async operations.
300    /// It's needed only to be able to initialize the gRPC transport of OTLP tracing that needs to
301    /// be called inside a tokio runtime context.
302    pub async fn init_otlp_tracing(
303        &mut self,
304        _layers: &mut Layers,
305    ) -> eyre::Result<OtlpInitStatus> {
306        if let Some(endpoint) = self.otlp.as_mut() {
307            self.protocol.validate_endpoint(endpoint)?;
308
309            #[cfg(feature = "otlp")]
310            {
311                {
312                    let mut config = reth_tracing_otlp::OtlpConfig::new(
313                        self.service_name.clone(),
314                        endpoint.clone(),
315                        self.protocol,
316                        self.sample_ratio,
317                    )?;
318                    if let Some(version) = &self.service_version {
319                        config = config.with_service_version(version.clone());
320                    }
321
322                    _layers.with_span_layer(config.clone(), self.otlp_filter.clone())?;
323
324                    Ok(OtlpInitStatus::Started(config.endpoint().clone()))
325                }
326            }
327            #[cfg(not(feature = "otlp"))]
328            {
329                Ok(OtlpInitStatus::NoFeature)
330            }
331        } else {
332            Ok(OtlpInitStatus::Disabled)
333        }
334    }
335
336    /// Initialize OTLP logs export with the given layers.
337    ///
338    /// This method handles OTLP logs initialization based on the configured options,
339    /// including validation and protocol selection.
340    ///
341    /// Returns the initialization status to allow callers to log appropriate messages.
342    pub async fn init_otlp_logs(&mut self, _layers: &mut Layers) -> eyre::Result<OtlpLogsStatus> {
343        if let Some(endpoint) = self.logs_otlp.as_mut() {
344            self.protocol.validate_logs_endpoint(endpoint)?;
345
346            #[cfg(feature = "otlp-logs")]
347            {
348                let mut config = reth_tracing_otlp::OtlpLogsConfig::new(
349                    self.service_name.clone(),
350                    endpoint.clone(),
351                    self.protocol,
352                )?;
353                if let Some(version) = &self.service_version {
354                    config = config.with_service_version(version.clone());
355                }
356
357                _layers.with_log_layer(config.clone(), self.logs_otlp_filter.clone())?;
358
359                Ok(OtlpLogsStatus::Started(config.endpoint().clone()))
360            }
361            #[cfg(not(feature = "otlp-logs"))]
362            {
363                Ok(OtlpLogsStatus::NoFeature)
364            }
365        } else {
366            Ok(OtlpLogsStatus::Disabled)
367        }
368    }
369}
370
371/// Status of OTLP tracing initialization.
372#[derive(Debug)]
373pub enum OtlpInitStatus {
374    /// OTLP tracing was successfully started with the given endpoint.
375    Started(Url),
376    /// OTLP tracing is disabled (no endpoint configured).
377    Disabled,
378    /// OTLP arguments provided but feature is not compiled.
379    NoFeature,
380}
381
382/// Status of OTLP logs initialization.
383#[derive(Debug)]
384pub enum OtlpLogsStatus {
385    /// OTLP logs export was successfully started with the given endpoint.
386    Started(Url),
387    /// OTLP logs export is disabled (no endpoint configured).
388    Disabled,
389    /// OTLP logs arguments provided but feature is not compiled.
390    NoFeature,
391}
392
393// Parses an OTLP endpoint url.
394fn parse_otlp_endpoint(arg: &str) -> eyre::Result<Url> {
395    Url::parse(arg).wrap_err("Invalid URL for OTLP trace output")
396}
397
398#[cfg(test)]
399mod tests {
400    use super::{DefaultTraceValues, TraceArgs};
401    use reth_tracing_otlp::OtlpProtocol;
402
403    #[test]
404    fn default_trace_values_can_override_filters() {
405        let defaults =
406            DefaultTraceValues::default().with_otlp_filter("trace").with_logs_otlp_filter("debug");
407
408        assert_eq!(defaults.otlp_filter, "trace");
409        assert_eq!(defaults.logs_otlp_filter, "debug");
410    }
411
412    #[test]
413    fn default_trace_values_can_override_all_defaults() {
414        let otlp = "http://localhost:4318/v1/traces".parse().unwrap();
415        let logs_otlp = "http://localhost:4318/v1/logs".parse().unwrap();
416        let defaults = DefaultTraceValues::default()
417            .with_otlp(Some(otlp))
418            .with_logs_otlp(Some(logs_otlp))
419            .with_otlp_default_endpoint("http://collector:4318/v1/traces")
420            .with_logs_otlp_default_endpoint("http://collector:4318/v1/logs")
421            .with_protocol(OtlpProtocol::Grpc)
422            .with_service_name("custom")
423            .with_service_version("1.2.3")
424            .with_otlp_filter("info")
425            .with_logs_otlp_filter("debug")
426            .with_sample_ratio(Some(0.5));
427
428        let args = TraceArgs {
429            otlp: defaults.otlp.clone(),
430            logs_otlp: defaults.logs_otlp.clone(),
431            protocol: defaults.protocol,
432            otlp_filter: defaults.otlp_filter.parse().unwrap(),
433            logs_otlp_filter: defaults.logs_otlp_filter.parse().unwrap(),
434            service_name: defaults.service_name.clone(),
435            service_version: defaults.service_version.clone(),
436            sample_ratio: defaults.sample_ratio,
437        };
438
439        assert!(args.otlp.is_some());
440        assert!(args.logs_otlp.is_some());
441        assert_eq!(args.protocol, OtlpProtocol::Grpc);
442        assert_eq!(args.service_name, "custom");
443        assert_eq!(args.service_version.as_deref(), Some("1.2.3"));
444        assert_eq!(args.sample_ratio, Some(0.5));
445        assert_eq!(defaults.otlp_default_endpoint, "http://collector:4318/v1/traces");
446        assert_eq!(defaults.logs_otlp_default_endpoint, "http://collector:4318/v1/logs");
447    }
448}