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