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
18static TRACER_PROVIDER: OnceLock<SdkTracerProvider> = OnceLock::new();
23
24#[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#[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#[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
151pub 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
165pub fn setup_tracing(otel: OpenTelemetry) -> anyhow::Result<Option<OtelGuard>> {
179 setup_tracing_with_config(TracingConfig::from_env(otel))
180}
181
182pub 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 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 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 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 tracing::Span::current().set_error(wrapped.as_ref());
235
236 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 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 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#[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}