telemetry_init/
telemetry.rs1use std::sync::atomic::{AtomicBool, Ordering};
5#[cfg(feature = "metrics")]
6use std::sync::Arc;
7
8use tracing::Subscriber;
9use tracing_subscriber::layer::{Layer, SubscriberExt};
10use tracing_subscriber::{fmt, EnvFilter};
11
12use crate::config::{LogFormat, TelemetryConfig};
13use crate::error::TelemetryError;
14
15#[cfg(feature = "otlp")]
19type OtelProvider = opentelemetry_sdk::trace::SdkTracerProvider;
20#[cfg(not(feature = "otlp"))]
21type OtelProvider = ();
22
23pub struct Telemetry {
30 #[cfg(feature = "metrics")]
31 metrics: Arc<metrics_kit::Registry>,
32 #[cfg(feature = "otlp")]
33 tracer_provider: Option<OtelProvider>,
34 shutdown: AtomicBool,
37}
38
39impl std::fmt::Debug for Telemetry {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("Telemetry").finish_non_exhaustive()
44 }
45}
46
47impl Telemetry {
48 #[allow(clippy::needless_pass_by_value)]
80 pub fn init(config: TelemetryConfig) -> Result<Self, TelemetryError> {
81 #[cfg(feature = "metrics")]
82 let metrics = Arc::new(metrics_kit::Registry::with_max_series(
83 config.metrics_budget,
84 ));
85
86 #[cfg(feature = "otlp")]
87 let (subscriber, provider) = build_pipeline(&config)?;
88 #[cfg(not(feature = "otlp"))]
89 let (subscriber, _) = build_pipeline(&config)?;
90
91 if tracing::subscriber::set_global_default(subscriber).is_err() {
92 #[cfg(feature = "otlp")]
95 if let Some(provider) = provider {
96 let _ = provider.shutdown();
97 }
98 return Err(TelemetryError::AlreadyInitialized);
99 }
100
101 Ok(Self {
102 #[cfg(feature = "metrics")]
103 metrics,
104 #[cfg(feature = "otlp")]
105 tracer_provider: provider,
106 shutdown: AtomicBool::new(false),
107 })
108 }
109
110 #[cfg(feature = "metrics")]
116 pub fn metrics(&self) -> Arc<metrics_kit::Registry> {
117 Arc::clone(&self.metrics)
118 }
119
120 pub fn shutdown(&self) -> Result<(), TelemetryError> {
133 if self.shutdown.swap(true, Ordering::AcqRel) {
134 return Ok(());
135 }
136 #[cfg(feature = "otlp")]
137 if let Some(provider) = &self.tracer_provider {
138 provider
139 .shutdown()
140 .map_err(|e| TelemetryError::InitFailed(e.to_string()))?;
141 }
142 Ok(())
143 }
144}
145
146impl Drop for Telemetry {
147 fn drop(&mut self) {
151 if self.shutdown.swap(true, Ordering::AcqRel) {
152 return; }
154 #[cfg(feature = "otlp")]
155 if let Some(provider) = self.tracer_provider.take() {
156 let _ = provider.force_flush();
158 }
159 }
160}
161
162pub fn build_subscriber(
176 config: &TelemetryConfig,
177) -> Result<Box<dyn Subscriber + Send + Sync>, TelemetryError> {
178 build_pipeline(config).map(|(subscriber, _)| subscriber)
179}
180
181pub(crate) fn resolve_filter(config: &TelemetryConfig) -> Result<EnvFilter, TelemetryError> {
184 match std::env::var("RUST_LOG") {
185 Ok(directive) if !directive.trim().is_empty() => build_filter(&directive),
186 _ => build_filter(&config.log_level),
187 }
188}
189
190pub(crate) fn build_filter(directive: &str) -> Result<EnvFilter, TelemetryError> {
193 EnvFilter::try_new(directive).map_err(|e| {
194 TelemetryError::InitFailed(format!("invalid env-filter directive {directive:?}: {e}"))
195 })
196}
197
198fn build_pipeline(
202 config: &TelemetryConfig,
203) -> Result<(Box<dyn Subscriber + Send + Sync>, Option<OtelProvider>), TelemetryError> {
204 let filter = resolve_filter(config)?;
205
206 #[cfg(not(feature = "otlp"))]
207 {
208 let subscriber = tracing_subscriber::registry()
209 .with(filter)
210 .with(build_fmt_layer(config.log_format)?);
211 return Ok((Box::new(subscriber), None));
212 }
213
214 #[cfg(feature = "otlp")]
215 {
216 let provider = build_tracer_provider(config)?;
217 let otel_layer = provider.as_ref().map(|p| {
218 use opentelemetry::trace::TracerProvider as _;
219 let tracer = p.tracer(config.service_name.clone());
220 tracing_opentelemetry::layer().with_tracer(tracer)
221 });
222 let subscriber = tracing_subscriber::registry()
223 .with(filter)
224 .with(build_fmt_layer(config.log_format)?)
225 .with(otel_layer);
226 Ok((Box::new(subscriber), provider))
227 }
228}
229
230#[allow(clippy::unnecessary_wraps)]
236fn build_fmt_layer<S>(format: LogFormat) -> Result<Box<dyn Layer<S> + Send + Sync>, TelemetryError>
237where
238 S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
239{
240 match format {
241 LogFormat::Json => {
242 #[cfg(feature = "json")]
243 {
244 Ok(fmt::layer().json().with_ansi(false).boxed())
247 }
248 #[cfg(not(feature = "json"))]
249 {
250 Err(TelemetryError::InitFailed(
251 "LogFormat::Json requested but the `json` feature is not compiled in; \
252 enable the default `json` feature or select LogFormat::Pretty"
253 .to_owned(),
254 ))
255 }
256 }
257 LogFormat::Pretty => Ok(fmt::layer().boxed()),
258 }
259}
260
261#[cfg(feature = "otlp")]
267fn build_tracer_provider(config: &TelemetryConfig) -> Result<Option<OtelProvider>, TelemetryError> {
268 use opentelemetry_otlp::WithExportConfig;
269
270 let Some(endpoint) = config.otlp_endpoint.as_deref() else {
271 return Ok(None);
272 };
273
274 let resource = opentelemetry_sdk::Resource::builder()
275 .with_attributes([
276 opentelemetry::KeyValue::new("service.name", config.service_name.clone()),
277 opentelemetry::KeyValue::new("service.version", config.service_version.clone()),
278 ])
279 .build();
280
281 let exporter = opentelemetry_otlp::SpanExporter::builder()
282 .with_http()
283 .with_endpoint(endpoint)
284 .build()
285 .map_err(|e| TelemetryError::InitFailed(format!("OTLP exporter build failed: {e}")))?;
286
287 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
288 .with_resource(resource)
289 .with_sampler(opentelemetry_sdk::trace::Sampler::ParentBased(Box::new(
290 opentelemetry_sdk::trace::Sampler::TraceIdRatioBased(f64::from(config.sample_rate)),
291 )))
292 .with_batch_exporter(exporter)
293 .build();
294
295 Ok(Some(provider))
296}
297
298#[cfg(test)]
299mod tests {
300 #![allow(clippy::unwrap_used, clippy::expect_used)]
301 use super::*;
302 use tracing::level_filters::LevelFilter;
303
304 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
307
308 #[cfg(feature = "otlp")]
312 fn raw_telemetry(provider: Option<OtelProvider>) -> Telemetry {
313 Telemetry {
314 #[cfg(feature = "metrics")]
315 metrics: Arc::new(metrics_kit::Registry::new()),
316 tracer_provider: provider,
317 shutdown: AtomicBool::new(false),
318 }
319 }
320
321 #[cfg(not(feature = "otlp"))]
322 fn raw_telemetry() -> Telemetry {
323 Telemetry {
324 shutdown: AtomicBool::new(false),
325 }
326 }
327
328 fn with_rust_log<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
331 let guard = ENV_LOCK.lock().unwrap();
332 let saved = std::env::var("RUST_LOG").ok();
333 match value {
334 Some(v) => std::env::set_var("RUST_LOG", v),
335 None => std::env::remove_var("RUST_LOG"),
336 }
337 let out = f();
338 match saved {
339 Some(v) => std::env::set_var("RUST_LOG", v),
340 None => std::env::remove_var("RUST_LOG"),
341 }
342 drop(guard);
343 out
344 }
345
346 #[test]
347 fn build_filter_parses_directives_and_levels() {
348 assert_eq!(
349 build_filter("info").unwrap().max_level_hint(),
350 Some(LevelFilter::INFO)
351 );
352 assert_eq!(
353 build_filter("warn,hyper=debug").unwrap().max_level_hint(),
354 Some(LevelFilter::DEBUG)
355 );
356 assert!(matches!(
357 build_filter("hyper=notalevel"),
358 Err(TelemetryError::InitFailed(_))
359 ));
360 assert!(matches!(
361 build_filter("a=b=c"),
362 Err(TelemetryError::InitFailed(_))
363 ));
364 }
365
366 #[test]
367 fn resolve_filter_uses_config_when_rust_log_unset() {
368 with_rust_log(None, || {
369 let cfg = TelemetryConfig::new("svc").log_level("warn");
370 assert_eq!(
371 resolve_filter(&cfg).unwrap().max_level_hint(),
372 Some(LevelFilter::WARN)
373 );
374 });
375 }
376
377 #[test]
378 fn resolve_filter_rust_log_wins_over_config() {
379 with_rust_log(Some("debug"), || {
380 let cfg = TelemetryConfig::new("svc").log_level("info");
381 assert_eq!(
382 resolve_filter(&cfg).unwrap().max_level_hint(),
383 Some(LevelFilter::DEBUG)
384 );
385 });
386 }
387
388 #[test]
389 fn resolve_filter_treats_empty_rust_log_as_unset() {
390 with_rust_log(Some(" "), || {
391 let cfg = TelemetryConfig::new("svc").log_level("error");
392 assert_eq!(
393 resolve_filter(&cfg).unwrap().max_level_hint(),
394 Some(LevelFilter::ERROR)
395 );
396 });
397 }
398
399 #[test]
400 fn resolve_filter_propagates_invalid_rust_log() {
401 with_rust_log(Some("a=b=c"), || {
402 let cfg = TelemetryConfig::new("svc").log_level("info");
403 assert!(matches!(
404 resolve_filter(&cfg),
405 Err(TelemetryError::InitFailed(_))
406 ));
407 });
408 }
409
410 #[test]
411 fn build_subscriber_smoke_pretty() {
412 let cfg = TelemetryConfig::new("svc").log_format(LogFormat::Pretty);
413 let subscriber = build_subscriber(&cfg).unwrap();
414 assert_eq!(subscriber.max_level_hint(), Some(LevelFilter::INFO));
415 }
416
417 #[cfg(feature = "json")]
418 #[test]
419 fn build_subscriber_smoke_json() {
420 let cfg = TelemetryConfig::new("svc");
421 let subscriber = build_subscriber(&cfg).unwrap();
422 assert_eq!(subscriber.max_level_hint(), Some(LevelFilter::INFO));
423 }
424
425 #[test]
426 fn init_reports_already_initialized_when_global_is_taken() {
427 let _ = tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::new());
430 let err =
431 Telemetry::init(TelemetryConfig::new("svc").log_format(LogFormat::Pretty)).unwrap_err();
432 assert!(matches!(err, TelemetryError::AlreadyInitialized));
433 }
434
435 #[cfg(feature = "otlp")]
436 #[test]
437 fn init_with_otlp_reports_already_initialized_without_leaking_provider() {
438 let _ = tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::new());
441 let cfg = TelemetryConfig::new("svc").otlp_endpoint("http://127.0.0.1:4317");
442 let err = Telemetry::init(cfg).unwrap_err();
443 assert!(matches!(err, TelemetryError::AlreadyInitialized));
444 }
445
446 #[cfg(feature = "otlp")]
447 #[test]
448 fn shutdown_is_idempotent() {
449 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
450 let t = raw_telemetry(Some(provider));
451 t.shutdown().unwrap();
452 t.shutdown().unwrap();
453 drop(t);
455 }
456
457 #[cfg(feature = "otlp")]
458 #[test]
459 fn shutdown_maps_provider_error_to_init_failed_then_stays_ok() {
460 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
461 provider.shutdown().unwrap(); let t = raw_telemetry(Some(provider));
463 assert!(matches!(
464 t.shutdown().unwrap_err(),
465 TelemetryError::InitFailed(_)
466 ));
467 t.shutdown().unwrap();
469 }
470
471 #[cfg(feature = "otlp")]
472 #[test]
473 fn drop_is_a_best_effort_flush_short_circuit() {
474 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
475 let t = raw_telemetry(Some(provider));
476 t.shutdown().unwrap(); drop(t);
478 drop(raw_telemetry(Some(
481 opentelemetry_sdk::trace::SdkTracerProvider::builder().build(),
482 )));
483 }
484
485 #[cfg(not(feature = "otlp"))]
486 #[test]
487 fn shutdown_without_otlp_is_trivially_idempotent() {
488 let t = raw_telemetry();
489 t.shutdown().unwrap();
490 t.shutdown().unwrap();
491 drop(t);
492 }
493}