Trace Analytics API
Read-only, team-scoped time-series analytics over the whole spans table —
every span ingested via POST /api/v1/traces (gateway completions and
SDK-posted spans). This is a superset of docs/api/gateway/usage.mdx's
GET /gateway/usage, which only aggregates gateway_requests (gateway
traffic). Any team member can read (requireAnyAuth — session cookie or
personal API key, no role gate). Base: /api/v1/traces.
All examples below were verified with real curl output against a dev server
running this branch's code (confirmed via a probe call returning the analytics
envelope, not a :id-route 404 — see verification note at the bottom).
GET /api/v1/traces/analytics
Query params (all optional):
from,to— ISO dates; default window is the last 30 days (applied to spanstarted_at).group_by—day|model|session|prompt_version|span_name(defaultday).kind— span-kind filter:llm|tool|retrieval|embedding|agent|chain|other.model— exact-model filter.
Response shape:
{
"from": "...", "to": "...", "groupBy": "...",
"totals": {
"requests", "errorRate", "promptTokens", "completionTokens", "totalTokens",
"costUsd", "latencyMs": { "p50", "p95", "p99" }
},
"costPerTrace": { "p50", "p99" },
"buckets": [ { "key": "...", ...same metrics as totals } ]
}
costPerTrace is the median and p99 cost of one trace: each trace's spans that match
the window and the kind/model filters are summed, then the percentiles are taken over
those sums. A trace with no priced span is left out, so unpriced traffic does not show as
$0. Both are null when no priced trace is in the window. It is not repeated per bucket,
because one trace can fall into several buckets.
Notes (verified below):
costUsdsums nullcost_usdas0.- Spans with a null
latency_msare counted inrequestsbut excluded from thelatencyMspercentiles. - Buckets with a null group key (e.g. no model, no session, no prompt version)
are omitted from
buckets— they still count towardstotals.
Data used in every example below was seeded via POST /api/v1/traces (see
docs/api/traces/traces.mdx):
t4-support-run(2026-06-20, sessiont4-session-1):gpt-4o-minillm span, ok, 100/40/140 tokens,$0.00002, latency 1200ms; plus atoolspan (200ms child, not llm-kind).t4-error-run(2026-06-21, sessiont4-session-2):gpt-4ollm span, error, 300/0/300 tokens,$0.00009, latency 2000ms.t4-support-followup(2026-06-22, sessiont4-session-1):gpt-4o-minillm span, ok, 80/20/100 tokens,$0.000015, latency 800ms.
None of the seeded spans carry a promptVersionId, which is what the
group_by=prompt_version example below demonstrates (empty buckets, null
group key omitted, totals still reflects all 4 spans).
Default call (group_by=day, explicit window)
- curl
- TypeScript
- Python
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?from=2026-06-01&to=2026-07-01"
const analytics = await hub.traces.analytics({
from: "2026-06-01",
to: "2026-07-01",
});
analytics = hub.traces.analytics(
from_="2026-06-01",
to="2026-07-01",
)
Response (status 200):
{
"from": "2026-06-01",
"to": "2026-07-01",
"groupBy": "day",
"totals": {
"requests": 4,
"errorRate": 0.25,
"promptTokens": 480,
"completionTokens": 60,
"totalTokens": 540,
"costUsd": 0.000125,
"latencyMs": { "p50": 1000, "p95": 1879.9999999999998, "p99": 1975.9999999999998 }
},
"buckets": [
{
"key": "2026-06-20",
"requests": 2,
"errorRate": 0,
"promptTokens": 100,
"completionTokens": 40,
"totalTokens": 140,
"costUsd": 0.00002,
"latencyMs": { "p50": 700, "p95": 1150, "p99": 1190 }
},
{
"key": "2026-06-21",
"requests": 1,
"errorRate": 1,
"promptTokens": 300,
"completionTokens": 0,
"totalTokens": 300,
"costUsd": 0.00009,
"latencyMs": { "p50": 2000, "p95": 2000, "p99": 2000 }
},
{
"key": "2026-06-22",
"requests": 1,
"errorRate": 0,
"promptTokens": 80,
"completionTokens": 20,
"totalTokens": 100,
"costUsd": 0.000015,
"latencyMs": { "p50": 800, "p95": 800, "p99": 800 }
}
]
}
requests on 2026-06-20 is 2 (the tool span counts too, even though the
kind=llm filter below excludes it); the latencyMs percentiles blend both
spans on that day.
group_by=model
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?group_by=model&from=2026-06-01&to=2026-07-01"
Response (status 200):
{
"from": "2026-06-01",
"to": "2026-07-01",
"groupBy": "model",
"totals": {
"requests": 4,
"errorRate": 0.25,
"promptTokens": 480,
"completionTokens": 60,
"totalTokens": 540,
"costUsd": 0.000125,
"latencyMs": { "p50": 1000, "p95": 1879.9999999999998, "p99": 1975.9999999999998 }
},
"buckets": [
{
"key": "gpt-4o",
"requests": 1,
"errorRate": 1,
"promptTokens": 300,
"completionTokens": 0,
"totalTokens": 300,
"costUsd": 0.00009,
"latencyMs": { "p50": 2000, "p95": 2000, "p99": 2000 }
},
{
"key": "gpt-4o-mini",
"requests": 2,
"errorRate": 0,
"promptTokens": 180,
"completionTokens": 60,
"totalTokens": 240,
"costUsd": 0.000035,
"latencyMs": { "p50": 1000, "p95": 1180, "p99": 1196 }
}
]
}
The tool span (no model) has a null group key under group_by=model, so it
does not appear as its own bucket — it just is not counted here since its
model is null; the two gpt-4o-mini llm spans (1200ms, 800ms) are the ones
rolled up under gpt-4o-mini.
group_by=session
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?group_by=session&from=2026-06-01&to=2026-07-01"
Response (status 200):
{
"from": "2026-06-01",
"to": "2026-07-01",
"groupBy": "session",
"totals": {
"requests": 4,
"errorRate": 0.25,
"promptTokens": 480,
"completionTokens": 60,
"totalTokens": 540,
"costUsd": 0.000125,
"latencyMs": { "p50": 1000, "p95": 1879.9999999999998, "p99": 1975.9999999999998 }
},
"buckets": [
{
"key": "t4-session-1",
"requests": 3,
"errorRate": 0,
"promptTokens": 180,
"completionTokens": 60,
"totalTokens": 240,
"costUsd": 0.000035,
"latencyMs": { "p50": 800, "p95": 1160, "p99": 1192 }
},
{
"key": "t4-session-2",
"requests": 1,
"errorRate": 1,
"promptTokens": 300,
"completionTokens": 0,
"totalTokens": 300,
"costUsd": 0.00009,
"latencyMs": { "p50": 2000, "p95": 2000, "p99": 2000 }
}
]
}
t4-session-1 rolls up 3 spans total: t4-support-run (1 llm + 1 tool) plus
t4-support-followup (1 llm) — 2 llm + 1 tool = 3, matching requests: 3
above. session_id lives on traces, so every span across a session's traces
is joined in.
group_by=span_name — one bucket per step
Each step inside a trace is a span with a name, such as search_docs or draft-reply.
Grouping by that name shows which step is slow, failing or expensive. Two traces, each with
a search_docs tool span and a draft-reply llm span, were sent first; one search_docs
call failed.
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?group_by=span_name&from=2026-03-02&to=2026-03-03"
Response (status 200):
{
"from": "2026-03-02",
"to": "2026-03-03",
"groupBy": "span_name",
"totals": {
"requests": 4,
"errorRate": 0.25,
"promptTokens": 1600,
"completionTokens": 240,
"totalTokens": 1840,
"costUsd": 0.000384,
"latencyMs": {
"p50": 1050,
"p95": 1879.9999999999998,
"p99": 1975.9999999999998
}
},
"buckets": [
{
"key": "draft-reply",
"requests": 2,
"errorRate": 0,
"promptTokens": 1600,
"completionTokens": 240,
"totalTokens": 1840,
"costUsd": 0.000384,
"latencyMs": {
"p50": 1050,
"p95": 1185,
"p99": 1197
}
},
{
"key": "search_docs",
"requests": 2,
"errorRate": 0.5,
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"costUsd": 0,
"latencyMs": {
"p50": 1060,
"p95": 1906,
"p99": 1981.2
}
}
]
}
A gateway call sent without an x-span-name header is named after the model you asked
for, so those calls get one bucket per model. A span whose name is its own start time,
such as 2026-03-02T09:30:00.000Z, goes into one (unnamed) bucket instead of a bucket
per call.
Add kind to look at one kind of step only:
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?group_by=span_name&kind=tool&from=2026-03-02&to=2026-03-03"
Response (status 200):
{
"from": "2026-03-02",
"to": "2026-03-03",
"groupBy": "span_name",
"totals": {
"requests": 2,
"errorRate": 0.5,
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"costUsd": 0,
"latencyMs": {
"p50": 1060,
"p95": 1906,
"p99": 1981.2
}
},
"buckets": [
{
"key": "search_docs",
"requests": 2,
"errorRate": 0.5,
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"costUsd": 0,
"latencyMs": {
"p50": 1060,
"p95": 1906,
"p99": 1981.2
}
}
]
}
kind=llm filter
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?kind=llm&from=2026-06-01&to=2026-07-01"
Response (status 200):
{
"from": "2026-06-01",
"to": "2026-07-01",
"groupBy": "day",
"totals": {
"requests": 3,
"errorRate": 0.3333333333333333,
"promptTokens": 480,
"completionTokens": 60,
"totalTokens": 540,
"costUsd": 0.000125,
"latencyMs": { "p50": 1200, "p95": 1920, "p99": 1984 }
},
"buckets": [
{
"key": "2026-06-20",
"requests": 1,
"errorRate": 0,
"promptTokens": 100,
"completionTokens": 40,
"totalTokens": 140,
"costUsd": 0.00002,
"latencyMs": { "p50": 1200, "p95": 1200, "p99": 1200 }
},
{
"key": "2026-06-21",
"requests": 1,
"errorRate": 1,
"promptTokens": 300,
"completionTokens": 0,
"totalTokens": 300,
"costUsd": 0.00009,
"latencyMs": { "p50": 2000, "p95": 2000, "p99": 2000 }
},
{
"key": "2026-06-22",
"requests": 1,
"errorRate": 0,
"promptTokens": 80,
"completionTokens": 20,
"totalTokens": 100,
"costUsd": 0.000015,
"latencyMs": { "p50": 800, "p95": 800, "p99": 800 }
}
]
}
requests on 2026-06-20 drops from 2 to 1 compared to the unfiltered call
above — the non-llm tool span is excluded.
model= filter
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?model=gpt-4o-mini&from=2026-06-01&to=2026-07-01"
Response (status 200):
{
"from": "2026-06-01",
"to": "2026-07-01",
"groupBy": "day",
"totals": {
"requests": 2,
"errorRate": 0,
"promptTokens": 180,
"completionTokens": 60,
"totalTokens": 240,
"costUsd": 0.000035,
"latencyMs": { "p50": 1000, "p95": 1180, "p99": 1196 }
},
"buckets": [
{
"key": "2026-06-20",
"requests": 1,
"errorRate": 0,
"promptTokens": 100,
"completionTokens": 40,
"totalTokens": 140,
"costUsd": 0.00002,
"latencyMs": { "p50": 1200, "p95": 1200, "p99": 1200 }
},
{
"key": "2026-06-22",
"requests": 1,
"errorRate": 0,
"promptTokens": 80,
"completionTokens": 20,
"totalTokens": 100,
"costUsd": 0.000015,
"latencyMs": { "p50": 800, "p95": 800, "p99": 800 }
}
]
}
Only the two gpt-4o-mini spans remain; gpt-4o and the model-less tool
span are excluded.
group_by=prompt_version — null group key omitted
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?group_by=prompt_version&from=2026-06-01&to=2026-07-01"
Response (status 200):
{
"from": "2026-06-01",
"to": "2026-07-01",
"groupBy": "prompt_version",
"totals": {
"requests": 4,
"errorRate": 0.25,
"promptTokens": 480,
"completionTokens": 60,
"totalTokens": 540,
"costUsd": 0.000125,
"latencyMs": { "p50": 1000, "p95": 1879.9999999999998, "p99": 1975.9999999999998 }
},
"buckets": []
}
totals still reflects all 4 spans, but buckets is empty because none of
them carry a promptVersionId — confirming null group keys are counted in the
totals but dropped from the per-bucket breakdown.
Default window (no from/to)
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/traces/analytics"
Response (status 200) — no from/to supplied, so the window defaults to the trailing 30 days computed from the server's current time (2026-07-05), i.e. [2026-06-05, 2026-07-05). All three seeded traces (06-20/21/22) fall inside that window, so this is identical to the explicit from=2026-06-01&to=2026-07-01 call above except for the echoed from/to:
{
"from": "2026-06-05",
"to": "2026-07-05",
"groupBy": "day",
"totals": {
"requests": 4,
"errorRate": 0.25,
"promptTokens": 480,
"completionTokens": 60,
"totalTokens": 540,
"costUsd": 0.000125,
"latencyMs": { "p50": 1000, "p95": 1879.9999999999998, "p99": 1975.9999999999998 }
},
"buckets": [
{
"key": "2026-06-20",
"requests": 2,
"errorRate": 0,
"promptTokens": 100,
"completionTokens": 40,
"totalTokens": 140,
"costUsd": 0.00002,
"latencyMs": { "p50": 700, "p95": 1150, "p99": 1190 }
},
{
"key": "2026-06-21",
"requests": 1,
"errorRate": 1,
"promptTokens": 300,
"completionTokens": 0,
"totalTokens": 300,
"costUsd": 0.00009,
"latencyMs": { "p50": 2000, "p95": 2000, "p99": 2000 }
},
{
"key": "2026-06-22",
"requests": 1,
"errorRate": 0,
"promptTokens": 80,
"completionTokens": 20,
"totalTokens": 100,
"costUsd": 0.000015,
"latencyMs": { "p50": 800, "p95": 800, "p99": 800 }
}
]
}
This confirms the default window is the trailing 30 days computed from the
current server time (echoed from = to − 30d) — not an all-time aggregate.
Supplying from/to outside the data's range narrows or empties the result
accordingly.
Cost per trace
The three traces seeded above cost $0.00002, $0.00009 and $0.000015 (the tool span has no
cost). The median is the middle one; percentile_cont interpolates the p99 between the two
most expensive traces.
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?from=2026-06-01&to=2026-07-01"
Response (status 200):
{"from":"2026-06-01","to":"2026-07-01","groupBy":"day","totals":{"requests":4,"errorRate":0.25,"promptTokens":480,"completionTokens":60,"totalTokens":540,"costUsd":0.000125,"latencyMs":{"p50":1000,"p95":1879.9999999999998,"p99":1975.9999999999998}},"costPerTrace":{"p50":0.00002,"p99":0.0000886},"buckets":[{"key":"2026-06-20","requests":2,"errorRate":0,"promptTokens":100,"completionTokens":40,"totalTokens":140,"costUsd":0.00002,"latencyMs":{"p50":700,"p95":1150,"p99":1190}},{"key":"2026-06-21","requests":1,"errorRate":1,"promptTokens":300,"completionTokens":0,"totalTokens":300,"costUsd":0.00009,"latencyMs":{"p50":2000,"p95":2000,"p99":2000}},{"key":"2026-06-22","requests":1,"errorRate":0,"promptTokens":80,"completionTokens":20,"totalTokens":100,"costUsd":0.000015,"latencyMs":{"p50":800,"p95":800,"p99":800}}]}
Error responses
Invalid group_by (status 400):
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?group_by=banana"
{"error":{"code":"VALIDATION_ERROR","message":"Invalid enum value. Expected 'day' | 'model' | 'session' | 'prompt_version' | 'span_name', received 'banana'"}}
Invalid kind (status 400):
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?kind=banana"
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid enum value. Expected 'llm' | 'tool' | 'retrieval' | 'embedding' | 'agent' | 'chain' | 'other', received 'banana'" } }
Invalid from (status 400):
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics?from=not-a-date"
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid date" } }
No Authorization header (status 401):
curl -s "$ACRUXCORE_BASE_URL/traces/analytics"
{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required." } }
GET /api/v1/traces/analytics/scores
Quality over a window: thumbs feedback from people and verdicts from online evaluation rules. Same auth as above.
Query params (both optional):
from,to— ISO dates; default window is the last 30 days, applied to when the feedback or verdict was recorded (created_at).
Response fields:
from,to— the first and the last day the window holds. The query'stois exclusive, soto=2026-10-07returns"to": "2026-10-06".feedback—upanddown: feedback with a rating of1and of-1. Feedback with no rating counts as neither.rules— one entry per rule with at least one verdict:scored(verdicts with a pass/fail result),passed,failed, andavgScore(mean 0–100 score, ornull). A judge call that could not score is left out.days— one entry per UTC day that has either kind:up,down,passed,failedsummed over every rule.
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics/scores"
Response (status 200):
{
"from": "2026-09-16",
"to": "2026-10-16",
"feedback": { "up": 0, "down": 1 },
"rules": [
{
"ruleId": "8cd75bc2-e496-4851-8a12-762238b73206",
"name": "One-sentence answer quality",
"scored": 17,
"passed": 6,
"failed": 11,
"avgScore": 47.05882352941177
}
],
"days": [
{ "day": "2026-10-05", "up": 0, "down": 0, "passed": 6, "failed": 11 },
{ "day": "2026-10-13", "up": 0, "down": 1, "passed": 0, "failed": 0 }
]
}
A custom window. to=2026-10-07 is exclusive, so the last day returned is 6 October:
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics/scores?from=2026-10-04&to=2026-10-07"
Response (status 200):
{
"from": "2026-10-04",
"to": "2026-10-06",
"feedback": { "up": 0, "down": 0 },
"rules": [
{
"ruleId": "8cd75bc2-e496-4851-8a12-762238b73206",
"name": "One-sentence answer quality",
"scored": 17,
"passed": 6,
"failed": 11,
"avgScore": 47.05882352941177
}
],
"days": [
{ "day": "2026-10-05", "up": 0, "down": 0, "passed": 6, "failed": 11 }
]
}
from after to (status 400):
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/traces/analytics/scores?from=2026-07-01&to=2026-06-01"
{"error":{"code":"VALIDATION_ERROR","message":"`from` must be on or before `to`."}}
Verification note: confirming the route serves current code
Before seeding data, a probe call with a fresh team's key against the already-
running dev server (tsx watch server.ts, port 3000, cwd apps/api, this
branch checked out) returned the analytics envelope — not a trace-not-found
404 that a stale /traces/:id route would produce for the literal path segment
analytics:
curl -s -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/traces/analytics"
This returned:
{"from":"2026-06-05","to":"2026-07-05","groupBy":"day","totals":{"requests":0, ...},"buckets":[]}
This confirmed the running server already had GET /traces/analytics mounted
ahead of GET /traces/:id, so no fresh server start was needed for this task.