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