Observability
Local-first observation events for every run — model calls, retries, tools, approvals, checkpoints, compaction and sub-agents. No hosted service, no OTel dependency, privacy-first by default.
Every Deuz run can emit a versioned event protocol (ObserveEvent, schemaVersion: 1) describing its full lifecycle: run start/end, model calls with TTFT and retries, agent steps, per-tool timings, approval decisions, durable checkpoints, compaction passes, sub-agent trees and cost. Everything stays in your process — no API key, no account, no data leaves your machine.
Quick start
Inject an observer through the Dependencies seam:
import { generateText } from '@deuz-sdk/core';
import { createMemoryObserver, summarizeRun } from '@deuz-sdk/core/observe';
const observer = createMemoryObserver();
await generateText({
model,
messages: [{ role: 'user', content: 'Prepare the release notes.' }],
tools,
maxSteps: 5,
deps: { observer },
});
const summary = summarizeRun(observer.latestRun() ?? []);
// { status: 'completed', stepCount, modelCallCount, toolCallCount,
// retryCount, approvalCount, checkpointCount, usage, costUsd?, errors }A client-level observer (createClient({ deps: { observer } })) flows into every call; a per-call deps.observer overrides it.
JSONL persistence (Node)
import { createJsonlObserver, readJsonlEvents } from '@deuz-sdk/core/observe/node';
const observer = createJsonlObserver({ file: '.deuz/runs.jsonl' });
await generateText({ model, messages, tools, deps: { observer } });
await observer.close(); // flushes the internal queue
const events = await readJsonlEvents('.deuz/runs.jsonl');One event per line, every line valid JSON, Uint8Array payloads round-trip via the durable $deuzBytes codec. emit() is synchronous — writes flow through a bounded internal queue (maxQueueSize, default 10 000; overflow drops and counts in droppedCount), so a slow or failing disk can never affect the run. You own the file's security — treat it like a log that may contain whatever you opted into capturing.
Built-in observers
| Factory | What it does |
|---|---|
createMemoryObserver({ maxEvents?, maxBytes?, overflow? }) | Capped in-memory ring buffer with events() / eventsForRun(runId) / latestRun() |
createCallbackObserver(fn, options?) | Wraps a plain callback; a throwing callback is swallowed |
composeObservers(...observers) | Fan-out with per-sink capture projection; one throwing child never blocks its siblings |
filterObserver(observer, predicate) | Forward only matching events |
createJsonlObserver({ file, … }) | Node-only JSONL file sink (@deuz-sdk/core/observe/node) |
createMemoryObserver accepts a total byte budget alongside the event cap (1.6.1): maxBytes measures each stored event's approximate JSON size and evicts by the shared overflow strategy ('drop-oldest' default / 'drop-newest'), counting evictions in droppedCount. maxEvents and maxBytes compose — whichever budget is exceeded first wins.
Privacy defaults and capture
By default events carry only counts, ids, names, durations and small enums — never prompts, tool inputs/outputs, reasoning or error message text. Raw content capture is opt-in per field:
const observer = createMemoryObserver({
observation: {
capture: { outputText: true, toolInputs: true }, // everything defaults to false
sampleRate: 0.25, // deterministic per runId — a run is all-in or all-out
metadata: { app: 'deuz-console' }, // flat primitives, merged into every event
},
});Captured payloads always pass the observation redaction profile (API keys, Bearer/JWT/PEM patterns, password/cookie/token-style keys → [REDACTED]) plus structural limits (string/array/depth caps with [Truncated] markers). Since 1.6.1 the built-in profile also runs after your optional custom redact hook — a buggy or malicious redactor cannot reintroduce a secret, and truncation can never split a secret into a decodable prefix. This is regression-tested: planted secrets in prompts, tool outputs, headers and factory keys never reach an event or a JSONL line.
Composite observers see only what they opted into (1.6.1)
composeObservers still merges options for event production (capture = OR, sampleRate = max, limits = min), but each child now receives a projection of every event: captured* fields its own capture options don't allow are stripped, error.message is dropped unless it set capture.errorMessages, and a child redact hook only ever runs on that child's view. A composed sink with no options behaves exactly like a standalone observer with defaults — it gets no captured content.
const observer = composeObservers(
createMemoryObserver({ observation: { capture: { messages: true } } }), // sees prompts
remoteSink, // no capture options — receives counts/ids/durations only
);Draining async events: observation.settled
cost.calculated can resolve asynchronously (an async priceProvider). Results expose an optional observation.settled promise — await it before closing a file-backed observer so late events aren't dropped:
const res = await generateText({ model, messages, deps: { observer } });
await res.observation?.settled; // all pending observe enrichments flushed
await observer.close();The field exists only when a run was actually observed; nothing about the run itself ever waits on it (G2 holds).
Guarantees
- Observers can never break a run. A throwing, slow or closed observer is isolated;
usage/finishReasonpromises and stream parts are untouched. - Fast path. With no observer (and no tracer) the hot path pays one boolean branch: no event objects, no extra
generateId()draws — scripted-id test fixtures stay stable. streamChatstays lazy (G2).run.startedfires when the pump actually starts, never at call time.- One terminal event per execution leg (
run.completed/run.suspended/run.aborted/run.failed); an asynccost.calculatedmay follow it. - Durable correlation. A durable run keeps one
runIdacross suspend/resume legs; each leg gets a freshexecutionId, andcheckpoint.loadedprecedesrun.started { resumed: true }. - Sub-agents share the parent's
runIdand are identified byagentPath; their usage is folded into the parent exactly once.
Tracer bridge
An injected Dependencies.tracer now receives the full documented span hierarchy, driven by the same events:
invoke (one per run)
└── step (one per model round-trip; deuz.step.index, gen_ai.request.model)
└── execute_tool (one per tool call; gen_ai.tool.name, deuz.tool.is_error)Span names and attribute keys are unchanged from 1.5 (gen_ai.* semconv + deuz.*); the behavioral difference is that agentic loops now produce one invoke with children instead of N flat invokes. A user abort still ends the span without an exception. An OTel exporter plugs into this seam.
Dashboards built against the 1.5 flat topology can opt back into it with deps: { tracerMode: 'legacy' } (1.6.1): every model call becomes its own parent-less invoke span with deuz.step.count: 1, no step/execute_tool children, retries landing on that call's span. The default remains 'hierarchical'.
See the full event catalog in Observation events.
Build a Coding Agent
An end-to-end, Codex/Claude-Code-style autonomous coding agent — an orchestrator that delegates to a coder sub-agent, with file/shell/test tools, budgets, and automatic compaction.
Memory
Long-term agent memory with a mem0-style extract → reconcile → apply pipeline, behind one swappable MemoryStore seam.