Deuz SDK
Modules

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

FactoryWhat 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/finishReason promises 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.
  • streamChat stays lazy (G2). run.started fires when the pump actually starts, never at call time.
  • One terminal event per execution leg (run.completed / run.suspended / run.aborted / run.failed); an async cost.calculated may follow it.
  • Durable correlation. A durable run keeps one runId across suspend/resume legs; each leg gets a fresh executionId, and checkpoint.loaded precedes run.started { resumed: true }.
  • Sub-agents share the parent's runId and are identified by agentPath; 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.

Bu sayfada