The analytics export streams the router's raw routing decisions — one row per upstream action, nothing aggregated, nothing rounded — so your team can load them into a warehouse and do its own analysis.
It is a pull API. You issue a read-only key, poll a cursor, and append what comes back. There is no dashboard in the loop and no pre-aggregation: every number the router's own dashboard shows is derived from these same rows.
Read SEMANTICS.md first for the difference between a session, a round, a turn, and an action. The distinction decides what a row is.
Analytics keys are read-only. They carry the ra_ prefix, they authenticate
only the /v1/analytics/* endpoints, and they cannot route inference,
resolve provider credentials, or spend money. A key that is stolen from an ETL
config can read telemetry and nothing else.
Self-hosted: Settings → API keys → Issue key → scope Analytics (read-only).
Managed: ask your Weave contact, or issue one from the dashboard's API-keys page the same way.
export WEAVE_ANALYTICS_KEY=ra_...
export WEAVE_ROUTER_URL=https://your-router.example.comRouting keys (rk_) are rejected here with 401, and analytics keys are
rejected on every routing endpoint. The separation is enforced on both sides,
not by convention.
curl -sS --compressed \
-H "Authorization: Bearer $WEAVE_ANALYTICS_KEY" \
"$WEAVE_ROUTER_URL/v1/analytics/routing-decisions?since=2026-01-01T00:00:00Z&limit=1000" \
-D headers.txt -o page.ndjsonThe body is NDJSON: one JSON object per line, no envelope, no trailing array. Pipe it straight into a loader.
| Parameter | Meaning |
|---|---|
since |
RFC3339, inclusive. Required on the first page; ignored once you pass cursor. |
until |
RFC3339, exclusive. Optional. Use it to close a backfill window. |
cursor |
Opaque resume token from the previous page's X-Weave-Next-Cursor. |
limit |
Rows per page. Default 1000, max 10000. |
format |
ndjson (the only supported value today). |
| Response header | Meaning |
|---|---|
X-Weave-Next-Cursor |
Pass as cursor to get the next page. |
X-Weave-Has-More |
true while more rows are ready; false means you have caught up. |
Send Accept-Encoding: gzip (curl's --compressed) — these rows are
repetitive JSON and compress by roughly an order of magnitude.
cursor=""
while :; do
url="$WEAVE_ROUTER_URL/v1/analytics/routing-decisions?limit=10000"
if [ -z "$cursor" ]; then url="$url&since=2026-01-01T00:00:00Z"; else url="$url&cursor=$cursor"; fi
curl -sS --compressed -H "Authorization: Bearer $WEAVE_ANALYTICS_KEY" \
"$url" -D headers.txt >> routing_decisions.ndjson
cursor=$(grep -i '^x-weave-next-cursor:' headers.txt | tr -d '\r' | cut -d' ' -f2)
[ "$(grep -i '^x-weave-has-more:' headers.txt | tr -d '\r' | cut -d' ' -f2)" = "true" ] || break
donePersist cursor between runs. A job that stores its cursor and polls every few
minutes is a complete incremental pipeline — the same loop does the initial
backfill and the steady-state tail.
Rate limit: 60 requests per minute per key, returning 429 with
Retry-After when exceeded. At the maximum page size that is 600k rows a
minute, so it constrains only pathological polling.
One row per upstream action. An action is a single API call the agent makes to a model. That is the router's finest routing grain, and it is deliberately not "one row per user message". A single thing a person would call one request commonly produces several rows:
- a retry after an upstream error,
- a failover to a different provider,
- a context-compaction call,
- a title-generation call,
- a classifier call,
- a sub-agent turn.
So COUNT(*) is a count of model calls, not of user requests. To count
user-visible turns, filter turn_type = 'main_loop'. To collapse an action's
retries and failovers, group by request_id.
-- Model calls per day (what the router actually did)
SELECT date_trunc('day', recorded_at) AS day, count(*)
FROM routing_decisions GROUP BY 1;
-- User-visible turns per day
SELECT date_trunc('day', recorded_at) AS day, count(*)
FROM routing_decisions WHERE turn_type = 'main_loop' GROUP BY 1;
-- Distinct routed requests (retries and failovers collapsed)
SELECT count(DISTINCT request_id) FROM routing_decisions;A row is written once and never updated. id is unique. Deduplicate on id
and a replayed page is a no-op merge — safe to re-run any page, any time.
MERGE INTO routing_decisions t USING staging s ON t.id = s.id
WHEN NOT MATCHED THEN INSERT ...;The export is ordered by recorded_at (ingest time), then id — not by
requested_at (event time). Telemetry is written after a turn completes, so
event time is not monotonic: within one page requested_at can move backwards.
Order by ingest time is what makes "resume at this cursor, miss nothing" true.
For the same reason the export withholds the most recent 60 seconds. A row
committing right now could land just behind a cursor you already passed. The
tail you can read is always about a minute behind live; that is the price of
never losing a row. If you need a fixed window, an explicit until narrower
than the holdback boundary is honored as-is.
Consequence worth designing around: partition your warehouse table on
requested_at, but bookmark on recorded_at. They are different clocks.
GET /v1/analytics/schema returns the machine-readable field dictionary —
name, type, nullability, description — plus the grain, ordering, and holdback
semantics above. Generate your DDL from it rather than hand-transcribing this
page.
curl -sS -H "Authorization: Bearer $WEAVE_ANALYTICS_KEY" \
"$WEAVE_ROUTER_URL/v1/analytics/schema" | jq .Field groups:
| Group | Fields |
|---|---|
| Identity | id, recorded_at, requested_at, request_id, trace_id, session_id, device_id, client_app, turn_type |
| End user | user_id, user_email, user_account_uuid |
| Decision | requested_model, decision_model, decision_provider, candidate_models, chosen_score, decision_reason, sticky_hit, failover_used, cross_format |
| Tokens | estimated_input_tokens, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens |
| Economics | subscription_served, actual_input_cost_usd, actual_output_cost_usd |
| Performance | route_latency_ms, upstream_latency_ms, total_latency_ms, ttft_ms |
| Outcome | upstream_status_code, upstream_finish_reason, stop_reason, tool_use_blocks, invalid_tool_args_blocks |
The export deliberately omits the scorer's internals (cluster assignments, per-candidate scores, exploration propensities, policy artifact identifiers), shadow and counterfactual evaluations, and every credential-bearing field.
Schema changes are additive within a version; version in the /schema
response bumps only if a field is removed or changes meaning. Write your loader
to tolerate new columns. Version 2 dropped requested_input_cost_usd,
requested_output_cost_usd, and savings_usd — see
Computing savings yourself. Version 3 added
subscription_served and zeroed the actual_* costs of those rows — see
Subscription-served turns cost $0.
It is free-form diagnostic text written for humans debugging a decision, and
its format changes between router versions. Do not parse it and do not
group by it — a dashboard built on string matching will break silently on a
router upgrade. Group on the stable fields instead: decision_model,
sticky_hit, failover_used, candidate_models.
The export reports only what each turn actually cost (actual_*). It does not
ship a counterfactual cost or a precomputed savings_usd, because the
counterfactual depends on assumptions you should own: which baseline model you
would otherwise have run, whether its prompt cache would have been as warm, and
which price you hold it to.
Build the baseline yourself from the token columns and a price book:
WITH priced AS (
SELECT d.actual_input_cost_usd + d.actual_output_cost_usd AS actual_usd,
(d.input_tokens / 1e6) * p.input_usd_per_1m
+ (d.output_tokens / 1e6) * p.output_usd_per_1m AS baseline_usd
FROM routing_decisions d
JOIN model_prices p ON p.model = d.requested_model
WHERE d.recorded_at >= now() - interval '30 days'
AND d.actual_input_cost_usd IS NOT NULL
)
SELECT sum(baseline_usd - actual_usd) AS savings_usd FROM priced;GET /v1/analytics/models publishes the price book that populates
model_prices — per model, per provider, input and output USD per 1M tokens
plus the cache-read multiplier. Note it reports current prices, not the
prices in force when an old row was recorded; pin your own price table if you
need the historical ones.
Cost fields are null for rows the router could not price (an unknown model, an
upstream error before any usage was reported). They are null rather than 0 so
they drop out of an average instead of dragging it down — filter explicitly, as
above, if you want the priced subset.
When a turn is dispatched on the caller's own Claude or Codex subscription, that
subscription's quota pays for it and no API-priced spend is incurred.
subscription_served is true on those rows, their token counts are the real
upstream numbers, and actual_input_cost_usd / actual_output_cost_usd are
0 — pricing them at catalog rates would overcount spend. A turn that starts on
a spent subscription and fails over to a Weave or BYOK key is priced normally:
the flag follows the credential that actually served the turn, not the one the
client presented.
- The export reads the same
router.model_router_request_telemetrytable the dashboard reads, through a dedicated partial index on(installation_id, created_at, id) WHERE span_type = 'router.upstream'. Pages are index scans with no sort at any page depth; a deep backfill does not degrade into a growingOFFSETscan. - The export is read-only and safe to point at a read replica.
- Rows are scoped to the key's installation. There is no cross-installation query surface.
- If the endpoints return
404, the router was started without an analytics service — check that the process has database wiring; the routes mount only when it is present.