1use crate::error::CleanroomError;
5
6#[cfg(feature = "otel-traces")]
7use {
8 opentelemetry::{global, KeyValue, propagation::TextMapCompositePropagator, trace::TracerProvider},
9 opentelemetry_sdk::{
10 propagation::{BaggagePropagator, TraceContextPropagator},
11 trace::{Sampler, SdkTracerProvider, SpanExporter},
12 Resource,
13 error::OTelSdkResult,
14 },
15 tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Registry},
16};
17
18#[cfg(feature = "otel-metrics")]
19use opentelemetry_sdk::metrics::SdkMeterProvider;
20
21
22#[cfg(feature = "otel-traces")]
23use tracing_opentelemetry::OpenTelemetryLayer;
24
25
26#[derive(Clone, Debug)]
28pub enum Export {
29 OtlpHttp { endpoint: &'static str },
31 OtlpGrpc { endpoint: &'static str },
33 Stdout,
35}
36
37#[cfg(feature = "otel-traces")]
39#[derive(Debug)]
40enum SpanExporterType {
41 Otlp(opentelemetry_otlp::SpanExporter),
42 #[cfg(feature = "otel-stdout")]
43 Stdout(opentelemetry_stdout::SpanExporter),
44}
45
46#[cfg(feature = "otel-traces")]
47#[allow(refining_impl_trait)]
48impl SpanExporter for SpanExporterType {
49 fn export(
50 &self,
51 batch: Vec<opentelemetry_sdk::trace::SpanData>,
52 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = OTelSdkResult> + Send + '_>> {
53 match self {
54 SpanExporterType::Otlp(exporter) => Box::pin(exporter.export(batch)),
55 #[cfg(feature = "otel-stdout")]
56 SpanExporterType::Stdout(exporter) => Box::pin(exporter.export(batch)),
57 }
58 }
59
60 fn shutdown(&mut self) -> OTelSdkResult {
61 match self {
62 SpanExporterType::Otlp(exporter) => exporter.shutdown(),
63 #[cfg(feature = "otel-stdout")]
64 SpanExporterType::Stdout(exporter) => exporter.shutdown(),
65 }
66 }
67}
68
69#[derive(Clone, Debug)]
71pub struct OtelConfig {
72 pub service_name: &'static str,
73 pub deployment_env: &'static str, pub sample_ratio: f64, pub export: Export,
76 pub enable_fmt_layer: bool, }
78
79pub struct OtelGuard {
81 #[cfg(feature = "otel-traces")]
82 tracer_provider: SdkTracerProvider,
83 #[cfg(feature = "otel-metrics")]
84 meter_provider: Option<SdkMeterProvider>,
85 #[cfg(feature = "otel-logs")]
86 logger_provider: Option<opentelemetry_sdk::logs::SdkLoggerProvider>,
87}
88
89impl Drop for OtelGuard {
90 fn drop(&mut self) {
91 #[cfg(feature = "otel-traces")]
92 {
93 let _ = self.tracer_provider.shutdown();
94 }
95 #[cfg(feature = "otel-metrics")]
96 {
97 if let Some(mp) = self.meter_provider.take() {
98 let _ = mp.shutdown();
99 }
100 }
101 #[cfg(feature = "otel-logs")]
102 {
103 if let Some(lp) = self.logger_provider.take() {
104 let _ = lp.shutdown();
105 }
106 }
107 }
108}
109
110#[cfg(feature = "otel-traces")]
112pub fn init_otel(cfg: OtelConfig) -> Result<OtelGuard, CleanroomError> {
113 global::set_text_map_propagator(TextMapCompositePropagator::new(vec![
115 Box::new(TraceContextPropagator::new()),
116 Box::new(BaggagePropagator::new()),
117 ]));
118
119 let resource = Resource::builder_empty()
121 .with_service_name(cfg.service_name)
122 .with_attributes([
123 KeyValue::new("deployment.environment", cfg.deployment_env),
124 KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
125 KeyValue::new("telemetry.sdk.language", "rust"),
126 KeyValue::new("telemetry.sdk.name", "opentelemetry"),
127 KeyValue::new("telemetry.sdk.version", "0.31.0"),
128 ])
129 .build();
130
131 let sampler = Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(cfg.sample_ratio)));
133
134 let span_exporter = match cfg.export {
136 Export::OtlpHttp { endpoint: _ } => {
137 tracing::warn!("OTLP HTTP export not yet compatible with opentelemetry-otlp 0.31, falling back to stdout");
140 #[cfg(feature = "otel-stdout")]
141 {
142 SpanExporterType::Stdout(opentelemetry_stdout::SpanExporter::default())
143 }
144 #[cfg(not(feature = "otel-stdout"))]
145 {
146 return Err(CleanroomError::internal_error(
147 "OTLP HTTP export requires opentelemetry-otlp API compatibility fix. Use stdout export for now."
148 ));
149 }
150 },
151 Export::OtlpGrpc { endpoint: _ } => {
152 tracing::warn!("OTLP gRPC export not yet compatible with opentelemetry-otlp 0.31, falling back to stdout");
155 #[cfg(feature = "otel-stdout")]
156 {
157 SpanExporterType::Stdout(opentelemetry_stdout::SpanExporter::default())
158 }
159 #[cfg(not(feature = "otel-stdout"))]
160 {
161 return Err(CleanroomError::internal_error(
162 "OTLP gRPC export requires opentelemetry-otlp API compatibility fix. Use stdout export for now."
163 ));
164 }
165 },
166 #[cfg(feature = "otel-stdout")]
167 Export::Stdout => SpanExporterType::Stdout(opentelemetry_stdout::SpanExporter::default()),
168 #[cfg(not(feature = "otel-stdout"))]
169 Export::Stdout => panic!("Stdout export requires 'otel-stdout' feature"),
170 };
171
172 let tp = opentelemetry_sdk::trace::SdkTracerProvider::builder()
174 .with_batch_exporter(span_exporter)
175 .with_sampler(sampler)
176 .with_resource(resource)
177 .build();
178
179 let otel_layer = OpenTelemetryLayer::new(tp.tracer("clnrm"));
181 let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
182
183 let fmt_layer = if cfg.enable_fmt_layer {
184 Some(tracing_subscriber::fmt::layer().compact())
185 } else {
186 None
187 };
188
189 let subscriber = Registry::default()
190 .with(env_filter)
191 .with(otel_layer)
192 .with(fmt_layer);
193
194 tracing::subscriber::set_global_default(subscriber).ok();
195
196 #[cfg(feature = "otel-metrics")]
198 let meter_provider = {
199 Some(SdkMeterProvider::builder().build())
201 };
202
203 #[cfg(feature = "otel-logs")]
205 let logger_provider = {
206 Some(opentelemetry_sdk::logs::SdkLoggerProvider::builder().build())
208 };
209
210 Ok(OtelGuard {
211 tracer_provider: tp,
212 #[cfg(feature = "otel-metrics")]
213 meter_provider,
214 #[cfg(feature = "otel-logs")]
215 logger_provider,
216 })
217}
218
219#[cfg(feature = "otel-logs")]
221pub fn add_otel_logs_layer() {
222 tracing_subscriber::registry().init();
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn test_export_enum_variants() {
234 let http_export = Export::OtlpHttp { endpoint: "http://localhost:4318" };
235 let grpc_export = Export::OtlpGrpc { endpoint: "http://localhost:4317" };
236 let stdout_export = Export::Stdout;
237
238 assert!(matches!(http_export, Export::OtlpHttp { .. }));
239 assert!(matches!(grpc_export, Export::OtlpGrpc { .. }));
240 assert!(matches!(stdout_export, Export::Stdout));
241 }
242
243 #[test]
244 fn test_otel_config_creation() {
245 let config = OtelConfig {
246 service_name: "test-service",
247 deployment_env: "test",
248 sample_ratio: 1.0,
249 export: Export::Stdout,
250 enable_fmt_layer: true,
251 };
252
253 assert_eq!(config.service_name, "test-service");
254 assert_eq!(config.deployment_env, "test");
255 assert_eq!(config.sample_ratio, 1.0);
256 assert!(config.enable_fmt_layer);
257 }
258
259 #[cfg(feature = "otel-traces")]
260 #[test]
261 fn test_otel_initialization_with_stdout() {
262 use opentelemetry::trace::{Tracer, Span};
263
264 let config = OtelConfig {
265 service_name: "test-service",
266 deployment_env: "test",
267 sample_ratio: 1.0,
268 export: Export::Stdout,
269 enable_fmt_layer: false, };
271
272 let result = init_otel(config);
273 assert!(result.is_ok(), "OTel initialization should succeed with stdout export");
274
275 let tracer = opentelemetry::global::tracer("test");
277 let mut span = tracer.start("test-span");
278 span.end();
279 }
280
281 #[cfg(feature = "otel-traces")]
282 #[test]
283 fn test_otel_initialization_with_http_fallback() {
284 let config = OtelConfig {
285 service_name: "test-service",
286 deployment_env: "test",
287 sample_ratio: 1.0,
288 export: Export::OtlpHttp { endpoint: "http://localhost:4318" },
289 enable_fmt_layer: false,
290 };
291
292 let result = init_otel(config);
293 assert!(result.is_ok(), "OTel initialization should succeed with HTTP fallback to stdout");
294 }
295
296 #[cfg(feature = "otel-traces")]
297 #[test]
298 fn test_otel_initialization_with_grpc_fallback() {
299 let config = OtelConfig {
300 service_name: "test-service",
301 deployment_env: "test",
302 sample_ratio: 1.0,
303 export: Export::OtlpGrpc { endpoint: "http://localhost:4317" },
304 enable_fmt_layer: false,
305 };
306
307 let result = init_otel(config);
308 assert!(result.is_ok(), "OTel initialization should succeed with gRPC fallback to stdout");
309 }
310
311 #[test]
312 fn test_otel_guard_drop() {
313 let config = OtelConfig {
315 service_name: "test-service",
316 deployment_env: "test",
317 sample_ratio: 1.0,
318 export: Export::Stdout,
319 enable_fmt_layer: false,
320 };
321
322 #[cfg(feature = "otel-traces")]
323 {
324 let guard = init_otel(config).expect("Should initialize successfully");
325 drop(guard); }
327
328 #[cfg(not(feature = "otel-traces"))]
329 {
330 assert_eq!(config.service_name, "test-service");
332 }
333 }
334
335
336 #[test]
337 fn test_otel_config_clone() {
338 let config = OtelConfig {
339 service_name: "test-service",
340 deployment_env: "test",
341 sample_ratio: 0.5,
342 export: Export::OtlpHttp { endpoint: "http://localhost:4318" },
343 enable_fmt_layer: false,
344 };
345
346 let cloned = config.clone();
347 assert_eq!(cloned.service_name, config.service_name);
348 assert_eq!(cloned.sample_ratio, config.sample_ratio);
349 }
350
351 #[cfg(feature = "otel-traces")]
356 #[test]
357 fn test_sample_ratios() {
358 let ratios = vec![0.0, 0.1, 0.5, 1.0];
359
360 for ratio in ratios {
361 let config = OtelConfig {
362 service_name: "test-service",
363 deployment_env: "test",
364 sample_ratio: ratio,
365 export: Export::OtlpHttp { endpoint: "http://localhost:4318" },
366 enable_fmt_layer: false,
367 };
368
369 assert_eq!(config.sample_ratio, ratio);
370 }
371 }
372
373 #[test]
374 fn test_export_debug_format() {
375 let http = Export::OtlpHttp { endpoint: "http://localhost:4318" };
376 let debug_str = format!("{:?}", http);
377 assert!(debug_str.contains("OtlpHttp"));
378 assert!(debug_str.contains("4318"));
379 }
380
381 #[cfg(feature = "otel-traces")]
382 #[test]
383 fn test_deployment_environments() {
384 let envs = vec!["dev", "staging", "prod"];
385
386 for env in envs {
387 let config = OtelConfig {
388 service_name: "test-service",
389 deployment_env: env,
390 sample_ratio: 1.0,
391 export: Export::OtlpHttp { endpoint: "http://localhost:4318" },
392 enable_fmt_layer: true,
393 };
394
395 assert_eq!(config.deployment_env, env);
396 }
397 }
398
399 #[test]
400 fn test_export_clone() {
401 let http_export = Export::OtlpHttp { endpoint: "http://localhost:4318" };
402 let cloned = http_export.clone();
403
404 match cloned {
405 Export::OtlpHttp { endpoint } => assert_eq!(endpoint, "http://localhost:4318"),
406 _ => panic!("Expected OtlpHttp variant"),
407 }
408 }
409
410 #[test]
411 fn test_otel_config_debug_format() {
412 let config = OtelConfig {
413 service_name: "debug-test",
414 deployment_env: "debug",
415 sample_ratio: 0.75,
416 export: Export::OtlpGrpc { endpoint: "http://localhost:4317" },
417 enable_fmt_layer: true,
418 };
419
420 let debug_str = format!("{:?}", config);
421 assert!(debug_str.contains("debug-test"));
422 assert!(debug_str.contains("debug"));
423 assert!(debug_str.contains("0.75"));
424 }
425
426 #[cfg(feature = "otel-traces")]
427 #[test]
428 fn test_otel_config_with_different_exports() {
429 let http_config = OtelConfig {
430 service_name: "http-service",
431 deployment_env: "test",
432 sample_ratio: 1.0,
433 export: Export::OtlpHttp { endpoint: "http://localhost:4318" },
434 enable_fmt_layer: false,
435 };
436
437 let grpc_config = OtelConfig {
438 service_name: "grpc-service",
439 deployment_env: "test",
440 sample_ratio: 1.0,
441 export: Export::OtlpGrpc { endpoint: "http://localhost:4317" },
442 enable_fmt_layer: false,
443 };
444
445 assert_eq!(http_config.service_name, "http-service");
446 assert_eq!(grpc_config.service_name, "grpc-service");
447
448 match http_config.export {
449 Export::OtlpHttp { endpoint } => assert_eq!(endpoint, "http://localhost:4318"),
450 _ => panic!("Expected OtlpHttp variant"),
451 }
452
453 match grpc_config.export {
454 Export::OtlpGrpc { endpoint } => assert_eq!(endpoint, "http://localhost:4317"),
455 _ => panic!("Expected OtlpGrpc variant"),
456 }
457 }
458
459 #[test]
460 fn test_export_stdout_variant() {
461 let stdout_export = Export::Stdout;
462 assert!(matches!(stdout_export, Export::Stdout));
463 }
464}