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.
Le contenu des pages de documentation est en anglais. La navigation, la recherche et l’interface suivent la langue choisie.
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.
2.4 spans
The 2.4 subsystems report through the same seam, with no new dependency. Each
reported operation is an operation.started / operation.completed (or
operation.failed) pair whose operation is the span name and whose
subsystem is context, memory, goal, schedule, process or stream.
Attributes ride on the events' attributes field, split between the two
events (what is known at the start, then what the work found);
createOtelObserver turns each pair into one OTel span of that name with all
of them. A moment, such as a cancel request or a fencing refusal, is a
zero-length span.
import { createGoal } from '@deuz-sdk/core/goal';
import { createDurableScheduler, createInMemoryGoalRuntimeStore } from '@deuz-sdk/core/goal/runtime';
import { createMemoryObserver } from '@deuz-sdk/core/observe';
const observer = createMemoryObserver();
const store = createInMemoryGoalRuntimeStore();
await store.save(createGoal({ goalId: 'digest', scope: 'acme', objective: 'Send the digest.' }, { now: 0 }));
const scheduler = createDurableScheduler({
store,
deps: { clock: { now: () => Date.parse('2026-10-15T08:00:00Z'), setTimeout: () => () => {} }, observer },
});
await scheduler.put({ scheduleId: 'daily', scope: 'acme', goalId: 'digest', spec: { cron: '0 9 * * *' } });
await scheduler.tick({ now: Date.parse('2026-10-15T09:00:05Z') });
for (const event of observer.events())
if (event.type === 'operation.completed') console.log(event.subsystem, event.operation, event.attributes);
// schedule deuz.schedule.delivery { 'deuz.schedule.id': 'daily', 'deuz.schedule.occurrence_id': 'daily@1792054800000', ... }| Span | Attributes |
|---|---|
deuz.context.compile | deuz.context.selected, .excluded, .tokens, .estimator, .refresh_reason, .fallback, .cache_prefix_changed, .manifest_digest, and per source deuz.context.source.<id>.selected, .excluded, .tokens |
deuz.memory.op | deuz.memory.operation (remember, extract, revise, retract, forget, reembed, import; prune-history and prune-operations from the SQL stores), .record_count, .conflict_retries, .embedding_generation |
deuz.goal.claim, .dispatch, .retry, .cancel_requested, .cancel_applied, .drain, .generation | deuz.goal.id, .revision, .spec_generation, .run_id, .attempt |
deuz.schedule.delivery | deuz.schedule.id, .occurrence_id, .disposition, .catch_up, .reason (skipped only), deuz.goal.id |
deuz.process.start, .poll, .cancel, .exit, .reconcile | deuz.process.operation_id, .state, .replayed, .exit_code, .truncated, .matched, .orphans, .lost, .removed, .reconcile_result |
deuz.stream.batch, deuz.stream.fenced | deuz.stream.batch_frames, .batch_bytes, .commit_ms; .fencing_token on a fenced producer |
Attributes carry only counts, ids, digests, token values and small enums:
never memory text, transcripts, tool output or payloads. In a native run, the
deuz.context.compile span (the overflow recompile included) is a child of the
step span and shares the run's runId and executionId. Chat writes and
transcript searches report no span, and a skill proposal's model call appears
as an ordinary generate-text run.
Each module page lists its own spans: context, versioned memory, goal runtime, Docker runner and durable streams.
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 your own Manus
A fully autonomous, self-verifying agent — planner → executor → verifier, acting by writing and running code in a sandbox, with externalized workspace memory, durable checkpoints, and a live plan/activity feed. Composed entirely from published Deuz 1.8 primitives.
Retention and forget
The explicit cleanup calls of every 2.4 store, what each keeps so that deduplication and audits still hold, and how a versioned memory forget redacts the compiled context packages of V3 runs (2.4).