arete_server/
telemetry.rs1use 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 #[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 #[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 #[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 #[cfg(feature = "otel")]
92 pub fn with_metrics_period(mut self, period: Duration) -> Self {
93 self.metrics_period = period;
94 self
95 }
96
97 #[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}