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");from duraton.client import AISpendOptions, AsyncDuratonClient
async with AsyncDuratonClient() as duraton:
spend = await duraton.ai.spend(AISpendOptions(since="2026-07-01T00:00:00Z"))
print(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)");from duraton.client import AISpendOptions
spend = await duraton.ai.spend(AISpendOptions(app="assistant", since="2026-07-01T00:00:00Z"))
for m in spend.by_model:
print(m.model, m.tokens, m.cost or "(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");
}from duraton.client import ListSessionsOptions, SendEventInput
await duraton.events.send(SendEventInput(
name="chat.message",
app="assistant",
session=conversation_id, # the OpenTelemetry gen_ai.conversation.id
data={"text": text},
))
for s in await duraton.sessions.list(ListSessionsOptions(app="assistant")):
print(s.session, s.run_count, "runs,", s.ai_tokens or 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)");from duraton.client import RunStatsOptions, RunTimeSeriesOptions
stats = await duraton.runs.stats(RunStatsOptions(app="assistant"))
print(stats.succeeded, "/", stats.total, "-", stats.success_rate)
series = await duraton.runs.timeseries(RunTimeSeriesOptions(since="2026-07-01T00:00:00Z", bucket=3600))
for b in series.buckets:
print(b.ts, b.total, b.avg_ms or "(no terminal run)")stats, err := dx.Runs.Stats(context.Background(), client.RunStatsOptions{App: "assistant"})
fmt.Println(stats.Succeeded, "/", stats.Total, "-", stats.SuccessRate)
series, err := dx.Runs.TimeSeries(context.Background(), client.RunTimeSeriesOptions{
Since: time.Now().Add(-24 * time.Hour), Bucket: 3600, // Bucket is seconds
})
for _, b := range series.Buckets {
fmt.Println(b.Ts, b.Total, b.AvgMs)
}| Read | Returns |
|---|---|
GET /runs/stats | total, active, queued, running, succeeded, failed, successRate, plus p50Ms / p95Ms over the filter's finished runs (both absent when none have finished). |
GET /runs/timeseries | Buckets 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);
}from duraton.client import AsyncDuratonClient
async with AsyncDuratonClient() as dx:
async for token in dx.runs.explain(run_id):
print(token, end="")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.
| Attribute | On a step.ai span |
|---|---|
gen_ai.operation.name | chat for generate / wrap / loop / infer, embeddings for embed |
gen_ai.provider.name | The provider that served the call, when known |
gen_ai.response.model | The model that actually answered (post-fallback) |
gen_ai.usage.input_tokens | Input token count |
gen_ai.usage.output_tokens | Output token count |
gen_ai.usage.cache_read.input_tokens | Input tokens served from a provider cache, when reported |
gen_ai.usage.cache_creation.input_tokens | Input tokens written to a provider cache, when reported |
gen_ai.response.finish_reasons | Why 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:
| Attribute | Meaning |
|---|---|
duraton.run.id / duraton.step.name | Correlate the span back to its run and step |
duraton.ai.kind | The exact call kind (generate / wrap / embed / loop / infer) that gen_ai.operation.name collapses |
duraton.ai.wraps | The client library a wrap recognized (e.g. openai) |
duraton.ai.batches / duraton.ai.dims | embed batch count and vector dimensions |
duraton.ai.iteration / duraton.ai.tools | loop turn index and the tools available |
duraton.ai.reask | The 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
| View | Shows | Reads |
|---|---|---|
| AI | Tiles for spend, tokens, avg latency, and cache-hit rate, then charts by hour, model, and workflow; a Sessions table of conversations. | /ai/spend, /sessions |
| Budgets | The 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 |
| Runs | Token and cost per run, and an Explain action on a failed AI run. | /runs, /runs/{id}/explain |