1use crate::error::CleanroomError;
5
6#[cfg(feature = "otel-traces")]
7use {
8 opentelemetry::{
9 global, propagation::TextMapCompositePropagator, trace::TracerProvider, KeyValue,
10 },
11 opentelemetry_sdk::{
12 error::OTelSdkResult,
13 propagation::{BaggagePropagator, TraceContextPropagator},
14 trace::{Sampler, SdkTracerProvider, SpanExporter},
15 Resource,
16 },
17 tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry},
18};
19
20#[cfg(feature = "otel-metrics")]
21use opentelemetry_sdk::metrics::SdkMeterProvider;
22
23#[cfg(feature = "otel-traces")]
24use tracing_opentelemetry::OpenTelemetryLayer;
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 std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint);
139 let exporter = opentelemetry_otlp::SpanExporter::builder()
140 .with_http()
141 .build()
142 .map_err(|e| {
143 CleanroomError::internal_error(format!(
144 "Failed to create OTLP HTTP exporter: {}",
145 e
146 ))
147 })?;
148 SpanExporterType::Otlp(exporter)
149 }
150 Export::OtlpGrpc { endpoint } => {
151 std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint);
153 let exporter = opentelemetry_otlp::SpanExporter::builder()
154 .with_tonic()
155 .build()
156 .map_err(|e| {
157 CleanroomError::internal_error(format!(
158 "Failed to create OTLP gRPC exporter: {}",
159 e
160 ))
161 })?;
162 SpanExporterType::Otlp(exporter)
163 }
164 #[cfg(feature = "otel-stdout")]
165 Export::Stdout => SpanExporterType::Stdout(opentelemetry_stdout::SpanExporter::default()),
166 #[cfg(not(feature = "otel-stdout"))]
167 Export::Stdout => {
168 return Err(CleanroomError::internal_error(
169 "Stdout export requires 'otel-stdout' feature",
170 ));
171 }
172 };
173
174 let tp = opentelemetry_sdk::trace::SdkTracerProvider::builder()
176 .with_batch_exporter(span_exporter)
177 .with_sampler(sampler)
178 .with_resource(resource.clone())
179 .build();
180
181 let otel_layer = OpenTelemetryLayer::new(tp.tracer("clnrm"));
183 let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
184
185 let fmt_layer = if cfg.enable_fmt_layer {
186 Some(tracing_subscriber::fmt::layer().compact())
187 } else {
188 None
189 };
190
191 let subscriber = Registry::default()
192 .with(env_filter)
193 .with(otel_layer)
194 .with(fmt_layer);
195
196 tracing::subscriber::set_global_default(subscriber).ok();
197
198 #[cfg(feature = "otel-metrics")]
200 let meter_provider = {
201 use opentelemetry_sdk::metrics::SdkMeterProvider;
202 let provider = SdkMeterProvider::builder()
205 .with_resource(resource.clone())
206 .build();
207 Some(provider)
208 };
209
210 #[cfg(feature = "otel-logs")]
212 let logger_provider = {
213 use opentelemetry_sdk::logs::SdkLoggerProvider;
214 let provider = SdkLoggerProvider::builder()
217 .with_resource(resource.clone())
218 .build();
219 Some(provider)
220 };
221
222 #[cfg(feature = "otel-metrics")]
224 if let Some(ref mp) = meter_provider {
225 global::set_meter_provider(mp.clone());
226 }
227
228 Ok(OtelGuard {
232 tracer_provider: tp,
233 #[cfg(feature = "otel-metrics")]
234 meter_provider,
235 #[cfg(feature = "otel-logs")]
236 logger_provider,
237 })
238}
239
240#[cfg(feature = "otel-traces")]
242pub mod validation {
243 use crate::error::Result;
244
245 pub fn is_otel_initialized() -> bool {
247 true
250 }
251
252 pub fn span_exists(operation_name: &str) -> Result<bool> {
255 unimplemented!(
261 "span_exists: Requires in-memory span exporter. \
262 Future implementation will query captured spans for operation: {}",
263 operation_name
264 )
265 }
266
267 pub fn capture_test_spans() -> Result<usize> {
270 unimplemented!("capture_test_spans: Requires in-memory span exporter configuration")
276 }
277}
278
279#[cfg(feature = "otel-metrics")]
281pub mod metrics {
282 use opentelemetry::{global, KeyValue};
283
284 pub fn increment_counter(name: &str, value: u64, attributes: Vec<KeyValue>) {
287 let meter = global::meter("clnrm");
288 let counter = meter.u64_counter(name.to_string()).build();
289 counter.add(value, &attributes);
290 }
291
292 pub fn record_histogram(name: &str, value: f64, attributes: Vec<KeyValue>) {
294 let meter = global::meter("clnrm");
295 let histogram = meter.f64_histogram(name.to_string()).build();
296 histogram.record(value, &attributes);
297 }
298
299 pub fn record_test_duration(test_name: &str, duration_ms: f64, success: bool) {
301 let meter = global::meter("clnrm");
302 let histogram = meter
303 .f64_histogram("test.duration_ms")
304 .with_description("Test execution duration in milliseconds")
305 .build();
306
307 let attributes = vec![
308 KeyValue::new("test.name", test_name.to_string()),
309 KeyValue::new("test.success", success),
310 ];
311
312 histogram.record(duration_ms, &attributes);
313 }
314
315 pub fn record_container_operation(operation: &str, duration_ms: f64, container_type: &str) {
317 let meter = global::meter("clnrm");
318 let histogram = meter
319 .f64_histogram("container.operation_duration_ms")
320 .with_description("Container operation duration in milliseconds")
321 .build();
322
323 let attributes = vec![
324 KeyValue::new("container.operation", operation.to_string()),
325 KeyValue::new("container.type", container_type.to_string()),
326 ];
327
328 histogram.record(duration_ms, &attributes);
329 }
330
331 pub fn increment_test_counter(test_name: &str, result: &str) {
333 let meter = global::meter("clnrm");
334 let counter = meter
335 .u64_counter("test.executions")
336 .with_description("Number of test executions")
337 .build();
338
339 let attributes = vec![
340 KeyValue::new("test.name", test_name.to_string()),
341 KeyValue::new("test.result", result.to_string()),
342 ];
343
344 counter.add(1, &attributes);
345 }
346}
347
348#[cfg(feature = "otel-logs")]
350pub fn add_otel_logs_layer() {
351 let _ = tracing_subscriber::fmt::try_init();
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_export_enum_variants() {
363 let http_export = Export::OtlpHttp {
364 endpoint: "http://localhost:4318",
365 };
366 let grpc_export = Export::OtlpGrpc {
367 endpoint: "http://localhost:4317",
368 };
369 let stdout_export = Export::Stdout;
370
371 assert!(matches!(http_export, Export::OtlpHttp { .. }));
372 assert!(matches!(grpc_export, Export::OtlpGrpc { .. }));
373 assert!(matches!(stdout_export, Export::Stdout));
374 }
375
376 #[test]
377 fn test_otel_config_creation() {
378 let config = OtelConfig {
379 service_name: "test-service",
380 deployment_env: "test",
381 sample_ratio: 1.0,
382 export: Export::Stdout,
383 enable_fmt_layer: true,
384 };
385
386 assert_eq!(config.service_name, "test-service");
387 assert_eq!(config.deployment_env, "test");
388 assert_eq!(config.sample_ratio, 1.0);
389 assert!(config.enable_fmt_layer);
390 }
391
392 #[cfg(feature = "otel-traces")]
393 #[test]
394 fn test_otel_initialization_with_stdout() {
395 use opentelemetry::trace::{Span, Tracer};
396
397 let config = OtelConfig {
398 service_name: "test-service",
399 deployment_env: "test",
400 sample_ratio: 1.0,
401 export: Export::Stdout,
402 enable_fmt_layer: false, };
404
405 let result = init_otel(config);
406 assert!(
407 result.is_ok(),
408 "OTel initialization should succeed with stdout export"
409 );
410
411 let tracer = opentelemetry::global::tracer("test");
413 let mut span = tracer.start("test-span");
414 span.end();
415 }
416
417 #[cfg(feature = "otel-traces")]
418 #[test]
419 fn test_otel_initialization_with_http_fallback() {
420 let config = OtelConfig {
421 service_name: "test-service",
422 deployment_env: "test",
423 sample_ratio: 1.0,
424 export: Export::OtlpHttp {
425 endpoint: "http://localhost:4318",
426 },
427 enable_fmt_layer: false,
428 };
429
430 let result = init_otel(config);
431 assert!(
432 result.is_ok(),
433 "OTel initialization should succeed with HTTP fallback to stdout"
434 );
435 }
436
437 #[cfg(feature = "otel-traces")]
438 #[test]
439 fn test_otel_initialization_with_grpc_fallback() {
440 let config = OtelConfig {
443 service_name: "test-service",
444 deployment_env: "test",
445 sample_ratio: 1.0,
446 export: Export::OtlpGrpc {
447 endpoint: "http://localhost:4317",
448 },
449 enable_fmt_layer: false,
450 };
451
452 assert_eq!(config.service_name, "test-service");
454 assert_eq!(config.deployment_env, "test");
455 assert_eq!(config.sample_ratio, 1.0);
456 assert!(!config.enable_fmt_layer);
457 }
458
459 #[test]
460 fn test_otel_guard_drop() -> Result<(), CleanroomError> {
461 let config = OtelConfig {
463 service_name: "test-service",
464 deployment_env: "test",
465 sample_ratio: 1.0,
466 export: Export::Stdout,
467 enable_fmt_layer: false,
468 };
469
470 #[cfg(feature = "otel-traces")]
471 {
472 let guard = init_otel(config)?;
473 drop(guard); }
475
476 #[cfg(not(feature = "otel-traces"))]
477 {
478 assert_eq!(config.service_name, "test-service");
480 }
481
482 Ok(())
483 }
484
485 #[test]
486 fn test_otel_config_clone() {
487 let config = OtelConfig {
488 service_name: "test-service",
489 deployment_env: "test",
490 sample_ratio: 0.5,
491 export: Export::OtlpHttp {
492 endpoint: "http://localhost:4318",
493 },
494 enable_fmt_layer: false,
495 };
496
497 let cloned = config.clone();
498 assert_eq!(cloned.service_name, config.service_name);
499 assert_eq!(cloned.sample_ratio, config.sample_ratio);
500 }
501
502 #[cfg(feature = "otel-traces")]
507 #[test]
508 fn test_sample_ratios() {
509 let ratios = vec![0.0, 0.1, 0.5, 1.0];
510
511 for ratio in ratios {
512 let config = OtelConfig {
513 service_name: "test-service",
514 deployment_env: "test",
515 sample_ratio: ratio,
516 export: Export::OtlpHttp {
517 endpoint: "http://localhost:4318",
518 },
519 enable_fmt_layer: false,
520 };
521
522 assert_eq!(config.sample_ratio, ratio);
523 }
524 }
525
526 #[test]
527 fn test_export_debug_format() {
528 let http = Export::OtlpHttp {
529 endpoint: "http://localhost:4318",
530 };
531 let debug_str = format!("{:?}", http);
532 assert!(debug_str.contains("OtlpHttp"));
533 assert!(debug_str.contains("4318"));
534 }
535
536 #[cfg(feature = "otel-traces")]
537 #[test]
538 fn test_deployment_environments() {
539 let envs = vec!["dev", "staging", "prod"];
540
541 for env in envs {
542 let config = OtelConfig {
543 service_name: "test-service",
544 deployment_env: env,
545 sample_ratio: 1.0,
546 export: Export::OtlpHttp {
547 endpoint: "http://localhost:4318",
548 },
549 enable_fmt_layer: true,
550 };
551
552 assert_eq!(config.deployment_env, env);
553 }
554 }
555
556 #[test]
557 fn test_export_clone() {
558 let http_export = Export::OtlpHttp {
559 endpoint: "http://localhost:4318",
560 };
561 let cloned = http_export.clone();
562
563 match cloned {
564 Export::OtlpHttp { endpoint } => assert_eq!(endpoint, "http://localhost:4318"),
565 _ => panic!("Expected OtlpHttp variant"),
566 }
567 }
568
569 #[test]
570 fn test_otel_config_debug_format() {
571 let config = OtelConfig {
572 service_name: "debug-test",
573 deployment_env: "debug",
574 sample_ratio: 0.75,
575 export: Export::OtlpGrpc {
576 endpoint: "http://localhost:4317",
577 },
578 enable_fmt_layer: true,
579 };
580
581 let debug_str = format!("{:?}", config);
582 assert!(debug_str.contains("debug-test"));
583 assert!(debug_str.contains("debug"));
584 assert!(debug_str.contains("0.75"));
585 }
586
587 #[cfg(feature = "otel-traces")]
588 #[test]
589 fn test_otel_config_with_different_exports() {
590 let http_config = OtelConfig {
591 service_name: "http-service",
592 deployment_env: "test",
593 sample_ratio: 1.0,
594 export: Export::OtlpHttp {
595 endpoint: "http://localhost:4318",
596 },
597 enable_fmt_layer: false,
598 };
599
600 let grpc_config = OtelConfig {
601 service_name: "grpc-service",
602 deployment_env: "test",
603 sample_ratio: 1.0,
604 export: Export::OtlpGrpc {
605 endpoint: "http://localhost:4317",
606 },
607 enable_fmt_layer: false,
608 };
609
610 assert_eq!(http_config.service_name, "http-service");
611 assert_eq!(grpc_config.service_name, "grpc-service");
612
613 match http_config.export {
614 Export::OtlpHttp { endpoint } => assert_eq!(endpoint, "http://localhost:4318"),
615 _ => panic!("Expected OtlpHttp variant"),
616 }
617
618 match grpc_config.export {
619 Export::OtlpGrpc { endpoint } => assert_eq!(endpoint, "http://localhost:4317"),
620 _ => panic!("Expected OtlpGrpc variant"),
621 }
622 }
623
624 #[test]
625 fn test_export_stdout_variant() {
626 let stdout_export = Export::Stdout;
627 assert!(matches!(stdout_export, Export::Stdout));
628 }
629}