1use std::sync::atomic::{AtomicBool, Ordering};
5#[cfg(feature = "metrics")]
6use std::sync::Arc;
7
8use tracing::Subscriber as _SubscriberTrait;
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(config: &TelemetryConfig) -> Result<Subscriber, TelemetryError> {
176 build_pipeline(config).map(|(subscriber, _)| Subscriber(subscriber))
177}
178
179pub struct Subscriber(Box<dyn _SubscriberTrait + Send + Sync>);
186
187impl std::fmt::Debug for Subscriber {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.debug_tuple("Subscriber").field(&"[subscriber]").finish()
192 }
193}
194
195impl _SubscriberTrait for Subscriber {
196 fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
197 self.0.enabled(metadata)
198 }
199
200 fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
201 self.0.new_span(span)
202 }
203
204 fn record(&self, span: &tracing::span::Id, values: &tracing::span::Record<'_>) {
205 self.0.record(span, values)
206 }
207
208 fn record_follows_from(&self, span: &tracing::span::Id, follows: &tracing::span::Id) {
209 self.0.record_follows_from(span, follows)
210 }
211
212 fn event(&self, event: &tracing::Event<'_>) {
213 self.0.event(event)
214 }
215
216 fn enter(&self, span: &tracing::span::Id) {
217 self.0.enter(span)
218 }
219
220 fn exit(&self, span: &tracing::span::Id) {
221 self.0.exit(span)
222 }
223
224 fn clone_span(&self, span: &tracing::span::Id) -> tracing::span::Id {
225 self.0.clone_span(span)
226 }
227
228 fn try_close(&self, id: tracing::span::Id) -> bool {
229 self.0.try_close(id)
230 }
231
232 fn current_span(&self) -> tracing_core::span::Current {
233 self.0.current_span()
234 }
235
236 fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
239 self.0.max_level_hint()
240 }
241}
242
243#[must_use]
250pub fn effective_max_level(
251 config: &TelemetryConfig,
252) -> Option<tracing::level_filters::LevelFilter> {
253 let filter = resolve_filter(config).ok()?;
254 filter.max_level_hint()
255}
256
257pub(crate) fn resolve_filter(config: &TelemetryConfig) -> Result<EnvFilter, TelemetryError> {
260 match std::env::var("RUST_LOG") {
261 Ok(directive) if !directive.trim().is_empty() => build_filter(&directive),
262 _ => build_filter(&config.log_level),
263 }
264}
265
266pub(crate) fn build_filter(directive: &str) -> Result<EnvFilter, TelemetryError> {
269 EnvFilter::try_new(directive).map_err(|e| {
270 TelemetryError::InitFailed(format!("invalid env-filter directive {directive:?}: {e}"))
271 })
272}
273
274fn build_pipeline(
278 config: &TelemetryConfig,
279) -> Result<
280 (
281 Box<dyn _SubscriberTrait + Send + Sync>,
282 Option<OtelProvider>,
283 ),
284 TelemetryError,
285> {
286 let filter = resolve_filter(config)?;
287
288 #[cfg(not(feature = "otlp"))]
289 {
290 let subscriber = tracing_subscriber::registry()
291 .with(filter)
292 .with(build_fmt_layer(config.log_format)?);
293 return Ok((Box::new(subscriber), None));
294 }
295
296 #[cfg(feature = "otlp")]
297 {
298 let provider = build_tracer_provider(config)?;
299 let otel_layer = provider.as_ref().map(|p| {
300 use opentelemetry::trace::TracerProvider as _;
301 let tracer = p.tracer(config.service_name.clone());
302 tracing_opentelemetry::layer().with_tracer(tracer)
303 });
304 let subscriber = tracing_subscriber::registry()
305 .with(filter)
306 .with(build_fmt_layer(config.log_format)?)
307 .with(otel_layer);
308 Ok((Box::new(subscriber), provider))
309 }
310}
311
312#[allow(clippy::unnecessary_wraps)]
318fn build_fmt_layer<S>(format: LogFormat) -> Result<Box<dyn Layer<S> + Send + Sync>, TelemetryError>
319where
320 S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
321{
322 match format {
323 LogFormat::Json => {
324 #[cfg(feature = "json")]
325 {
326 Ok(fmt::layer().json().with_ansi(false).boxed())
329 }
330 #[cfg(not(feature = "json"))]
331 {
332 Err(TelemetryError::InitFailed(
333 "LogFormat::Json requested but the `json` feature is not compiled in; \
334 enable the default `json` feature or select LogFormat::Pretty"
335 .to_owned(),
336 ))
337 }
338 }
339 LogFormat::Pretty => Ok(fmt::layer().boxed()),
340 }
341}
342
343#[cfg(feature = "otlp")]
349fn build_tracer_provider(config: &TelemetryConfig) -> Result<Option<OtelProvider>, TelemetryError> {
350 use opentelemetry_otlp::WithExportConfig;
351
352 let Some(endpoint) = config.otlp_endpoint.as_deref() else {
353 return Ok(None);
354 };
355
356 let resource = opentelemetry_sdk::Resource::builder()
357 .with_attributes([
358 opentelemetry::KeyValue::new("service.name", config.service_name.clone()),
359 opentelemetry::KeyValue::new("service.version", config.service_version.clone()),
360 ])
361 .build();
362
363 let exporter = opentelemetry_otlp::SpanExporter::builder()
364 .with_http()
365 .with_endpoint(endpoint)
366 .build()
367 .map_err(|e| TelemetryError::InitFailed(format!("OTLP exporter build failed: {e}")))?;
368
369 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
370 .with_resource(resource)
371 .with_sampler(opentelemetry_sdk::trace::Sampler::ParentBased(Box::new(
372 opentelemetry_sdk::trace::Sampler::TraceIdRatioBased(f64::from(config.sample_rate)),
373 )))
374 .with_batch_exporter(exporter)
375 .build();
376
377 Ok(Some(provider))
378}
379
380#[cfg(test)]
381mod tests {
382 #![allow(clippy::unwrap_used, clippy::expect_used)]
383 use super::*;
384 use tracing::level_filters::LevelFilter;
385
386 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
389
390 #[cfg(feature = "otlp")]
394 fn raw_telemetry(provider: Option<OtelProvider>) -> Telemetry {
395 Telemetry {
396 #[cfg(feature = "metrics")]
397 metrics: Arc::new(metrics_kit::Registry::new()),
398 tracer_provider: provider,
399 shutdown: AtomicBool::new(false),
400 }
401 }
402
403 #[cfg(not(feature = "otlp"))]
404 fn raw_telemetry() -> Telemetry {
405 Telemetry {
406 #[cfg(feature = "metrics")]
407 metrics: Arc::new(metrics_kit::Registry::new()),
408 shutdown: AtomicBool::new(false),
409 }
410 }
411
412 fn with_rust_log<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
415 let guard = ENV_LOCK.lock().unwrap();
416 let saved = std::env::var("RUST_LOG").ok();
417 match value {
418 Some(v) => std::env::set_var("RUST_LOG", v),
419 None => std::env::remove_var("RUST_LOG"),
420 }
421 let out = f();
422 match saved {
423 Some(v) => std::env::set_var("RUST_LOG", v),
424 None => std::env::remove_var("RUST_LOG"),
425 }
426 drop(guard);
427 out
428 }
429
430 #[test]
431 fn build_filter_parses_directives_and_levels() {
432 assert_eq!(
433 build_filter("info").unwrap().max_level_hint(),
434 Some(LevelFilter::INFO)
435 );
436 assert_eq!(
437 build_filter("warn,hyper=debug").unwrap().max_level_hint(),
438 Some(LevelFilter::DEBUG)
439 );
440 assert!(matches!(
441 build_filter("hyper=notalevel"),
442 Err(TelemetryError::InitFailed(_))
443 ));
444 assert!(matches!(
445 build_filter("a=b=c"),
446 Err(TelemetryError::InitFailed(_))
447 ));
448 }
449
450 #[test]
451 fn resolve_filter_uses_config_when_rust_log_unset() {
452 with_rust_log(None, || {
453 let cfg = TelemetryConfig::new("svc").log_level("warn");
454 assert_eq!(
455 resolve_filter(&cfg).unwrap().max_level_hint(),
456 Some(LevelFilter::WARN)
457 );
458 });
459 }
460
461 #[test]
462 fn resolve_filter_rust_log_wins_over_config() {
463 with_rust_log(Some("debug"), || {
464 let cfg = TelemetryConfig::new("svc").log_level("info");
465 assert_eq!(
466 resolve_filter(&cfg).unwrap().max_level_hint(),
467 Some(LevelFilter::DEBUG)
468 );
469 });
470 }
471
472 #[test]
473 fn resolve_filter_treats_empty_rust_log_as_unset() {
474 with_rust_log(Some(" "), || {
475 let cfg = TelemetryConfig::new("svc").log_level("error");
476 assert_eq!(
477 resolve_filter(&cfg).unwrap().max_level_hint(),
478 Some(LevelFilter::ERROR)
479 );
480 });
481 }
482
483 #[test]
484 fn resolve_filter_propagates_invalid_rust_log() {
485 with_rust_log(Some("a=b=c"), || {
486 let cfg = TelemetryConfig::new("svc").log_level("info");
487 assert!(matches!(
488 resolve_filter(&cfg),
489 Err(TelemetryError::InitFailed(_))
490 ));
491 });
492 }
493
494 #[test]
495 fn build_subscriber_smoke_pretty() {
496 let cfg = TelemetryConfig::new("svc").log_format(LogFormat::Pretty);
497 let subscriber = build_subscriber(&cfg).unwrap();
498 assert_eq!(subscriber.max_level_hint(), Some(LevelFilter::INFO));
499 }
500
501 #[cfg(feature = "json")]
502 #[test]
503 fn build_subscriber_smoke_json() {
504 let cfg = TelemetryConfig::new("svc");
505 let subscriber = build_subscriber(&cfg).unwrap();
506 assert_eq!(subscriber.max_level_hint(), Some(LevelFilter::INFO));
507 }
508
509 #[test]
510 fn init_reports_already_initialized_when_global_is_taken() {
511 let _ = tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::new());
514 let err =
515 Telemetry::init(TelemetryConfig::new("svc").log_format(LogFormat::Pretty)).unwrap_err();
516 assert!(matches!(err, TelemetryError::AlreadyInitialized));
517 }
518
519 #[cfg(feature = "otlp")]
520 #[test]
521 fn init_with_otlp_reports_already_initialized_without_leaking_provider() {
522 let _ = tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::new());
525 let cfg = TelemetryConfig::new("svc").otlp_endpoint("http://127.0.0.1:4317");
526 let err = Telemetry::init(cfg).unwrap_err();
527 assert!(matches!(err, TelemetryError::AlreadyInitialized));
528 }
529
530 #[cfg(feature = "otlp")]
531 #[test]
532 fn shutdown_is_idempotent() {
533 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
534 let t = raw_telemetry(Some(provider));
535 t.shutdown().unwrap();
536 t.shutdown().unwrap();
537 drop(t);
539 }
540
541 #[cfg(feature = "otlp")]
542 #[test]
543 fn shutdown_maps_provider_error_to_init_failed_then_stays_ok() {
544 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
545 provider.shutdown().unwrap(); let t = raw_telemetry(Some(provider));
547 assert!(matches!(
548 t.shutdown().unwrap_err(),
549 TelemetryError::InitFailed(_)
550 ));
551 t.shutdown().unwrap();
553 }
554
555 #[cfg(feature = "otlp")]
556 #[test]
557 fn drop_is_a_best_effort_flush_short_circuit() {
558 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
559 let t = raw_telemetry(Some(provider));
560 t.shutdown().unwrap(); drop(t);
562 drop(raw_telemetry(Some(
565 opentelemetry_sdk::trace::SdkTracerProvider::builder().build(),
566 )));
567 }
568
569 #[cfg(not(feature = "otlp"))]
570 #[test]
571 fn shutdown_without_otlp_is_trivially_idempotent() {
572 let t = raw_telemetry();
573 t.shutdown().unwrap();
574 t.shutdown().unwrap();
575 drop(t);
576 }
577}