Skip to main content

miden_node_utils/
logging.rs

1use std::str::FromStr;
2use std::sync::OnceLock;
3
4use opentelemetry::trace::TracerProvider as _;
5use opentelemetry::{KeyValue, Value};
6use opentelemetry_otlp::WithTonicConfig as _;
7use opentelemetry_sdk::Resource;
8use opentelemetry_sdk::propagation::TraceContextPropagator;
9use opentelemetry_sdk::resource::{EnvResourceDetector, TelemetryResourceDetector};
10use opentelemetry_sdk::trace::SdkTracerProvider;
11use tracing::subscriber::Subscriber;
12use tracing_opentelemetry::OpenTelemetryLayer;
13use tracing_subscriber::layer::{Filter, SubscriberExt};
14use tracing_subscriber::{EnvFilter, Layer, Registry};
15
16use crate::tracing::{ErrorSpanExt, error};
17
18/// Global tracer provider for flushing traces on panic.
19///
20/// This is necessary because the panic hook needs access to the tracer provider to flush
21/// pending spans before the program terminates.
22static TRACER_PROVIDER: OnceLock<SdkTracerProvider> = OnceLock::new();
23
24/// Default OpenTelemetry resource attributes for this process.
25#[derive(Clone)]
26pub struct ResourceConfig {
27    service_name: Option<&'static str>,
28    attributes: Vec<(&'static str, &'static str)>,
29}
30
31impl Default for ResourceConfig {
32    fn default() -> Self {
33        Self {
34            service_name: None,
35            attributes: vec![("service.version", env!("CARGO_PKG_VERSION"))],
36        }
37    }
38}
39
40impl ResourceConfig {
41    #[must_use]
42    pub fn with_name(mut self, service_name: &'static str) -> Self {
43        self.service_name = Some(service_name);
44        self
45    }
46
47    #[must_use]
48    pub fn with_attribute(mut self, key: &'static str, value: &'static str) -> Self {
49        self.attributes.push((key, value));
50        self
51    }
52}
53
54/// Configures [`setup_tracing`] to enable or disable the open-telemetry exporter.
55#[derive(Clone)]
56pub enum OpenTelemetry {
57    Enabled(ResourceConfig),
58    Disabled,
59}
60
61impl OpenTelemetry {
62    pub fn enabled() -> Self {
63        OpenTelemetry::Enabled(ResourceConfig::default())
64    }
65
66    pub fn from_env() -> Self {
67        if otlp_endpoint_configured() {
68            OpenTelemetry::enabled()
69        } else {
70            OpenTelemetry::Disabled
71        }
72    }
73
74    #[must_use]
75    pub fn with_name(self, service_name: &'static str) -> Self {
76        match self {
77            OpenTelemetry::Enabled(config) => {
78                OpenTelemetry::Enabled(config.with_name(service_name))
79            },
80            OpenTelemetry::Disabled => OpenTelemetry::Disabled,
81        }
82    }
83
84    #[must_use]
85    pub fn with_attribute(self, key: &'static str, value: &'static str) -> Self {
86        match self {
87            OpenTelemetry::Enabled(config) => {
88                OpenTelemetry::Enabled(config.with_attribute(key, value))
89            },
90            OpenTelemetry::Disabled => OpenTelemetry::Disabled,
91        }
92    }
93
94    fn is_enabled(&self) -> bool {
95        matches!(self, OpenTelemetry::Enabled(_))
96    }
97
98    fn resource_config(self) -> Option<ResourceConfig> {
99        match self {
100            OpenTelemetry::Enabled(config) => Some(config),
101            OpenTelemetry::Disabled => None,
102        }
103    }
104}
105
106/// Tracing subscriber configuration.
107#[derive(Clone)]
108pub struct TracingConfig {
109    pub open_telemetry: OpenTelemetry,
110    pub stdout_filter: String,
111    pub otel_filter: String,
112}
113
114impl TracingConfig {
115    #[must_use]
116    pub fn from_env(open_telemetry: OpenTelemetry) -> Self {
117        Self {
118            open_telemetry,
119            stdout_filter: filter_env_or_default(
120                "MIDEN_STDOUT_FILTER",
121                "info,user=debug,miden_prover=warn",
122            ),
123            otel_filter: filter_env_or_default(
124                "MIDEN_OTEL_FILTER",
125                "info,axum::rejection=trace,miden_prover=warn",
126            ),
127        }
128    }
129}
130
131fn filter_env_or_default(var: &str, default: &str) -> String {
132    std::env::var(var)
133        .ok()
134        .filter(|value| !value.trim().is_empty())
135        .or_else(|| {
136            std::env::var(EnvFilter::DEFAULT_ENV)
137                .ok()
138                .filter(|value| !value.trim().is_empty())
139        })
140        .unwrap_or_else(|| default.to_owned())
141}
142
143fn filter_from_string<S>(
144    filter: &str,
145) -> anyhow::Result<Box<dyn Filter<S> + Send + Sync + 'static>> {
146    use tracing_subscriber::filter::FilterExt;
147
148    Ok(FilterExt::boxed(EnvFilter::from_str(filter)?))
149}
150
151/// A guard that shuts down the tracer provider when dropped. This ensures that the logs are flushed
152/// to the exporter before the program exits.
153pub struct OtelGuard {
154    tracer_provider: SdkTracerProvider,
155}
156
157impl Drop for OtelGuard {
158    fn drop(&mut self) {
159        if let Err(err) = self.tracer_provider.shutdown() {
160            eprintln!("{err:?}");
161        }
162    }
163}
164
165/// Initializes tracing to stdout and optionally an open-telemetry exporter.
166///
167/// Stdout trace filtering is configured with `MIDEN_STDOUT_FILTER`, then `RUST_LOG`, then `info,user=debug,miden_prover=warn`.
168/// OpenTelemetry export filtering is configured with `MIDEN_OTEL_FILTER`, then `RUST_LOG`, then
169/// `info,axum::rejection=trace,miden_prover=warn`.
170///
171/// The open-telemetry configuration is controlled via environment variables as defined in the
172/// [specification](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md#opentelemetry-protocol-exporter)
173///
174/// Registers a panic hook so that panic errors are reported to the open-telemetry exporter.
175///
176/// Returns an [`OtelGuard`] if open-telemetry is enabled, otherwise `None`. When this guard is
177/// dropped, the tracer provider is shutdown.
178pub fn setup_tracing(otel: OpenTelemetry) -> anyhow::Result<Option<OtelGuard>> {
179    setup_tracing_with_config(TracingConfig::from_env(otel))
180}
181
182/// Initializes tracing from explicit stdout and OpenTelemetry filter configuration.
183///
184/// Returns an [`OtelGuard`] if open-telemetry is enabled, otherwise `None`. When this guard is
185/// dropped, the tracer provider is shutdown.
186pub fn setup_tracing_with_config(config: TracingConfig) -> anyhow::Result<Option<OtelGuard>> {
187    let TracingConfig {
188        open_telemetry: otel,
189        stdout_filter,
190        otel_filter,
191    } = config;
192
193    if otel.is_enabled() {
194        opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
195    }
196
197    // Note: open-telemetry requires a tokio-runtime, so this _must_ be lazily evaluated (aka not
198    // `then_some`) to avoid crashing sync callers (with OpenTelemetry::Disabled set). Examples of
199    // such callers are tests with logging enabled.
200    let tracer_provider = if otel.is_enabled() {
201        let provider = init_tracer_provider(
202            otel.resource_config()
203                .expect("resource config is set when OpenTelemetry is enabled"),
204        )?;
205
206        // Store the provider globally so the panic hook can flush it. SdkTracerProvider is
207        // internally reference-counted, so cloning is cheap.
208        TRACER_PROVIDER
209            .set(provider.clone())
210            .expect("setup_tracing should only be called once");
211
212        Some(provider)
213    } else {
214        None
215    };
216    let otel_layer = tracer_provider.as_ref().map(|provider| {
217        OpenTelemetryLayer::new(provider.tracer("tracing-otel-subscriber")).boxed()
218    });
219
220    let subscriber = Registry::default()
221        .with(stdout_layer().with_filter(filter_from_string(&stdout_filter)?))
222        .with(otel_layer.with_filter(filter_from_string(&otel_filter)?));
223    tracing::subscriber::set_global_default(subscriber).map_err(Into::<anyhow::Error>::into)?;
224
225    // Register panic hook now that tracing is initialized. This chains with the default panic hook
226    // to preserve backtrace printing.
227    let default_hook = std::panic::take_hook();
228    std::panic::set_hook(Box::new(move |info| {
229        let info_str = info.to_string();
230        let wrapped = anyhow::Error::msg(info_str);
231        error!(&wrapped, "panic", panic = true);
232
233        // Mark the current span as failed for OpenTelemetry.
234        tracing::Span::current().set_error(wrapped.as_ref());
235
236        // Flush traces before the program terminates. This ensures the panic trace is exported even
237        // though the OtelGuard won't be dropped.
238        if let Some(provider) = TRACER_PROVIDER.get() {
239            if let Err(err) = provider.force_flush() {
240                eprintln!("Failed to flush traces on panic: {err:?}");
241            }
242        }
243
244        // Call the default hook to print the backtrace.
245        default_hook(info);
246    }));
247
248    Ok(tracer_provider.map(|tracer_provider| OtelGuard { tracer_provider }))
249}
250
251fn init_tracer_provider(resource_config: ResourceConfig) -> anyhow::Result<SdkTracerProvider> {
252    let exporter = opentelemetry_otlp::SpanExporter::builder()
253        .with_tonic()
254        .with_tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
255        .build()?;
256    let resource = resource(resource_config);
257
258    Ok(opentelemetry_sdk::trace::SdkTracerProvider::builder()
259        .with_resource(resource)
260        .with_batch_exporter(exporter)
261        .build())
262}
263
264fn resource(config: ResourceConfig) -> Resource {
265    let detected_resource = Resource::builder_empty()
266        .with_detector(Box::new(TelemetryResourceDetector))
267        .with_detector(Box::new(EnvResourceDetector::new()))
268        .build();
269
270    resource_from_detected(config, &detected_resource, otel_service_name_override())
271}
272
273fn resource_from_detected(
274    config: ResourceConfig,
275    detected_resource: &Resource,
276    service_name_override: Option<Value>,
277) -> Resource {
278    const SERVICE_NAME: &str = "service.name";
279    const SERVICE_NAMESPACE: &str = "service.namespace";
280
281    let mut attributes =
282        std::collections::BTreeMap::from([(SERVICE_NAMESPACE.to_string(), Value::from("miden"))]);
283
284    if let Some(service_name) = config.service_name {
285        attributes.insert(SERVICE_NAME.to_string(), Value::from(service_name));
286    }
287
288    for (key, value) in config.attributes {
289        attributes.insert(key.to_string(), Value::from(value));
290    }
291
292    // Environment resource attributes override defaults above, and OTEL_SERVICE_NAME overrides
293    // both.
294    for (key, value) in detected_resource {
295        attributes.insert(key.as_str().to_string(), value.clone());
296    }
297
298    if let Some(service_name) = service_name_override {
299        attributes.insert(SERVICE_NAME.to_string(), service_name);
300    }
301
302    Resource::builder_empty()
303        .with_attributes(attributes.into_iter().map(|(key, value)| KeyValue::new(key, value)))
304        .build()
305}
306
307fn otel_service_name_override() -> Option<Value> {
308    std::env::var("OTEL_SERVICE_NAME")
309        .ok()
310        .filter(|value| !value.is_empty())
311        .map(Value::from)
312}
313
314fn otlp_endpoint_configured() -> bool {
315    ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"]
316        .into_iter()
317        .any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty()))
318}
319
320/// Initializes tracing to a test exporter.
321///
322/// Allows trace content to be inspected via the returned receiver.
323///
324/// All tests that use this function must be annotated with `#[serial(open_telemetry_tracing)]`.
325/// This forces serialization of all such tests. Otherwise, the tested spans could
326/// be interleaved during runtime. Also, the global exporter could be re-initialized in
327/// the middle of a concurrently running test.
328#[cfg(feature = "testing")]
329pub fn setup_test_tracing() -> anyhow::Result<(
330    tokio::sync::mpsc::UnboundedReceiver<opentelemetry_sdk::trace::SpanData>,
331    tokio::sync::mpsc::UnboundedReceiver<()>,
332)> {
333    let (exporter, rx_export, rx_shutdown) =
334        opentelemetry_sdk::testing::trace::new_tokio_test_exporter();
335
336    let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
337        .with_batch_exporter(exporter)
338        .build();
339    let otel_layer =
340        OpenTelemetryLayer::new(tracer_provider.tracer("tracing-otel-subscriber")).boxed();
341    let subscriber = Registry::default()
342        .with(stdout_layer().with_filter(filter_from_string("debug")?))
343        .with(otel_layer.with_filter(filter_from_string("info,axum::rejection=trace")?));
344    tracing::subscriber::set_global_default(subscriber)?;
345    Ok((rx_export, rx_shutdown))
346}
347
348#[cfg(not(feature = "tracing-forest"))]
349fn stdout_layer<S>() -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync + 'static>
350where
351    S: Subscriber,
352    for<'a> S: tracing_subscriber::registry::LookupSpan<'a>,
353{
354    tracing_subscriber::fmt::layer()
355        .compact()
356        .with_level(true)
357        .with_file(false)
358        .with_line_number(false)
359        .with_target(false)
360        .boxed()
361}
362
363#[cfg(feature = "tracing-forest")]
364fn stdout_layer<S>() -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync + 'static>
365where
366    S: Subscriber,
367    for<'a> S: tracing_subscriber::registry::LookupSpan<'a>,
368{
369    tracing_forest::ForestLayer::default().boxed()
370}
371
372#[cfg(test)]
373mod tests {
374    use opentelemetry::Key;
375
376    use super::*;
377
378    #[test]
379    fn resource_uses_configured_defaults() {
380        let detected_resource = Resource::builder_empty()
381            .with_attributes([KeyValue::new("telemetry.sdk.language", "rust")])
382            .build();
383
384        let resource = resource_from_detected(
385            ResourceConfig::default()
386                .with_name("node")
387                .with_attribute("miden.node.role", "sequencer"),
388            &detected_resource,
389            None,
390        );
391
392        assert_eq!(resource_value(&resource, "service.name"), Some(Value::from("node")),);
393        assert_eq!(
394            resource_value(&resource, "service.version"),
395            Some(Value::from(env!("CARGO_PKG_VERSION"))),
396        );
397        assert_eq!(resource_value(&resource, "service.namespace"), Some(Value::from("miden")),);
398        assert_eq!(resource_value(&resource, "miden.node.role"), Some(Value::from("sequencer")),);
399        assert_eq!(resource_value(&resource, "telemetry.sdk.language"), Some(Value::from("rust")),);
400    }
401
402    #[test]
403    fn resource_prefers_detected_attributes_over_configured_defaults() {
404        let detected_resource = Resource::builder_empty()
405            .with_attributes([
406                KeyValue::new("service.name", "custom-node"),
407                KeyValue::new("service.version", "custom-version"),
408                KeyValue::new("service.namespace", "custom-namespace"),
409                KeyValue::new("miden.node.role", "custom-role"),
410            ])
411            .build();
412
413        let resource = resource_from_detected(
414            ResourceConfig::default()
415                .with_name("node")
416                .with_attribute("miden.node.role", "sequencer"),
417            &detected_resource,
418            None,
419        );
420
421        assert_eq!(resource_value(&resource, "service.name"), Some(Value::from("custom-node")),);
422        assert_eq!(
423            resource_value(&resource, "service.version"),
424            Some(Value::from("custom-version")),
425        );
426        assert_eq!(
427            resource_value(&resource, "service.namespace"),
428            Some(Value::from("custom-namespace")),
429        );
430        assert_eq!(resource_value(&resource, "miden.node.role"), Some(Value::from("custom-role")),);
431    }
432
433    #[test]
434    fn resource_prefers_explicit_service_name_override() {
435        let detected_resource = Resource::builder_empty()
436            .with_attributes([KeyValue::new("service.name", "resource-attribute-node")])
437            .build();
438
439        let resource = resource_from_detected(
440            ResourceConfig::default().with_name("node"),
441            &detected_resource,
442            Some(Value::from("service-env-node")),
443        );
444
445        assert_eq!(
446            resource_value(&resource, "service.name"),
447            Some(Value::from("service-env-node")),
448        );
449    }
450
451    fn resource_value(resource: &Resource, key: &'static str) -> Option<Value> {
452        resource.get(&Key::from_static_str(key))
453    }
454}