> ## Documentation Index
> Fetch the complete documentation index at: https://docs.weaveos.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Analytics export

> Pull raw, row-level routing decisions into your own warehouse with a read-only key

The analytics export streams the router's **raw routing decisions** — one row per upstream model call, nothing aggregated — so your data team can load them into its own warehouse and run its own analysis.

It is a pull API: issue a read-only key, poll a cursor, append what comes back. Every number in the [Router report](/router/reporting) is derived from these same rows, so you can reproduce or challenge any of them.

<Note>
  Use the [Router report](/router/reporting) if you want charts. Use this export if you want the underlying rows in your own warehouse, or anything else that can load newline-delimited JSON.
</Note>

## Issue an analytics key

Analytics keys are a distinct credential from your `rk_` router key. They carry the `ra_` prefix, they authenticate **only** the `/v1/analytics/*` endpoints and the read-only `GET /v1/sessions/{session_id}/cost` endpoint, and they cannot route inference, reach your provider credentials, or spend credits. A key sitting in an ETL config can read telemetry and nothing else.

Issue one from **Settings → Weave Router → Your API keys → New analytics key**. The raw key is shown once, at issuance — store it before closing the dialog, because it cannot be recovered afterwards. Self-hosted deployments issue them from the router's own dashboard at **Settings → API keys → Issue key → Analytics (read-only)**.

Analytics keys appear in the same key list as routing keys, marked with an **Analytics** badge and a read-only spend column. To replace one, issue a new key and revoke the old one.

```bash theme={null}
export WEAVE_ANALYTICS_KEY=ra_...
```

Router keys are rejected on the analytics endpoints, and analytics keys are rejected on every routing endpoint. The separation is enforced on both sides.

## Pull a page

```bash theme={null}
curl -sS --compressed \
  -H "Authorization: Bearer $WEAVE_ANALYTICS_KEY" \
  "https://router.workweave.ai/v1/analytics/routing-decisions?since=2026-01-01T00:00:00Z&limit=1000" \
  -D headers.txt -o page.ndjson
```

The body is newline-delimited JSON: one object per line, no envelope, no wrapping array. Pipe it straight into a loader.

| Parameter | Meaning |
| - | - |
| `since` | RFC3339, inclusive. Required on the first page; ignored once you pass a `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` header. |
| `limit` | Rows per page. Default 1000, maximum 10000. |
| `format` | `ndjson` — the only supported value today. |

| Response header | Meaning |
| - | - |
| `X-Weave-Next-Cursor` | Pass as `cursor` to fetch the next page. |
| `X-Weave-Has-More` | `true` while more rows are ready, `false` once you're current. |

Send `Accept-Encoding: gzip` (curl's `--compressed`). These rows are repetitive JSON and compress by roughly an order of magnitude.

Each key is limited to 60 requests per minute, answering `429` with `Retry-After` beyond that. At the maximum page size that is 600,000 rows a minute.

## Keep a warehouse in sync

Store the cursor between runs. The same loop does the initial backfill and the steady-state tail — when `X-Weave-Has-More` turns `false`, you are caught up.

```python theme={null}
import gzip, io, json, os, requests

BASE = "https://router.workweave.ai/v1/analytics/routing-decisions"
headers = {"Authorization": f"Bearer {os.environ['WEAVE_ANALYTICS_KEY']}"}
cursor = load_cursor()  # your own state store; None on the first ever run

while True:
    params = {"limit": 10000}
    params["cursor" if cursor else "since"] = cursor or "2026-01-01T00:00:00Z"

    r = requests.get(BASE, headers=headers, params=params, timeout=120)
    r.raise_for_status()

    rows = [json.loads(line) for line in r.text.splitlines() if line]
    load_into_warehouse(rows)          # MERGE on id — see below

    cursor = r.headers["X-Weave-Next-Cursor"]
    save_cursor(cursor)
    if r.headers["X-Weave-Has-More"] != "true":
        break
```

Rows are written once and never updated, and `id` is unique, so deduplicating on `id` makes any replayed page a harmless no-op:

```sql theme={null}
MERGE INTO routing_decisions t USING staging s ON t.id = s.id
WHEN NOT MATCHED THEN INSERT ...;
```

## What a row is

**One row per upstream model call**, not per user message. That is deliberately the finest grain the router addresses, and one thing a person would call a single request commonly produces several rows: a retry after an upstream error, a failover to another provider, a context-compaction call, a title-generation call, a classifier call, or a sub-agent turn.

So `COUNT(*)` counts **model calls**. To count user-visible turns, filter on `turn_type = 'main_loop'`. To collapse retries and failovers, group by `request_id`.

```sql theme={null}
-- 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;
```

<Warning>
  This is the one thing teams get wrong on first load. A model-call count will not match a request count from any other source, and it is not supposed to.
</Warning>

## Ordering and freshness

Rows come back ordered by **`recorded_at`** (when Weave recorded the row), not by `requested_at` (when the turn happened). Telemetry is written after a turn completes, so event time is not monotonic — within a single page `requested_at` can move backwards. Ordering on ingest time is what makes "resume at this cursor and miss nothing" true.

For the same reason the export withholds the most recent **60 seconds**: a row committing right now could otherwise land just behind a cursor you already passed. The readable tail is always about a minute behind live.

<Tip>
  Partition your warehouse table on `requested_at`, but bookmark your job on `recorded_at`. They are different clocks.
</Tip>

## Fields

`GET /v1/analytics/schema` returns the machine-readable field dictionary — name, type, nullability, description — plus the grain, ordering, and holdback semantics. Generate your DDL from it rather than hand-transcribing this page.

```bash theme={null}
curl -sS -H "Authorization: Bearer $WEAVE_ANALYTICS_KEY" \
  "https://router.workweave.ai/v1/analytics/schema" | jq .
```

| 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_creation_1h_tokens`, `cache_read_tokens`, `reasoning_tokens` |
| Economics | `subscription_served`, `credential_source`, `actual_input_cost_usd`, `actual_output_cost_usd` |
| Subscription attribution | `subscription_account_id`, `subscription_owner_id`, `subscription_tier`, `intended_model_family`, `final_model_family` |
| 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 table covers the core groups. `/schema` also lists routing provenance (`routing_strategy`, `roster_version`, `policy_artifact_id`, and related release identifiers), experiment assignment, plan and settlement, and trial-mode git context fields.

Field additions are made within a schema version, so write your loader to tolerate new columns; the `version` in the `/schema` response changes only if a field is removed or changes meaning. Version `2` removed `requested_input_cost_usd`, `requested_output_cost_usd`, and `savings_usd`. Version `3` added `subscription_served` and reports `0` in both `actual_*` cost columns for those rows.

<Warning>
  `decision_reason` is free-form diagnostic text written for humans, and its wording changes between router versions. Do not parse it and do not group by it — group on `decision_model`, `sticky_hit`, `failover_used`, or `candidate_models` instead.
</Warning>

## Computing savings yourself

The export reports what each turn actually cost (`actual_*`) and nothing else. There is deliberately no counterfactual cost column and no precomputed `savings_usd`: the counterfactual rests 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 from the token columns and a price table of your own:

```sql theme={null}
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 >= current_date - 30
      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). It reports **current** prices, not the prices in force when an older row was recorded, so pin your own price table if you need historical ones.

Cost fields are null — not zero — for rows the router could not price, so they drop out of an average instead of dragging it down. Filter them explicitly, as above, when you want the priced subset. Subscription-served rows (`subscription_served = true`) report `0` instead, with real token counts, so add `AND NOT d.subscription_served` to the query above if plan-covered turns shouldn't count as savings.

See [how savings are calculated](/router/reporting#how-savings-are-calculated) for how Weave's own dashboard makes the comparison, and what it does and does not claim.

## What the export does not include

* Prompt or completion content. The router's telemetry stores no message text, so none is available to export.
* Weave's internal scoring artifacts: cluster assignments, per-candidate scores, and exploration probabilities. Policy, roster, and release identifiers are exported (see `/schema`).
* Shadow and counterfactual evaluations of models that did not serve the turn.
* Credentials, key identifiers, and key fragments.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.