clnrm_core/telemetry/
test_execution.rs

1//! Test Execution Telemetry - Schema-Compliant Attribute Emission
2//!
3//! This module provides instrumentation for test execution that emits ALL attributes
4//! defined in registry/core/test_execution.yaml, closing the 70% attribute gap.
5//!
6//! ## Critical Attributes (MUST emit 100%):
7//! - test.name ✅ (already emitted)
8//! - test.duration_ms ✅ (already emitted)
9//! - test.result ❌ (MISSING - 70% gap starts here)
10//! - test.error_message ❌ (MISSING)
11//! - test.start_timestamp ❌ (MISSING)
12//! - test.end_timestamp ❌ (MISSING)
13//! - container.id ❌ (MISSING - critical proof attribute)
14//! - container.exit_code ❌ (MISSING)
15//! - plugin.execution_time_ms ❌ (MISSING)
16//!
17//! ## Design Principles:
18//! 1. **Schema-first**: Every attribute matches test_execution.yaml exactly
19//! 2. **Type-safe**: Use Rust types that map to schema types (string, double, int, boolean, enum)
20//! 3. **Required vs Optional**: Required attributes MUST be set, optional can be None
21//! 4. **No fake data**: Attributes come from actual test execution, not placeholders
22
23use opentelemetry::trace::{Span, SpanKind, Status, Tracer};
24use opentelemetry::{global, KeyValue};
25use std::time::{Duration, SystemTime};
26use tracing::{error, info};
27
28/// Test result matching schema enum: pass | fail | error
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum TestResult {
31    /// Test passed all assertions
32    Pass,
33    /// Test failed one or more assertions
34    Fail,
35    /// Test encountered an execution error
36    Error,
37}
38
39impl TestResult {
40    /// Convert to schema-compliant string value
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            TestResult::Pass => "pass",
44            TestResult::Fail => "fail",
45            TestResult::Error => "error",
46        }
47    }
48}
49
50/// Container information for test execution
51#[derive(Debug, Clone)]
52pub struct ContainerInfo {
53    /// Unique container identifier (CRITICAL PROOF attribute)
54    pub id: String,
55    /// Docker/OCI image name
56    pub image_name: String,
57    /// Image tag
58    pub image_tag: Option<String>,
59    /// Container exit code
60    pub exit_code: Option<i32>,
61}
62
63impl ContainerInfo {
64    /// Create container info from testcontainer
65    pub fn new(id: String, image: String) -> Self {
66        // Parse image into name:tag
67        let (name, tag) = if let Some(pos) = image.rfind(':') {
68            let (n, t) = image.split_at(pos);
69            (n.to_string(), Some(t[1..].to_string()))
70        } else {
71            (image, Some("latest".to_string()))
72        };
73
74        Self {
75            id,
76            image_name: name,
77            image_tag: tag,
78            exit_code: None,
79        }
80    }
81
82    /// Set exit code after container stops
83    pub fn with_exit_code(mut self, code: i32) -> Self {
84        self.exit_code = Some(code);
85        self
86    }
87}
88
89/// Complete test execution context with ALL schema attributes
90#[derive(Debug, Clone)]
91pub struct TestExecutionContext {
92    /// test.name (required)
93    pub test_name: String,
94    /// test.suite (required)
95    pub test_suite: String,
96    /// test.isolated (required, must be true for clnrm)
97    pub test_isolated: bool,
98    /// test.result (required)
99    pub test_result: TestResult,
100    /// test.duration_ms (required, must be > 0)
101    pub test_duration_ms: f64,
102    /// test.start_timestamp (Unix timestamp in milliseconds)
103    pub test_start_timestamp: i64,
104    /// test.end_timestamp (Unix timestamp in milliseconds)
105    pub test_end_timestamp: i64,
106    /// container.id (required - CRITICAL PROOF)
107    pub container_info: Option<ContainerInfo>,
108    /// error.type (conditionally required when result is 'error')
109    pub error_type: Option<String>,
110    /// error.message (conditionally required when result is 'fail' or 'error')
111    pub error_message: Option<String>,
112    /// test.assertion_count (recommended)
113    pub assertion_count: Option<u32>,
114    /// test.cleanup_performed (required, must be true)
115    pub cleanup_performed: bool,
116    /// plugin.execution_time_ms (recommended)
117    pub plugin_execution_time_ms: Option<f64>,
118}
119
120impl TestExecutionContext {
121    /// Create a new test execution context with required fields
122    pub fn new(test_name: String, test_suite: String) -> Self {
123        Self {
124            test_name,
125            test_suite,
126            test_isolated: true, // Always true for clnrm hermetic tests
127            test_result: TestResult::Pass,
128            test_duration_ms: 0.0,
129            test_start_timestamp: Self::now_unix_ms(),
130            test_end_timestamp: 0,
131            container_info: None,
132            error_type: None,
133            error_message: None,
134            assertion_count: None,
135            cleanup_performed: false,
136            plugin_execution_time_ms: None,
137        }
138    }
139
140    /// Get current time as Unix timestamp in milliseconds
141    fn now_unix_ms() -> i64 {
142        SystemTime::now()
143            .duration_since(std::time::UNIX_EPOCH)
144            .unwrap_or_default()
145            .as_millis() as i64
146    }
147
148    /// Set container information (CRITICAL for validation)
149    pub fn with_container(mut self, container: ContainerInfo) -> Self {
150        self.container_info = Some(container);
151        self
152    }
153
154    /// Set test result and timestamps
155    pub fn with_result(mut self, result: TestResult, duration: Duration) -> Self {
156        self.test_result = result;
157        self.test_duration_ms = duration.as_secs_f64() * 1000.0;
158        self.test_end_timestamp = Self::now_unix_ms();
159        self
160    }
161
162    /// Set error information (for fail/error results)
163    pub fn with_error(mut self, error_type: String, error_message: String) -> Self {
164        self.error_type = Some(error_type);
165        self.error_message = Some(error_message);
166        self
167    }
168
169    /// Set cleanup status
170    pub fn with_cleanup(mut self, performed: bool) -> Self {
171        self.cleanup_performed = performed;
172        self
173    }
174
175    /// Set assertion count
176    pub fn with_assertions(mut self, count: u32) -> Self {
177        self.assertion_count = Some(count);
178        self
179    }
180
181    /// Set plugin execution time
182    pub fn with_plugin_time(mut self, time_ms: f64) -> Self {
183        self.plugin_execution_time_ms = Some(time_ms);
184        self
185    }
186
187    /// Emit test execution span with ALL attributes
188    ///
189    /// This creates a span matching registry/core/test_execution.yaml exactly.
190    /// All required attributes are emitted. This is the ONLY source of truth for
191    /// test execution - not test assertions, not return codes, only this span.
192    pub fn emit_span(&self) {
193        info!(
194            "🔍 Emitting test execution span: {} (result={}, duration={:.2}ms)",
195            self.test_name,
196            self.test_result.as_str(),
197            self.test_duration_ms
198        );
199
200        let tracer = global::tracer("clnrm");
201        let mut span = tracer
202            .span_builder("clnrm.test_execution")
203            .with_kind(SpanKind::Internal)
204            .start(&tracer);
205
206        // Required attributes (MUST emit all 9)
207        span.set_attribute(KeyValue::new("test.name", self.test_name.clone()));
208        span.set_attribute(KeyValue::new("test.suite", self.test_suite.clone()));
209        span.set_attribute(KeyValue::new("test.isolated", self.test_isolated));
210        span.set_attribute(KeyValue::new("test.result", self.test_result.as_str()));
211        span.set_attribute(KeyValue::new("test.duration_ms", self.test_duration_ms));
212        span.set_attribute(KeyValue::new(
213            "test.start_timestamp",
214            self.test_start_timestamp,
215        ));
216        span.set_attribute(KeyValue::new("test.end_timestamp", self.test_end_timestamp));
217        span.set_attribute(KeyValue::new(
218            "test.cleanup_performed",
219            self.cleanup_performed,
220        ));
221
222        // Container attributes (CRITICAL PROOF - cannot fake these)
223        if let Some(ref container) = self.container_info {
224            span.set_attribute(KeyValue::new("container.id", container.id.clone()));
225            span.set_attribute(KeyValue::new(
226                "container.image.name",
227                container.image_name.clone(),
228            ));
229
230            if let Some(ref tag) = container.image_tag {
231                span.set_attribute(KeyValue::new("container.image.tag", tag.clone()));
232            }
233
234            if let Some(exit_code) = container.exit_code {
235                span.set_attribute(KeyValue::new("container.exit_code", exit_code as i64));
236            }
237        } else {
238            error!(
239                "⚠️  Test '{}' missing container.id - VALIDATION WILL FAIL",
240                self.test_name
241            );
242        }
243
244        // Error attributes (conditionally required)
245        if let Some(ref error_type) = self.error_type {
246            span.set_attribute(KeyValue::new("error.type", error_type.clone()));
247        }
248
249        if let Some(ref error_message) = self.error_message {
250            span.set_attribute(KeyValue::new("error.message", error_message.clone()));
251        }
252
253        // Recommended attributes
254        if let Some(count) = self.assertion_count {
255            span.set_attribute(KeyValue::new("test.assertion_count", count as i64));
256        }
257
258        if let Some(time) = self.plugin_execution_time_ms {
259            span.set_attribute(KeyValue::new("plugin.execution_time_ms", time));
260        }
261
262        // Set span status based on test result
263        match self.test_result {
264            TestResult::Pass => {
265                span.set_status(Status::Ok);
266            }
267            TestResult::Fail | TestResult::Error => {
268                let msg = self
269                    .error_message
270                    .clone()
271                    .unwrap_or_else(|| "Test failed".to_string());
272                span.set_status(Status::error(msg));
273            }
274        }
275
276        span.end();
277
278        info!(
279            "✅ Test execution span emitted: {} attributes ({}% complete)",
280            if self.container_info.is_some() {
281                "9/9 required"
282            } else {
283                "8/9 required"
284            },
285            if self.container_info.is_some() {
286                100
287            } else {
288                89
289            }
290        );
291    }
292
293    /// Validate that all required attributes are present
294    ///
295    /// Returns true if context is complete and ready to emit.
296    /// This catches missing attributes at runtime before emission.
297    pub fn validate(&self) -> Result<(), String> {
298        let mut errors = Vec::new();
299
300        // Required string attributes
301        if self.test_name.is_empty() {
302            errors.push("test.name is empty");
303        }
304        if self.test_suite.is_empty() {
305            errors.push("test.suite is empty");
306        }
307
308        // Duration must be > 0
309        if self.test_duration_ms <= 0.0 {
310            errors.push("test.duration_ms must be > 0 (proves actual execution)");
311        }
312
313        // End timestamp must be set
314        if self.test_end_timestamp == 0 {
315            errors.push("test.end_timestamp must be set (proves completion)");
316        }
317
318        // Container info is CRITICAL
319        if self.container_info.is_none() {
320            errors.push("container.id is missing (CRITICAL PROOF attribute)");
321        }
322
323        // Error attributes conditionally required
324        match self.test_result {
325            TestResult::Error => {
326                if self.error_type.is_none() {
327                    errors.push("error.type required when test.result is 'error'");
328                }
329                if self.error_message.is_none() {
330                    errors.push("error.message required when test.result is 'error'");
331                }
332            }
333            TestResult::Fail => {
334                if self.error_message.is_none() {
335                    errors.push("error.message required when test.result is 'fail'");
336                }
337            }
338            TestResult::Pass => {}
339        }
340
341        if errors.is_empty() {
342            Ok(())
343        } else {
344            Err(format!(
345                "Invalid test execution context: {}",
346                errors.join(", ")
347            ))
348        }
349    }
350}
351
352/// Builder for test execution context (fluent API)
353pub struct TestExecutionBuilder {
354    context: TestExecutionContext,
355    start_time: std::time::Instant,
356}
357
358impl TestExecutionBuilder {
359    /// Start building a test execution context
360    pub fn new(test_name: String, test_suite: String) -> Self {
361        Self {
362            context: TestExecutionContext::new(test_name, test_suite),
363            start_time: std::time::Instant::now(),
364        }
365    }
366
367    /// Set container information
368    pub fn container(mut self, container: ContainerInfo) -> Self {
369        self.context = self.context.with_container(container);
370        self
371    }
372
373    /// Set error information
374    pub fn error(mut self, error_type: String, error_message: String) -> Self {
375        self.context = self.context.with_error(error_type, error_message);
376        self
377    }
378
379    /// Set assertion count
380    pub fn assertions(mut self, count: u32) -> Self {
381        self.context = self.context.with_assertions(count);
382        self
383    }
384
385    /// Set plugin execution time
386    pub fn plugin_time(mut self, time_ms: f64) -> Self {
387        self.context = self.context.with_plugin_time(time_ms);
388        self
389    }
390
391    /// Mark cleanup as performed
392    pub fn cleanup_done(mut self) -> Self {
393        self.context = self.context.with_cleanup(true);
394        self
395    }
396
397    /// Complete test with result and emit span
398    pub fn finish(mut self, result: TestResult) -> TestExecutionContext {
399        let duration = self.start_time.elapsed();
400        self.context = self.context.with_result(result, duration);
401
402        // Validate before emission
403        if let Err(e) = self.context.validate() {
404            error!("⚠️  Test execution context invalid: {}", e);
405            error!("⚠️  Span will be emitted but may fail validation");
406        }
407
408        // Emit span
409        self.context.emit_span();
410
411        self.context
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_result_as_str() {
421        assert_eq!(TestResult::Pass.as_str(), "pass");
422        assert_eq!(TestResult::Fail.as_str(), "fail");
423        assert_eq!(TestResult::Error.as_str(), "error");
424    }
425
426    #[test]
427    fn test_container_info_parsing() {
428        let container = ContainerInfo::new("abc123".to_string(), "postgres:15".to_string());
429        assert_eq!(container.id, "abc123");
430        assert_eq!(container.image_name, "postgres");
431        assert_eq!(container.image_tag, Some("15".to_string()));
432
433        let container2 = ContainerInfo::new("def456".to_string(), "alpine".to_string());
434        assert_eq!(container2.image_name, "alpine");
435        assert_eq!(container2.image_tag, Some("latest".to_string()));
436    }
437
438    #[test]
439    fn test_context_validation_pass() {
440        let mut context = TestExecutionContext::new("test_1".to_string(), "suite_1".to_string());
441        context.test_duration_ms = 100.0;
442        context.test_end_timestamp = 1730250000000; // Unix timestamp in ms
443        context.container_info = Some(ContainerInfo::new(
444            "container123".to_string(),
445            "alpine:latest".to_string(),
446        ));
447
448        assert!(context.validate().is_ok());
449    }
450
451    #[test]
452    fn test_context_validation_missing_container() {
453        let mut context = TestExecutionContext::new("test_1".to_string(), "suite_1".to_string());
454        context.test_duration_ms = 100.0;
455        context.test_end_timestamp = 1730250000000;
456        // Missing container_info
457
458        let result = context.validate();
459        assert!(result.is_err());
460        assert!(result.unwrap_err().contains("container.id"));
461    }
462
463    #[test]
464    fn test_context_validation_error_requires_error_type() {
465        let mut context = TestExecutionContext::new("test_1".to_string(), "suite_1".to_string());
466        context.test_duration_ms = 100.0;
467        context.test_end_timestamp = 1730250000000;
468        context.container_info = Some(ContainerInfo::new(
469            "container123".to_string(),
470            "alpine:latest".to_string(),
471        ));
472        context.test_result = TestResult::Error;
473        // Missing error_type and error_message
474
475        let result = context.validate();
476        assert!(result.is_err());
477        let err_msg = result.unwrap_err();
478        assert!(err_msg.contains("error.type"));
479        assert!(err_msg.contains("error.message"));
480    }
481
482    #[test]
483    fn test_builder_fluent_api() {
484        let container = ContainerInfo::new("test123".to_string(), "alpine:3.18".to_string());
485        let builder =
486            TestExecutionBuilder::new("test_example".to_string(), "integration".to_string())
487                .container(container)
488                .assertions(5)
489                .plugin_time(45.2)
490                .cleanup_done();
491
492        // Don't finish in test to avoid emitting spans
493        assert_eq!(builder.context.test_name, "test_example");
494        assert_eq!(builder.context.assertion_count, Some(5));
495        assert_eq!(builder.context.plugin_execution_time_ms, Some(45.2));
496        assert!(builder.context.cleanup_performed);
497    }
498}