How it works
The SDK buffers events in-process and ships them to the Dunetrace ingest service in the background — a 200ms drain loop, batched at 100 events per flush.
your TS agent → POST /v1/ingest → detector → dashboard + Slack alerts
Prerequisites
- Dunetrace backend running (
docker compose up -d) - Node 22+ (built-in
fetchandAsyncLocalStoragerequired)
AUTH_MODE=dev. API keys are only required for production.1. Install
npm install dunetrace
Set environment variables:
DUNETRACE_ENDPOINT=http://localhost:8001 # ingest service (port 8001, not 8002)
DUNETRACE_API_KEY= # empty for local dev
2. Auto-instrument your LLM client (recommended)
autoInstrument() patches the OpenAI and Anthropic SDKs once, globally. Every client instance is then tracked inside a dt.run() — including clients constructed by code you don't control, and clients created after the call.
import { Dunetrace, autoInstrument } from "dunetrace";
import OpenAI from "openai";
const dt = new Dunetrace();
autoInstrument({ openai: OpenAI }); // add `anthropic: Anthropic` if you use it
const openai = new OpenAI(); // constructed after the patch — still tracked
await dt.run("my-ts-agent", { model: "gpt-4o" }, async (run) => {
await openai.chat.completions.create({ model: "gpt-4o", messages });
run.finalAnswer();
});
await dt.shutdown();
Pass the imported class as shown — that works under ESM, CommonJS, and bundlers alike. Calling autoInstrument() with no arguments auto-detects each SDK via require, which only resolves under CommonJS. It returns the targets it patched, e.g. ["openai", "http"], and is idempotent.
Outbound HTTP is instrumented too, via the global fetch — each request emits tool.called / tool.responded named by hostname. Requests made by an instrumented LLM SDK are excluded, so one LLM call doesn't also count as a tool call; so is Dunetrace's own ingest traffic. Narrow the targets to opt out: autoInstrument({ openai: OpenAI, targets: ["openai"] }).
A failure inside event emission never propagates into your LLM call — it warns and continues.
Wrapping a single client
To instrument one client rather than the whole SDK, call dt.wrapOpenAI() or dt.wrapAnthropic() once and every LLM call inside a dt.run() context is tracked automatically. Use dt.tool() to wrap tool functions. Safe to combine with autoInstrument() — events are not double-counted.
import { Dunetrace } from "dunetrace";
import OpenAI from "openai";
const dt = new Dunetrace();
const openai = dt.wrapOpenAI(new OpenAI()); // patch once at startup
const search = dt.tool(webSearch); // wrap tools once at startup
await dt.run("my-ts-agent", { model: "gpt-4o", tools: ["web_search"] }, async (run) => {
const response = await openai.chat.completions.create({ model: "gpt-4o", messages });
// ↑ llm.called + llm.responded emitted automatically (tokens, latency, finish_reason)
const results = await search(query);
// ↑ tool.called + tool.responded emitted automatically
run.finalAnswer();
});
await dt.shutdown();
Anthropic:
import Anthropic from "@anthropic-ai/sdk";
const anthropic = dt.wrapAnthropic(new Anthropic());
await dt.run("my-agent", { model: "claude-3-5-haiku-20241022" }, async (run) => {
const response = await anthropic.messages.create({ model: "claude-3-5-haiku-20241022", max_tokens: 1024, messages });
run.finalAnswer();
});
llm.called is emitted at call time, and the stream is returned through a pass-through proxy that observes chunks as you consume them, emitting llm.responded when it ends. Nothing is consumed on your behalf, and members like .tee() and .controller still work. Pass stream_options: { include_usage: true } for OpenAI, or the API never sends token counts. A stream you never iterate reports no llm.responded — there is nothing to report.3. dt.tool() and dt.trace()
dt.tool(fn, name?) wraps any sync or async function to auto-emit tool.called / tool.responded. No-op when called outside a dt.run() context.
dt.trace(fn, agentId?, opts?) wraps an async function so it automatically opens and closes a run each time it is called. The first argument is used as userInput. Calls run.finalAnswer() on clean return.
const agent = dt.trace(myAgent, "my-agent", { model: "gpt-4o", tools: ["web_search"] });
const answer = await agent("What is the capital of France?");
await dt.shutdown();
4. getCurrentRun()
Returns the active DunetraceRun for the current async context (via AsyncLocalStorage), or null. Use inside helpers to access the run without prop drilling.
import { getCurrentRun } from "dunetrace";
async function dbQuery(sql: string) {
const run = getCurrentRun();
if (run) run.toolCalled("db_query", { sql });
const result = await db.query(sql);
if (run) run.toolResponded("db_query", true, result.length);
return result;
}
5. Manual tracking (full control)
Use run.llmCalled / run.llmResponded directly for fine-grained control:
import { randomUUID } from "crypto";
const ENDPOINT = process.env.DUNETRACE_ENDPOINT ?? "http://localhost:8001";
const API_KEY = process.env.DUNETRACE_API_KEY ?? "";
type EventType =
| "run.started" | "run.completed" | "run.errored"
| "llm.called" | "llm.responded"
| "tool.called" | "tool.responded"
| "retrieval.called" | "retrieval.responded"
| "external.signal";
interface AgentEvent {
event_type: EventType;
run_id: string;
agent_id: string;
agent_version: string;
step_index: number;
timestamp: number;
payload: Record<string, unknown>;
parent_run_id?: string | null;
}
export class DunetraceRun {
readonly runId: string = randomUUID();
private step = 0;
private events: AgentEvent[] = [];
constructor(private readonly agentId: string, private readonly version: string) {}
private emit(type: EventType, payload: Record<string, unknown>): void {
this.step++;
this.events.push({
event_type: type, run_id: this.runId, agent_id: this.agentId,
agent_version: this.version, step_index: this.step,
timestamp: Date.now() / 1000, payload,
});
}
llmCalled(model: string, promptTokens = 0): void {
this.emit("llm.called", { model, prompt_tokens: promptTokens });
}
llmResponded(opts: { completionTokens?: number; latencyMs?: number; finishReason?: string; outputLength?: number }): void {
this.emit("llm.responded", {
completion_tokens: opts.completionTokens ?? 0,
latency_ms: opts.latencyMs ?? 0,
finish_reason: opts.finishReason ?? "stop",
output_length: opts.outputLength ?? 0,
});
}
toolCalled(toolName: string, args: Record<string, unknown> = {}): void {
this.emit("tool.called", {
tool_name: toolName,
args: JSON.stringify(args),
});
}
toolResponded(toolName: string, success: boolean, outputLength = 0, latencyMs = 0, error?: string): void {
const payload: Record<string, unknown> = { tool_name: toolName, success, output_length: outputLength, latency_ms: latencyMs };
if (error) payload["error"] = error;
this.emit("tool.responded", payload);
}
retrievalCalled(indexName: string, query = ""): void {
this.emit("retrieval.called", { index_name: indexName, query });
}
retrievalResponded(indexName: string, resultCount: number, topScore?: number, latencyMs = 0): void {
this.emit("retrieval.responded", { index_name: indexName, result_count: resultCount, top_score: topScore ?? null, latency_ms: latencyMs });
}
externalSignal(signalName: string, source = "", meta: Record<string, unknown> = {}): void {
this.step++;
this.events.push({
event_type: "external.signal", run_id: this.runId, agent_id: this.agentId,
agent_version: this.version, step_index: this.step,
timestamp: Date.now() / 1000,
payload: { signal_name: signalName, ...(source ? { source } : {}), ...meta },
});
}
finalAnswer(): void {
this.emit("run.completed", { exit_reason: "final_answer", total_steps: this.step });
}
getEvents(): AgentEvent[] { return this.events; }
}
export class Dunetrace {
async run(
agentId: string,
opts: { model?: string; tools?: string[] },
fn: (run: DunetraceRun) => Promise<void>,
): Promise<void> {
const version = opts.model ?? "unknown";
const run = new DunetraceRun(agentId, version);
const startEvent: AgentEvent = {
event_type: "run.started", run_id: run.runId, agent_id: agentId,
agent_version: version, step_index: 0, timestamp: Date.now() / 1000,
payload: { model: opts.model ?? "unknown", tools: opts.tools ?? [] },
};
try {
await fn(run);
} catch (err) {
await this._flush(agentId, [startEvent, ...run.getEvents(), {
event_type: "run.errored", run_id: run.runId, agent_id: agentId,
agent_version: version, step_index: run.getEvents().length + 1,
timestamp: Date.now() / 1000,
payload: { error_type: (err as Error).name ?? "Error" },
}]);
throw err;
}
await this._flush(agentId, [startEvent, ...run.getEvents()]);
}
private async _flush(agentId: string, events: AgentEvent[]): Promise<void> {
try {
await fetch(`${ENDPOINT}/v1/ingest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: API_KEY, agent_id: agentId, events }),
});
} catch (err) {
console.warn("[dunetrace] Failed to flush events:", err);
}
}
}
Basic example
import { Dunetrace } from "./dunetrace";
const dt = new Dunetrace();
await dt.run("my-ts-agent", { model: "gpt-4o", tools: ["web_search"] }, async (run) => {
run.llmCalled("gpt-4o", 150);
const t0 = Date.now();
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: query }],
});
run.llmResponded({
completionTokens: response.usage?.completion_tokens,
latencyMs: Date.now() - t0,
finishReason: response.choices[0].finish_reason ?? "stop",
outputLength: response.choices[0].message.content?.length,
});
run.toolCalled("web_search", { query });
const t1 = Date.now();
const results = await webSearch(query);
run.toolResponded("web_search", true, results.length, Date.now() - t1);
run.finalAnswer();
});
RAG agent
await dt.run("rag-agent", { model: "gpt-4o" }, async (run) => {
run.llmCalled("gpt-4o", 200);
run.llmResponded({ finishReason: "tool_calls" });
run.retrievalCalled("product-docs");
const t0 = Date.now();
const docs = await vectorStore.search(query);
run.retrievalResponded("product-docs", docs.length, docs[0]?.score, Date.now() - t0);
run.llmCalled("gpt-4o", 600);
run.llmResponded({ finishReason: "stop", completionTokens: 120 });
run.finalAnswer();
});
Infrastructure signals
await dt.run("my-ts-agent", { model: "gpt-4o" }, async (run) => {
try {
run.toolCalled("external_api");
const result = await callExternalApi();
run.toolResponded("external_api", true, result.length);
} catch (err) {
if (isRateLimitError(err)) {
run.externalSignal("rate_limit", "external_api", { http_status: 429 });
}
run.toolResponded("external_api", false, 0, 0, String(err));
}
run.finalAnswer();
});
6. Verify the integration
Run your agent once, then check localhost:3000 — the run should appear within 15 seconds under my-ts-agent.
To confirm detectors fire, trigger a tool loop:
await dt.run("my-ts-agent", { model: "gpt-4o", tools: ["web_search"] }, async (run) => {
for (let i = 0; i < 5; i++) {
run.llmCalled("gpt-4o", 200 + i * 50);
run.llmResponded({ finishReason: "tool_calls" });
run.toolCalled("web_search", { query: "same query every time" });
run.toolResponded("web_search", true, 256);
}
run.finalAnswer();
});
This triggers TOOL_LOOP (same tool ≥3 times in a 5-call window). The signal should appear in the dashboard within ~15 seconds.
7. Deploy markers
Fire-and-forget deploy markers let the dashboard overlay release boundaries on detector rate charts.
dt.markDeploy("my-ts-agent", "v1.4.2", { commit: "abc123f", environment: "production" });
8. Voice agents
Voice hooks on the run object capture a spoken turn end to end: speech-to-text in, your normal LLM and tool work, text-to-speech out, plus the VAD and turn-taking signals a voice loop runs on. Pass conversationId (the call id) so every turn rolls up into one call in the dashboard's Calls view.
await dt.run("voice-support", { model: "gpt-4o", conversationId: callId }, async (run) => {
// 1. Speech-to-text for the caller's turn (advances the step)
run.transcriptionReceived(transcript, { confidence: 0.92, latencyMs: 140, audioSeconds: 3.1 });
// 2. Your normal LLM / tool work, tracked as usual
run.llmCalled("gpt-4o", 800);
const reply = await generateReply(transcript);
run.llmResponded({ completionTokens: 120, latencyMs: 900 });
// 3. Text-to-speech for the agent's reply (does NOT advance the step)
run.ttsGenerated(reply, {
latencyMs: 210, audioSeconds: 4.4,
provider: "elevenlabs", voiceId: "rachel", model: "eleven_turbo_v2",
});
run.finalAnswer();
});
Real-time signals annotate the turn without advancing the step:
run.voiceActivityDetected("barge_in", 200); // speech_start | speech_end | silence | barge_in
run.turnTaking("agent_speaking", true, false); // agent_speaking | user_speaking | both_speaking | neither
run.recordingMetadata("https://audio.example/call.wav", { durationSeconds: 61, format: "wav" });
transcriptionReceived advances the step counter, so a full voice turn stays ~one step and the always-on step detectors don't false-fire. audioSeconds is optional but enables per-minute STT cost attribution, and provider/voiceId/model let Dunetrace pull provider-side generation history (ElevenLabs) back to the exact turn.9. Export to OpenTelemetry
Emit every run / LLM / tool / retrieval / voice event as OpenTelemetry spans to whatever backend you already run (Datadog, Grafana Tempo, Honeycomb, Signoz, Jaeger), alongside Dunetrace's own ingest. It's additive and opt-in — a failure in the exporter never touches ingest. Requires the optional OpenTelemetry peer dependencies.
npm install @opentelemetry/api @opentelemetry/sdk-trace-base
import { Dunetrace } from "dunetrace";
import { DunetraceOtelExporter } from "dunetrace/integrations/otel";
import { trace } from "@opentelemetry/api";
const dt = new Dunetrace({
exporter: new DunetraceOtelExporter({ tracer: trace.getTracer("dunetrace") }),
});
Spans follow the OpenTelemetry GenAI semantic conventions: a dunetrace.run root (its trace id derived from the run id, so a Dunetrace run is one trace), chat {model} LLM spans with gen_ai.* attributes (gen_ai.provider.name, input/output tokens, finish reason), HTTP-shaped tool spans with the stable HTTP conventions (url.full, server.address, http.response.status_code), retrieval spans, and voice spans. Content-bearing attributes (tool args, request URL, retrieval query) are gated behind captureContent (default on).
10. Ingest existing OpenTelemetry traces
Already instrumented with an OTel tracer (OpenLLMetry, Traceloop, OpenLIT)? Attach the receiver as a span processor and Dunetrace translates your gen_ai.* spans into events and runs the full detector suite — no dt.run() calls needed.
npm install @opentelemetry/api @opentelemetry/sdk-trace-base @opentelemetry/sdk-trace-node
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { Dunetrace } from "dunetrace";
import { DunetraceOtelReceiver } from "dunetrace/integrations/otel-receiver";
const dt = new Dunetrace({ apiKey: "dt_live_..." });
const provider = new NodeTracerProvider({
spanProcessors: [new SimpleSpanProcessor(new DunetraceOtelReceiver(dt, "my-agent"))],
});
provider.register(); // your existing pipeline is unchanged; Dunetrace is a second exporter
The receiver reads both the current GenAI naming (gen_ai.usage.input_tokens) and the legacy OpenLLMetry naming, plus Traceloop's structured gen_ai.output.messages. Voice events have no OTel convention and are best sent through the SDK's voice hooks directly.
11. Manual client (no npm)
If you prefer not to use npm, a self-contained client can be copied into your project. The npm package is recommended — it adds background buffering, dt.tool(), dt.trace(), getCurrentRun(), and dt.markDeploy(). The manual client sends all events synchronously at run completion.
See the full manual client source in the GitHub docs.
RunContext API reference
| Method | When to call |
|---|---|
run.llmCalled(model, promptTokens?) | Before each LLM API call |
run.llmResponded({ completionTokens?, latencyMs?, finishReason?, outputLength? }) | After LLM responds |
run.toolCalled(toolName, args?) | Before each tool execution |
run.toolResponded(toolName, success, outputLength?, latencyMs?, error?) | After tool returns |
run.retrievalCalled(indexName, queryHash?) | Before vector search |
run.retrievalResponded(indexName, resultCount, topScore?, latencyMs?) | After retrieval returns |
run.transcriptionReceived(text, { confidence?, latencyMs?, audioSeconds? }) | Voice: speech-to-text for a caller turn (advances the step) |
run.ttsGenerated(text, { latencyMs?, truncated?, voiceId?, model?, provider? }) | Voice: text-to-speech for the agent reply |
run.voiceActivityDetected(type, durationMs?) | Voice: VAD transition (speech_start / speech_end / silence / barge_in) |
run.turnTaking(action, fromAgent?, toUser?) | Voice: conversational-floor change |
run.recordingMetadata(url, { durationSeconds?, format? }) | Voice: where the call audio lives |
run.externalSignal(signalName, source?, meta?) | Rate limits, cache misses, upstream errors |
run.finalAnswer() | When agent produces its final output |
Troubleshooting
No runs appear in the dashboard
- Check
DUNETRACE_ENDPOINTpoints to the ingest service (port 8001, not 8002). - Confirm the backend is healthy:
curl http://localhost:8001/health - Check the Node console for
[dunetrace] Failed to flush eventswarnings.
Token counts missing
Pass completionTokens and promptTokens if your LLM client exposes them — they are optional but improve CONTEXT_BLOAT and LLM_TRUNCATION_LOOP detection accuracy.
Detectors fire too aggressively
Tune thresholds in detectors.yml on the server — see the detector reference.