tracing/observability

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.

Enabling Tracing

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::builder(model.clone())
            .context_window_tokens(200_000)
            .build()
            .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.

Lashlang Execution Graphs

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::new(
        lash::rlm::ExecutionBound::instructions(1_000_000),
        lash::rlm::ExecutionBound::secs(30),
    ),
    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::new(
        lash::rlm::ExecutionBound::instructions(1_000_000),
        lash::rlm::ExecutionBound::secs(30),
    ),
    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.

Lashlang Graph Updates

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.

Static map
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.
Dynamic updates
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 }
}
Static map with dynamic overlays
flowchart LR A["Find candidates
@label
completed"] --> B{"Choose path
@label
selected branch"} B -- else selected --> C["Start child review
@label
completed"] B -. then rejected .-> D["Submit empty result
@label
unobserved"] C --> E["Collect child result
@label
running"] C == child graph link ==> P["process:review_one
Review file"] classDef completed fill:#d8f3dc,stroke:#2d6a4f,color:#10251b; classDef running fill:#dbeafe,stroke:#2563eb,color:#111827; classDef rejected fill:#f3f4f6,stroke:#9ca3af,color:#4b5563,stroke-dasharray:4 4; class A,C completed; class B,E running; class D rejected;

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"
    }
  ]
}

The Sink Trait

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(())
    }
}

What Is Emitted

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.

Session lifecycle

SessionStarted, TurnStarted, TurnCompleted (with AgentFrame switch id where applicable).

Prompt

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.

Rolling history

RollingHistoryCompactionNeeded and RollingHistoryPromptPruned are turn-scoped decisions; RollingHistoryCompactionStarted and RollingHistoryCompactionCompleted are runtime-operation-scoped because an explicit /compact can run without a turn. dropped_prefix_messages and retained_messages describe only the ephemeral prompt view; durable session history remains unchanged. A needed decision with no valid cut point terminates with a pruned record carrying zero dropped messages. Old attachment payloads can also be elided before the compaction threshold; that attachment-only optimization is deliberately untraced because the placeholder remains visible in the emitted provider request and no durable content changes.

LLM interaction

LlmCallStarted, LlmCallCompleted, LlmCallFailed. Provider, model, variant, request shape. LlmCallCompleted also carries generation_disposition when the adapter reports one: per generation option, whether the caller requested it and whether the request that ran actually carried it.

Streaming (extended only)

ProviderRequest (the complete serialized request body with its byte length and SHA-256), ProviderStreamEvent (raw provider chunks), and RuntimeStreamEvent (post-projection SDK deltas).

Tool calls

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.

Usage

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.

Protocol steps

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

Custom { name, payload }: escape hatch for host-specific events.

The JSONL Schema

One line of UTF-8 JSON per record. TRACE_SCHEMA_VERSION = 3. 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": 3,
  "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>().

Viewing A Trace

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.

OpenTelemetry Export

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.113"
lash-core    = { version = "=0.1.0-alpha.113", 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.

read on ·