Session lifecycle
SessionStarted, TurnStarted, TurnCompleted (with AgentFrame switch id where applicable).
Structured runtime records emit through TraceSink. The bundled JsonlTraceSink writes one record per line; fan-out, OTel, and custom sinks are wrappers. Lashlang execution graphs are a separate opt-in sink for foreground blocks, durable process runs, and trace-derived graph snapshots. lash-trace-viewer renders JSONL to a self-contained HTML page.
Attach a sink at builder time. It propagates to every session from that core.
use std::sync::Arc;
use lash::{
LashCore,
tracing::{JsonlTraceSink, TraceLevel, TraceSink},
};
let trace_sink: Arc<dyn TraceSink> = Arc::new(JsonlTraceSink::new("./.lash-data/trace.jsonl"));
let core = lash::LashCore::standard_builder()
.provider(provider)
.model(
lash::ModelSpec::from_token_limits(model.clone(), None, 200_000, None)
.expect("valid model metadata"),
)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
.trace_sink(trace_sink)
.trace_level(TraceLevel::Extended)
.build()?;
TraceLevel::Standard (default): turns, tool calls, LLM start/complete, token usage. TraceLevel::Extended adds provider stream chunks, prompt-component hashes, and runtime stream deltas. Extended is verbose; use for provider debugging.
Host-level Lashlang execution graphs are a separate opt-in sink. They are observability only: they are not written to process_events, do not create process-registry storage, and the normal trace_sink does not receive them unless the host explicitly tees the sink itself. Commands remain on the process admin surfaces: the session-scoped SessionProcessAdmin and the runtime-level LashCore::processes() handle; observation is optional trace projection.
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::default(),
std::sync::Arc::new(lash::persistence::InMemoryLashlangArtifactStore::new()),
)
.with_lashlang_execution_jsonl_path("./.lash-data/lashlang-execution.jsonl");
let core = lash::LashCore::rlm_builder(factory)
.provider(provider)
.model(model)
.effect_host(std::sync::Arc::new(
lash::durability::InlineEffectHost::default(),
))
.attachment_store(std::sync::Arc::new(
lash::persistence::InMemoryAttachmentStore::new(),
))
.build()?;
use std::sync::Arc;
use lash::tracing::{JsonlTraceSink, TeeTraceSink, TraceLashlangGraphStore, TraceSink};
let lashlang_graphs = Arc::new(TraceLashlangGraphStore::default());
let lashlang_execution_sink = Arc::new(TeeTraceSink::new([
Arc::clone(&lashlang_graphs) as Arc<dyn TraceSink>,
Arc::new(JsonlTraceSink::new("./.lash-data/lashlang-execution.jsonl"))
as Arc<dyn TraceSink>,
]));
let factory = lash::rlm::RlmProtocolPluginFactory::new(
lash::rlm::RlmProtocolPluginConfig::default(),
Arc::new(lash::persistence::InMemoryLashlangArtifactStore::new()),
)
.with_lashlang_execution_sink(lashlang_execution_sink);
let core = lash::LashCore::rlm_builder(factory)
.provider(provider)
.model(model)
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
.build()?;
let graph = lashlang_graphs.graph("process:process-id");
Tracking records use TraceEvent::LashlangExecution with typed payloads for execution start/finish, node start/complete/fail, branch selection, and child execution links. Every payload includes a deterministic event_key so TraceLashlangGraphStore dedupes replayed records. Graph keys are derived from runtime identity, such as effect:session:turn:effect-id for foreground code and process:process-id for durable processes. Graph snapshots are derived from trace events and safe for host UIs, dashboards, tests, and debugging; they are not canonical process state.
When the linked Lashlang artifact contains static @label annotations, ExecutionStarted.execution_map.nodes carries each node's optional label_metadata with title and optional description. Lifecycle events continue to reference node_id only, and TraceLashlangGraphStore reduces the metadata from the initial execution map into graph nodes for host renderers.
Process wakes carry typed provenance through MessageOrigin::Process.caused_by. Hosts can inspect that semantic metadata to relate a wake back to a trigger occurrence, session node, or other runtime cause; rendering remains the host's responsibility.
A Lashlang graph is split into a static execution map and dynamic observation events. The static map gives a renderer the shape before any node runs; the dynamic stream updates node status, branch selection, timing, failures, and child execution links as the VM crosses observed sites.
ExecutionStarted carries TraceLashlangMap: graph identity, nodes, edges, node kinds, generated labels, and optional @label metadata. TraceLashlangGraphStore seeds those nodes as unobserved and branch edges as unknown. This is the data a UI uses to draw the full skeleton up front.NodeStarted, NodeCompleted, and NodeFailed update one node by node_id, set timestamps, duration, occurrence, and latest error. BranchSelected marks the chosen then or else edge as selected, marks the sibling branch edge as rejected, and completes the selected branch-arm node. ChildStarted links a parent node to another graph key, such as a started process graph. ExecutionFinished sets the graph-level status.@label@label(title: "...", description: "...") is static authoring metadata only. It travels in the execution map as label_metadata, becomes part of the module artifact identity, and never appears as a runtime command or dynamic update. A renderer should display the title/description from the static node and overlay the live status from dynamic events.process review_one(path: str) {
@label(title: "Review file")
report = await agents.default.spawn({
task: format("Review {}", path),
capability: "explore"
})?
finish report
}
@label(title: "Find candidates")
paths = await workspace.default.glob({ pattern: "src/**/*.rs" })?
@label(title: "Choose path")
if empty(paths) {
@label(title: "Submit empty result")
finish { reviewed: 0 }
} else {
@label(title: "Start child review")
child = start review_one(path: paths[0])
@label(title: "Collect child result")
report = (await child)?
finish { reviewed: 1, report: report }
}
For the run shown above, the graph snapshot keeps every static node even when the branch is not taken. A host UI can render the rejected branch faintly, keep the child-process link attached to the Start child review node, and update Collect child result from running to completed or failed when the next node event arrives.
{
"graph_key": "effect:session-1:turn-7:exec-1",
"status": "running",
"nodes": [
{
"id": "n1",
"kind": "operation",
"label": "workspace.default.glob",
"label_metadata": { "title": "Find candidates" },
"status": "completed",
"duration_ms": 12
},
{
"id": "n2",
"kind": "branch",
"label": "if empty(paths)",
"label_metadata": { "title": "Choose path" },
"status": "completed"
},
{
"id": "n3",
"kind": "start",
"label": "start review_one",
"label_metadata": { "title": "Start child review" },
"status": "completed"
}
],
"edges": [
{ "id": "e-then", "from": "n2", "to": "then", "label": "then", "selection": "rejected" },
{ "id": "e-else", "from": "n2", "to": "else", "label": "else", "selection": "selected" }
],
"children": [
{
"parent_node_id": "n3",
"child_graph_key": "process:review-process-id",
"child_entry_name": "review_one"
}
]
}
One synchronous append. Called on the runtime thread; must not block. JsonlTraceSink serializes and appends under a short-held mutex. A defaulted flush lets a host force buffered records to durable storage before process exit.
pub trait TraceSink: Send + Sync {
fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError>;
// Force buffered trace data to durable storage before exit.
// Default: no-op. Override when the sink buffers or can fsync.
fn flush(&self) -> Result<(), TraceSinkError> { Ok(()) }
}
Call flush before the process exits so records a sink has not yet committed are not lost. JsonlTraceSink::flush is honest about what it owns: each append already writes its record through to the OS (open, append, close — no in-process buffer), so flush only issues an fsync to push the OS page cache to disk. TeeTraceSink::flush fans out to every wrapped sink. StderrTraceSink and the default keep the no-op. A host that handed lash the sink already holds its own Arc and can flush it directly; LashCore::flush_trace_sink is the equivalent lever for hosts that did not retain the handle.
Fan out to multiple destinations by wrapping sinks:
struct FanoutTraceSink {
sinks: Vec<Arc<dyn TraceSink>>,
}
impl TraceSink for FanoutTraceSink {
fn append(&self, record: &TraceRecord) -> Result<(), TraceSinkError> {
for sink in &self.sinks {
// Treat errors per-sink; one failing destination shouldn't take the others down.
let _ = sink.append(record);
}
Ok(())
}
}
Tagged enums on TraceEvent. TraceEvent::kind() is the single source of truth for each variant's type tag — consumers such as the trace viewer match on the enum and read the kind from there rather than re-deriving tag strings. New variants and optional fields are additive; renames and removals bump the schema. The full rule set lives in Reporting channels → Schema evolution.
SessionStarted, TurnStarted, TurnCompleted (with AgentFrame switch id where applicable).
PromptBuilt: combined prompt_hash, prompt_chars, per-component fingerprints. The runtime caches rendered prompts by fingerprint; an unexpected hash change flags a drifting plugin contribution, tool list, or template entry.
LlmCallStarted, LlmCallCompleted, LlmCallFailed. Provider, model, variant, request shape.
ProviderStreamEvent (raw provider chunks) and RuntimeStreamEvent (post-projection SDK deltas).
ToolCallStarted, ToolCallCompleted. Args, output outcome (success / failure / cancelled), duration. Emitted per tool from one shared tool-execution seam, so a standard native call and a tool run inside a code block both produce exactly one Started + Completed pair. Containment (code block, batch parent) rides the matching TurnActivity; see Reporting channels.
TokenUsage: per-turn uncached input, output, cache-read input, cache-write input, and reasoning-output deltas. Reasoning is an output subset, not an additive total bucket. Mirrors the session usage ledger.
ProtocolStep: agentic-loop iterations (RLM execute / observe / finish). The exec_code_completed diagnostic carries a per-tool tool_calls roll-up (call_id, name, duration_ms, status) and a matching tool_call_count inside its free-form payload — additive, no schema bump.
Custom { name, payload }: escape hatch for host-specific events.
One line of UTF-8 JSON per record. TRACE_SCHEMA_VERSION = 2. Reject or warn on higher unrecognized versions. When to bump it — and when an additive change (a new variant, an optional field, a reshaped Custom / ProtocolStep payload) does not — is the schema-evolution policy.
{
"schema_version": 2,
"id": "6621dfa7-2cd0-4296-ac20-15c0f2d3cec1",
"timestamp": "2026-05-11T11:42:01.234+00:00",
"context": {
"session_id": "chat-123",
"turn_id": "turn-7"
},
"type": "tool_call_completed",
"call_id": "call-9",
"name": "read_file",
"args": { "...": "..." },
"output": { "outcome": { "status": "success", "payload": { "...": "..." } } },
"duration_ms": 8
}
Record type: lash_trace::TraceRecord. Event variants: lash_trace::TraceEvent. Both Deserialize; parse directly with serde_json::from_str::<TraceRecord>().
Renders JSONL to a self-contained HTML timeline with nested LLM calls, tool calls, and streaming chunks inline. Valid-JSON records of an unrecognized schema are preserved as raw text; a line that is not valid JSON aborts the load.
$ cargo run -p lash-trace-viewer -- ./.lash-data/trace.jsonl
# Writes ./.lash-data/trace.html next to the input
$ cargo run -p lash-trace-viewer -- trace.jsonl \
--out report.html \
--title "Session 2026-05-11"
# Custom output path and title.
Workspace-only; no separate release. Run with cargo run -p lash-trace-viewer from the workspace root.
Optional otel-trace cargo feature converts events to OTel spans for export to Jaeger, Honeycomb, Datadog, or any OTLP backend. Off by default. The sink lives in lash-trace and is re-exported by lash-core under the feature.
# In your downstream Cargo.toml — pull in lash-core with the otel-trace
# feature alongside the lash-runtime facade.
lash-runtime = "=0.1.0-alpha.84"
lash-core = { version = "=0.1.0-alpha.84", features = ["otel-trace"] }
lash_core::OtelTraceSink wraps an opentelemetry tracer and converts every event to a span with payload attributes. Use in place of (or alongside) JsonlTraceSink:
use std::sync::Arc;
use lash::{
LashCore,
tracing::{OtelTraceSink, TraceLevel, TraceSink},
};
// Exporter/provider setup stays with the host; this reads the
// process-global OpenTelemetry tracer provider.
let sink: Arc<dyn TraceSink> = Arc::new(OtelTraceSink::from_global_provider());
let core = lash::LashCore::standard_builder()
.effect_host(Arc::new(lash::durability::InlineEffectHost::default()))
.attachment_store(Arc::new(lash::persistence::InMemoryAttachmentStore::new()))
.trace_sink(sink)
.trace_level(TraceLevel::Extended)
.build()?;
OtelTraceSink::flush is deliberately a no-op: the sink only starts and ends spans on your tracer, while the buffering that risks span loss on exit lives in your BatchSpanProcessor / exporter. Flushing it is the host's duty — call force_flush() (or shutdown()) on your TracerProvider before the process exits. Lash never owns the provider, so it cannot do this for you; LashCore::flush_trace_sink flushes the sink only.
Span names are derived from the typed event. Turn, LLM, and tool lifecycles become nested spans (lash.turn, lash.llm, lash.tool); a tool produces one lash.tool span whether it was a standard native call or ran inside a code block, because both flow through the same emission seam. Runtime exec diagnostics collapse into a lash.exec_code family, with the precise phase (exec_code_started / exec_code_completed / exec_code_failed) on the lash.protocol.diagnostic_phase attribute; observation-projection diagnostics get lash.observation_projection, and other protocol steps stay lash.protocol_step. The channel map that ties these span names back to the other reporting surfaces is Reporting channels.