Concepts

AI observability

Show someone what an agent did and what it cost: token and cost rollups, conversation sessions, run time-series, and GenAI spans read from the durable journal.

Every step.ai call records a journal block: the model that answered, its token usage, and the call shape. Every surface on this page is a read over that journal.

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({
  url: process.env.DURATON_URL!,
  apiKey: process.env.DURATON_API_KEY,
});

const spend = await duraton.ai.spend({ since: "2026-07-01T00:00:00Z" });
console.log(spend.tokens, "tokens across", spend.calls, "calls");

The journal holds metering facts - model name, token counts, an optional supplied cost. Your prompt, the response text, and your provider key stay in your runner: the provider key is passed per call to the provider SDK and is never sent to Duraton.

Token and cost spend

duraton.ai.spend() (GET /ai/spend) rolls the journal up across a project: window totals plus breakdowns by hour, by model, and by workflow.

const spend = await duraton.ai.spend({ app: "assistant", since: "2026-07-01T00:00:00Z" });
for (const m of spend.byModel) console.log(m.model, m.tokens, m.cost ?? "(no price)");

Prop

Type

spend({ app, workflow, since, bucket }) scopes the rollup: app and workflow filter it, since sets the window (an RFC3339 string or a Date), and bucket sets the hourly bucket width in seconds (default 3600).

Tokens always, cost only when supplied

Duraton meters tokens and holds no price list, so tokens is always present while cost appears only where a call supplied one. The distinction between "no cost reported" and "zero cost" is preserved: every cost field stays absent rather than defaulting to 0. Supply prices with the resolveCost option on connect() / serve() and every rollup above carries cost too.

Metering is read-only. To enforce a ceiling, declare a budget cap, a rolling-window budget, or a token throttle on the workflow. maxTokens always applies; maxCost bites only once resolveCost supplies a price.

Conversation sessions

Runs that belong to the same conversation form a session. Set an event's session to a stable conversation id and every run it starts joins that session; omit it and each run is a session of one.

await duraton.events.send({
  name: "chat.message",
  app: "assistant",
  session: conversationId, // the OpenTelemetry gen_ai.conversation.id
  data: { text },
});

for (const s of await duraton.sessions.list({ app: "assistant" })) {
  console.log(s.session, s.runCount, "runs,", s.aiTokens ?? 0, "tokens");
}

Prop

Type

list({ app, since, limit }) filters by app, bounds the window with since, and caps how many sessions come back (default 100, max 500).

Run counts over time

runs.stats() returns the current per-status counts plus p50/p95 latency; runs.timeseries() returns them bucketed over time, with a latency summary (average, longest, p50, p95) per bucket. These are the two reads behind the console's run charts.

const stats = await duraton.runs.stats({ app: "assistant" });
console.log(stats.succeeded, "/", stats.total, "-", stats.successRate);

const series = await duraton.runs.timeseries({ since: "2026-07-01T00:00:00Z", bucket: 3600 });
for (const b of series.buckets) console.log(b.ts, b.total, b.avgMs ?? "(no terminal run)");
ReadReturns
GET /runs/statstotal, active, queued, running, succeeded, failed, successRate, plus p50Ms / p95Ms over the filter's finished runs (both absent when none have finished).
GET /runs/timeseriesBuckets of ts, per-status counts, total, plus avgMs / maxMs / p50Ms / p95Ms over the bucket's terminal runs (all absent when a bucket has none).

Both accept app, workflow, and since; timeseries also takes bucket (width in seconds, default 3600).

Latency percentiles

p50Ms is the median finished-run duration and p95Ms the 95th percentile, both in whole milliseconds and over the same population as avgMs / maxMs (the scope's, or bucket's, finished runs). They are continuous percentiles with linear interpolation between adjacent durations, so a p95 may fall between two observed values rather than on one - the standard reading of "95% of runs finished at or below this."

Percentiles use a single continuous definition (linear interpolation between adjacent durations), so a percentile is never an average relabelled: p95 is the duration at or below which 95% of the matched runs finished.

Logs and the run timeline

ctx.log lines and every status transition append to one durable per-run timeline. Read it as history with runs.logs(id), or tail it live with runs.watch(id).

for await (const frame of duraton.runs.watch(runId)) {
  if (frame.kind === "log") console.log(frame.level, frame.message);
}

Explain a failure

For a failed run, runs.explain(id) (POST /runs/{id}/explain) streams a plain-language summary of what went wrong, token by token.

for await (const token of duraton.runs.explain(runId)) {
  process.stdout.write(token);
}

The model is sent an allowlist projection of the run: workflow name, app, trigger kind, run status and attempt, the terminal error message (its stack is dropped, since a stack can embed argument values), and per step its name, status, attempt, and error. Inputs, outputs, prompts, response text, and keys are structurally absent from that projection.

explain rejects before the first token with 409 when the run is not failed. Pass { signal } to abort the stream.

Traces in your own backend

The spans for your step bodies are emitted in your runner process by the SDK, and exported by whatever OpenTelemetry provider you register there (a NodeSDK, for example). Register none and every step span is a no-op. Duraton runs the engine for you, so its internal telemetry is the platform's to operate; what reaches your own backend is what your runner emits.

import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";

new NodeSDK({ traceExporter: new OTLPTraceExporter({ url: "https://otlp.example.com/v1/traces" }) }).start();

Duraton sends the run's W3C trace context with every invoke, so one run is one trace id: every pass span and every step span of that run - across retries, and across the runners it touches - joins it.

A step.ai span carries the OpenTelemetry GenAI semantic conventions, so a vendor-neutral backend reads it as a model call with no custom mapping. Only response-side facts the journal holds are emitted; a field it does not hold (temperature, max tokens, the requested model before a fallback) is absent, never guessed.

AttributeOn a step.ai span
gen_ai.operation.namechat for generate / wrap / loop / infer, embeddings for embed
gen_ai.provider.nameThe provider that served the call, when known
gen_ai.response.modelThe model that actually answered (post-fallback)
gen_ai.usage.input_tokensInput token count
gen_ai.usage.output_tokensOutput token count
gen_ai.usage.cache_read.input_tokensInput tokens served from a provider cache, when reported
gen_ai.usage.cache_creation.input_tokensInput tokens written to a provider cache, when reported
gen_ai.response.finish_reasonsWhy generation stopped, as a one-element array

The span is named {operation} {model} (e.g. chat claude-opus-4-8), or the bare operation when the model is unknown - the convention's own naming rule. Facts the convention has no attribute for live under a duraton.* vendor prefix, so a standard backend ignores them:

AttributeMeaning
duraton.run.id / duraton.step.nameCorrelate the span back to its run and step
duraton.ai.kindThe exact call kind (generate / wrap / embed / loop / infer) that gen_ai.operation.name collapses
duraton.ai.wrapsThe client library a wrap recognized (e.g. openai)
duraton.ai.batches / duraton.ai.dimsembed batch count and vector dimensions
duraton.ai.iteration / duraton.ai.toolsloop turn index and the tools available
duraton.ai.reaskThe durable re-ask attempt index

From an AI assistant

The same rollups are MCP tools: ai_spend returns the spend rollup and list_sessions returns the conversation list, both scoped to the caller's project. Paired with the read tools for runs and steps, an assistant can answer "which workflow is burning the most tokens" against your live project.

In the console

ViewShowsReads
AITiles for spend, tokens, avg latency, and cache-hit rate, then charts by hour, model, and workflow; a Sessions table of conversations./ai/spend, /sessions
BudgetsThe budgets, token throttles, and per-run caps each workflow declares, metered against live spend. Read-only: they are declared in workflow code./workflows, /ai/spend
RunsToken and cost per run, and an Explain action on a failed AI run./runs, /runs/{id}/explain

On this page