Skip to main content

ravenclaws/
telemetry.rs

1//! RavenClaws
2//!
3//! Provides opt-in distributed tracing via OpenTelemetry. When configured,
4//! RavenClaws exports traces to an OTLP-compatible collector (e.g., Jaeger,
5//! Grafana Tempo, SigNoz, or a self-hosted OpenTelemetry Collector).
6//!
7//! # Configuration
8//!
9//! | Env var | CLI flag | Default | Description |
10//! |---|---|---|---|
11//! | `RAVENCLAW_OTEL_ENDPOINT` | `--otel-endpoint` | `http://localhost:4317` | OTLP gRPC endpoint |
12//! | `RAVENCLAWS_OTEL_SERVICE_NAME` | `--otel-service-name` | `ravenclaws` | Service name for traces |
13//! | `RAVENCLAW_OTEL_DISABLED` | `--otel-disabled` | `false` | Disable OpenTelemetry entirely |
14//!
15//! # Usage
16//!
17//! ```ignore
18//! use crate::telemetry;
19//!
20//! let guard = telemetry::init_tracing(&config.telemetry)?;
21//! // ... application code ...
22//! drop(guard); // Flush and shutdown OTel exporter
23//! ```
24
25use opentelemetry::trace::TracerProvider;
26use opentelemetry_sdk::trace::SdkTracerProvider;
27use opentelemetry_sdk::Resource;
28use tracing_opentelemetry::OpenTelemetryLayer;
29use tracing_subscriber::layer::SubscriberExt;
30use tracing_subscriber::util::SubscriberInitExt;
31use tracing_subscriber::EnvFilter;
32
33#[cfg(feature = "otel-grpc")]
34use opentelemetry_otlp::WithExportConfig;
35
36use crate::config::TelemetryConfig;
37
38/// Guard that flushes and shuts down the OTel tracer provider on drop.
39/// Must be kept alive for the lifetime of the application.
40pub struct TelemetryGuard {
41    tracer_provider: Option<SdkTracerProvider>,
42}
43
44impl Drop for TelemetryGuard {
45    fn drop(&mut self) {
46        if let Some(provider) = self.tracer_provider.take() {
47            if let Err(e) = provider.shutdown() {
48                tracing::warn!(error = %e, "OpenTelemetry tracer provider shutdown failed");
49            }
50        }
51    }
52}
53
54/// Initialize OpenTelemetry tracing.
55///
56/// Returns a `TelemetryGuard` that must be kept alive for the lifetime of the
57/// application. When the guard is dropped, the OTel exporter is flushed and
58/// shut down gracefully.
59///
60/// If `config.otel_disabled` is true, this is a no-op and returns an empty guard.
61pub fn init_tracing(config: &TelemetryConfig) -> anyhow::Result<TelemetryGuard> {
62    if config.otel_disabled {
63        tracing::info!("OpenTelemetry tracing is disabled");
64        return Ok(TelemetryGuard {
65            tracer_provider: None,
66        });
67    }
68
69    let service_name = config
70        .otel_service_name
71        .clone()
72        .unwrap_or_else(|| "ravenclaws".to_string());
73
74    let endpoint = config
75        .otel_endpoint
76        .clone()
77        .unwrap_or_else(|| "http://localhost:4317".to_string());
78
79    let resource = Resource::builder()
80        .with_attribute(opentelemetry::KeyValue::new(
81            "service.name",
82            service_name.clone(),
83        ))
84        .with_attribute(opentelemetry::KeyValue::new(
85            "service.version",
86            env!("CARGO_PKG_VERSION"),
87        ))
88        .build();
89
90    // Build the OTLP exporter or stdout exporter based on available features
91    #[cfg(feature = "otel-grpc")]
92    let tracer_provider = {
93        let exporter = opentelemetry_otlp::SpanExporter::builder()
94            .with_tonic()
95            .with_endpoint(&endpoint)
96            .build()
97            .map_err(|e| anyhow::anyhow!("Failed to create OTLP span exporter: {}", e))?;
98
99        SdkTracerProvider::builder()
100            .with_resource(resource)
101            .with_batch_exporter(exporter)
102            .build()
103    };
104
105    #[cfg(not(feature = "otel-grpc"))]
106    let tracer_provider = {
107        // Fallback: use stdout exporter if available, otherwise no-op
108        #[cfg(feature = "otel-stdout")]
109        {
110            let exporter = opentelemetry_stdout::SpanExporter::default();
111            SdkTracerProvider::builder()
112                .with_resource(resource)
113                .with_simple_exporter(exporter)
114                .build()
115        }
116        #[cfg(not(feature = "otel-stdout"))]
117        {
118            tracing::warn!(
119                "OpenTelemetry tracing requested but no exporter feature enabled. \
120                 Enable 'otel-grpc' or 'otel-stdout' feature."
121            );
122            SdkTracerProvider::builder().with_resource(resource).build()
123        }
124    };
125
126    let tracer = tracer_provider.tracer("ravenclaws");
127    let telemetry_layer = OpenTelemetryLayer::new(tracer);
128
129    // Register the OTel layer on top of the existing subscriber.
130    // We use a try_init approach since the subscriber may already be registered
131    // (e.g., from main.rs). If it fails, we log a warning and continue.
132    let registry = tracing_subscriber::registry()
133        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "ravenclaws=info".into()))
134        .with(tracing_subscriber::fmt::layer().json())
135        .with(telemetry_layer);
136
137    if registry.try_init().is_err() {
138        tracing::warn!(
139            "Tracing subscriber already initialized — OpenTelemetry layer not registered. \
140             Set RAVENCLAW_OTEL_DISABLED=true if this is unexpected."
141        );
142    }
143
144    tracing::info!(
145        endpoint = %endpoint,
146        service = %service_name,
147        "OpenTelemetry tracing initialized"
148    );
149
150    Ok(TelemetryGuard {
151        tracer_provider: Some(tracer_provider),
152    })
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_telemetry_config_default() {
161        let config = TelemetryConfig::default();
162        assert!(config.otel_endpoint.is_none());
163        assert!(config.otel_service_name.is_none());
164        assert!(!config.otel_disabled);
165    }
166
167    #[test]
168    fn test_telemetry_config_disabled() {
169        let config = TelemetryConfig {
170            otel_disabled: true,
171            ..TelemetryConfig::default()
172        };
173        let guard = init_tracing(&config).unwrap();
174        assert!(guard.tracer_provider.is_none());
175    }
176
177    #[test]
178    fn test_telemetry_guard_drop_no_panic() {
179        let guard = TelemetryGuard {
180            tracer_provider: None,
181        };
182        drop(guard); // Should not panic
183    }
184
185    #[test]
186    fn test_telemetry_config_custom() {
187        let config = TelemetryConfig {
188            otel_endpoint: Some("http://jaeger:4317".to_string()),
189            otel_service_name: Some("my-ravenclaws".to_string()),
190            otel_disabled: false,
191        };
192        assert_eq!(config.otel_endpoint.as_deref(), Some("http://jaeger:4317"));
193        assert_eq!(config.otel_service_name.as_deref(), Some("my-ravenclaws"));
194    }
195}