> ## Documentation index
> Fetch the complete documentation index at: https://rolter-ai-rolter.docs7.io/llms.txt
> Ask it with GET https://rolter-ai-rolter.docs7.io/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.

# Reading the Dashboard's Analytics over the API

> Read the spend, usage and per-model figures the Dashboard shows, over any time window and narrowed to one model, virtual key, business unit or customer.

The **Dashboard**'s cards and charts are three control-plane reads over the request log in ClickHouse (a fourth, `by-variant`, feeds the route sheet). They need the control plane's `CLICKHOUSE_URL`; without it they answer `503`.

| Method | Path                           | What it returns                                                                                                                                       |
| ------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`  | `/api/v1/analytics/summary`    | one row of totals: requests, tokens, cost, unpriced, withheld and unknown-usage traffic, response-cache hits, errors, average latency and p95 latency |
| `GET`  | `/api/v1/analytics/timeseries` | requests, tokens and cost per time bucket                                                                                                             |
| `GET`  | `/api/v1/analytics/by-model`   | requests, tokens, cost, unpriced, withheld and unknown-usage traffic, errors and p50/p95 latency per model, costliest first                           |
| `GET`  | `/api/v1/analytics/by-variant` | requests, tokens, cost, unpriced traffic, errors and p50/p95 latency per route and variant, busiest first                                             |

Each answers `{"data": [...]}`, the rows straight from ClickHouse.

The summary's `cache_hit_requests` counts the requests the gateway answered from its own response cache, scaled by the log's sampling weight like every other count; the Dashboard divides it by `requests` for its _response-cache hit rate_. It does not count provider prompt caching, which shows in the summary's `cache_read_tokens` instead: the prompt tokens the provider served from its own cache, scaled by the sampling weight like `prompt_tokens`, so the Dashboard's _prompt-cache read share_ is `cache_read_tokens / prompt_tokens`. It is `0` when no provider in the window reports cached tokens, which a read share cannot tell from a provider that never hits. `p95_latency_ms` is the 95th percentile of latency over the whole window, weighted by each row's sampling weight like the by-model `p50_latency_ms` and `p95_latency_ms`; `avg_latency_ms` is still sent, as a plain average of the stored rows. A control plane older than these fields sends neither.

**What counts as an error.** `errors` (and the error rate the dashboard derives from it as `errors / requests`) counts a request whose status is `0` or `400` and above. Status `0` is a request that never got an answer, a transport failure or an exhausted retry, and it counts because it is a failure the caller saw; the LLM Logs _Errors_ filter selects exactly the same rows, so the list and the totals agree. The [`error_rate` alert signal](/observability/alerting#signals) is narrower on purpose: it counts status `0` and `500` and above, and leaves out 4xx responses.

`by-variant` groups requests by route and by the weighted [variant](/concepts/providers-and-routes) that served them, one row per `model` (the route's public name) and `variant`; pass `model` to read one route's rows, so an A/B split or a canary can be compared with its configured weights. Requests the gateway served without a variant (the classic single pool, or traffic from before the route had variants) come back under the empty `variant`, so the rows add up to the route's whole traffic. It reads the same row scope and filters as the other reads, and the log records the variant that answered, so a variant that others fail over to counts the failovers it served as well as its first attempts.

## Query parameters

Every parameter is optional. A filter you leave out, or send empty, applies no filter.

| Parameter       | Applies to   | Meaning                                                                       |
| --------------- | ------------ | ----------------------------------------------------------------------------- |
| `since`         | all three    | inclusive lower bound of the window; defaults to 7 days ago                   |
| `until`         | all three    | exclusive upper bound of the window; defaults to now                          |
| `bucket`        | `timeseries` | `hour`, `day` (the default), `week` or `month`                                |
| `model`         | all three    | one exact model name                                                          |
| `key`           | all three    | one exact virtual key id                                                      |
| `business_unit` | all three    | comma-separated business unit ids; a row attributed to any of them is counted |
| `customer`      | all three    | comma-separated customer ids; a row attributed to any of them is counted      |
| `project`       | all three    | one exact project id                                                          |

`since` and `until` take the [time window](/observability/logs-and-cost#time-windows) forms, and anything else is a `400` with `code: "invalid_time_bound"`. A `bucket` outside the list is a `400` too.

The five filters are the ones `GET /api/v1/analytics/invocations` takes, with the same shapes and the same meaning, so a filter counts the same requests on the Dashboard as it lists on **LLM Logs**. Filters combine: `model=gpt-4o&customer=…` counts only that customer's `gpt-4o` traffic. An id nothing was attributed to matches nothing, so a filter only ever narrows.

```bash
# one customer's spend this month, by day
curl -H "Authorization: Bearer $SESSION_TOKEN" \
  "$ROLTER_URL/api/v1/analytics/timeseries?since=2026-10-01T00:00:00Z&bucket=day&customer=7c1e4f0a-2b3d-4e5f-8a9b-0c1d2e3f4a5b"

# a team's errors this week, per model, across two business units
curl -H "Authorization: Bearer $SESSION_TOKEN" \
  "$ROLTER_URL/api/v1/analytics/by-model?business_unit=0b9f3c1e-6f1a-4a7e-9a52-0f7d6c1a2b3c,5a2d7e9f-1c3b-4d6e-8f0a-2b4c6d8e0f1a"
```

## What you can read

The filters narrow what your roles already reach and never widen it. Every read is limited to the request log of the orgs, teams and projects you hold a role in, before any filter applies, so naming another tenant's key, unit or customer counts nothing rather than their traffic. See [Who sees which request logs](/security/rbac#who-sees-which-request-logs) for how roles map to rows.

A **saved view** on the `dashboard` surface can hold `model`, `key`, `business_unit` and `customer` (not `project`) beside `window` and `bucket`; see [Saved views](/security/rbac#saved-views).

## Sampling

With a [`sample_rate`](/observability/log-settings#sampling) below `1` the gateway logs only a share of the requests, and every count, token total and spend figure above is scaled up from that sample, so it is an estimate. `GET /api/v1/analytics/sampling` answers the rate in force, so a client can say so:

```json
{ "sample_rate": 0.25 }
```

`1` means every request is logged and the figures are exact. Any role that can read the analytics above can read the rate, viewers included: it is one deployment-wide number and names no tenant. The rest of the logging policy (payload capture, retention) stays behind `GET /api/v1/logging-settings`, which only a superadmin can read. The rate comes from the control plane's database, not ClickHouse, so it answers without `CLICKHOUSE_URL`; a control plane with no database has no route for it.

## Guardrail outcomes

`GET /api/v1/analytics/guardrails` takes the same `since` and `until` as the other reads and answers what each guardrail did to the requests in that window:

```json
{
  "recorded": true,
  "data": [
    { "action": "redacted", "rule": "Redact customer email", "requests": 12 },
    { "action": "blocked", "rule": "Block override attempts", "requests": 3 },
    { "action": "provider_blocked", "rule": "", "requests": 1 }
  ]
}
```

`action` is `blocked`, `redacted` or `annotated` for a rule named in `rule`, and `provider_blocked` or `provider_transformed` for a guardrail provider, with the provider's name in `rule` (empty for rows written before the name was recorded, or by a provider that has none). A request counts once per rule, scaled by the log's sampling weight like every other count. The rows are limited to the request log you can read, as everywhere on this page. `recorded` is `false`, with `data` empty, when the ClickHouse table lacks `clickhouse/018_guardrail_events.sql`.

`GET /api/v1/analytics/invocations` takes `guardrail=<action>:<rule>` (`provider_blocked:<provider name>` / `provider_transformed:<provider name>`, or the bare `provider_blocked` / `provider_transformed`) to list exactly those requests, and each row carries `guardrail_events`, an array of the same strings.

`GET /api/v1/analytics/invocations` also takes `plugin=<outcome>:<slug>` (or `error:<slug>:<kind>`), named as the plugin rollup below names its entries, to list exactly the requests a plugin acted on; each row carries `plugin_events`, an array of the same strings. Unlike the four shared filters, `guardrail` and `plugin` are only on the invocation list. So are `withheld=true` and `usage_unknown=true`, which keep the requests whose response a policy withheld after the provider billed them, and the successes whose upstream reported no usage; any other value applies no filter. So is `outcome=<name>`, which keeps the requests the gateway recorded with that [outcome](/observability/logs-and-cost#the-outcome-of-each-request) (`served`, `upstream_error`, `refused_budget`, ..., or `unknown` for rows from before it was recorded); an outcome it does not know is a `400`. `GET /api/v1/analytics/invocations/export` takes every one of these filters too, so a filtered view exports the rows it shows. Both narrow inside the rows you can read, and they combine with every other filter, though a withheld request is a `403` and an unknown-usage one a success, so asking for both returns nothing. On a table without `clickhouse/019_plugin_events.sql` a `plugin` filter matches nothing.

## Parameter adjustments

`GET /api/v1/analytics/param-adjustments` takes the same `since` and `until` and answers which request parameters [dialect translation](/api/overview#adjusted-parameters) did not forward as sent, per route and provider, in that window:

```json
{
  "recorded": true,
  "data": [
    {
      "route": "claude-sonnet",
      "provider": "anthropic",
      "disposition": "dropped",
      "param": "presence_penalty",
      "requests": 40,
      "route_requests": 100,
      "share": 0.4
    }
  ]
}
```

`disposition` is `dropped`, `clamped` or `translated`, as in the `x-rolter-param-adjustments` header. `requests` counts the requests that had it, `route_requests` every request that route sent that provider in the window, and `share` is their ratio, so the row above reads "this route drops `presence_penalty` on 40% of its Anthropic traffic". Counts are scaled by the log's sampling weight, and the rows are limited to the request log you can read. A route that translates nothing has no rows. `recorded` is `false`, with `data` empty, when the ClickHouse table lacks `clickhouse/022_param_adjustments.sql`. Each row of `GET /api/v1/analytics/invocations` carries the same entries as `param_adjustments`, an array of `<disposition>:<param>` strings. The **Compatibility** screen lists this rollup under **Parameters not forwarded as sent**, and the **LLM Logs** drawer lists a request's own adjustments.

## Plugin outcomes

`GET /api/v1/analytics/plugins` takes the same `since` and `until` and answers what each [plugin](/configuration/plugins#what-each-plugin-did) did to the requests in that window, one row per organization and plugin slug:

```json
{
  "recorded": true,
  "data": [
    {
      "org_id": "0c8e9b1e-4d1f-4f6e-9c43-1d2a6f6f2b10",
      "plugin": "pii-redaction",
      "calls": 120,
      "blocked": 4,
      "transformed": 30,
      "annotated": 0,
      "errors": 2,
      "last_error_at": "2026-10-13T08:41:07.000Z",
      "last_error_kind": "timeout"
    }
  ]
}
```

`calls` is every consultation, and `blocked`, `transformed`, `annotated` and `errors` are the parts of it that ended that way; the rest were allowed. A call that got no verdict is an `errors` one, so a fail-closed plugin that refused traffic because its endpoint was down counts there and not under `blocked`. `last_error_kind` is `refused`, `timeout`, `connect`, `status_<code>` or `body`; `last_error_at` and `last_error_kind` are `null` and empty while `errors` is `0`. Counts are scaled by the log's sampling weight and limited to the request log you can read, like every other count here. `recorded` is `false`, with `data` empty, when the ClickHouse table lacks `clickhouse/019_plugin_events.sql`. Each row of `GET /api/v1/analytics/invocations` carries `plugin_events`, the entries behind the counts.

## CSV exports

Three reads have a file form, for the spreadsheet a chargeback or an invoice reconciliation ends in. Each is the same read as its JSON twin, so it takes the same window and filters and shows a caller exactly the rows they could page through: a project member exports their project's requests, an org viewer their org's, and nobody another tenant's. The Export CSV button on **Cost Attribution** and **LLM Logs** calls them.

| Route                                         | File                                                                                              |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `GET /api/v1/analytics/by-attribution/export` | one row per business unit or customer (`dimension`, `include_unattributed`), with the unit's name |
| `GET /api/v1/analytics/timeseries/export`     | one row per `bucket`, under the Dashboard's filters                                               |
| `GET /api/v1/analytics/invocations/export`    | the request log, newest first, under the LLM Logs filters; `limit` and `cursor` are ignored       |

The file name says what it holds: `<what>_<from>_to_<to>_<CURRENCY>.csv`, such as `cost-attribution-business_unit_2026-10-01_to_2026-11-01_USD.csv`. The `cost` columns are in the deployment's base currency, named in the `currency` column of every row.

**Unpriced requests are flagged, never folded in.** The rollups carry `unpriced_requests` and `cost_is_partial` (`true` when that count is above zero), so a total that leaves out requests with no price is not read as final. In the request log, `unpriced` is `1` on such a row and its `cost` is left blank rather than written as `0`, as the screen shows it as unpriced instead of free.

**The request log is metadata only.** The columns are fixed: identifiers, model, provider, status, token counts, cost, currency, `unpriced`, latency and `sample_weight`. No captured request or response body, error text, guardrail or plugin detail is ever in the file, whatever the caller's payload role. Counts and sums in the rollups are scaled by the sampling weight like the JSON reads, while each log row is one stored row.

**The log is streamed.** The control plane reads it one bounded page at a time with the keyset cursor the JSON list uses and writes each page as it arrives, so a month of requests does not wait for, or hold in memory, the whole result, and rows logged during the export neither repeat nor go missing. ClickHouse failing before the first row answers a JSON error; failing part way ends the body with an error so the download fails instead of passing for a complete file.

**The request log export is bounded.** The log can span the whole retention period, and a stream holds a connection and a run of ClickHouse queries for as long as it takes, so the control plane limits it four ways, each set by an environment variable (`0` turns that limit off):

| Limit                                  | Default   | Variable                                  | Past it                                                            |
| -------------------------------------- | --------- | ----------------------------------------- | ------------------------------------------------------------------ |
| rows in one export                     | `1000000` | `ROLTER_EXPORT_MAX_ROWS`                  | the body ends in an error after the last row that fits             |
| wall-clock time of one export          | `600` s   | `ROLTER_EXPORT_MAX_SECS`                  | the body ends in an error                                          |
| exports running at once in one process | `4`       | `ROLTER_EXPORT_MAX_CONCURRENT`            | `429 Too Many Requests` with `Retry-After`, before any row is read |
| exports running at once for one caller | `1`       | `ROLTER_EXPORT_MAX_CONCURRENT_PER_CALLER` | `429 Too Many Requests` with `Retry-After`, before any row is read |

A row or time limit ends the body with the same error a failed page does, so the download fails instead of passing for a complete file: the status line was already `200` by then, and a client that checks only the status would keep a short file. Narrow the window (`since`, `until`) or the filters and export again. A caller is an account or an API token; the admin token and open mode share one. The concurrency counts are per control-plane process, so with several replicas each holds its own.

**Cells are safe to open.** A value a spreadsheet would run as a formula (one starting with `=`, `+`, `-`, `@`, a tab or a carriage return, such as a business unit someone named `=Payments`) is written with a leading `'`. Numbers are written as they are.

## The savings advisor

`GET /api/v1/analytics/savings` re-prices the same rows at the cheaper models already configured in each route's pool, and says where the window's spend went. It takes the window plus `org`, `project`, `route` and `key`, answers one object rather than `{"data": [...]}`, and is gated by the `savings` capability. It re-prices and never evaluates quality; see [Savings advisor](/observability/savings-advisor) for how to read it and what it does not claim.
