1use crate::config::types::TestConfig;
10use crate::error::{CleanroomError, Result};
11use crate::validation::count_validator::{CountBound, CountExpectation};
12use crate::validation::graph_validator::GraphExpectation;
13use crate::validation::hermeticity_validator::HermeticityExpectation;
14use crate::validation::order_validator::OrderExpectation;
15use crate::validation::span_validator::{SpanData, SpanValidator};
16use crate::validation::status_validator::{StatusCode, StatusExpectation};
17use crate::validation::window_validator::WindowExpectation;
18use sha2::{Digest, Sha256};
19use std::path::Path;
20
21fn load_spans_from_artifacts(test_config: &TestConfig) -> Result<Vec<SpanData>> {
35 let mut all_spans = Vec::new();
36 let mut found_any_artifacts = false;
37
38 if !test_config.scenario.is_empty() {
40 for scenario in &test_config.scenario {
41 let artifact_path = format!(".clnrm/artifacts/{}/spans.json", scenario.name);
42
43 if Path::new(&artifact_path).exists() {
44 tracing::info!(
45 scenario = %scenario.name,
46 path = %artifact_path,
47 "Loading spans from artifact"
48 );
49
50 let validator = SpanValidator::from_file(&artifact_path)?;
51 let spans = validator.spans();
52 all_spans.extend_from_slice(spans);
53 found_any_artifacts = true;
54 } else {
55 tracing::debug!(
56 scenario = %scenario.name,
57 path = %artifact_path,
58 "No artifacts found for scenario"
59 );
60 }
61 }
62 }
63
64 if !found_any_artifacts {
66 let test_name = test_config.get_name()?;
67 let artifact_path = format!(".clnrm/artifacts/{}/spans.json", test_name);
68
69 if Path::new(&artifact_path).exists() {
70 tracing::info!(
71 test = %test_name,
72 path = %artifact_path,
73 "Loading spans from artifact (legacy format)"
74 );
75
76 let validator = SpanValidator::from_file(&artifact_path)?;
77 let spans = validator.spans();
78 all_spans.extend_from_slice(spans);
79 found_any_artifacts = true;
80 }
81 }
82
83 if !found_any_artifacts {
84 return Err(CleanroomError::validation_error(
85 "No artifact files found. Run tests with artifact collection enabled first, \
86 or provide --traces flag explicitly.",
87 ));
88 }
89
90 tracing::info!(span_count = all_spans.len(), "Loaded spans from artifacts");
91
92 Ok(all_spans)
93}
94
95pub fn analyze_traces(test_file: &Path, traces_file: Option<&Path>) -> Result<AnalysisReport> {
109 let config_str = std::fs::read_to_string(test_file).map_err(|e| {
111 CleanroomError::config_error(format!(
112 "Failed to read test file {}: {}",
113 test_file.display(),
114 e
115 ))
116 })?;
117
118 let config: TestConfig = toml::from_str(&config_str)
119 .map_err(|e| CleanroomError::config_error(format!("Failed to parse test TOML: {}", e)))?;
120
121 let (validator, traces_source) = if let Some(traces_path) = traces_file {
123 tracing::info!(
124 path = %traces_path.display(),
125 "Loading spans from explicit traces file"
126 );
127
128 if !traces_path.exists() {
130 return Err(CleanroomError::validation_error(format!(
131 "Trace file not found: {}\n\n\
132 📚 OTEL Collector Setup Required:\n\
133 \n\
134 To collect OTEL traces:\n\
135 1. Start OTEL collector: docker-compose up otel-collector\n\
136 2. Configure exporter in your tests\n\
137 3. Run tests to generate traces\n\
138 4. Traces will be in: /tmp/traces/ or collector output\n\
139 \n\
140 📖 Full documentation:\n\
141 - docs/OPENTELEMETRY_INTEGRATION_GUIDE.md\n\
142 - https://github.com/seanchatmangpt/clnrm#opentelemetry\n\
143 \n\
144 💡 Quick start:\n\
145 export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318\n\
146 clnrm run --otel-enabled tests/example.toml",
147 traces_path.display()
148 )));
149 }
150
151 let validator = SpanValidator::from_file(traces_path)?;
152 (validator, traces_path.display().to_string())
153 } else {
154 tracing::info!("Auto-loading spans from artifacts");
155 let spans = load_spans_from_artifacts(&config)?;
156 let validator = SpanValidator { spans };
157 (validator, ".clnrm/artifacts/**/spans.json".to_string())
158 };
159
160 let spans = validator.spans();
161
162 let test_name = config
164 .meta
165 .as_ref()
166 .map(|m| m.name.clone())
167 .or_else(|| config.test.as_ref().map(|t| t.metadata.name.clone()))
168 .unwrap_or_else(|| "unknown".to_string());
169
170 let digest = compute_trace_digest(spans)?;
172
173 let mut report = AnalysisReport {
174 test_name: test_name.clone(),
175 traces_file: traces_source,
176 span_count: spans.len(),
177 event_count: count_events(spans),
178 digest,
179 validators: Vec::new(),
180 };
181
182 if let Some(ref expect) = config.expect {
184 if !expect.span.is_empty() {
186 let result = validate_span_expectations(&expect.span, spans);
187 report.validators.push(result);
188 }
189
190 if let Some(ref graph_config) = expect.graph {
192 let result = validate_graph_structure(graph_config, spans);
193 report.validators.push(result);
194 }
195
196 if let Some(ref counts_config) = expect.counts {
198 let result = validate_counts(counts_config, spans);
199 report.validators.push(result);
200 }
201
202 if !expect.window.is_empty() {
204 let result = validate_windows(&expect.window, spans);
205 report.validators.push(result);
206 }
207
208 if let Some(ref order_config) = expect.order {
210 let result = validate_ordering(order_config, spans);
211 report.validators.push(result);
212 }
213
214 if let Some(ref status_config) = expect.status {
216 let result = validate_status(status_config, spans);
217 report.validators.push(result);
218 }
219
220 if let Some(ref hermetic_config) = expect.hermeticity {
222 let result = validate_hermeticity(hermetic_config, spans);
223 report.validators.push(result);
224 }
225 }
226
227 Ok(report)
228}
229
230fn validate_span_expectations(
232 span_configs: &[crate::config::otel::SpanExpectationConfig],
233 spans: &[SpanData],
234) -> ValidatorResult {
235 let mut passed_count = 0;
236 let total_count = span_configs.len();
237 let mut errors = Vec::new();
238
239 for config in span_configs {
240 let matching_spans: Vec<_> = spans.iter().filter(|s| s.name == config.name).collect();
242
243 if matching_spans.is_empty() {
244 errors.push(format!("Expected span '{}' not found", config.name));
245 continue;
246 }
247
248 for span in matching_spans {
250 let mut span_valid = true;
251
252 if let Some(ref attrs_config) = config.attrs {
254 if let Some(ref all_attrs) = attrs_config.all {
255 for (key, expected_value) in all_attrs {
256 if let Some(actual_value) = span.attributes.get(key) {
257 let actual_str = actual_value.to_string();
258 if !actual_str.contains(expected_value) {
259 errors.push(format!(
260 "Span '{}': attribute '{}' expected '{}', got '{}'",
261 config.name, key, expected_value, actual_str
262 ));
263 span_valid = false;
264 }
265 } else {
266 errors.push(format!(
267 "Span '{}': missing expected attribute '{}'",
268 config.name, key
269 ));
270 span_valid = false;
271 }
272 }
273 }
274 }
275
276 if span_valid {
277 passed_count += 1;
278 }
279 }
280 }
281
282 ValidatorResult {
283 name: "Span Expectations".to_string(),
284 passed: errors.is_empty(),
285 details: if passed_count > 0 {
286 format!("{}/{} passed", passed_count, total_count)
287 } else {
288 format!("FAIL: {}", errors.join(", "))
289 },
290 }
291}
292
293fn validate_graph_structure(
295 graph_config: &crate::config::otel::GraphExpectationConfig,
296 spans: &[SpanData],
297) -> ValidatorResult {
298 let edges: Vec<_> = graph_config
300 .must_include
301 .as_ref()
302 .map(|edges| {
303 edges
304 .iter()
305 .filter_map(|edge| {
306 if edge.len() >= 2 {
307 Some((edge[0].clone(), edge[1].clone()))
308 } else {
309 None
310 }
311 })
312 .collect()
313 })
314 .unwrap_or_default();
315
316 if edges.is_empty() {
317 return ValidatorResult {
318 name: "Graph Structure".to_string(),
319 passed: true,
320 details: "no edges to validate".to_string(),
321 };
322 }
323
324 let graph = GraphExpectation::new(edges.clone());
325
326 match graph.validate(spans) {
327 Ok(_) => ValidatorResult {
328 name: "Graph Structure".to_string(),
329 passed: true,
330 details: format!("all {} edges present", edges.len()),
331 },
332 Err(e) => ValidatorResult {
333 name: "Graph Structure".to_string(),
334 passed: false,
335 details: format!("FAIL: {}", e),
336 },
337 }
338}
339
340fn validate_counts(
342 counts_config: &crate::config::otel::CountExpectationConfig,
343 spans: &[SpanData],
344) -> ValidatorResult {
345 let mut expectation = CountExpectation::new();
346
347 if let Some(ref total) = counts_config.spans_total {
349 let bound = if let Some(eq) = total.eq {
350 CountBound::eq(eq)
351 } else if let Some(gte) = total.gte {
352 if let Some(lte) = total.lte {
353 match CountBound::range(gte, lte) {
354 Ok(b) => b,
355 Err(_) => CountBound::gte(gte),
356 }
357 } else {
358 CountBound::gte(gte)
359 }
360 } else if let Some(lte) = total.lte {
361 CountBound::lte(lte)
362 } else {
363 CountBound {
365 gte: None,
366 lte: None,
367 eq: None,
368 }
369 };
370 expectation = expectation.with_spans_total(bound);
371 }
372
373 if let Some(ref by_name) = counts_config.by_name {
375 for (name, bounds) in by_name {
376 let bound = if let Some(eq) = bounds.eq {
377 CountBound::eq(eq)
378 } else if let Some(gte) = bounds.gte {
379 if let Some(lte) = bounds.lte {
380 match CountBound::range(gte, lte) {
381 Ok(b) => b,
382 Err(_) => CountBound::gte(gte),
383 }
384 } else {
385 CountBound::gte(gte)
386 }
387 } else if let Some(lte) = bounds.lte {
388 CountBound::lte(lte)
389 } else {
390 continue;
391 };
392
393 expectation = expectation.with_name_count(name.clone(), bound);
394 }
395 }
396
397 match expectation.validate(spans) {
398 Ok(_) => ValidatorResult {
399 name: "Counts".to_string(),
400 passed: true,
401 details: format!("spans_total: {}", spans.len()),
402 },
403 Err(e) => ValidatorResult {
404 name: "Counts".to_string(),
405 passed: false,
406 details: format!("FAIL: {}", e),
407 },
408 }
409}
410
411fn validate_windows(
413 window_configs: &[crate::config::otel::WindowExpectationConfig],
414 spans: &[SpanData],
415) -> ValidatorResult {
416 let mut passed = 0;
417 let mut failed = 0;
418 let mut errors = Vec::new();
419
420 for config in window_configs {
421 let window = WindowExpectation {
422 outer: config.outer.clone(),
423 contains: config.contains.clone(),
424 };
425
426 match window.validate(spans) {
427 Ok(_) => passed += 1,
428 Err(e) => {
429 failed += 1;
430 errors.push(format!("window '{}': {}", config.outer, e));
431 }
432 }
433 }
434
435 ValidatorResult {
436 name: "Window Containment".to_string(),
437 passed: failed == 0,
438 details: if failed == 0 {
439 format!("all {} windows satisfied", passed)
440 } else {
441 format!("FAIL: {}", errors.join(", "))
442 },
443 }
444}
445
446fn validate_ordering(
448 order_config: &crate::config::otel::OrderExpectationConfig,
449 spans: &[SpanData],
450) -> ValidatorResult {
451 let mut must_precede = Vec::new();
452 let mut must_follow = Vec::new();
453
454 if let Some(ref precede) = order_config.must_precede {
456 for edge in precede {
457 if edge.len() >= 2 {
458 must_precede.push((edge[0].clone(), edge[1].clone()));
459 }
460 }
461 }
462
463 if let Some(ref follow) = order_config.must_follow {
465 for edge in follow {
466 if edge.len() >= 2 {
467 must_follow.push((edge[0].clone(), edge[1].clone()));
468 }
469 }
470 }
471
472 if must_precede.is_empty() && must_follow.is_empty() {
473 return ValidatorResult {
474 name: "Ordering".to_string(),
475 passed: true,
476 details: "no ordering constraints".to_string(),
477 };
478 }
479
480 let expectation = OrderExpectation::new()
481 .with_must_precede(must_precede)
482 .with_must_follow(must_follow);
483
484 match expectation.validate(spans) {
485 Ok(_) => ValidatorResult {
486 name: "Ordering".to_string(),
487 passed: true,
488 details: "all constraints satisfied".to_string(),
489 },
490 Err(e) => ValidatorResult {
491 name: "Ordering".to_string(),
492 passed: false,
493 details: format!("FAIL: {}", e),
494 },
495 }
496}
497
498fn validate_status(
500 status_config: &crate::config::otel::StatusExpectationConfig,
501 spans: &[SpanData],
502) -> ValidatorResult {
503 let mut expectation = StatusExpectation::new();
504
505 if let Some(ref all_status) = status_config.all {
507 if let Ok(status) = StatusCode::parse(all_status) {
508 expectation = expectation.with_all(status);
509 } else {
510 return ValidatorResult {
511 name: "Status".to_string(),
512 passed: false,
513 details: format!("FAIL: invalid status code '{}'", all_status),
514 };
515 }
516 }
517
518 if let Some(ref by_name) = status_config.by_name {
520 for (pattern, expected) in by_name {
521 if let Ok(status) = StatusCode::parse(expected) {
522 expectation.by_name.insert(pattern.clone(), status);
523 } else {
524 return ValidatorResult {
525 name: "Status".to_string(),
526 passed: false,
527 details: format!(
528 "FAIL: invalid status code '{}' for pattern '{}'",
529 expected, pattern
530 ),
531 };
532 }
533 }
534 }
535
536 match expectation.validate(spans) {
537 Ok(_) => ValidatorResult {
538 name: "Status".to_string(),
539 passed: true,
540 details: "all spans OK".to_string(),
541 },
542 Err(e) => ValidatorResult {
543 name: "Status".to_string(),
544 passed: false,
545 details: format!("FAIL: {}", e),
546 },
547 }
548}
549
550fn validate_hermeticity(
552 hermetic_config: &crate::config::otel::HermeticityExpectationConfig,
553 spans: &[SpanData],
554) -> ValidatorResult {
555 let mut expectation = HermeticityExpectation {
556 no_external_services: hermetic_config.no_external_services,
557 resource_attrs_must_match: None,
558 sdk_resource_attrs_must_match: None,
559 span_attrs_forbid_keys: None,
560 };
561
562 if let Some(ref resource_attrs) = hermetic_config.resource_attrs {
564 if let Some(ref must_match) = resource_attrs.must_match {
565 expectation.resource_attrs_must_match = Some(must_match.clone());
566 }
567 }
568
569 if let Some(ref span_attrs) = hermetic_config.span_attrs {
571 if let Some(ref forbid_keys) = span_attrs.forbid_keys {
572 expectation.span_attrs_forbid_keys = Some(forbid_keys.clone());
573 }
574 }
575
576 match expectation.validate(spans) {
577 Ok(_) => ValidatorResult {
578 name: "Hermeticity".to_string(),
579 passed: true,
580 details: "no external services detected".to_string(),
581 },
582 Err(e) => ValidatorResult {
583 name: "Hermeticity".to_string(),
584 passed: false,
585 details: format!("FAIL: {}", e),
586 },
587 }
588}
589
590fn count_events(spans: &[SpanData]) -> usize {
592 spans
593 .iter()
594 .filter_map(|s| s.events.as_ref())
595 .map(|events| events.len())
596 .sum()
597}
598
599fn compute_trace_digest(spans: &[SpanData]) -> Result<String> {
601 let json = serde_json::to_string(spans)
602 .map_err(|e| CleanroomError::internal_error(format!("Failed to serialize spans: {}", e)))?;
603
604 let mut hasher = Sha256::new();
605 hasher.update(json.as_bytes());
606 let result = hasher.finalize();
607
608 Ok(format!("sha256:{:x}", result))
609}
610
611#[derive(Debug, Clone)]
613pub struct AnalysisReport {
614 pub test_name: String,
616 pub traces_file: String,
618 pub span_count: usize,
620 pub event_count: usize,
622 pub digest: String,
624 pub validators: Vec<ValidatorResult>,
626}
627
628impl AnalysisReport {
629 pub fn is_success(&self) -> bool {
631 self.validators.iter().all(|v| v.passed)
632 }
633
634 pub fn failure_count(&self) -> usize {
636 self.validators.iter().filter(|v| !v.passed).count()
637 }
638
639 pub fn pass_count(&self) -> usize {
641 self.validators.iter().filter(|v| v.passed).count()
642 }
643
644 pub fn format_report(&self) -> String {
646 let mut output = String::new();
647
648 output.push_str("📊 OTEL Validation Report\n");
649 output.push_str("========================\n\n");
650
651 output.push_str(&format!("Test: {}\n", self.test_name));
652 output.push_str(&format!(
653 "Traces: {} spans, {} events\n\n",
654 self.span_count, self.event_count
655 ));
656
657 output.push_str("Validators:\n");
658 for validator in &self.validators {
659 let icon = if validator.passed { "✅" } else { "❌" };
660 output.push_str(&format!(
661 " {} {} ({})\n",
662 icon, validator.name, validator.details
663 ));
664 }
665
666 output.push('\n');
667
668 if self.is_success() {
669 output.push_str(&format!(
670 "Result: PASS ({}/{} validators passed)\n",
671 self.pass_count(),
672 self.validators.len()
673 ));
674 } else {
675 output.push_str(&format!(
676 "Result: FAIL ({}/{} validators failed)\n",
677 self.failure_count(),
678 self.validators.len()
679 ));
680 }
681
682 output.push_str(&format!(
683 "Digest: {} (recorded for reproduction)\n",
684 self.digest
685 ));
686
687 output
688 }
689}
690
691#[derive(Debug, Clone)]
693pub struct ValidatorResult {
694 pub name: String,
696 pub passed: bool,
698 pub details: String,
700}