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 cached_input_tokens: i64,
401 pub reasoning_tokens: i64,
402}
403
404#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
405pub struct TraceAgentFrameSwitch {
406 pub frame_id: String,
407}
408
409#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
410pub struct TraceRuntimeScope {
411 pub session_id: String,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub turn_id: Option<String>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub turn_index: Option<usize>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub protocol_iteration: Option<usize>,
418}
419
420impl TraceRuntimeScope {
421 pub fn new(session_id: impl Into<String>) -> Self {
422 Self {
423 session_id: session_id.into(),
424 turn_id: None,
425 turn_index: None,
426 protocol_iteration: None,
427 }
428 }
429}
430
431#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(tag = "type", rename_all = "snake_case")]
433pub enum TraceRuntimeSubject {
434 Effect { effect_id: String, kind: String },
435 Process { process_id: String },
436}
437
438impl TraceRuntimeSubject {
439 pub fn graph_key(&self, scope: &TraceRuntimeScope) -> String {
440 match self {
441 Self::Effect { effect_id, .. } => match scope.turn_id.as_deref() {
442 Some(turn_id) if !turn_id.is_empty() => {
443 format!("effect:{}:{turn_id}:{effect_id}", scope.session_id)
444 }
445 _ => format!("effect:{}:{effect_id}", scope.session_id),
446 },
447 Self::Process { process_id } => format!("process:{process_id}"),
448 }
449 }
450}
451
452#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
453pub struct TraceLashlangExecutionIdentity {
454 pub scope: TraceRuntimeScope,
455 pub subject: TraceRuntimeSubject,
456 pub module_ref: String,
457 pub entry_kind: String,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub entry_ref: Option<String>,
460 pub entry_name: String,
461}
462
463impl TraceLashlangExecutionIdentity {
464 pub fn graph_key(&self) -> String {
465 self.subject.graph_key(&self.scope)
466 }
467}
468
469#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
470#[serde(tag = "kind", rename_all = "snake_case")]
471pub enum TraceLashlangExecutionEvent {
472 ExecutionStarted {
473 event_key: String,
474 identity: TraceLashlangExecutionIdentity,
475 execution_map: TraceLashlangMap,
476 },
477 ExecutionFinished {
478 event_key: String,
479 identity: TraceLashlangExecutionIdentity,
480 status: TraceLashlangStatus,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 error: Option<String>,
483 },
484 NodeStarted {
485 event_key: String,
486 identity: TraceLashlangExecutionIdentity,
487 node_id: String,
488 node_kind: String,
489 label: String,
490 occurrence: u64,
491 },
492 NodeCompleted {
493 event_key: String,
494 identity: TraceLashlangExecutionIdentity,
495 node_id: String,
496 node_kind: String,
497 label: String,
498 occurrence: u64,
499 },
500 NodeFailed {
501 event_key: String,
502 identity: TraceLashlangExecutionIdentity,
503 node_id: String,
504 node_kind: String,
505 label: String,
506 occurrence: u64,
507 error: String,
508 },
509 BranchSelected {
510 event_key: String,
511 identity: TraceLashlangExecutionIdentity,
512 node_id: String,
513 occurrence: u64,
514 edge_id: String,
515 selected: TraceBranchSelection,
516 },
517 ChildStarted {
518 event_key: String,
519 identity: TraceLashlangExecutionIdentity,
520 parent_node_id: String,
521 occurrence: u64,
522 child: TraceLashlangChildExecution,
523 },
524}
525
526#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
527pub struct TraceLashlangChildExecution {
528 pub scope: TraceRuntimeScope,
529 pub subject: TraceRuntimeSubject,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub module_ref: Option<String>,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub entry_ref: Option<String>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub entry_name: Option<String>,
536}
537
538impl TraceLashlangChildExecution {
539 pub fn graph_key(&self) -> String {
540 self.subject.graph_key(&self.scope)
541 }
542}
543
544#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
545#[serde(rename_all = "snake_case")]
546pub enum TraceLashlangStatus {
547 Running,
548 Completed,
549 Failed,
550 Cancelled,
551}
552
553#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
554#[serde(rename_all = "snake_case")]
555pub enum TraceBranchSelection {
556 Then,
557 Else,
558}
559
560#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
561pub struct TraceLashlangMap {
562 pub module_ref: String,
563 pub entry_kind: String,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub entry_ref: Option<String>,
566 pub entry_name: String,
567 #[serde(default)]
568 pub nodes: Vec<TraceLashlangMapNode>,
569 #[serde(default)]
570 pub edges: Vec<TraceLashlangMapEdge>,
571}
572
573#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
574pub struct TraceLashlangMapNode {
575 pub id: String,
576 pub kind: String,
577 pub label: String,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub label_metadata: Option<TraceLabelMetadata>,
580}
581
582#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
583pub struct TraceLabelMetadata {
584 pub title: String,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub description: Option<String>,
587}
588
589#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
590pub struct TraceLashlangMapEdge {
591 pub id: String,
592 pub from: String,
593 pub to: String,
594 pub label: String,
595}
596
597#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
598pub struct TraceError {
599 pub message: String,
600 pub retryable: bool,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub terminal_reason: Option<String>,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub code: Option<String>,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub raw: Option<String>,
607}
608
609#[derive(Debug, thiserror::Error)]
610pub enum TraceSinkError {
611 #[error("failed to serialize trace record: {0}")]
612 Serialize(#[from] serde_json::Error),
613 #[error("trace sink lock poisoned")]
614 LockPoisoned,
615 #[error("failed to create trace directory {path}: {source}")]
616 CreateDir { path: PathBuf, source: io::Error },
617 #[error("failed to open trace file {path}: {source}")]
618 Open { path: PathBuf, source: io::Error },
619 #[error("failed to write trace file {path}: {source}")]
620 Write { path: PathBuf, source: io::Error },
621}
622
623pub trait TraceSink: Send + Sync {
624 fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError>;
625}
626
627pub struct JsonlTraceSink {
628 path: PathBuf,
629 lock: Mutex<()>,
630}
631
632impl JsonlTraceSink {
633 pub fn new(path: impl Into<PathBuf>) -> Self {
634 Self {
635 path: path.into(),
636 lock: Mutex::new(()),
637 }
638 }
639
640 pub fn path(&self) -> &Path {
641 &self.path
642 }
643}
644
645impl TraceSink for JsonlTraceSink {
646 fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError> {
647 let line = serde_json::to_string(record)?;
648 let _guard = self.lock.lock().map_err(|_| TraceSinkError::LockPoisoned)?;
649 if let Some(parent) = self.path.parent()
650 && !parent.as_os_str().is_empty()
651 {
652 std::fs::create_dir_all(parent).map_err(|source| TraceSinkError::CreateDir {
653 path: parent.to_path_buf(),
654 source,
655 })?;
656 }
657 let mut file = OpenOptions::new()
658 .create(true)
659 .append(true)
660 .open(&self.path)
661 .map_err(|source| TraceSinkError::Open {
662 path: self.path.clone(),
663 source,
664 })?;
665 writeln!(file, "{line}").map_err(|source| TraceSinkError::Write {
666 path: self.path.clone(),
667 source,
668 })
669 }
670}
671
672#[derive(Default)]
675pub struct StderrTraceSink {
676 lock: Mutex<()>,
677}
678
679impl TraceSink for StderrTraceSink {
680 fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError> {
681 let line = serde_json::to_string(record)?;
682 let _guard = self.lock.lock().map_err(|_| TraceSinkError::LockPoisoned)?;
683 eprintln!("{line}");
684 Ok(())
685 }
686}
687
688pub struct TeeTraceSink {
691 sinks: Vec<Arc<dyn TraceSink>>,
692}
693
694impl TeeTraceSink {
695 pub fn new(sinks: impl IntoIterator<Item = Arc<dyn TraceSink>>) -> Self {
696 Self {
697 sinks: sinks.into_iter().collect(),
698 }
699 }
700}
701
702impl TraceSink for TeeTraceSink {
703 fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError> {
704 for sink in &self.sinks {
705 sink.append(record)?;
706 }
707 Ok(())
708 }
709}
710
711pub fn sha256_hex(input: impl AsRef<[u8]>) -> String {
712 let mut hasher = Sha256::new();
713 hasher.update(input.as_ref());
714 format!("{:x}", hasher.finalize())
715}
716
717pub fn json_hash(value: &Value) -> String {
718 sha256_hex(serde_json::to_vec(value).unwrap_or_default())
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724
725 #[test]
726 fn jsonl_sink_writes_record() {
727 let dir = std::env::temp_dir().join(format!("lash-trace-{}", uuid::Uuid::new_v4()));
728 std::fs::create_dir_all(&dir).unwrap();
729 let path = dir.join("trace.jsonl");
730 let sink = JsonlTraceSink::new(&path);
731 sink.append(&TraceRecord::new(
732 TraceContext::default().for_session("root"),
733 TraceEvent::Custom {
734 name: "test.event".to_string(),
735 payload: serde_json::json!({"ok": true}),
736 },
737 ))
738 .unwrap();
739 let text = std::fs::read_to_string(&path).unwrap();
740 assert!(text.contains("\"type\":\"custom\""));
741 assert!(text.contains("\"session_id\":\"root\""));
742 }
743
744 #[test]
745 fn tool_start_and_frame_switch_records_are_jsonl_shaped() {
746 let started = TraceRecord::new(
747 TraceContext::default().for_session("root"),
748 TraceEvent::ToolCallStarted {
749 call_id: Some("call-1".to_string()),
750 name: "read_file".to_string(),
751 args: serde_json::json!({"path": "README.md"}),
752 },
753 );
754 let completed = TraceRecord::new(
755 TraceContext::default().for_session("root"),
756 TraceEvent::TurnCompleted {
757 status: "completed".to_string(),
758 done_reason: "modelstop".to_string(),
759 agent_frame_switch: Some(TraceAgentFrameSwitch {
760 frame_id: "frame-1".to_string(),
761 }),
762 },
763 );
764
765 let started_json = serde_json::to_value(started).unwrap();
766 assert_eq!(started_json["type"], "tool_call_started");
767 assert_eq!(started_json["call_id"], "call-1");
768
769 let completed_json = serde_json::to_value(completed).unwrap();
770 assert_eq!(completed_json["type"], "turn_completed");
771 assert_eq!(completed_json["agent_frame_switch"]["frame_id"], "frame-1");
772 }
773
774 #[test]
775 fn lashlang_execution_records_are_jsonl_shaped() {
776 let identity = TraceLashlangExecutionIdentity {
777 scope: TraceRuntimeScope::new("s1"),
778 subject: TraceRuntimeSubject::Process {
779 process_id: "p1".to_string(),
780 },
781 module_ref: "module".to_string(),
782 entry_kind: "process".to_string(),
783 entry_ref: Some("component:0".to_string()),
784 entry_name: "main".to_string(),
785 };
786 let event = TraceLashlangExecutionEvent::NodeStarted {
787 event_key: "process:p1:node:n1:1:started".to_string(),
788 identity,
789 node_id: "n1".to_string(),
790 node_kind: "resource_operation".to_string(),
791 label: "read_file".to_string(),
792 occurrence: 1,
793 };
794 let record = TraceRecord::new(
795 TraceContext::default().for_session("s1"),
796 TraceEvent::LashlangExecution { event },
797 );
798
799 let json = serde_json::to_value(&record).expect("serialize lashlang execution");
800 assert_eq!(json["type"], "lashlang_execution");
801 assert_eq!(json["event"]["kind"], "node_started");
802 assert_eq!(json["event"]["event_key"], "process:p1:node:n1:1:started");
803
804 let round_trip =
805 serde_json::from_value::<TraceRecord>(json).expect("deserialize lashlang execution");
806 assert!(matches!(
807 round_trip.event,
808 TraceEvent::LashlangExecution {
809 event: TraceLashlangExecutionEvent::NodeStarted { .. }
810 }
811 ));
812 }
813
814 #[test]
815 fn tool_completion_serializes_typed_failure_output() {
816 let record = TraceRecord::new(
817 TraceContext::default().for_session("root"),
818 TraceEvent::ToolCallCompleted {
819 call_id: Some("call-1".to_string()),
820 name: "read_file".to_string(),
821 args: serde_json::json!({"path": "missing"}),
822 output: TraceToolCallOutput {
823 outcome: TraceToolCallOutcome::Failure(serde_json::json!({
824 "class": "invalid_request",
825 "code": "invalid_tool_args",
826 "message": "bad args",
827 "source": "runtime",
828 "retry": { "type": "never" },
829 "raw": { "path": "missing" }
830 })),
831 control: None,
832 },
833 duration_ms: 3,
834 },
835 );
836
837 let json = serde_json::to_value(record).unwrap();
838 assert_eq!(json["type"], "tool_call_completed");
839 assert_eq!(json["output"]["outcome"]["status"], "failure");
840 assert_eq!(
841 json["output"]["outcome"]["payload"]["code"],
842 "invalid_tool_args"
843 );
844 assert_eq!(
845 json["output"]["outcome"]["payload"]["raw"]["path"],
846 "missing"
847 );
848 }
849
850 #[test]
851 fn jsonl_sink_creates_parent_directories() {
852 let dir = std::env::temp_dir().join(format!("lash-trace-{}", uuid::Uuid::new_v4()));
853 let path = dir.join("nested").join("trace.jsonl");
854 let sink = JsonlTraceSink::new(&path);
855 sink.append(&TraceRecord::new(
856 TraceContext::default().for_session("root"),
857 TraceEvent::RuntimeStreamEvent {
858 event: TraceRuntimeStreamEvent {
859 sequence: 1,
860 elapsed_ms: 0,
861 event_name: "delta".to_string(),
862 raw_text: Some("hello".to_string()),
863 visible_text: Some("hello".to_string()),
864 item_id: None,
865 output_index: None,
866 call_id: None,
867 tool_name: None,
868 input_json: None,
869 usage: None,
870 },
871 },
872 ))
873 .unwrap();
874 assert!(path.exists());
875 let _ = std::fs::remove_dir_all(dir);
876 }
877}