Skip to main content

arete_server/
telemetry.rs

1//! Optional telemetry initialization helper.
2//!
3//! Provides a convenient way to initialize tracing with optional OpenTelemetry integration.
4//! This is an optional helper - you can configure tracing yourself if you prefer.
5
6use tracing_subscriber::layer::SubscriberExt;
7use tracing_subscriber::util::SubscriberInitExt;
8use tracing_subscriber::EnvFilter;
9
10#[cfg(feature = "otel")]
11use opentelemetry::KeyValue;
12#[cfg(feature = "otel")]
13use opentelemetry_sdk::trace::Sampler;
14#[cfg(feature = "otel")]
15use std::time::Duration;
16
17#[derive(Debug, Clone)]
18pub struct TelemetryConfig {
19    pub service_name: String,
20    pub json_logs: bool,
21    #[cfg(feature = "otel")]
22    pub otlp_endpoint: Option<String>,
23    #[cfg(feature = "otel")]
24    pub resource_attributes: Vec<KeyValue>,
25    #[cfg(feature = "otel")]
26    pub metrics_period: Duration,
27    #[cfg(feature = "otel")]
28    pub trace_sampler: Sampler,
29}
30
31impl Default for TelemetryConfig {
32    fn default() -> Self {
33        Self {
34            service_name: "arete".to_string(),
35            json_logs: false,
36            #[cfg(feature = "otel")]
37            otlp_endpoint: None,
38            #[cfg(feature = "otel")]
39            resource_attributes: Vec::new(),
40            #[cfg(feature = "otel")]
41            metrics_period: Duration::from_secs(60),
42            #[cfg(feature = "otel")]
43            trace_sampler: Sampler::ParentBased(Box::new(Sampler::AlwaysOn)),
44        }
45    }
46}
47
48impl TelemetryConfig {
49    pub fn new(service_name: impl Into<String>) -> Self {
50        Self {
51            service_name: service_name.into(),
52            ..Default::default()
53        }
54    }
55
56    pub fn with_json_logs(mut self, enabled: bool) -> Self {
57        self.json_logs = enabled;
58        self
59    }
60
61    #[cfg(feature = "otel")]
62    pub fn with_otlp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
63        self.otlp_endpoint = Some(endpoint.into());
64        self
65    }
66
67    /// Add resource attributes shared by every signal emitted by this process.
68    #[cfg(feature = "otel")]
69    pub fn with_resource_attributes(mut self, attributes: Vec<KeyValue>) -> Self {
70        self.resource_attributes.extend(attributes);
71        self
72    }
73
74    /// Set the standard OpenTelemetry service version resource attribute.
75    #[cfg(feature = "otel")]
76    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
77        self.resource_attributes
78            .push(KeyValue::new("service.version", version.into()));
79        self
80    }
81
82    /// Set the standard OpenTelemetry deployment environment resource attribute.
83    #[cfg(feature = "otel")]
84    pub fn with_deployment_environment(mut self, environment: impl Into<String>) -> Self {
85        self.resource_attributes
86            .push(KeyValue::new("deployment.environment", environment.into()));
87        self
88    }
89
90    /// Set the interval at which metrics are exported. Defaults to 60 seconds.
91    #[cfg(feature = "otel")]
92    pub fn with_metrics_period(mut self, period: Duration) -> Self {
93        self.metrics_period = period;
94        self
95    }
96
97    /// Set the SDK's head sampler.
98    ///
99    /// The default is parent-based always-on sampling, allowing a collector to
100    /// make the final tail-sampling decision.
101    #[cfg(feature = "otel")]
102    pub fn with_trace_sampler(mut self, sampler: Sampler) -> Self {
103        self.trace_sampler = sampler;
104        self
105    }
106}
107
108pub fn init(config: TelemetryConfig) -> anyhow::Result<()> {
109    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
110
111    let registry = tracing_subscriber::registry().with(env_filter);
112
113    if config.json_logs {
114        let fmt_layer = tracing_subscriber::fmt::layer().json().flatten_event(true);
115        registry.with(fmt_layer).init();
116    } else {
117        let fmt_layer = tracing_subscriber::fmt::layer();
118        registry.with(fmt_layer).init();
119    }
120
121    Ok(())
122}
123
124#[cfg(feature = "otel")]
125pub fn init_with_otel(config: TelemetryConfig) -> anyhow::Result<TelemetryGuard> {
126    use opentelemetry::global;
127    use opentelemetry_otlp::WithExportConfig;
128    use opentelemetry_sdk::propagation::TraceContextPropagator;
129    use opentelemetry_sdk::trace::Tracer;
130
131    anyhow::ensure!(
132        !config.metrics_period.is_zero(),
133        "OpenTelemetry metrics export period must be greater than zero"
134    );
135    global::set_text_map_propagator(TraceContextPropagator::new());
136
137    let endpoint = config
138        .otlp_endpoint
139        .as_deref()
140        .unwrap_or("http://localhost:4317");
141
142    let resource = telemetry_resource(&config);
143
144    let tracer: Tracer = opentelemetry_otlp::new_pipeline()
145        .tracing()
146        .with_exporter(
147            opentelemetry_otlp::new_exporter()
148                .tonic()
149                .with_endpoint(endpoint),
150        )
151        .with_trace_config(
152            opentelemetry_sdk::trace::config()
153                .with_sampler(config.trace_sampler)
154                .with_resource(resource.clone()),
155        )
156        .install_batch(opentelemetry_sdk::runtime::Tokio)?;
157
158    let meter_provider = opentelemetry_otlp::new_pipeline()
159        .metrics(opentelemetry_sdk::runtime::Tokio)
160        .with_exporter(
161            opentelemetry_otlp::new_exporter()
162                .tonic()
163                .with_endpoint(endpoint),
164        )
165        .with_resource(resource)
166        .with_period(config.metrics_period)
167        .build()?;
168    global::set_meter_provider(meter_provider.clone());
169
170    let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
171
172    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
173
174    let registry = tracing_subscriber::registry()
175        .with(env_filter)
176        .with(otel_layer);
177
178    if config.json_logs {
179        let fmt_layer = tracing_subscriber::fmt::layer().json().flatten_event(true);
180        registry.with(fmt_layer).init();
181    } else {
182        let fmt_layer = tracing_subscriber::fmt::layer();
183        registry.with(fmt_layer).init();
184    }
185
186    Ok(TelemetryGuard { meter_provider })
187}
188
189#[cfg(feature = "otel")]
190fn telemetry_resource(config: &TelemetryConfig) -> opentelemetry_sdk::Resource {
191    let mut attributes = vec![opentelemetry::KeyValue::new(
192        "service.name",
193        config.service_name.clone(),
194    )];
195    attributes.extend(config.resource_attributes.iter().cloned());
196    opentelemetry_sdk::Resource::new(attributes)
197}
198
199#[cfg(feature = "otel")]
200pub struct TelemetryGuard {
201    meter_provider: opentelemetry_sdk::metrics::SdkMeterProvider,
202}
203
204#[cfg(feature = "otel")]
205impl Drop for TelemetryGuard {
206    fn drop(&mut self) {
207        if let Err(error) = self.meter_provider.shutdown() {
208            tracing::warn!(%error, "failed to shut down OpenTelemetry meter provider");
209        }
210        opentelemetry::global::shutdown_tracer_provider();
211    }
212}
213
214#[cfg(all(test, feature = "otel"))]
215mod tests {
216    use super::*;
217    use opentelemetry::Key;
218
219    #[test]
220    fn telemetry_resource_contains_service_and_configured_attributes() {
221        let config = TelemetryConfig::new("test-service")
222            .with_service_version("1.2.3")
223            .with_deployment_environment("test")
224            .with_resource_attributes(vec![KeyValue::new("custom.key", "custom-value")]);
225
226        let resource = telemetry_resource(&config);
227
228        assert_eq!(
229            resource.get(Key::new("service.name")),
230            Some("test-service".into())
231        );
232        assert_eq!(
233            resource.get(Key::new("service.version")),
234            Some("1.2.3".into())
235        );
236        assert_eq!(
237            resource.get(Key::new("deployment.environment")),
238            Some("test".into())
239        );
240        assert_eq!(
241            resource.get(Key::new("custom.key")),
242            Some("custom-value".into())
243        );
244    }
245
246    #[test]
247    fn zero_metrics_period_is_rejected() {
248        let error = match init_with_otel(
249            TelemetryConfig::new("test-service").with_metrics_period(Duration::ZERO),
250        ) {
251            Ok(_) => panic!("a zero export period must not reach Tokio's interval"),
252            Err(error) => error,
253        };
254
255        assert!(error.to_string().contains("must be greater than zero"));
256    }
257}