Skip to main content

lash_trace/
lib.rs

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    /// Stable id of the span this record represents (e.g. `turn:<session>:<turn>`,
56    /// `llm:<call_id>`, `tool:<call_id>`). Populated by the runtime for turn /
57    /// llm / tool / session records so a consumer can build a nested span tree
58    /// from `(graph_node_id, parent_graph_node_id)` with a single `id -> span`
59    /// map. Lashlang execution sets its own graph node id here.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub graph_node_id: Option<String>,
62    /// Id of the enclosing span — the value of some other record's
63    /// `graph_node_id`. A turn's parent is its causal origin (the spawning tool
64    /// call / effect, via `caused_by`) when known, otherwise the session root.
65    #[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/// Writes each trace record as one JSON line to stderr — handy for `cargo run`
673/// debugging without a trace file.
674#[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
688/// Fans each trace record out to several sinks in order (e.g. stderr + a JSONL
689/// file). Stops at the first sink that errors.
690pub 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}