MeterUsageTable
A turnkey query-and-render table. Configure authentication once, then provide the
source and query. No load, data, useEffect, row mapping, cursor tracking or
pagination code is required. Existing MeterTable data/load modes are unchanged.
Loading interactive preview…
Loading interactive preview…
View code
Actual React source for this preview, including demo controls, shared styles and isolated mocks. The docs host supplies the client and theme; application installation and React / Vue usage follow below.
import { useState } from "react"import { MeterUsageTable } from "@meterry/component-react"import { METRICS, type PreviewEnvironment } from "./preview-data"import { styleProps, eventColumns, RequestSummary } from "./preview-shared"
export function AutomaticTablePreview({ environment,}: { environment: PreviewEnvironment}) { const [source, setSource] = useState<"events" | "usage">("events") return ( <div className="docs-preview-stack" data-testid="automatic-table-preview"> <label className="docs-preview-chart-select"> Table source <select value={source} onChange={(event) => setSource(event.target.value as "events" | "usage") } > <option value="events">Events · automatic cursor</option> <option value="usage">Aggregate · one bounded result</option> </select> </label> <MeterUsageTable key={source} {...styleProps} source={source} title="Usage · automatic query and pagination" query={ source === "events" ? { start_time: environment.startTime, end_time: environment.endTime, } : { start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket", "model", "metric"], measures: ["quantity"], } } columns={source === "events" ? eventColumns : undefined} pageSize={10} batchSize={20} pageSizeOptions={[10, 20]} /> <RequestSummary environment={environment} /> <div className="docs-preview-actions"> <button type="button" onClick={() => environment.failNextRequest()}> Fail next API request </button> <span className="docs-preview-filter-summary"> Then use Refresh or fetch another batch; Retry recovers. </span> </div> </div> )}import { useSyncExternalStore } from "react"import { createEChartsSeriesRegistry, type ChartSeries, type MeterClassNames, type MeterFilterValues, type TableColumn, type UsageTableRow,} from "@meterry/component"import { METRICS, MODELS, type PreviewEnvironment, type PreviewRow, type TrendRow,} from "./preview-data"
export const EMPTY_FILTERS: MeterFilterValues = {}const classNames: MeterClassNames = { root: "docs-preview-root", card: "docs-preview-card", header: "docs-preview-header", caption: "docs-preview-caption", title: "docs-preview-title", body: "docs-preview-body", toolbar: "docs-preview-filter-bar", filterBar: "docs-preview-filter-bar", filter: "docs-preview-filter", table: "docs-preview-table", list: "docs-preview-list", listFooter: "docs-preview-pagination", pagination: "docs-preview-pagination", actions: "docs-preview-actions", button: "docs-preview-button", chart: "docs-preview-chart", value: "docs-preview-value", unit: "docs-preview-unit", empty: "docs-preview-status", loading: "docs-preview-status", error: "docs-preview-status",}export const styleProps = { unstyled: true, classNames }// Adapt only preview label colors to Starlight, without mutating the global registry.export const previewSeriesRegistry = createEChartsSeriesRegistry()for (const type of ["pie", "doughnut"]) { const builtin = previewSeriesRegistry.builder(type) previewSeriesRegistry.register( type, (series, index, model) => ({ ...builtin(series, index, model), label: { color: model.theme.colors.text, textBorderWidth: 0, }, }), { polar: true } )}
export const columns: TableColumn<PreviewRow>[] = [ { key: "bucket", header: "Date", sortable: true }, { key: "model", header: "Model", sortable: true, filter: { type: "select", options: MODELS.map((value) => ({ label: value, value })), }, }, { key: "provider", header: "Provider" }, { key: "metric", header: "Metric", filter: { type: "select", options: METRICS.map((value) => ({ label: value, value })), }, }, { key: "quantity", header: "Quantity", sortable: true, align: "right", filter: { type: "number", placeholder: "Minimum quantity" }, format: (value) => Number(value).toLocaleString("en-US"), }, { key: "amount", header: "Amount (USD)", sortable: true, align: "right", format: (value) => `$${Number(value).toFixed(4)}`, },]// Shared Model/Provider controls and table-local Metric/Quantity controls are independent.export const sharedColumns = columns.map((column) => column.key === "model" ? { ...column, filter: undefined } : column)
export const trendSeries: ChartSeries<TrendRow>[] = [ { key: "input", label: "Input tokens", value: "input_tokens" }, { key: "output", label: "Output tokens", value: "output_tokens" },]
// API identity is raw_event_id; override the snapshot's legacy default event column.export const eventColumns: TableColumn<UsageTableRow>[] = [ { key: "raw_event_id", header: "Event ID" }, { key: "occurred_at", header: "Occurred at", format: (value) => new Date(Number(value) * 1000).toISOString().slice(0, 10), }, { key: "source", header: "Source" }, { key: "model", header: "Model", accessor: (row) => (row.labels as Record<string, string> | undefined)?.model, },]
export function RequestSummary({ environment,}: { environment: PreviewEnvironment}) { useSyncExternalStore( environment.subscribeRequests, () => environment.requests.length, () => 0 ) const details = environment.requests.filter((request) => request.path.endsWith("/details") ).length const trend = environment.requests.filter((request) => request.path.endsWith("/analysis/query") ).length const aggregate = environment.requests.filter((request) => request.path.endsWith("/usage/query") ).length return ( <output className="docs-preview-filter-summary" data-testid="request-count"> Mock API requests: {environment.requests.length} · Events: {details} · Trend: {trend} · Aggregate: {aggregate} </output> )}import { SubjectClient, createSubjectTokenProvider, mergeMeterFilters, type AnalysisQueryRequest, type AnalysisQueryResponse, type FetchLike, type MeterFilterValues, type TableQuery, type TableResult, type UsageQueryRequest, type UsageQueryResponse,} from "@meterry/component"
export const DAY = 86_400export const MODELS = ["gpt-4o-mini", "claude-sonnet", "glm-5.1"] as constexport const PROVIDERS = ["openai", "anthropic", "r9s"] as constexport const METRICS = ["input_tokens", "output_tokens"] as constexport type PreviewScenario = "sample" | "empty" | "error"export type PreviewRow = { bucket: string model: string provider: string metric: string quantity: number amount: number}export type TrendRow = { bucket: string input_tokens: number output_tokens: number}export type PreviewRequest = { path: string; afterId?: string }export type PreviewEnvironment = { requests: PreviewRequest[] subscribeRequests: (listener: () => void) => () => void failNextRequest: () => void client: SubjectClient startTime: number endTime: number}
/** Synthetic browser-only fixtures, not production usage or pricing. */export function createFixture(now = Date.now()) { const today = Math.floor(now / 1000 / DAY) * DAY const startTime = today - 6 * DAY const rows: PreviewRow[] = [] for (let day = 0; day < 7; day++) { for (let model = 0; model < MODELS.length; model++) { for (let metric = 0; metric < METRICS.length; metric++) { const quantity = 1000 + day * 140 + model * 600 + metric * 400 rows.push({ bucket: new Date((startTime + day * DAY) * 1000) .toISOString() .slice(0, 10), model: MODELS[model]!, provider: PROVIDERS[model]!, metric: METRICS[metric]!, quantity, amount: Number((quantity * 0.000002).toFixed(6)), }) } } } return { rows, startTime, endTime: Math.floor(now / 1000) + 1 }}
function selectRows( rows: PreviewRow[], request: UsageQueryRequest | AnalysisQueryRequest) { const metrics = request.metrics?.length ? request.metrics : METRICS return rows.filter((row) => { const timestamp = Date.parse(row.bucket) / 1000 if ( timestamp < (request.start_time ?? -Infinity) || timestamp >= (request.end_time ?? Infinity) ) return false if (!metrics.includes(row.metric as (typeof METRICS)[number])) return false return Object.entries(request.filters ?? {}).every(([key, value]) => { if (value === "") return true if (key === "quantity" || key === "amount") { return Number.isFinite(Number(value)) && row[key] >= Number(value) } return key in row && String(row[key as keyof PreviewRow]) === value }) })}
function analysisResponse( rows: PreviewRow[], request: AnalysisQueryRequest): AnalysisQueryResponse { const groups = new Map< string, { metric: string measure: string model?: string points: Map<string, number> } >() for (const row of selectRows(rows, request)) { for (const measure of request.measures) { const model = request.group_by?.includes("model") ? row.model : undefined const key = [row.metric, measure, model].filter(Boolean).join(":") const group = groups.get(key) ?? { metric: row.metric, measure, model, points: new Map(), } const value = measure === "amount" ? row.amount : measure === "item_count" ? 1 : row.quantity group.points.set(row.bucket, (group.points.get(row.bucket) ?? 0) + value) groups.set(key, group) } } let remaining = request.limit ?? Infinity const series: AnalysisQueryResponse["series"] = [] for (const [key, group] of groups) { const points = [...group.points] .sort(([a], [b]) => a.localeCompare(b)) .slice(0, remaining) .map(([bucket, value]) => ({ bucket, value })) remaining -= points.length if (points.length) series.push({ key, label: group.metric, measure: group.measure, dimensions: { metric: group.metric, ...(group.model ? { model: group.model } : {}), }, points, }) } return { group_by: request.group_by ?? ["bucket"], measures: request.measures, series, meta: { timezone: "UTC", bucket_size: request.bucket_size ?? "1d", start_time: request.start_time, end_time: request.end_time, }, }}
function usageResponse( rows: PreviewRow[], request: UsageQueryRequest): UsageQueryResponse { const dimensions = request.group_by ?? ["bucket", "model", "metric"] const groups = new Map< string, { dimensions: Record<string, string>; measures: Record<string, number> } >() for (const row of selectRows(rows, request)) { const values = Object.fromEntries( dimensions.map((key) => [key, String(row[key as keyof PreviewRow] ?? "")]) ) const key = JSON.stringify(values) const group = groups.get(key) ?? { dimensions: values, measures: {} } for (const measure of request.measures ?? ["quantity"]) { const value = measure === "amount" ? row.amount : measure === "item_count" ? 1 : row.quantity group.measures[measure] = (group.measures[measure] ?? 0) + value } groups.set(key, group) } return { rows: [...groups.values()].slice(0, request.limit ?? groups.size) }}
function delay( milliseconds: number, signal?: AbortSignal | null): Promise<void> { return new Promise((resolve, reject) => { const abort = () => { clearTimeout(timer) signal?.removeEventListener("abort", abort) reject(new DOMException("Preview request aborted", "AbortError")) } const timer = setTimeout(() => { signal?.removeEventListener("abort", abort) resolve() }, milliseconds) if (signal?.aborted) abort() else signal?.addEventListener("abort", abort, { once: true }) })}
/** Every request stays in this fetch adapter; no network or stored credentials. */export function createPreviewEnvironment( options: { scenario?: PreviewScenario now?: number delayMs?: number } = {}): PreviewEnvironment { const fixture = createFixture(options.now) const rows = options.scenario === "empty" ? [] : fixture.rows const requests: PreviewRequest[] = [] const listeners = new Set<() => void>() let failNext = false const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }) const fetch: FetchLike = async (input, init) => { const url = new URL( typeof input === "string" ? input : input instanceof URL ? input.href : input.url, "https://docs-preview.invalid" ) requests.push({ path: url.pathname, afterId: url.searchParams.get("after_id") ?? undefined, }) for (const listener of listeners) listener() await delay(options.delayMs ?? 180, init?.signal) if ( url.origin !== "https://docs-preview.invalid" || ![ "/v1/subject/analysis/query", "/v1/subject/usage/query", "/v1/subject/usage/events/details", ].includes(url.pathname) ) return json({ message: "Unknown preview endpoint" }, 404) if (options.scenario === "error" || failNext) { failNext = false return json({ message: "Simulated preview request failure" }, 503) } if (url.pathname.endsWith("/details")) { if (init?.method !== "GET") return json({ message: "Preview details require GET" }, 405) const request: UsageQueryRequest = { start_time: Number(url.searchParams.get("start_time")) || undefined, end_time: Number(url.searchParams.get("end_time")) || undefined, metrics: url.searchParams .getAll("metrics") .flatMap((value) => value.split(",")), metric: url.searchParams.get("metric") ?? undefined, } if (!request.metrics?.length && request.metric) request.metrics = [request.metric] const events = selectRows(rows, request).map((row) => ({ raw_event_id: `raw_preview_${String(fixture.rows.indexOf(row) + 1).padStart(4, "0")}`, occurred_at: Date.parse(row.bucket) / 1000, source: "documentation", subject_type: "user", subject_id: "documentation-preview", labels: { model: row.model, provider: row.provider }, metrics: { [row.metric]: { value: String(row.quantity), amount: String(row.amount), currency: "USD", }, }, })) const after = url.searchParams.get("after_id") const cursorIndex = after ? events.findIndex((event) => event.raw_event_id === after) : -1 if (after && cursorIndex < 0) return json({ message: "Invalid preview cursor" }, 400) const start = cursorIndex + 1 const limit = Math.min( 1000, Math.max(1, Number(url.searchParams.get("limit")) || 100) ) const page = events.slice(start, start + limit) return json({ usage_events: page, total: events.length, ...(start + page.length < events.length ? { next_after_id: page.at(-1)?.raw_event_id } : {}), }) } if (init?.method !== "POST") return json({ message: "Preview query requires POST" }, 405) let body: UsageQueryRequest | AnalysisQueryRequest try { body = JSON.parse(String(init?.body ?? "{}")) } catch { return json({ message: "Invalid preview query" }, 400) } if (!Array.isArray(body.measures)) return json({ message: "Missing preview measures" }, 400) return json( url.pathname.endsWith("/analysis/query") ? analysisResponse(rows, body as AnalysisQueryRequest) : usageResponse(rows, body) ) } const provider = createSubjectTokenProvider( async () => ({ token_type: "Bearer", access_token: "documentation-preview-only", expires_in: 900, expires_at: Math.floor(Date.now() / 1000) + 900, subject: { type: "user", id: "documentation-preview" }, rules: ["analysis"], }), { tokenStorage: null } ) return { requests, subscribeRequests: (listener) => { listeners.add(listener) return () => { listeners.delete(listener) } }, failNextRequest: () => { failNext = true }, client: new SubjectClient(provider, { baseUrl: "https://docs-preview.invalid", credentials: "omit", fetch, }), startTime: fixture.startTime, endTime: fixture.endTime, }}
export async function fetchPreviewTrend( environment: PreviewEnvironment, filters: MeterFilterValues = {}, signal?: AbortSignal): Promise<TrendRow[]> { const response = await environment.client.analysisQuery( { bucket_size: "1d", start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket"], measures: ["quantity"], filters, }, { signal } ) const byBucket = new Map<string, TrendRow>() for (const series of response.series) { const metric = series.dimensions?.metric if (metric !== "input_tokens" && metric !== "output_tokens") continue for (const point of series.points) { const row = byBucket.get(point.bucket) ?? { bucket: point.bucket, input_tokens: 0, output_tokens: 0, } row[metric] += Number(point.value) byBucket.set(point.bucket, row) } } return [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket))}
export async function loadPreviewRows( environment: PreviewEnvironment, query: TableQuery, shared: MeterFilterValues = {}): Promise<TableResult<PreviewRow>> { const response = await environment.client.usageQuery({ bucket_size: "1d", start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket", "model", "provider", "metric"], measures: ["quantity", "amount"], filters: mergeMeterFilters(query.filters, shared), limit: 500, }) const rows: PreviewRow[] = response.rows.map((row) => ({ bucket: String(row.dimensions.bucket), model: String(row.dimensions.model), provider: String(row.dimensions.provider), metric: String(row.dimensions.metric), quantity: Number(row.measures.quantity), amount: Number(row.measures.amount), })) if (query.sort) { const { key, direction } = query.sort rows.sort((a, b) => { const left = a[key as keyof PreviewRow], right = b[key as keyof PreviewRow] const order = typeof left === "number" && typeof right === "number" ? left - right : String(left).localeCompare(String(right)) return direction === "desc" ? -order : order }) } return { total: rows.length, rows: rows.slice( (query.page - 1) * query.pageSize, query.page * query.pageSize ), }}Installation
Section titled “Installation”npm install @meterry/component @meterry/component-react echarts echarts-for-reactnpm install @meterry/component @meterry/component-vue echarts vue-echartsFor default styles, optionally install @meterry/component-tailwindcss. For authentication, shared clients and theming, see Getting Started.
The demo deliberately uses 20-row API batches and 10-row UI pages. Moving to page
3 automatically fetches the next batch; returning to a cached page makes no request.
The component default API batch size is 500. Use the request counter below to observe fetching; switch to Aggregate to see the limit warning. The preview uses the current raw_event_id response field.
baseUrl: '' sends Subject API requests to same-origin /v1/.... Set it to your actual API origin or proxy prefix; /api/meter/subject-token is a separate application-owned endpoint, not a built-in Meter route. The preview makes no real network requests or persistent token writes.
import { MeterClientProvider, MeterUsageTable } from '@meterry/component-react'import type { TableColumn, UsageTableRow } from '@meterry/component'
// Current Meter detail responses use raw_event_id as their sole event identity.const eventColumns: TableColumn<UsageTableRow>[] = [ { key: 'raw_event_id', header: 'Event ID' }, { key: 'occurred_at', header: 'Occurred at', format: (v) => new Date(Number(v) * 1000).toLocaleString() }, { key: 'source', header: 'Source' },]
export function UsagePage() { return ( <MeterClientProvider tokenEndpoint="/api/meter/subject-token" options={{ baseUrl: '', credentials: 'include', tokenStorage: null }} > <MeterUsageTable source="events" columns={eventColumns} rangeDays={7} pageSize={20} /> </MeterClientProvider> )}tokenEndpoint performs a session-authenticated GET to your application backend,
which must return a Subject Token grant with access_token and optionally
expires_at/expires_in. It is not a tenant API-key endpoint. client takes
precedence over getJwt, which takes precedence over tokenEndpoint. Existing
client and getJwt integration still works. Provider options.fetch is used
for both the token endpoint and API requests. Use an authentication-scoped
tokenStorageKey and clear credentials when switching users/tenants, as described
in SubjectClient & Auth.
Provide the client in an ancestor’s setup:
<script setup lang="ts">import { provideMeterClient } from '@meterry/component-vue'import UsagePage from './UsagePage.vue'
provideMeterClient({ tokenEndpoint: '/api/meter/subject-token', options: { baseUrl: '', credentials: 'include', tokenStorage: null },})</script>
<template><UsagePage /></template>In the descendant UsagePage.vue:
<script setup lang="ts">import { MeterUsageTable } from '@meterry/component-vue'import type { TableColumn, UsageTableRow } from '@meterry/component'
const eventColumns: TableColumn<UsageTableRow>[] = [ { key: 'raw_event_id', header: 'Event ID' }, { key: 'source', header: 'Source' },]</script>
<template> <MeterUsageTable source="events" :columns="eventColumns" :range-days="7" :page-size="20" /></template>Alternatively pass an existing client directly to either framework component.
Data sources and completeness
Section titled “Data sources and completeness”| Source | Endpoint | Behavior |
|---|---|---|
usage (default) |
POST /v1/subject/usage/query |
Fetches one aggregate result and pages that result locally; generates columns from group_by and measures |
events |
GET /v1/subject/usage/events/details |
Automatically follows next_after_id using after_id, fetching batches only when required |
When a control toggles between events and usage, use <MeterUsageTable key={source} source={source} ... /> (Vue: :key="source") to remount the query-owned table. Reusing one instance can otherwise attempt to render old event rows through new aggregate accessors during the transition. The interactive preview uses a separate keyed instance per source.
Aggregate query example:
<MeterUsageTable source="usage" query={{ metrics: ['total_tokens'], group_by: ['bucket', 'model', 'metric'], measures: ['quantity'], filters: { provider: 'openai' }, }}/>The current aggregate API contract has no cursor. If an aggregate response
reaches batchSize, a visible warning states that more results may exist. Its
exact total remains unknown; the component does not invent cursors or silently
claim that the prefix is the entire result. Narrow the time range or filters to
reduce the result. This limit cannot be solved solely in the UI without backend
pagination support.
Events queries accept only start_time, end_time, metric and metrics.
Unsupported fields produce an actionable error rather than being ignored.
Compatibility: the default event ID column uses the old usage_event_id field. The current Meter API exposes only raw_event_id; use the custom columns above. This changes presentation, not the automatic pagination pipeline.
Event rows retain their original API fields (labels, metrics, etc.). Aggregate
rows expose both flat dimension/measure fields and the original nested
dimensions/measures objects, so custom TableColumn accessors and formatters
work without an application mapper.
Cursor paging, cache and totals
Section titled “Cursor paging, cache and totals”pageSizeis the UI page size;batchSizeis the API fetch size.- A UI page may span multiple API batches. Short/empty batches with a next cursor do not imply the end of the dataset.
- A fixed time window is used throughout a query session. Refresh starts a new session and recomputes relative dates.
- Cached pages do not refetch. An LRU row budget bounds memory; revisiting an evicted batch refetches it with its saved cursor and original time window.
- The backend detail response includes
total, but this component does not use it as its pagination authority. Until the final cursor is exhausted, the footer shows the page and loaded row count, not an exact total. Next-page availability follows the cursor. - Once exhaustion is confirmed, the exact discovered total and last page are shown.
- No arbitrary distant jumps or last-page button are offered for unknown totals.
- Concurrent requests for the same batch are deduplicated. Query changes/unmount abort pending work, and stale responses are ignored even if custom fetch ignores cancellation. Page-navigation failures retain the last successful page and offer Retry. Explicit Refresh, filter changes and query changes clear the old session/rows; if that new session fails, Retry starts it again without displaying old rows.
- Query identity uses content, not object reference. Inline equivalent queries and changed column/style objects do not reset the query cache.
Sorting and filters
Section titled “Sorting and filters”Neither wrapped endpoint declares server-side sorting. Therefore this component
disables sortable column controls, including sortable: true on custom
columns. It never presents a cached-prefix sort as a global sort. Use MeterTable
with a complete local dataset if local sorting is desired.
Aggregate dimension filters are forwarded to the service. The generated dimension
columns provide text filter inputs; the service decides matching semantics.
Only columns whose keys are in group_by can expose a filter. Events do not expose
column filters because their current request contract only supports time/metrics.
Changing a filter resets the page, cursor and cache. Query filters and column
filters merge, with column values taking precedence; clearing column filters
preserves base query.filters.
| Prop | Default | Description |
|---|---|---|
client |
Provider client | Explicit client takes precedence over context |
source |
usage |
usage or events |
query |
— | Source-specific API query, without limit/cursor |
rangeDays |
7 |
Default relative time range; explicit query timestamps take precedence |
columns |
Generated | Optional TableColumn<UsageTableRow>[]; format/accessor/cell slots supported |
pageSize |
20 |
Displayed rows per page |
pageSizeOptions |
[10, 25, 50] plus current size |
Page-size menu |
batchSize |
500 |
Requested API batch size, subject to the backend’s supported limit |
maxCachedRows |
10000 |
LRU budget; must be at least batchSize and pageSize |
refreshable |
true |
Show the built-in Refresh action |
title, caption, loading, empty, error, slots, messages |
Existing defaults | Same rendering customization as MeterTable |
className, classNames, unstyled |
Existing defaults | Same semantic classes and styling as MeterTable |
The default error UI includes Retry. A custom error renderer replaces it; custom
pagination slots replace the default pagination and refresh controls. New message
keys are cursorPaginationSummary, usageTableTruncated, retry, and refresh.
Vue component refs also expose refresh().
Headless use
Section titled “Headless use”import { createUsageTableController } from '@meterry/component'
const controller = createUsageTableController({ client, source: 'events' })await controller.load()await controller.setPage(2)// Optional framework-independent integration: subscribe/getState/getColumns.// Cleanup:controller.dispose()The controller implements the existing TableController<UsageTableRow> contract
and adds dispose(). state.pagination carries total: number | null,
loadedCount, hasNextPage, hasPreviousPage, and truncated. Legacy
state.total is the discovered count; custom cursor footers must consult
state.pagination.total before claiming an exact total. load() retries the last
requested page, while refresh() invalidates the query and restarts at page 1.
setData and global setSort are unsupported for this query-owned controller.
Backend contract versus wrapper support
Section titled “Backend contract versus wrapper support”The Subject Usage Details API supports more filters than the current automatic wrapper: the wrapper only accepts time and metric selectors, even though the endpoint also supports label/account/raw-ID/revocation filters. Use the client plus advanced MeterTable when those additional controls are required. The API supports up to 1000 events per request; 500 is the component default, not a universal backend maximum. With 20-row UI pages and 500-row batches, page 26 fetches the second batch automatically (provided a next cursor exists).
after_id is opaque. Never derive it from row position or assume it is a normalized usage-event ID. Count, page and continuation checks are not a snapshot across requests; concurrent ingestion or revocation may change results. A fixed time window does not provide transactional snapshot isolation. If an evicted batch no longer matches, the component asks the user to refresh.
For aggregate query fields, supported measures, the 31-day range limit and the current 1000-row backend maximum, see Subject Usage Query. Automatic tables do not make a limited aggregate response an all-data export.