agentInstrumentation
Instrument any @pleach/core agent with bare lifecycle subscriptions — runtime.events.on(kind, ...) for stage, turn, recovery, retry, and model.called signals — then bridge model.called straight to a destination with observeSink({ destinations }). No recipe wrapper, no recorder threaded through call sites.
This is the do-it-yourself instrumentation path. You hold a
SessionRuntime and you want to watch how each turn runs —
stage transitions, recovery dispatches, retries, per-LLM-call
cost — without adopting a recipe wrapper or threading a recorder
argument through your code.
Two surfaces carry the signal. The durable runtime.events bus
carries lifecycle and per-call events for cross-cutting
subscribers. The per-turn StreamEvent iterator carries the
same lifecycle kinds inline with the turn, for UIs that render
from the stream. Pick the one that matches where your subscriber
lives.
Best fit: any @pleach/core consumer who wants
metrics or audit rows from an existing runtime without rewriting
the turn loop. For the wrapped-chatbot version of this story, see
observableChatbot. For the
no-@pleach/core brownfield version, see
BYOK observability.
Subscribe to the runtime bus
runtime.events.on(kind, handler) registers a handler for one
event kind on the durable bus. It returns an unsubscribe
function. The bus is the right surface for a metrics exporter, a
cost meter, or an alerting hook — anything that outlives a single
turn.
const offModel = runtime.events.on("model.called", (e) => {
// { provider, model, callClass, inputTokens, outputTokens, costUSD, latencyMs }
meter.record(e.model, e.latencyMs);
});
const offError = runtime.events.on("error", (e) => {
// { error, context }
alert(e.error);
});
// later, on teardown:
offModel();
offError();model.called fires once per LLM call. As of the seam-path
landing it fires for both call sites in a turn:
- the main agentic turn (the converse / synthesize loop), and
- each seam call — the dedicated
synthesize,reasoning, andutilityseam invocations the graph makes around the main turn.
That distinction matters for cost. See
What fires today below before you sum
costUSD across rows.
Subscribe inline with the turn
When your subscriber already owns the turn loop — a React UI, an
SSE forwarder — read the same lifecycle kinds off the
StreamEvent iterator instead. @pleach/react's useChat
exposes them through onEvent:
useChat({
onEvent: (e) => {
if (e.type === "stage.transition") {
// { from: StageId | null, to: StageId }
track("stage", e.to);
}
if (e.type === "turn.completed") {
// { outcome: "ok" | "error" | "interrupted", durationMs }
track("turn", e.outcome, e.durationMs);
}
},
});The same kinds arrive on the raw iterator if you do not use
useChat:
for await (const e of runtime.executeMessage(sessionId, prompt)) {
if (e.type === "recovery.fired") {
// { arm: RecoveryDispatchArm }
track("recovery", e.arm);
}
}These are StreamEvent members, not runtime.events bus events.
The full catalog — payload shapes and when each fires — is on
Stream events → Execution lifecycle.
model.called is the exception: it lives on the durable bus, not
the stream, because cost attribution is a long-lived concern.
Bridge model.called to a destination
observeSink turns the runtime.events.on("model.called", ...)
subscription into audit rows with one line. It is a handler
factory: observeSink({ destinations }) returns a model.called
handler that maps each event to an ObserveRow and writes it to
every destination you pass.
import { observeSink } from "@pleach/observe";
import { memory, postgres } from "@pleach/observe/destinations";
const store = memory();
runtime.events.on(
"model.called",
observeSink({
destinations: [store, postgres({ pgClient, tableName: "pleach_observe_calls" })],
}),
);
// every LLM call now writes one ObserveRow to both destinationsThe mapping is runtimeEventToObserveRow — a pure function from
a model.called event to an ObserveRow. observeSink calls it
for you; import it directly if you want the row without the write
(to enrich tags before handing it to your own sink, say):
import { runtimeEventToObserveRow } from "@pleach/observe";
runtime.events.on("model.called", (e) => {
const row = runtimeEventToObserveRow(e);
myKafkaTopic.publish({ ...row, tags: { region: "us-east-1" } });
});ObserveRow is the same shape the brownfield recordCall path
writes — a strict subset of @pleach/core's AuditableCall v13
record. See @pleach/observe for the row fields,
the four destinations, redaction, and sampling.
What fires today
model.called fires for both the main turn and the seam calls,
but the two carry cost differently.
| Source | When it fires | Tokens + latency | costUSD |
|---|---|---|---|
Seam calls (synthesize / reasoning / utility seams) | Every turn | Real | Real — computed from the family's per-1M-token rates |
| Main agentic turn (converse / synthesize loop) | Every turn | Real | 0 sentinel — derive downstream from the llm.turn token counts |
The seam-call rows are the ones with real costUSD. The
main-turn row reports tokens and latency but carries 0 for
costUSD by design — cost for the main turn is derived
downstream from llm.turn token metadata, not stamped on the
model.called event. If you sum costUSD naively across all
model.called rows in a turn, the main-turn row contributes
nothing; that is expected, not a dropped row.
The lifecycle stream events fire every turn regardless of the above:
turn.started/turn.completedstage.transition(andstage.lattice_violationwhen a transition leaves the advertised lattice)recovery.fired—arm∈"zeroToolRecovery"|"allToolsFailedMissingParams"|"maxStepsHit"retry.attempted—{ reason, attempt }stream.first_chunk/stream.completed— TTFB and total stream duration
So a UI can reconstruct the per-turn lattice walk and report TTFB and total latency from the stream alone, with no separate metrics pipeline.
Common gotchas
model.calledis a bus event, not a stream event. Subscribe withruntime.events.on("model.called", ...). It does not arrive in theexecuteMessageiterator or inuseChat({ onEvent })— those carry the lifecycle stream kinds (stage.*,turn.*,recovery.fired,retry.attempted,stream.*), not the per-call cost signal.- The main-turn
costUSDis a0sentinel. Real cost on the main turn is derived fromllm.turntoken counts downstream; the seam-call rows are the ones carrying computedcostUSD. Don't treat a0-cost main-turn row as a bug. observeSinkneeds noinit. Unlike the brownfieldrecordCallpath,observeSink({ destinations })writes straight to the destinations you pass — there is no process-singleton to initialize first.runtime.events.onreturns an unsubscribe function. Call it on teardown for long-lived processes; a leaked handler keeps the destination write alive past the runtime you meant to retire.
See also
- Stream events — the full
StreamEventcatalog, including the execution-lifecycle kinds this page subscribes to. @pleach/observe—observeSink,runtimeEventToObserveRow, theObserveRowshape, and the four destinations.observableChatbot— the same observability story as a chatbot recipe wrapper.- BYOK observability — the
brownfield path for loops not yet on
@pleach/core. - Observability — the runtime-side observability surface.
observableChatbot
Chatbot whose ask() calls inherit a @pleach/observe sub-agent attribution scope. Every recordCall(...) downstream of the runtime turn carries [serviceName, ...] without threading a recorder argument through call sites.
compliantChatbot
Chatbot with PII / PHI scrubbing at the message-content boundary and, when a Supabase client is supplied, at the durable EventLogWriter boundary. Four named profiles map to curated scrubber bundles from @pleach/compliance.