GROUP BY with a different
group_by and a different metric list.
They are a separate surface from the usage endpoints:
Reach for the usage endpoints when you want individual requests, and these when
you want a shape of the traffic.
These endpoints are not in the API Reference tab. They are served by a
separate process that publishes no OpenAPI document, so this page is the
contract. Field names and query parameters here were read off the running
service — if something on this page disagrees with what the API returns, the API
is right and we want to hear about it.
Authentication and scope
Base URLhttps://api.meshapi.ai/v1/analytics. Every endpoint is a GET, and
every one takes an rsk_ API key with the usage:read scope:
org_id, team_id and
member_user_id are ignored rather than trusted — a key cannot widen itself by
naming an org.
These same endpoints back the Analytics screens in the dashboard, where the
answer is scoped by your role instead: owners and admins see the whole org,
members see their own rows, and a team lead sees their own rows plus the teams
they lead. Your role is re-resolved on every request against the membership
record rather than read out of the token, through a cache with a few minutes’
TTL — so a revoked role stops reading within that window. The org-wide view is a
dashboard surface; from your own code you get your key’s rows.
A key caller’s scope also narrows what it can ask for. See
Dimensions and labels below — key_id and
member_user_id are already fixed for you, so grouping by either is refused.
The endpoints
/spend/summary, /reliability/attribution and /findings are fixed-shape
panels: their shape is the answer, so none takes
metric, group_by, rollup, rank_by, sort, limit or cursor. They do
not all take the same filters as /usage/summary, and an unknown query
parameter is ignored rather than refused — so a filter that does not apply
returns the unfiltered panel:
All three also take the scope parameters —
org_id, team_id and
member_user_id — on the same terms as /usage/summary.
/reliability/attribution also refuses stream=true&stream=false with a 400:
its latency block can only measure one half of the traffic, so honouring both
would report three figures over two populations.
What the surface accepts — GET /meta
The dimensions, the measures, each panel’s pin filters, the caps and the
raw-window boundary are also served as JSON, generated from the same definitions
the query layer uses. A client that reads meta at startup cannot drift from the
API the way a hard-coded list can, so prefer it over the tables here if the two
ever disagree.
It does not carry everything a request may send: the two population switches
(include_internal, include_retries) and /reliability/attribution’s bucket
are not in panels[].filters, which lists the repeatable pin filters only.
dimensions and measures arrays carry every one of them,
and the other three panels appear beside /usage/summary:
groupable
says whether you may name that dimension in group_by: an API key reads only
its own traffic, so key_id and member_user_id come back
groupable: false with pinned_by_scope: true — your scope has already fixed
them, and asking for that breakdown is a 400. It describes the request you are
about to send, not one you have narrowed: an org-wide reader is told
member_user_id is groupable, and adding member_user_id=X to that query then
fixes it and refuses the breakdown. Build your controls from this rather than
from the full dimension list, or you will offer a filter that cannot be used.
filterable has a narrow meaning: accepted as a repeatable pin parameter on
/usage/summary. It says nothing about org_id, team_id and
member_user_id, which are scope parameters rather than pins — team_id and
member_user_id come back filterable: false and are still settable by a
dashboard caller on the terms in the query-parameter table below.
value_type is string, uuid or boolean.
The per-measure flags answer the questions the rest of this page explains in
prose: rankable (one SQL aggregate — may be named in rank_by, and may be
added across groups or buckets), needs_stream_split (pin or group by stream
first), money (returned as a decimal string), reports_coverage (carries a
metadata.coverage_pct key) and estimator. estimator names the derivation
only where a measure is read off something other than its own columns; null
covers both a plain sum and a ratio of two sums, so it does not mean
additive — rankable is that signal.
fact_only marks a dimension the hourly cube does not carry, so naming it pins
the whole range to the raw tier — see The two tiers. When
raw_window_since is null the raw tier is switched off entirely: no range may
name such a dimension, so it comes back filterable: false and
groupable: false and drops out of panels[].filters altogether.
limit and export_rows are deployment settings rather than fixed constants, so
read them from meta instead of hard-coding the numbers above. export_rows
bounds a range-total export; an export carrying a rollup is bounded by
series_points — see CSV and Excel export.
/meta takes org_id and team_id on the same terms as the other endpoints,
and is the one that does not take member_user_id — it answers for your reach,
not for a query you have not sent yet.
Every answer states what it counted
A figure read without the set it was measured over is not a figure, so every response — and every row of every export — carries itspopulation:
traffic_type: external. Health checks and any other internal traffic class are out. Passinclude_internal=trueto lift it; the echo becomes"all".retries: excluded. A request that took three attempts counts once, as the attempt that served it. Passinclude_retries=trueto count every attempt; the echo becomes"included".
include_retries. Retry multiplicity is reported anyway, on
every response, measured with the retried rows in scope while the money and
token totals stay on the declared population:
Grouped usage — GET /usage/summary
Parameters
Dimensions
Thirteen, and they are not all equally reachable:
Two rules the API enforces rather than guesses at:
- Grouping by a dimension already fixed to one value is refused. It would be a one-row chart. This counts values fixed by your scope as well as ones you filtered on. Two pinned values still compare two things, so only a single fixed value is rejected.
- Omitting
group_bypicks an axis from what is not already fixed, and from how far you reach. A team-by-team comparison is nonsense once you have already picked a team; so is a per-member one for a caller who only ever reads one member. With nothing pinned at all, a team lead is broken down by member where an org-wide reader is broken down by team — and naming one of your teams fixesteam_id, so the axis falls back to the ordinary order (model). The chosen axis comes back in the response’sgroup_by, so render what you are given rather than assuming.
end_user_id is raw-window only
end_user_id is the safety_identifier — or the deprecated user — you sent
on the request. It is kept per request and is not in the hourly cube, so it
can only be grouped or filtered on inside the raw window: the last 31 days, the
boundary metadata.raw_window_since reports.
A range reaching older than that is refused with a 400, not answered over
the part that is covered. A partial answer under the same name would be a
different, smaller population than the one you asked for, and nothing in the
response would say so. When metadata.raw_window_since is null the raw tier
is switched off entirely and the dimension is unavailable at any range —
narrowing the range will not recover it.
An end user you have erased through POST /v1/end-users/erasures has their
identifier cleared from these rows too, so their traffic folds into the null
group rather than disappearing.
It is never chosen as the automatic breakdown axis when you omit group_by, so
a query that did not ask for it cannot be pushed into the raw tier by accident.
It is a dimension on /usage/summary and its CSV and Excel exports only.
/spend/summary, /reliability/attribution and /findings do not accept it —
see the panel table above, where an unrecognised filter is ignored rather than
refused.
labels
key_id and team_id are UUIDs on a row. For a dashboard caller they
resolve to display names, returned once per response rather than repeated on
every row:
member_user_id is never labelled, on any caller: its values are auth subject
ids, and resolving them would put members’ identities in an analytics response.
A breakdown on that axis — which is what a team lead gets by default — therefore
renders raw ids unless you map them yourself.
For an API-key caller labels is always {} — label lookup is scoped to the
org the rows were read under, and a key read carries no org. Resolve a key’s
name through GET /v1/keys with a mak_ admin key, or keep your own mapping.
Measures
Up to 12 per request, from these 31. Volume
Money. Returned as decimal strings, never floats.
Tokens
Caching and routing
Latency. All in milliseconds.
Rankable measures
A page is ordered and keyset-paged byrank_by, so it has to be one SQL
aggregate. Rates and averages are not: ranking a reliability table by
error_rate would put a three-request group at the top.
requests, attempts, errors, cost_usd, input_cost_usd,
output_cost_usd, tool_cost_usd, platform_fee_usd, spend_usd,
prompt_tokens, completion_tokens, total_tokens, cached_tokens,
reasoning_tokens, images_generated, retry_recovered,
failed_after_retry, response_cache_hits, gateway_cache_prompt_tokens,
gateway_cache_completion_tokens.
A real measure that is not on that list is a 400 naming the whole list. A name
that is not a measure at all is a 422.
Response
total_series is the per-bucket total across all groups, not only the page.
It is there so other = total − Σ(groups) is computable and a chart cannot
disagree with the tiles above it.
Reading a number honestly
coverage_pct — how much of the traffic a figure could be computed over
Not every request can contribute to every measure. ttft_ms is null on a
non-streamed request; the two latency legs are null on rows written before the
split existed. An average over them silently describes a subset.
So the latency measures carry the share of requests they were actually computed
over, as a percentage of requests. Which measures, and at which grain:
null at the group and bucket grain means you asked for neither leg mean, not
that coverage was zero. The exports follow the same rule: one
<measure>_coverage_pct column per leg mean requested.
gateway_overhead_ms_avg of 42 ms at 99.8% coverage is a property of that
group’s traffic. The same 42 ms at 1.2% coverage is a property of eleven
requests. Treat a low coverage as a reason to widen the range or not to publish
the number, not as a rounding detail — and show it beside the figure wherever a
person will read it.
Per-group coverage exists because leg coverage is not uniform across a page:
one model can carry both latency legs on every row and the next none at all.
estimator — how a non-additive figure was derived
ttft_histogram— the percentile was read off a cumulative histogram, so it is accurate to a bucket edge rather than exact. The bounds come back inmetadata.histogram_bounds_ms: 100, 250, 500, 1000, 2000, 5000, 10000 and 30000 ms. Anything slower than the last bound sits in an implicit overflow bucket, so an estimate saturates at 30000 rather than inventing an upper edge. Attft_p99_msof exactly 30000 means “at least 30 s”, not “30 s”.mean_over_leg_count— a mean over the rows that carried both latency legs, which is also whatcoverage_pctis measured against. The cube stores sums and counts, so there is no percentile per leg.
Percentiles are available for time to first token only. There is no
latency_p95_ms today.Streamed and non-streamed latency are different quantities
latency_ms is wall-clock on a non-streamed request and time-to-last-byte on a
streamed one. One average over both adds two different things together, and the
two legs cut from it inherit that.
So latency_ms_avg, provider_latency_ms_avg and gateway_overhead_ms_avg
require you to pin stream to one value or add it to group_by. Asking
for one without doing either is a 400.
If you group by stream, the groups split correctly but the totals row and
total_series still span both halves — so those two answer null for those
measures rather than a mixed average. The key stays in the object, so you never
have to guess whether a measure was asked for.
The two tiers
/v1/analytics/* reads from two stores, and tells you which one answered.
Three fields carry it:
GroupOut.tierandSeriesPoint.tier—raw,cube,mixed, ornullwhen the row counted nothing.mixedis a property of an answer — a bucket or group whose hours fall on both sides — never a store you can request.metadata.tiers— the stores this answer read, in time order, e.g.["cube", "raw"].metadata.raw_window_since— where the boundary stood when the query ran, so a chart can draw it.nullwhen the raw tier is switched off.
estimator still says ttft_histogram inside the
window. That is deliberate: an exact quantile inside the window and a bucket-edge
estimate outside it would be two different quantities in one series, and
percentiles do not add, so the halves could not be folded into a range total at
all.
tier is published by the summary surface. /spend/summary,
/reliability/attribution and /findings read both tiers too — so no two
panels answer one range from different stores — but they have no per-row field
to report it on.Paging
A page comes back withmetadata.truncated and, when there is more,
next_cursor. Pass it as cursor with every other parameter unchanged.
A cursor is bound to the query that issued it — the scope, range, grouping,
rollup, filters, population, metric list and sort. Change any of them and the
token is refused rather than silently answering about something else;
re-request the first page.
Caps that apply before any of this:
The series cap is checked up front and refused rather than truncated — a
quietly trimmed timeseries would make a quiet hour and a dropped hour look the
same. The error names the three ways out: a coarser rollup, a shorter range, or
a smaller
limit.
CSV and Excel export
GET /usage/summary.csv and GET /usage/summary.xlsx take the same range,
filter, metric, group_by and rollup parameters as /usage/summary and
return the same grouped rows — groups, never raw events.
limit and cursor are not parameters here. An export is not paged; it
picks its own bound, and passing either is ignored.
Column order is fixed:
bucket is present only when you passed rollup. The population rides on every
row, so a saved file still says what it counted, and a coverage column is added
per latency-leg measure for the same reason.
In the CSV only, any cell whose value begins with =, +, -, @, a tab
or a carriage return is written with a leading apostrophe, so a spreadsheet does
not evaluate it as a formula. That covers measure cells as well as dimension
values — a negative gateway_overhead_ms_avg arrives as '-3.2. Strip it when
loading if your own parser does not. The .xlsx adds no prefix: it stores each
value as text instead, so stripping a leading apostrophe there would corrupt a
value that genuinely begins with one.
Both formats bound their output, and the bound depends on whether you asked for
a rollup:
So
rollup=day over 366 days keeps the top 54 groups, and rollup=hour
over 30 days keeps the top 27. The rest are dropped. If you need every group,
export without a rollup, or narrow the range.
Neither format stops quietly. A file that hit either bound ends with a
# truncated at N rows or # truncated at N groups line, and the Excel export
also sends X-Mesh-Analytics-Truncated and X-Mesh-Analytics-Truncated-At
headers — it builds the whole file before responding, where the CSV streams and
can only say so on its last line. X-Mesh-Analytics-Truncated is always sent,
none included; absent would otherwise mean both “complete” and “the header
never arrived”.
Both send X-Mesh-Analytics-Population and X-Mesh-Analytics-Row-Limit. On a
rollup export, X-Mesh-Analytics-Row-Limit carries the group limit, not the
row count — the file can hold that many groups times its bucket count.
Concurrent Excel builds are bounded per worker. A request that waits too long
for a slot gets a 429 with Retry-After; retry rather than treating it as a
failure.
Drilling through to the logs
Every chart element should be able to open the requests behind it. Seven of the dimensions the analytics API groups by are also filters on the usage endpoints —POST /v1/usage and
POST /v1/usage/events, and the dashboard’s own events CSV export:
provider, fault_owner, is_byok, stream, traffic_type,
is_retry_attempt, end_user_id.
A member_user_id bar drills too, through UsageFilter.member_user_id — one
exact value, org-wide reach, and a team lead must send team_id alongside it.
end_user_id takes one exact value on the logs side, not a list as it does
here.
So a bar grouped by fault_owner=provider over
since=2026-10-01T00:00:00Z&until=2026-10-27T00:00:00Z drills to:
fault_owner is derived at query time on the logs side, through the same rule
that writes the stored column the charts group by, so the two agree row for row.
Errors
400 and 422 do not carry the same body. A 400 is the gateway’s standard
envelope with one message; a 422 is
{"error": {"code": "validation_error", "message": …, "details": [...]}}, where
details names each offending parameter. Handle both if you surface API errors
to a user.X-Request-Id; quote it in a support request.
Known limits
- Percentiles cover time to first token only. There is no total-latency percentile measure yet.
- Only
/usage/summarypublishestier. The other three panels read both stores but do not report which answered. end_user_idreaches back 31 days, not 24 months, and only on/usage/summaryand its exports.- History ends at 24 months, and one query may span at most 366 days of it. The usage endpoints have no such window — reach for those when you need a figure from further back than the cube keeps.