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    #[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/// Writes each trace record as one JSON line to stderr — handy for `cargo run`
665/// debugging without a trace file.
666#[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
680/// Fans each trace record out to several sinks in order (e.g. stderr + a JSONL
681/// file). Stops at the first sink that errors.
682pub 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}