1use std::collections::BTreeMap;
2use std::fs::OpenOptions;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10
11mod lashlang_graph;
12#[cfg(feature = "otel")]
13pub mod otel;
14
15pub use lashlang_graph::{
16 TraceLashlangEdgeSelection, TraceLashlangGraph, TraceLashlangGraphChildLink,
17 TraceLashlangGraphEdge, TraceLashlangGraphNode, TraceLashlangGraphStore,
18 TraceLashlangNodeStatus,
19};
20
21pub const TRACE_SCHEMA_VERSION: u32 = 2;
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum TraceLevel {
26 #[default]
27 Standard,
28 Extended,
29}
30
31impl TraceLevel {
32 pub fn is_extended(self) -> bool {
33 matches!(self, Self::Extended)
34 }
35}
36
37#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct TraceContext {
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub run_id: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub experiment_id: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub candidate_id: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub candidate_parent_id: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub example_id: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub split: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub session_id: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub turn_id: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub graph_node_id: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub parent_graph_node_id: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub turn_index: Option<usize>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub protocol_iteration: Option<usize>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub effect_id: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub llm_call_id: Option<String>,
75 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
76 pub metadata: BTreeMap<String, Value>,
77}
78
79impl TraceContext {
80 pub fn for_session(mut self, session_id: impl Into<String>) -> Self {
81 self.session_id = Some(session_id.into());
82 self
83 }
84
85 pub fn for_turn_index(mut self, turn_index: usize) -> Self {
86 self.turn_index = Some(turn_index);
87 self
88 }
89
90 pub fn for_turn(mut self, turn_id: impl Into<String>) -> Self {
91 self.turn_id = Some(turn_id.into());
92 self
93 }
94
95 pub fn for_protocol_iteration(mut self, protocol_iteration: usize) -> Self {
96 self.protocol_iteration = Some(protocol_iteration);
97 self
98 }
99
100 pub fn for_llm_call(mut self, llm_call_id: impl Into<String>) -> Self {
101 self.llm_call_id = Some(llm_call_id.into());
102 self
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct TraceRecord {
108 pub schema_version: u32,
109 pub id: String,
110 pub timestamp: String,
111 pub context: TraceContext,
112 #[serde(flatten)]
113 pub event: TraceEvent,
114}
115
116impl TraceRecord {
117 pub fn new(context: TraceContext, event: TraceEvent) -> Self {
118 Self::new_with_timestamp(context, event, chrono::Utc::now())
119 }
120
121 pub fn new_with_timestamp(
122 context: TraceContext,
123 event: TraceEvent,
124 timestamp: chrono::DateTime<chrono::Utc>,
125 ) -> Self {
126 Self {
127 schema_version: TRACE_SCHEMA_VERSION,
128 id: uuid::Uuid::new_v4().to_string(),
129 timestamp: timestamp.to_rfc3339(),
130 context,
131 event,
132 }
133 }
134}
135
136#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
137#[serde(tag = "type", rename_all = "snake_case")]
138#[allow(
139 clippy::large_enum_variant,
140 reason = "TraceEvent is a public DTO; keeping event payloads inline preserves ergonomic pattern matching"
141)]
142pub enum TraceEvent {
143 SessionStarted {
144 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
145 metadata: BTreeMap<String, Value>,
146 },
147 TurnStarted {
148 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
149 metadata: BTreeMap<String, Value>,
150 },
151 PromptBuilt {
152 prompt_hash: String,
153 prompt_chars: usize,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 components: Vec<TracePromptComponent>,
156 },
157 LlmCallStarted {
158 request: TraceLlmRequest,
159 },
160 LlmCallCompleted {
161 response: TraceLlmResponse,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 usage: Option<TraceTokenUsage>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 provider_usage: Option<Value>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 stream_summary: Option<Value>,
168 },
169 LlmCallFailed {
170 error: TraceError,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 stream_summary: Option<Value>,
173 },
174 ProviderStreamEvent {
175 event: TraceProviderStreamEvent,
176 },
177 RuntimeStreamEvent {
178 event: TraceRuntimeStreamEvent,
179 },
180 ToolCallStarted {
181 call_id: Option<String>,
182 name: String,
183 args: Value,
184 },
185 ToolCallCompleted {
186 call_id: Option<String>,
187 name: String,
188 args: Value,
189 output: TraceToolCallOutput,
190 duration_ms: u64,
191 },
192 ProtocolStep {
193 plugin_id: String,
194 payload: Value,
195 },
196 TokenUsage {
197 usage: TraceTokenUsage,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 cumulative: Option<TraceTokenUsage>,
200 },
201 LashlangExecution {
202 event: TraceLashlangExecutionEvent,
203 },
204 TurnCompleted {
205 status: String,
206 done_reason: String,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 agent_frame_switch: Option<TraceAgentFrameSwitch>,
209 },
210 Custom {
211 name: String,
212 payload: Value,
213 },
214}
215
216#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
217pub struct TraceToolCallOutput {
218 pub outcome: TraceToolCallOutcome,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub control: Option<Value>,
221}
222
223impl TraceToolCallOutput {
224 pub fn status(&self) -> TraceToolCallStatus {
225 match self.outcome {
226 TraceToolCallOutcome::Success(_) => TraceToolCallStatus::Success,
227 TraceToolCallOutcome::Failure(_) => TraceToolCallStatus::Failure,
228 TraceToolCallOutcome::Cancelled(_) => TraceToolCallStatus::Cancelled,
229 }
230 }
231
232 pub fn is_success(&self) -> bool {
233 self.status() == TraceToolCallStatus::Success
234 }
235
236 pub fn value_for_projection(&self) -> Value {
237 match &self.outcome {
238 TraceToolCallOutcome::Success(value)
239 | TraceToolCallOutcome::Failure(value)
240 | TraceToolCallOutcome::Cancelled(value) => value.clone(),
241 }
242 }
243}
244
245#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
246#[serde(tag = "status", content = "payload", rename_all = "snake_case")]
247pub enum TraceToolCallOutcome {
248 Success(Value),
249 Failure(Value),
250 Cancelled(Value),
251}
252
253#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum TraceToolCallStatus {
256 Success,
257 Failure,
258 Cancelled,
259}
260
261#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
262pub struct TracePromptComponent {
263 pub id: String,
264 pub kind: String,
265 pub hash: String,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub chars: Option<usize>,
268}
269
270#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
271pub struct TraceLlmRequest {
272 pub model: String,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub model_variant: Option<String>,
275 pub messages: Vec<TraceLlmMessage>,
276 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub attachments: Vec<TraceAttachment>,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 pub tools: Vec<TraceToolSpec>,
280 pub tool_choice: String,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub output_spec: Option<Value>,
283 pub stream: bool,
284}
285
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287pub struct TraceLlmMessage {
288 pub role: String,
289 pub blocks: Vec<TraceContentBlock>,
290}
291
292#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
293#[serde(tag = "kind", rename_all = "snake_case")]
294pub enum TraceContentBlock {
295 Text {
296 text: String,
297 #[serde(default, skip_serializing_if = "is_false")]
298 cache_breakpoint: bool,
299 },
300 Image {
301 attachment_idx: usize,
302 },
303 ToolCall {
304 call_id: Option<String>,
305 tool_name: String,
306 input_json: Value,
307 item_id: Option<String>,
308 has_signature: bool,
309 },
310 ToolResult {
311 call_id: Option<String>,
312 tool_name: Option<String>,
313 content: String,
314 },
315 Reasoning {
316 text: String,
317 item_id: Option<String>,
318 summary: Vec<String>,
319 has_encrypted: bool,
320 redacted: bool,
321 },
322}
323
324fn is_false(value: &bool) -> bool {
325 !*value
326}
327
328#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
329pub struct TraceAttachment {
330 pub mime: String,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub filename: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub bytes_sha256: Option<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub bytes_len: Option<usize>,
337}
338
339#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
340pub struct TraceToolSpec {
341 pub name: String,
342 pub description: String,
343 pub input_schema: Value,
344 pub output_schema: Value,
345}
346
347#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
348pub struct TraceLlmResponse {
349 pub text: String,
350 pub duration_ms: u64,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub terminal_reason: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub parts: Option<Value>,
355}
356
357#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
358pub struct TraceProviderStreamEvent {
359 pub provider: String,
360 pub sequence: u64,
361 pub elapsed_ms: u64,
362 pub event_name: String,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub item_id: Option<String>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub output_index: Option<i64>,
367 pub raw_len: usize,
368 pub raw_sha256: String,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub raw_json: Option<Value>,
371}
372
373#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
374pub struct TraceRuntimeStreamEvent {
375 pub sequence: u64,
376 pub elapsed_ms: u64,
377 pub event_name: String,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub raw_text: Option<String>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub visible_text: Option<String>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub item_id: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub output_index: Option<i64>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub call_id: Option<String>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub tool_name: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub input_json: Option<Value>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub usage: Option<TraceTokenUsage>,
394}
395
396#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
397pub struct TraceTokenUsage {
398 pub input_tokens: i64,
399 pub output_tokens: i64,
400 pub cache_read_input_tokens: i64,
401 pub cache_write_input_tokens: i64,
402 pub reasoning_output_tokens: i64,
403}
404
405#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
406pub struct TraceAgentFrameSwitch {
407 pub frame_id: String,
408}
409
410#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
411pub struct TraceRuntimeScope {
412 pub session_id: String,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub turn_id: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub turn_index: Option<usize>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub protocol_iteration: Option<usize>,
419}
420
421impl TraceRuntimeScope {
422 pub fn new(session_id: impl Into<String>) -> Self {
423 Self {
424 session_id: session_id.into(),
425 turn_id: None,
426 turn_index: None,
427 protocol_iteration: None,
428 }
429 }
430}
431
432#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(tag = "type", rename_all = "snake_case")]
434pub enum TraceRuntimeSubject {
435 Effect { effect_id: String, kind: String },
436 Process { process_id: String },
437}
438
439impl TraceRuntimeSubject {
440 pub fn graph_key(&self, scope: &TraceRuntimeScope) -> String {
441 match self {
442 Self::Effect { effect_id, .. } => match scope.turn_id.as_deref() {
443 Some(turn_id) if !turn_id.is_empty() => {
444 format!("effect:{}:{turn_id}:{effect_id}", scope.session_id)
445 }
446 _ => format!("effect:{}:{effect_id}", scope.session_id),
447 },
448 Self::Process { process_id } => format!("process:{process_id}"),
449 }
450 }
451}
452
453#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
454pub struct TraceLashlangExecutionIdentity {
455 pub scope: TraceRuntimeScope,
456 pub subject: TraceRuntimeSubject,
457 pub module_ref: String,
458 pub entry_kind: String,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub entry_ref: Option<String>,
461 pub entry_name: String,
462}
463
464impl TraceLashlangExecutionIdentity {
465 pub fn graph_key(&self) -> String {
466 self.subject.graph_key(&self.scope)
467 }
468}
469
470#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(tag = "kind", rename_all = "snake_case")]
472pub enum TraceLashlangExecutionEvent {
473 ExecutionStarted {
474 event_key: String,
475 identity: TraceLashlangExecutionIdentity,
476 execution_map: TraceLashlangMap,
477 },
478 ExecutionFinished {
479 event_key: String,
480 identity: TraceLashlangExecutionIdentity,
481 status: TraceLashlangStatus,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 error: Option<String>,
484 },
485 NodeStarted {
486 event_key: String,
487 identity: TraceLashlangExecutionIdentity,
488 node_id: String,
489 node_kind: String,
490 label: String,
491 occurrence: u64,
492 },
493 NodeCompleted {
494 event_key: String,
495 identity: TraceLashlangExecutionIdentity,
496 node_id: String,
497 node_kind: String,
498 label: String,
499 occurrence: u64,
500 },
501 NodeFailed {
502 event_key: String,
503 identity: TraceLashlangExecutionIdentity,
504 node_id: String,
505 node_kind: String,
506 label: String,
507 occurrence: u64,
508 error: String,
509 },
510 BranchSelected {
511 event_key: String,
512 identity: TraceLashlangExecutionIdentity,
513 node_id: String,
514 occurrence: u64,
515 edge_id: String,
516 selected: TraceBranchSelection,
517 },
518 ChildStarted {
519 event_key: String,
520 identity: TraceLashlangExecutionIdentity,
521 parent_node_id: String,
522 occurrence: u64,
523 child: TraceLashlangChildExecution,
524 },
525}
526
527#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
528pub struct TraceLashlangChildExecution {
529 pub scope: TraceRuntimeScope,
530 pub subject: TraceRuntimeSubject,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub module_ref: Option<String>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub entry_ref: Option<String>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub entry_name: Option<String>,
537}
538
539impl TraceLashlangChildExecution {
540 pub fn graph_key(&self) -> String {
541 self.subject.graph_key(&self.scope)
542 }
543}
544
545#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "snake_case")]
547pub enum TraceLashlangStatus {
548 Running,
549 Completed,
550 Failed,
551 Cancelled,
552}
553
554#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
555#[serde(rename_all = "snake_case")]
556pub enum TraceBranchSelection {
557 Then,
558 Else,
559}
560
561#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
562pub struct TraceLashlangMap {
563 pub module_ref: String,
564 pub entry_kind: String,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub entry_ref: Option<String>,
567 pub entry_name: String,
568 #[serde(default)]
569 pub nodes: Vec<TraceLashlangMapNode>,
570 #[serde(default)]
571 pub edges: Vec<TraceLashlangMapEdge>,
572}
573
574#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
575pub struct TraceLashlangMapNode {
576 pub id: String,
577 pub kind: String,
578 pub label: String,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub label_metadata: Option<TraceLabelMetadata>,
581}
582
583#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
584pub struct TraceLabelMetadata {
585 pub title: String,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub description: Option<String>,
588}
589
590#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
591pub struct TraceLashlangMapEdge {
592 pub id: String,
593 pub from: String,
594 pub to: String,
595 pub label: String,
596}
597
598#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
599pub struct TraceError {
600 pub message: String,
601 pub retryable: bool,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub terminal_reason: Option<String>,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub code: Option<String>,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub raw: Option<String>,
608}
609
610#[derive(Debug, thiserror::Error)]
611pub enum TraceSinkError {
612 #[error("failed to serialize trace record: {0}")]
613 Serialize(#[from] serde_json::Error),
614 #[error("trace sink lock poisoned")]
615 LockPoisoned,
616 #[error("failed to create trace directory {path}: {source}")]
617 CreateDir { path: PathBuf, source: io::Error },
618 #[error("failed to open trace file {path}: {source}")]
619 Open { path: PathBuf, source: io::Error },
620 #[error("failed to write trace file {path}: {source}")]
621 Write { path: PathBuf, source: io::Error },
622}
623
624pub trait TraceSink: Send + Sync {
625 fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError>;
626}
627
628pub struct JsonlTraceSink {
629 path: PathBuf,
630 lock: Mutex<()>,
631}
632
633impl JsonlTraceSink {
634 pub fn new(path: impl Into<PathBuf>) -> Self {
635 Self {
636 path: path.into(),
637 lock: Mutex::new(()),
638 }
639 }
640
641 pub fn path(&self) -> &Path {
642 &self.path
643 }
644}
645
646impl TraceSink for JsonlTraceSink {
647 fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError> {
648 let line = serde_json::to_string(record)?;
649 let _guard = self.lock.lock().map_err(|_| TraceSinkError::LockPoisoned)?;
650 if let Some(parent) = self.path.parent()
651 && !parent.as_os_str().is_empty()
652 {
653 std::fs::create_dir_all(parent).map_err(|source| TraceSinkError::CreateDir {
654 path: parent.to_path_buf(),
655 source,
656 })?;
657 }
658 let mut file = OpenOptions::new()
659 .create(true)
660 .append(true)
661 .open(&self.path)
662 .map_err(|source| TraceSinkError::Open {
663 path: self.path.clone(),
664 source,
665 })?;
666 writeln!(file, "{line}").map_err(|source| TraceSinkError::Write {
667 path: self.path.clone(),
668 source,
669 })
670 }
671}
672
673#[derive(Default)]
676pub struct StderrTraceSink {
677 lock: Mutex<()>,
678}
679
680impl TraceSink for StderrTraceSink {
681 fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError> {
682 let line = serde_json::to_string(record)?;
683 let _guard = self.lock.lock().map_err(|_| TraceSinkError::LockPoisoned)?;
684 eprintln!("{line}");
685 Ok(())
686 }
687}
688
689pub struct TeeTraceSink {
692 sinks: Vec<Arc<dyn TraceSink>>,
693}
694
695impl TeeTraceSink {
696 pub fn new(sinks: impl IntoIterator<Item = Arc<dyn TraceSink>>) -> Self {
697 Self {
698 sinks: sinks.into_iter().collect(),
699 }
700 }
701}
702
703impl TraceSink for TeeTraceSink {
704 fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError> {
705 for sink in &self.sinks {
706 sink.append(record)?;
707 }
708 Ok(())
709 }
710}
711
712pub fn sha256_hex(input: impl AsRef<[u8]>) -> String {
713 let mut hasher = Sha256::new();
714 hasher.update(input.as_ref());
715 format!("{:x}", hasher.finalize())
716}
717
718pub fn json_hash(value: &Value) -> String {
719 sha256_hex(serde_json::to_vec(value).unwrap_or_default())
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn jsonl_sink_writes_record() {
728 let dir = std::env::temp_dir().join(format!("lash-trace-{}", uuid::Uuid::new_v4()));
729 std::fs::create_dir_all(&dir).unwrap();
730 let path = dir.join("trace.jsonl");
731 let sink = JsonlTraceSink::new(&path);
732 sink.append(&TraceRecord::new(
733 TraceContext::default().for_session("root"),
734 TraceEvent::Custom {
735 name: "test.event".to_string(),
736 payload: serde_json::json!({"ok": true}),
737 },
738 ))
739 .unwrap();
740 let text = std::fs::read_to_string(&path).unwrap();
741 assert!(text.contains("\"type\":\"custom\""));
742 assert!(text.contains("\"session_id\":\"root\""));
743 }
744
745 #[test]
746 fn tool_start_and_frame_switch_records_are_jsonl_shaped() {
747 let started = TraceRecord::new(
748 TraceContext::default().for_session("root"),
749 TraceEvent::ToolCallStarted {
750 call_id: Some("call-1".to_string()),
751 name: "read_file".to_string(),
752 args: serde_json::json!({"path": "README.md"}),
753 },
754 );
755 let completed = TraceRecord::new(
756 TraceContext::default().for_session("root"),
757 TraceEvent::TurnCompleted {
758 status: "completed".to_string(),
759 done_reason: "modelstop".to_string(),
760 agent_frame_switch: Some(TraceAgentFrameSwitch {
761 frame_id: "frame-1".to_string(),
762 }),
763 },
764 );
765
766 let started_json = serde_json::to_value(started).unwrap();
767 assert_eq!(started_json["type"], "tool_call_started");
768 assert_eq!(started_json["call_id"], "call-1");
769
770 let completed_json = serde_json::to_value(completed).unwrap();
771 assert_eq!(completed_json["type"], "turn_completed");
772 assert_eq!(completed_json["agent_frame_switch"]["frame_id"], "frame-1");
773 }
774
775 #[test]
776 fn lashlang_execution_records_are_jsonl_shaped() {
777 let identity = TraceLashlangExecutionIdentity {
778 scope: TraceRuntimeScope::new("s1"),
779 subject: TraceRuntimeSubject::Process {
780 process_id: "p1".to_string(),
781 },
782 module_ref: "module".to_string(),
783 entry_kind: "process".to_string(),
784 entry_ref: Some("component:0".to_string()),
785 entry_name: "main".to_string(),
786 };
787 let event = TraceLashlangExecutionEvent::NodeStarted {
788 event_key: "process:p1:node:n1:1:started".to_string(),
789 identity,
790 node_id: "n1".to_string(),
791 node_kind: "resource_operation".to_string(),
792 label: "read_file".to_string(),
793 occurrence: 1,
794 };
795 let record = TraceRecord::new(
796 TraceContext::default().for_session("s1"),
797 TraceEvent::LashlangExecution { event },
798 );
799
800 let json = serde_json::to_value(&record).expect("serialize lashlang execution");
801 assert_eq!(json["type"], "lashlang_execution");
802 assert_eq!(json["event"]["kind"], "node_started");
803 assert_eq!(json["event"]["event_key"], "process:p1:node:n1:1:started");
804
805 let round_trip =
806 serde_json::from_value::<TraceRecord>(json).expect("deserialize lashlang execution");
807 assert!(matches!(
808 round_trip.event,
809 TraceEvent::LashlangExecution {
810 event: TraceLashlangExecutionEvent::NodeStarted { .. }
811 }
812 ));
813 }
814
815 #[test]
816 fn tool_completion_serializes_typed_failure_output() {
817 let record = TraceRecord::new(
818 TraceContext::default().for_session("root"),
819 TraceEvent::ToolCallCompleted {
820 call_id: Some("call-1".to_string()),
821 name: "read_file".to_string(),
822 args: serde_json::json!({"path": "missing"}),
823 output: TraceToolCallOutput {
824 outcome: TraceToolCallOutcome::Failure(serde_json::json!({
825 "class": "invalid_request",
826 "code": "invalid_tool_args",
827 "message": "bad args",
828 "source": "runtime",
829 "retry": { "type": "never" },
830 "raw": { "path": "missing" }
831 })),
832 control: None,
833 },
834 duration_ms: 3,
835 },
836 );
837
838 let json = serde_json::to_value(record).unwrap();
839 assert_eq!(json["type"], "tool_call_completed");
840 assert_eq!(json["output"]["outcome"]["status"], "failure");
841 assert_eq!(
842 json["output"]["outcome"]["payload"]["code"],
843 "invalid_tool_args"
844 );
845 assert_eq!(
846 json["output"]["outcome"]["payload"]["raw"]["path"],
847 "missing"
848 );
849 }
850
851 #[test]
852 fn jsonl_sink_creates_parent_directories() {
853 let dir = std::env::temp_dir().join(format!("lash-trace-{}", uuid::Uuid::new_v4()));
854 let path = dir.join("nested").join("trace.jsonl");
855 let sink = JsonlTraceSink::new(&path);
856 sink.append(&TraceRecord::new(
857 TraceContext::default().for_session("root"),
858 TraceEvent::RuntimeStreamEvent {
859 event: TraceRuntimeStreamEvent {
860 sequence: 1,
861 elapsed_ms: 0,
862 event_name: "delta".to_string(),
863 raw_text: Some("hello".to_string()),
864 visible_text: Some("hello".to_string()),
865 item_id: None,
866 output_index: None,
867 call_id: None,
868 tool_name: None,
869 input_json: None,
870 usage: None,
871 },
872 },
873 ))
874 .unwrap();
875 assert!(path.exists());
876 let _ = std::fs::remove_dir_all(dir);
877 }
878}