Skip to main content

stoffel/observability/
tracing.rs

1//! Tracing and OpenTelemetry setup helpers.
2//!
3//! Applications that already install subscribers can ignore this module. The
4//! helpers are for SDK users who want a quick structured tracing setup.
5
6use opentelemetry::trace::TracerProvider as _;
7use opentelemetry_sdk::{trace::SdkTracerProvider, Resource};
8use serde::{Deserialize, Serialize};
9use tracing::Level;
10use tracing_subscriber::filter::LevelFilter;
11use tracing_subscriber::fmt;
12use tracing_subscriber::prelude::*;
13
14use crate::error::{Error, Result};
15
16#[derive(Debug, Clone)]
17pub struct TracingConfig {
18    max_level: Level,
19    ansi: bool,
20    compact: bool,
21    service_name: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct TracingConfigSummary {
26    pub max_level: String,
27    pub ansi: bool,
28    pub compact: bool,
29    pub service_name: String,
30}
31
32impl Default for TracingConfig {
33    fn default() -> Self {
34        Self {
35            max_level: Level::INFO,
36            ansi: true,
37            compact: true,
38            service_name: "stoffel-rust-sdk".to_owned(),
39        }
40    }
41}
42
43impl TracingConfig {
44    pub fn builder() -> TracingConfigBuilder {
45        TracingConfigBuilder::default()
46    }
47
48    pub fn max_level(&self) -> Level {
49        self.max_level
50    }
51
52    pub fn ansi(&self) -> bool {
53        self.ansi
54    }
55
56    pub fn compact(&self) -> bool {
57        self.compact
58    }
59
60    pub fn service_name(&self) -> &str {
61        &self.service_name
62    }
63
64    pub fn summary(&self) -> TracingConfigSummary {
65        TracingConfigSummary {
66            max_level: self.max_level.to_string(),
67            ansi: self.ansi,
68            compact: self.compact,
69            service_name: self.service_name.clone(),
70        }
71    }
72
73    pub fn validate(&self) -> Result<()> {
74        if self.service_name.trim().is_empty() {
75            return Err(Error::Configuration(
76                "tracing service_name must not be empty".to_owned(),
77            ));
78        }
79        Ok(())
80    }
81
82    pub fn install(self) -> Result<()> {
83        self.validate()?;
84        if self.compact {
85            fmt()
86                .with_max_level(self.max_level)
87                .with_ansi(self.ansi)
88                .compact()
89                .try_init()
90        } else {
91            fmt()
92                .with_max_level(self.max_level)
93                .with_ansi(self.ansi)
94                .try_init()
95        }
96        .map_err(|error| Error::Configuration(format!("failed to initialize tracing: {error}")))
97    }
98
99    pub fn install_opentelemetry_stdout(self) -> Result<OpenTelemetryGuard> {
100        self.validate()?;
101        let provider = SdkTracerProvider::builder()
102            .with_simple_exporter(opentelemetry_stdout::SpanExporter::default())
103            .with_resource(
104                Resource::builder()
105                    .with_service_name(self.service_name.clone())
106                    .build(),
107            )
108            .build();
109        let level_filter = LevelFilter::from_level(self.max_level);
110        let init_result = if self.compact {
111            let tracer = provider.tracer("stoffel-rust-sdk");
112            let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
113            tracing_subscriber::registry()
114                .with(
115                    fmt::layer()
116                        .compact()
117                        .with_ansi(self.ansi)
118                        .with_filter(level_filter),
119                )
120                .with(telemetry)
121                .try_init()
122        } else {
123            let tracer = provider.tracer("stoffel-rust-sdk");
124            let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
125            tracing_subscriber::registry()
126                .with(fmt::layer().with_ansi(self.ansi).with_filter(level_filter))
127                .with(telemetry)
128                .try_init()
129        };
130        init_result.map_err(|error| {
131            Error::Configuration(format!(
132                "failed to initialize OpenTelemetry tracing: {error}"
133            ))
134        })?;
135
136        Ok(OpenTelemetryGuard {
137            provider: Some(provider),
138        })
139    }
140}
141
142#[derive(Debug)]
143pub struct OpenTelemetryGuard {
144    provider: Option<SdkTracerProvider>,
145}
146
147impl OpenTelemetryGuard {
148    pub fn shutdown(mut self) -> Result<()> {
149        shutdown_provider(self.provider.take())
150    }
151}
152
153impl Drop for OpenTelemetryGuard {
154    fn drop(&mut self) {
155        let _ = shutdown_provider(self.provider.take());
156    }
157}
158
159fn shutdown_provider(provider: Option<SdkTracerProvider>) -> Result<()> {
160    if let Some(provider) = provider {
161        provider.shutdown().map_err(|error| {
162            Error::Configuration(format!(
163                "failed to shut down OpenTelemetry tracer provider: {error}"
164            ))
165        })?;
166    }
167    Ok(())
168}
169
170#[derive(Debug, Clone, Default)]
171pub struct TracingConfigBuilder {
172    config: TracingConfig,
173}
174
175impl TracingConfigBuilder {
176    pub fn max_level(mut self, level: Level) -> Self {
177        self.config.max_level = level;
178        self
179    }
180
181    pub fn ansi(mut self, ansi: bool) -> Self {
182        self.config.ansi = ansi;
183        self
184    }
185
186    pub fn compact(mut self, compact: bool) -> Self {
187        self.config.compact = compact;
188        self
189    }
190
191    pub fn service_name(mut self, service_name: impl Into<String>) -> Self {
192        self.config.service_name = service_name.into();
193        self
194    }
195
196    pub fn build(self) -> TracingConfig {
197        self.config
198    }
199
200    pub fn install(self) -> Result<()> {
201        self.build().install()
202    }
203
204    pub fn install_opentelemetry_stdout(self) -> Result<OpenTelemetryGuard> {
205        self.build().install_opentelemetry_stdout()
206    }
207}
208
209pub fn init_tracing() -> Result<()> {
210    TracingConfig::default().install()
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn shutdown_provider_allows_absent_provider() {
219        shutdown_provider(None).expect("empty OpenTelemetry guard shutdown is a no-op");
220    }
221
222    #[test]
223    fn dropping_empty_guard_is_a_noop() {
224        drop(OpenTelemetryGuard { provider: None });
225    }
226}