MeterUsageChart
Automatic query, aggregation and rendering for line, area, bar, pie, doughnut and
registered custom chart types. No useEffect, loader, row mapping, pivot or manual
series definitions are needed. The existing MeterChart data-only API is 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 { MeterUsageChart } from "@meterry/component-react"import type { MeterThemeInput } from "@meterry/component"import { METRICS, type PreviewEnvironment } from "./preview-data"import { styleProps, previewSeriesRegistry, RequestSummary,} from "./preview-shared"
export function AutomaticChartPreview({ environment, theme,}: { environment: PreviewEnvironment theme: MeterThemeInput}) { const [type, setType] = useState("line") const [limited, setLimited] = useState(false) const [allowPartial, setAllowPartial] = useState(false) const category = type === "pie" || type === "doughnut" return ( <div className="docs-preview-stack" data-testid="automatic-chart-preview"> <div className="docs-preview-actions"> <label className="docs-preview-chart-select"> Automatic chart type <select value={type} onChange={(event) => setType(event.target.value)} > {["line", "area", "bar", "pie", "doughnut"].map((value) => ( <option key={value} value={value}> {value} </option> ))} </select> </label> <label className="docs-preview-chart-select"> <input type="checkbox" checked={limited} onChange={(event) => setLimited(event.target.checked)} /> Small result limit </label> <label className="docs-preview-chart-select"> <input type="checkbox" checked={allowPartial} onChange={(event) => setAllowPartial(event.target.checked)} /> Allow partial chart </label> </div> <MeterUsageChart {...styleProps} seriesRegistry={previewSeriesRegistry} type={type} theme={theme} height={280} title={ category ? "Input token share by model" : "Daily input and output tokens" } limit={limited ? 3 : 500} allowPartial={allowPartial} query={{ start_time: environment.startTime, end_time: environment.endTime, metrics: category ? ["input_tokens"] : [...METRICS], group_by: category ? ["model"] : ["bucket"], }} /> <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"> Refresh bypasses the result cache. Errors retain the previous chart. </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.
Switch between line, area and bar without refetching. Pie and doughnut share the same category result. Requests and completed results are cached per shared client.
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, MeterUsageChart } from '@meterry/component-react'
export function UsagePage() { return ( <MeterClientProvider tokenEndpoint="/api/meter/subject-token" options={{ baseUrl: '', credentials: 'include', tokenStorage: null }}> <MeterUsageChart type="line" query={{ metrics: ['input_tokens', 'output_tokens'], group_by: ['bucket', 'model'] }} rangeDays={7} /> <MeterUsageChart type="doughnut" query={{ metrics: ['total_tokens'], group_by: ['model'] }} rangeDays={7} /> </MeterClientProvider> )}Configure the same authentication-scoped client/provider as MeterUsageTable.
Explicit client takes precedence over the provider client. Token callbacks and
application token endpoints remain application-owned; tenant API keys never belong
in browser code.
Provide the client in an ancestor setup:
import { provideMeterClient } from '@meterry/component-vue'provideMeterClient({ tokenEndpoint: '/api/meter/subject-token', options: { baseUrl: '', tokenStorage: null } })In a descendant:
<script setup lang="ts">import { MeterUsageChart } from '@meterry/component-vue'</script><template> <MeterUsageChart type="area" :query="{ metrics: ['total_tokens'], group_by: ['bucket', 'model'] }" /> <MeterUsageChart type="pie" :query="{ metrics: ['total_tokens'], group_by: ['model'] }" /></template>Vue component refs also expose refresh().
Aggregation rules
Section titled “Aggregation rules”Trend charts
Section titled “Trend charts”line, area and bar default to trend mode. The component queries
analysis/query, adds bucket and metric to group_by, and generates one
series for each metric/measure/non-time-dimension combination. For example,
input_tokens + output_tokens grouped by model produces independent input and
output series for every model.
Repeated points for the same complete series identity and same bucket are summed, never overwritten. Different models/providers/metrics are not merged. Buckets are chronologically sorted; the component zero-fills each series over the union of returned buckets. It does not synthesize entirely absent intervals itself. The current Meter backend already pads existing series across the requested window, so those returned buckets are retained; backend bucket/timezone behavior remains authoritative. Numeric epoch buckets are formatted in the requested timezone (UTC by default).
A single chart selects one additive measure (quantity by default or amount).
Set measure="amount" for cost charts. If query.measures contains multiple
measures, select one explicitly with measure; mixing units is rejected. Average,
percentile and distinct-count measures are not summed as if they were additive.
Multiple metrics stay in separate series. No cross-metric grand total is shown,
so including input_tokens, output_tokens and total_tokens cannot silently
produce a double-counted total.
Category distributions
Section titled “Category distributions”pie and doughnut always use category mode. It queries usage/query, groups by
query.group_by (default model) and generates one value series. Returned rows
with the same requested category dimensions are summed, including rows split by
extra response dimensions. Categories sort by descending value.
Exactly one metric and one measure are required. Ambiguous multi-metric
requests are rejected instead of combining overlapping totals or unlike units.
Negative category totals are rejected; zero-valued categories are omitted so an
all-zero pie does not display fabricated equal shares. The current usage/query
contract does not support timezone; category requests with timezone are rejected
rather than silently dropping it. Explicit time boundaries still apply.
Custom chart types
Section titled “Custom chart types”Pass a registered type and optional seriesRegistry. Custom cartesian types
use trend mode; registered polar types use category mode. mode="category" can
also render grouped category totals as bars. Existing chart builders and
slots.chart({ model, option }) continue to work with automatically generated data.
Completeness and result limits
Section titled “Completeness and result limits”Neither wrapped aggregate endpoint declares a cursor or definitive completeness flag. A response at the requested limit may be incomplete. Trend detection counts returned points before merging/zero filling; category detection counts returned rows before summing categories. Trend checks are conservative, including series whose time grids were filled by the backend.
Potentially truncated charts are hidden by default, with a visible notice to
narrow the range or filters. This prevents a partial pie from looking like a full
account-wide distribution. allowPartial explicitly permits rendering the prefix
while keeping the notice. No cursor is invented and no automatic all-data export
is claimed. Both flags and source counts are available on the headless result.
Cache, lifecycle and errors
Section titled “Cache, lifecycle and errors”- Shared client + normalized query + mode + TTL form a request identity. Default completed-result cache TTL is 30 seconds, with at most 32 retained completed entries per client. Relative time windows stay anchored to the original cached request during that TTL; refresh starts a fresh window.
- Simultaneous identical queries share one request. An unmount/query change only aborts shared work when no other consumer still needs it.
- Inline equivalent query objects, type changes within the same mode, theme, height, formatters and styling do not refetch.
- Errors are not cached. Retry works without application logic; failed refreshes retain the last successful chart with a visible error. Superseded responses are ignored even when a custom fetch implementation disregards its signal.
cacheTimeMs={0}disables completed-result reuse but still shares in-flight work.client.clearToken()also invalidates this client’s chart query cache. If an external token provider changes identity without callingclearToken(), create a new authentication-scoped client or callclearUsageChartCache(client)and remount/refetch active charts. Do not share a client/cache across auth scopes.
| Prop | Default | Description |
|---|---|---|
client |
Provider client | Optional explicit shared client |
type |
line |
Built-in or registered chart type |
mode |
Inferred | trend or category; pie/doughnut and registered polar types always use category mode |
query |
— | Partial API query, with snake_case fields and no limit |
rangeDays |
7 |
Relative time range; explicit query timestamps take precedence |
measure |
First query measure or quantity |
One additive quantity or amount measure |
limit |
500 |
Aggregate API result limit, subject to backend constraints |
cacheTimeMs |
30000 |
Completed-result cache TTL |
allowPartial |
false |
Explicitly render potentially truncated data |
refreshable |
true |
Built-in Refresh action |
formatValue |
Default number formatter | Optional value formatter; does not affect fetching or aggregation |
title, caption, height, showLegend, showGrid, theme |
Existing chart defaults | Presentation only; category charts show legends by default |
loading, error, slots, messages |
Existing defaults | Custom loading/error rendering and header/chart slots |
className, classNames, unstyled |
Existing defaults | Existing semantic styles |
Custom error renderers replace the built-in Retry UI. The new localized notice
key is usageChartTruncated; existing refresh, retry, usageLoading and chart
messages are reused. No data, x or series props are required.
Headless API
Section titled “Headless API”import { createUsageChartController } from '@meterry/component'const controller = createUsageChartController({ client, query: { metrics: ['total_tokens'] } })const unsubscribe = controller.subscribe((state) => console.log(state))await controller.load()await controller.refresh()unsubscribe()controller.dispose()UsageChartState contains status, result and error. UsageChartResult contains
mode, measure, data, series, sourceCount and truncated; its rows have
label, optional raw bucket, and values. For direct model rendering use
createChartModel({ type, data: result.data, x: 'label', series: result.series }).
Headless callers decide how to present or suppress a possibly truncated result.
Pure helpers createUsageChartRequest, aggregateUsageTrend,
aggregateUsageCategories, resolveUsageChartMode and
serializeUsageChartQuery are also exported. MeterChart accepts optional
request: ChartRequestState plus loading/error rendering for consumers who
already own request state but want the same lifecycle UI.
Meter service constraints
Section titled “Meter service constraints”The Analysis API has a maximum of four grouping dimensions (including the automatically added bucket and metric), a 31-local-calendar-date range constraint, minimum bucket sizes and a 1000-row maximum limit. rangeDays does not bypass validation, and limit is not an event batch size. Use Usage Query for the category contract; it has no timezone or cursor. The component default metric is tokens; examples select metrics explicitly, and those names must exist in your project and be allowed by the Subject Token.
For measure="amount", filter to a comparable currency and unit before aggregation. Display formatting does not convert currencies, and JavaScript-number chart aggregation is not an accounting or invoice-calculation API. The existing MeterUsage total/summary semantics are unchanged; this new component does not silently replace it.