miden_note_transport_node/
logging.rs

1use std::str::FromStr;
2
3use anyhow::Result;
4use opentelemetry::trace::TracerProvider as _;
5use opentelemetry_otlp::WithExportConfig;
6use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
7use opentelemetry_sdk::propagation::TraceContextPropagator;
8use opentelemetry_sdk::trace::SpanExporter;
9use tracing::subscriber::Subscriber;
10use tracing_opentelemetry::OpenTelemetryLayer;
11use tracing_subscriber::layer::{Filter, SubscriberExt};
12use tracing_subscriber::{Layer, Registry};
13
14/// Configures [`setup_tracing`] to enable or disable the open-telemetry exporter.
15#[derive(Clone)]
16pub struct TracingConfig {
17    /// OpenTelemetry configuration
18    pub otel: OpenTelemetry,
19    /// Export data JSON-formatted
20    pub json_format: bool,
21}
22
23/// OpenTelemetry configuration
24#[derive(Clone)]
25pub enum OpenTelemetry {
26    /// Enable OpenTelemetry export
27    Enabled {
28        /// Endpoint
29        endpoint: String,
30    },
31    /// Disable OpenTelemetry
32    Disabled,
33}
34
35impl TracingConfig {
36    /// Tracing configuration constructor using environment variables
37    pub fn from_env() -> Self {
38        let otel = {
39            let otel_enabled = std::env::var("OTEL_ENABLED")
40                .unwrap_or_else(|_| "false".to_string())
41                .parse()
42                .unwrap_or(false);
43            if otel_enabled {
44                OpenTelemetry::Enabled {
45                    endpoint: std::env::var("OTEL_TRACES_ENDPOINT")
46                        .ok()
47                        .unwrap_or("http://localhost:4317".to_string()),
48                }
49            } else {
50                OpenTelemetry::Disabled
51            }
52        };
53
54        TracingConfig {
55            otel,
56            json_format: std::env::var("JSON_LOGGING")
57                .unwrap_or_else(|_| "false".to_string())
58                .parse()
59                .unwrap_or(false),
60        }
61    }
62}
63
64impl OpenTelemetry {
65    /// Is OpenTelemetry enabled
66    pub fn is_enabled(&self) -> bool {
67        matches!(self, OpenTelemetry::Enabled { .. })
68    }
69}
70
71/// Initializes tracing to stdout and optionally an open-telemetry exporter.
72///
73/// Trace filtering defaults to `INFO` and can be configured using the conventional `RUST_LOG`
74/// environment variable.
75///
76/// The open-telemetry configuration is controlled via environment variables as defined in the
77/// [specification](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md#opentelemetry-protocol-exporter)
78pub fn setup_tracing(cfg: TracingConfig) -> Result<()> {
79    if cfg.otel.is_enabled() {
80        opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
81
82        // Setup metrics export if OTEL is enabled
83        setup_metrics_export(&cfg.otel)?;
84    }
85
86    // Note: open-telemetry requires a tokio-runtime, so this _must_ be lazily evaluated (aka not
87    // `then_some`) to avoid crashing sync callers (with OpenTelemetry::Disabled set). Examples of
88    // such callers are tests with logging enabled.
89    let otel_layer = {
90        if let OpenTelemetry::Enabled { endpoint } = cfg.otel {
91            let exporter_builder =
92                opentelemetry_otlp::SpanExporter::builder().with_tonic().with_endpoint(endpoint);
93
94            match exporter_builder.build() {
95                Ok(exporter) => {
96                    Some(open_telemetry_layer(exporter, "miden-note-transport-node".to_string()))
97                },
98                Err(_) => None,
99            }
100        } else {
101            None
102        }
103    };
104
105    let subscriber = Registry::default()
106        .with(stdout_layer(cfg.json_format).with_filter(env_or_default_filter()))
107        .with(otel_layer.with_filter(env_or_default_filter()));
108
109    tracing::subscriber::set_global_default(subscriber).map_err(Into::into)
110}
111
112/// Setup OpenTelemetry metrics export using the proper SDK API
113fn setup_metrics_export(otel_cfg: &OpenTelemetry) -> Result<()> {
114    if let OpenTelemetry::Enabled { endpoint } = otel_cfg {
115        // Configure OTLP metrics pipeline
116        let exporter = opentelemetry_otlp::MetricExporter::builder()
117            .with_tonic()
118            .with_endpoint(endpoint)
119            .build()?;
120
121        let provider = SdkMeterProvider::builder()
122            .with_reader(
123                PeriodicReader::builder(exporter)
124                    .with_interval(std::time::Duration::from_secs(5)) // Push interval
125                    .build(),
126            )
127            .build();
128
129        // Set the meter provider globally
130        opentelemetry::global::set_meter_provider(provider);
131    }
132
133    Ok(())
134}
135
136/// Initializes tracing to a test exporter.
137///
138/// Allows trace content to be inspected via the returned receiver.
139///
140/// All tests that use this function must be annotated with `#[serial(open_telemetry_tracing)]`.
141/// This forces serialization of all such tests. Otherwise, the tested spans could
142/// be interleaved during runtime. Also, the global exporter could be re-initialized in
143/// the middle of a concurrently running test.
144#[cfg(test)]
145pub fn setup_test_tracing() -> Result<(
146    tokio::sync::mpsc::UnboundedReceiver<opentelemetry_sdk::trace::SpanData>,
147    tokio::sync::mpsc::UnboundedReceiver<()>,
148)> {
149    let (exporter, rx_export, rx_shutdown) =
150        opentelemetry_sdk::testing::trace::new_tokio_test_exporter();
151
152    let otel_layer = open_telemetry_layer(exporter, "test-service".to_string());
153    let subscriber = Registry::default()
154        .with(stdout_layer(true).with_filter(env_or_default_filter()))
155        .with(otel_layer.with_filter(env_or_default_filter()));
156    tracing::subscriber::set_global_default(subscriber)?;
157    Ok((rx_export, rx_shutdown))
158}
159
160fn open_telemetry_layer<S>(
161    exporter: impl SpanExporter + 'static,
162    service_name: String,
163) -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync + 'static>
164where
165    S: Subscriber + Sync + Send,
166    for<'a> S: tracing_subscriber::registry::LookupSpan<'a>,
167{
168    let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
169        .with_batch_exporter(exporter)
170        .build();
171
172    let tracer = provider.tracer(service_name);
173
174    // Set the tracer provider globally
175    opentelemetry::global::set_tracer_provider(provider);
176
177    OpenTelemetryLayer::new(tracer).boxed()
178}
179
180fn stdout_layer<S>(
181    json_logging: bool,
182) -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync + 'static>
183where
184    S: Subscriber,
185    for<'a> S: tracing_subscriber::registry::LookupSpan<'a>,
186{
187    use tracing_subscriber::fmt::format::FmtSpan;
188
189    if json_logging {
190        tracing_subscriber::fmt::layer()
191            .with_level(true)
192            .with_file(true)
193            .with_line_number(true)
194            .with_target(true)
195            .with_span_events(FmtSpan::CLOSE)
196            .json()
197            .boxed()
198    } else {
199        tracing_subscriber::fmt::layer()
200            .pretty()
201            .compact()
202            .with_level(true)
203            .with_file(true)
204            .with_line_number(true)
205            .with_target(true)
206            .with_span_events(FmtSpan::CLOSE)
207            .boxed()
208    }
209}
210
211/// Creates a filter from the `RUST_LOG` env var with a default of `INFO` if unset.
212///
213/// # Panics
214///
215/// Panics if `RUST_LOG` fails to parse.
216fn env_or_default_filter<S>() -> Box<dyn Filter<S> + Send + Sync + 'static> {
217    use tracing::level_filters::LevelFilter;
218    use tracing_subscriber::EnvFilter;
219    use tracing_subscriber::filter::{FilterExt, Targets};
220
221    // `tracing` does not allow differentiating between invalid and missing env var so we manually
222    // do this instead. The alternative is to silently ignore parsing errors which I think is worse.
223    match std::env::var(EnvFilter::DEFAULT_ENV) {
224        Ok(rust_log) => FilterExt::boxed(
225            EnvFilter::from_str(&rust_log)
226                .expect("RUST_LOG should contain a valid filter configuration"),
227        ),
228        Err(std::env::VarError::NotUnicode(_)) => panic!("RUST_LOG contained non-unicode"),
229        Err(std::env::VarError::NotPresent) => {
230            // Default level is INFO, and additionally enable logs from axum extractor rejections.
231            FilterExt::boxed(
232                Targets::new()
233                    .with_default(LevelFilter::INFO)
234                    .with_target("axum::rejection", LevelFilter::TRACE),
235            )
236        },
237    }
238}