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(feature = "otel-traces")]
360pub mod spans {
361 use tracing::{span, Level};
362
363 pub fn run_span(config_path: &str, test_count: usize) -> tracing::Span {
366 span!(
367 Level::INFO,
368 "clnrm.run",
369 clnrm.version = env!("CARGO_PKG_VERSION"),
370 test.config = config_path,
371 test.count = test_count,
372 otel.kind = "internal",
373 )
374 }
375
376 pub fn test_span(test_name: &str) -> tracing::Span {
379 span!(
380 Level::INFO,
381 "clnrm.test",
382 test.name = test_name,
383 test.hermetic = true,
384 otel.kind = "internal",
385 )
386 }
387
388 pub fn service_start_span(service_name: &str, service_type: &str) -> tracing::Span {
391 span!(
392 Level::INFO,
393 "clnrm.service.start",
394 service.name = service_name,
395 service.type = service_type,
396 otel.kind = "internal",
397 )
398 }
399
400 pub fn command_execute_span(command: &str) -> tracing::Span {
403 span!(
404 Level::INFO,
405 "clnrm.command.execute",
406 command = command,
407 otel.kind = "internal",
408 )
409 }
410
411 pub fn assertion_span(assertion_type: &str) -> tracing::Span {
414 span!(
415 Level::INFO,
416 "clnrm.assertion.validate",
417 assertion.type = assertion_type,
418 otel.kind = "internal",
419 )
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn test_export_enum_variants() {
429 let http_export = Export::OtlpHttp {
430 endpoint: "http://localhost:4318",
431 };
432 let grpc_export = Export::OtlpGrpc {
433 endpoint: "http://localhost:4317",
434 };
435 let stdout_export = Export::Stdout;
436
437 assert!(matches!(http_export, Export::OtlpHttp { .. }));
438 assert!(matches!(grpc_export, Export::OtlpGrpc { .. }));
439 assert!(matches!(stdout_export, Export::Stdout));
440 }
441
442 #[test]
443 fn test_otel_config_creation() {
444 let config = OtelConfig {
445 service_name: "test-service",
446 deployment_env: "test",
447 sample_ratio: 1.0,
448 export: Export::Stdout,
449 enable_fmt_layer: true,
450 };
451
452 assert_eq!(config.service_name, "test-service");
453 assert_eq!(config.deployment_env, "test");
454 assert_eq!(config.sample_ratio, 1.0);
455 assert!(config.enable_fmt_layer);
456 }
457
458 #[cfg(feature = "otel-traces")]
459 #[test]
460 fn test_otel_initialization_with_stdout() {
461 use opentelemetry::trace::{Span, Tracer};
462
463 let config = OtelConfig {
464 service_name: "test-service",
465 deployment_env: "test",
466 sample_ratio: 1.0,
467 export: Export::Stdout,
468 enable_fmt_layer: false, };
470
471 let result = init_otel(config);
472 assert!(
473 result.is_ok(),
474 "OTel initialization should succeed with stdout export"
475 );
476
477 let tracer = opentelemetry::global::tracer("test");
479 let mut span = tracer.start("test-span");
480 span.end();
481 }
482
483 #[cfg(feature = "otel-traces")]
484 #[test]
485 fn test_otel_initialization_with_http_fallback() {
486 let config = OtelConfig {
487 service_name: "test-service",
488 deployment_env: "test",
489 sample_ratio: 1.0,
490 export: Export::OtlpHttp {
491 endpoint: "http://localhost:4318",
492 },
493 enable_fmt_layer: false,
494 };
495
496 let result = init_otel(config);
497 assert!(
498 result.is_ok(),
499 "OTel initialization should succeed with HTTP fallback to stdout"
500 );
501 }
502
503 #[cfg(feature = "otel-traces")]
504 #[test]
505 fn test_otel_initialization_with_grpc_fallback() {
506 let config = OtelConfig {
509 service_name: "test-service",
510 deployment_env: "test",
511 sample_ratio: 1.0,
512 export: Export::OtlpGrpc {
513 endpoint: "http://localhost:4317",
514 },
515 enable_fmt_layer: false,
516 };
517
518 assert_eq!(config.service_name, "test-service");
520 assert_eq!(config.deployment_env, "test");
521 assert_eq!(config.sample_ratio, 1.0);
522 assert!(!config.enable_fmt_layer);
523 }
524
525 #[test]
526 fn test_otel_guard_drop() -> Result<(), CleanroomError> {
527 let config = OtelConfig {
529 service_name: "test-service",
530 deployment_env: "test",
531 sample_ratio: 1.0,
532 export: Export::Stdout,
533 enable_fmt_layer: false,
534 };
535
536 #[cfg(feature = "otel-traces")]
537 {
538 let guard = init_otel(config)?;
539 drop(guard); }
541
542 #[cfg(not(feature = "otel-traces"))]
543 {
544 assert_eq!(config.service_name, "test-service");
546 }
547
548 Ok(())
549 }
550
551 #[test]
552 fn test_otel_config_clone() {
553 let config = OtelConfig {
554 service_name: "test-service",
555 deployment_env: "test",
556 sample_ratio: 0.5,
557 export: Export::OtlpHttp {
558 endpoint: "http://localhost:4318",
559 },
560 enable_fmt_layer: false,
561 };
562
563 let cloned = config.clone();
564 assert_eq!(cloned.service_name, config.service_name);
565 assert_eq!(cloned.sample_ratio, config.sample_ratio);
566 }
567
568 #[cfg(feature = "otel-traces")]
573 #[test]
574 fn test_sample_ratios() {
575 let ratios = vec![0.0, 0.1, 0.5, 1.0];
576
577 for ratio in ratios {
578 let config = OtelConfig {
579 service_name: "test-service",
580 deployment_env: "test",
581 sample_ratio: ratio,
582 export: Export::OtlpHttp {
583 endpoint: "http://localhost:4318",
584 },
585 enable_fmt_layer: false,
586 };
587
588 assert_eq!(config.sample_ratio, ratio);
589 }
590 }
591
592 #[test]
593 fn test_export_debug_format() {
594 let http = Export::OtlpHttp {
595 endpoint: "http://localhost:4318",
596 };
597 let debug_str = format!("{:?}", http);
598 assert!(debug_str.contains("OtlpHttp"));
599 assert!(debug_str.contains("4318"));
600 }
601
602 #[cfg(feature = "otel-traces")]
603 #[test]
604 fn test_deployment_environments() {
605 let envs = vec!["dev", "staging", "prod"];
606
607 for env in envs {
608 let config = OtelConfig {
609 service_name: "test-service",
610 deployment_env: env,
611 sample_ratio: 1.0,
612 export: Export::OtlpHttp {
613 endpoint: "http://localhost:4318",
614 },
615 enable_fmt_layer: true,
616 };
617
618 assert_eq!(config.deployment_env, env);
619 }
620 }
621
622 #[test]
623 fn test_export_clone() {
624 let http_export = Export::OtlpHttp {
625 endpoint: "http://localhost:4318",
626 };
627 let cloned = http_export.clone();
628
629 match cloned {
630 Export::OtlpHttp { endpoint } => assert_eq!(endpoint, "http://localhost:4318"),
631 _ => panic!("Expected OtlpHttp variant"),
632 }
633 }
634
635 #[test]
636 fn test_otel_config_debug_format() {
637 let config = OtelConfig {
638 service_name: "debug-test",
639 deployment_env: "debug",
640 sample_ratio: 0.75,
641 export: Export::OtlpGrpc {
642 endpoint: "http://localhost:4317",
643 },
644 enable_fmt_layer: true,
645 };
646
647 let debug_str = format!("{:?}", config);
648 assert!(debug_str.contains("debug-test"));
649 assert!(debug_str.contains("debug"));
650 assert!(debug_str.contains("0.75"));
651 }
652
653 #[cfg(feature = "otel-traces")]
654 #[test]
655 fn test_otel_config_with_different_exports() {
656 let http_config = OtelConfig {
657 service_name: "http-service",
658 deployment_env: "test",
659 sample_ratio: 1.0,
660 export: Export::OtlpHttp {
661 endpoint: "http://localhost:4318",
662 },
663 enable_fmt_layer: false,
664 };
665
666 let grpc_config = OtelConfig {
667 service_name: "grpc-service",
668 deployment_env: "test",
669 sample_ratio: 1.0,
670 export: Export::OtlpGrpc {
671 endpoint: "http://localhost:4317",
672 },
673 enable_fmt_layer: false,
674 };
675
676 assert_eq!(http_config.service_name, "http-service");
677 assert_eq!(grpc_config.service_name, "grpc-service");
678
679 match http_config.export {
680 Export::OtlpHttp { endpoint } => assert_eq!(endpoint, "http://localhost:4318"),
681 _ => panic!("Expected OtlpHttp variant"),
682 }
683
684 match grpc_config.export {
685 Export::OtlpGrpc { endpoint } => assert_eq!(endpoint, "http://localhost:4317"),
686 _ => panic!("Expected OtlpGrpc variant"),
687 }
688 }
689
690 #[test]
691 fn test_export_stdout_variant() {
692 let stdout_export = Export::Stdout;
693 assert!(matches!(stdout_export, Export::Stdout));
694 }
695}