clnrm_core/cli/commands/
analyze.rs

1//! OTEL validation command - runs 7 validators on collected traces
2//!
3//! Provides the `clnrm analyze` command to validate OpenTelemetry traces
4//! against TOML-defined expectations, catching fake-green tests.
5//!
6//! This module implements first-failing-rule reporting, showing the FIRST
7//! validator that fails with detailed context and recommendations.
8
9use 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
21/// Load spans from artifact directories for scenarios
22///
23/// Scans `.clnrm/artifacts/<scenario-name>/spans.json` for each scenario
24/// defined in the test configuration.
25///
26/// # Arguments
27/// * `test_config` - Test configuration with scenario definitions
28///
29/// # Returns
30/// * `Result<Vec<SpanData>>` - All spans loaded from artifacts
31///
32/// # Errors
33/// * No artifacts found for any scenario
34fn 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    // Check for scenarios (v0.6.0+ format)
39    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 no scenarios, try to load from test name (v0.4.x compatibility)
65    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
95/// Run OTEL validation on collected traces
96///
97/// # Arguments
98/// * `test_file` - Path to test TOML file with expectations
99/// * `traces_file` - Optional path to JSON file containing OTEL traces.
100///   If not provided, auto-loads from `.clnrm/artifacts/<scenario>/spans.json`
101///
102/// # Returns
103/// * `Result<AnalysisReport>` - Validation report with pass/fail status
104///
105/// # Exit Code
106/// * 0 = All validators passed
107/// * 1 = Any validator failed
108pub fn analyze_traces(test_file: &Path, traces_file: Option<&Path>) -> Result<AnalysisReport> {
109    // Load test configuration to extract expectations
110    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    // Load OTEL traces from explicit file or artifacts
122    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        // Check if trace file exists and provide helpful error if not
129        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    // Extract test name for report
163    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    // Compute digest of traces for reproducibility
171    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    // Run validators based on expectations in config
183    if let Some(ref expect) = config.expect {
184        // 1. Span Expectations Validator
185        if !expect.span.is_empty() {
186            let result = validate_span_expectations(&expect.span, spans);
187            report.validators.push(result);
188        }
189
190        // 2. Graph Structure Validator
191        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        // 3. Counts Validator
197        if let Some(ref counts_config) = expect.counts {
198            let result = validate_counts(counts_config, spans);
199            report.validators.push(result);
200        }
201
202        // 4. Window Containment Validator
203        if !expect.window.is_empty() {
204            let result = validate_windows(&expect.window, spans);
205            report.validators.push(result);
206        }
207
208        // 5. Ordering Validator
209        if let Some(ref order_config) = expect.order {
210            let result = validate_ordering(order_config, spans);
211            report.validators.push(result);
212        }
213
214        // 6. Status Validator
215        if let Some(ref status_config) = expect.status {
216            let result = validate_status(status_config, spans);
217            report.validators.push(result);
218        }
219
220        // 7. Hermeticity Validator
221        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
230/// Validate span expectations (name, kind, attributes, events, duration)
231fn 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        // Find matching span(s)
241        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        // Validate each matching span
249        for span in matching_spans {
250            let mut span_valid = true;
251
252            // Validate attributes.all
253            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
293/// Validate graph structure (parent-child relationships)
294fn validate_graph_structure(
295    graph_config: &crate::config::otel::GraphExpectationConfig,
296    spans: &[SpanData],
297) -> ValidatorResult {
298    // Handle Option<Vec<Vec<String>>> from v1.0 schema
299    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
340/// Validate span counts
341fn validate_counts(
342    counts_config: &crate::config::otel::CountExpectationConfig,
343    spans: &[SpanData],
344) -> ValidatorResult {
345    let mut expectation = CountExpectation::new();
346
347    // Add total span count bounds
348    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            // No constraints
364            CountBound {
365                gte: None,
366                lte: None,
367                eq: None,
368            }
369        };
370        expectation = expectation.with_spans_total(bound);
371    }
372
373    // Add per-name count bounds
374    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
411/// Validate temporal windows (containment)
412fn 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
446/// Validate temporal ordering constraints
447fn 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    // Add must_precede rules (v1.0 schema: Vec<Vec<String>>)
455    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    // Add must_follow rules (v1.0 schema: Vec<Vec<String>>)
464    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
498/// Validate status codes
499fn validate_status(
500    status_config: &crate::config::otel::StatusExpectationConfig,
501    spans: &[SpanData],
502) -> ValidatorResult {
503    let mut expectation = StatusExpectation::new();
504
505    // Add global status rule
506    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    // Add per-name status rules
519    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
550/// Validate hermeticity (isolation, no external services)
551fn 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    // Handle v1.0 nested schema: resource_attrs.must_match
563    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    // Handle v1.0 nested schema: span_attrs.forbid_keys
570    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
590/// Count total events across all spans
591fn 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
599/// Compute SHA256 digest of traces for reproducibility
600fn 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/// Analysis report containing all validation results
612#[derive(Debug, Clone)]
613pub struct AnalysisReport {
614    /// Test name from TOML
615    pub test_name: String,
616    /// Path to traces file
617    pub traces_file: String,
618    /// Total span count
619    pub span_count: usize,
620    /// Total event count
621    pub event_count: usize,
622    /// SHA256 digest of traces
623    pub digest: String,
624    /// Individual validator results
625    pub validators: Vec<ValidatorResult>,
626}
627
628impl AnalysisReport {
629    /// Check if all validators passed
630    pub fn is_success(&self) -> bool {
631        self.validators.iter().all(|v| v.passed)
632    }
633
634    /// Count failed validators
635    pub fn failure_count(&self) -> usize {
636        self.validators.iter().filter(|v| !v.passed).count()
637    }
638
639    /// Count passed validators
640    pub fn pass_count(&self) -> usize {
641        self.validators.iter().filter(|v| v.passed).count()
642    }
643
644    /// Generate human-readable report
645    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/// Individual validator result
692#[derive(Debug, Clone)]
693pub struct ValidatorResult {
694    /// Validator name
695    pub name: String,
696    /// Whether validation passed
697    pub passed: bool,
698    /// Details or error message
699    pub details: String,
700}