1use crate::model::TestResultRow;
2
3pub mod genai;
4pub(crate) mod mcp_ingest;
5pub mod metrics;
6pub mod projection;
7pub mod redaction;
8pub mod semconv;
9
10#[derive(Debug, Clone, Default)]
11pub struct OTelConfig {
12 pub jsonl_path: Option<std::path::PathBuf>,
13 pub redact_prompts: bool,
14}
15
16pub fn export_jsonl(
17 cfg: &OTelConfig,
18 _suite: &str,
19 results: &[TestResultRow],
20) -> anyhow::Result<()> {
21 let Some(path) = &cfg.jsonl_path else {
22 return Ok(());
23 };
24 let mut f = std::fs::OpenOptions::new()
25 .create(true)
26 .append(true)
27 .open(path)?;
28 for r in results {
29 let row = serde_json::json!({
32 "timestamp": chrono::Utc::now().to_rfc3339(),
33 "attributes": {
34 "gen_ai.system": "assay",
35 "gen_ai.request.model": "unknown", "gen_ai.response.completion_tokens": 0, "assay.test_id": r.test_id,
38 "assay.status": format!("{:?}", r.status),
39 "assay.score": r.score,
40 "assay.cached": r.cached,
41 "assay.duration_ms": r.duration_ms,
42 }
43 });
44
45 use std::io::Write;
49 writeln!(f, "{}", row)?;
50 }
51 Ok(())
52}
53
54#[derive(Debug, Clone)]
57pub struct ToolObservation {
58 pub tool_name: String,
60 pub claim_class_outcome: String,
62 pub subject: Option<String>,
64}
65
66pub fn export_tool_spans_jsonl(
76 cfg: &OTelConfig,
77 run: &str,
78 observations: &[ToolObservation],
79) -> anyhow::Result<()> {
80 let Some(path) = &cfg.jsonl_path else {
81 return Ok(());
82 };
83 let mut f = std::fs::OpenOptions::new()
84 .create(true)
85 .append(true)
86 .open(path)?;
87 use std::io::Write;
88 for (seq, obs) in observations.iter().enumerate() {
89 let row = serde_json::json!({
92 "timestamp": chrono::Utc::now().to_rfc3339(),
93 "name": "execute_tool",
94 "attributes": {
95 "gen_ai.system": "assay",
96 "gen_ai.operation.name": "execute_tool",
97 "gen_ai.tool.name": obs.tool_name,
98 "assay.claim_class.outcome": obs.claim_class_outcome,
99 "assay.run": run,
100 "assay.seq": seq,
101 "assay.subject": obs.subject,
102 },
103 });
104 writeln!(f, "{}", row)?;
105 }
106 Ok(())
107}
108
109#[cfg(test)]
110mod tool_span_tests {
111 use super::*;
112
113 #[test]
114 fn export_tool_spans_writes_execute_tool_rows_with_claim_class() {
115 let path = std::env::temp_dir().join(format!(
116 "assay-otel-tool-spans-{}.jsonl",
117 std::process::id()
118 ));
119 let _ = std::fs::remove_file(&path);
120 let cfg = OTelConfig {
121 jsonl_path: Some(path.clone()),
122 redact_prompts: false,
123 };
124 let observations = vec![
125 ToolObservation {
126 tool_name: "fs.write".into(),
127 claim_class_outcome: "supported".into(),
128 subject: Some("/tmp/out.txt".into()),
129 },
130 ToolObservation {
131 tool_name: "net.connect".into(),
132 claim_class_outcome: "blocked".into(),
133 subject: None,
134 },
135 ];
136
137 export_tool_spans_jsonl(&cfg, "sandbox_testrun", &observations).expect("export");
138
139 let body = std::fs::read_to_string(&path).expect("read jsonl");
140 let lines: Vec<&str> = body.lines().collect();
141 assert_eq!(lines.len(), 2);
142 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
143 assert_eq!(first["name"], "execute_tool");
144 assert_eq!(first["attributes"]["gen_ai.operation.name"], "execute_tool");
145 assert_eq!(first["attributes"]["gen_ai.tool.name"], "fs.write");
146 assert_eq!(
147 first["attributes"]["assay.claim_class.outcome"],
148 "supported"
149 );
150 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
151 assert_eq!(second["attributes"]["assay.claim_class.outcome"], "blocked");
152
153 std::fs::remove_file(&path).ok();
154 }
155}