Skip to main content

Usage and accounting

The manager maintains a durable per-tenant / per-pool / per-user metering ledger built on the daily rollup store. When QOD_TELEMETRY_STORE=postgres (the default), each rollup tick writes one row per (tenant, pool, username, UTC day) into qodstate_stmt_rollup; the usage API reads and aggregates those rows. When QOD_TELEMETRY_STORE=none, nothing is metered, GET /api/usage returns empty, and the Usage page is hidden from the navigation.

The Prometheus counters (statements_total and statement_duration_seconds, both tagged by tenant, pool, and status) provide the real-time scrape view of the same quantities. The usage API and admin UI page are the durable ledger view, suited for monthly billing exports and capacity planning over longer windows.

The metering unit and its caveats

engineMs is the summed statement execution time as measured by the manager: wall-clock duration from receiving the FlightSQL execute call to the last byte streamed back to the client. It is not node CPU time; it includes routing latency and network round-trips to the node.

A few caveats apply before using these numbers for billing:

  • Current-day lag. The current day's totals are complete only up to the last rollup tick. With the default rollup interval (QOD_ROLLUP_INTERVAL_SEC=300), the current day may lag by up to 5 minutes.
  • Journal overflow undercounts. The event journal that feeds statement recording is a bounded in-process queue. Under extreme burst load, offers that cannot be enqueued are dropped and counted in qod_journal_dropped_total{table="stmt_history"}. Dropped rows never reach the rollup, so the ledger can undercount during sustained overload. This counter is the integrity signal: if it is non-zero, the affected period undercounts by that amount.
  • Best-effort, not transactional. The ledger is best-effort measurement. It is not a transactional accounting system. A manager restart during rollup computation, or a Postgres write failure, can cause a bucket to be partially computed or missing; the watermark-based rollup will recompute it on the next tick if the raw rows are still available.
  • Denied and error statements. Statements that never reached a node (admission denials, no-node, no-pool) are counted in denied or errors with near-zero engineMs. They appear in totals but do not contribute meaningfully to engine time.

API

GET /api/usage returns aggregated daily rollup data over a requested period. The endpoint requires authentication (either the X-API-Key header or a qod_session cookie from the admin UI). The examples below use the qod CLI; they assume qod login has stored a session, or QOD_API_KEY is set for CI scripts.

# Month-to-date usage per tenant (defaults: current calendar month UTC, group-by=tenant)
qod usage

# June 2026, per pool, one tenant
qod usage --from 2026-06-01T00:00:00Z --to 2026-07-01T00:00:00Z --group-by pool --tenant acme

# Per-user accounting for a billing export
qod usage --from 2026-06-01T00:00:00Z --to 2026-07-01T00:00:00Z --group-by user

Query parameters

ParameterDescription
fromISO-8601 instant (e.g. 2026-06-01T00:00:00Z). Start of the period, inclusive. Defaults to the first instant of the current calendar month (UTC).
toISO-8601 instant. End of the period, exclusive (half-open [from, to) interval). Defaults to the first instant of the following calendar month (UTC).
groupByGrouping dimension: tenant (default), pool, or user. Returns 400 invalid_group_by for any other value.
tenantFilter by tenant id. Superusers can specify any tenant; tenant admins are silently pinned to their own tenant regardless of this value.
poolFilter by pool key. Combined with tenant to narrow further.

Invalid ISO-8601 values for from or to return 400 invalid_time.

Response fields

{
"from": "2026-02-01T00:00:00Z",
"to": "2026-07-01T00:00:00Z",
"groupBy": "pool",
"dataStart": "2026-04-17T00:00:00Z",
"groups": [
{
"tenant": "acme",
"pool": "bi",
"username": null,
"statements": 18432,
"errors": 12,
"denied": 5,
"engineMs": 924000,
"days": [
{ "day": "2026-06-01T00:00:00Z", "statements": 830, "errors": 0, "engineMs": 41000 }
]
}
]
}
FieldTypeNotes
fromstringPeriod start as ISO-8601 UTC instant
tostringPeriod end as ISO-8601 UTC instant
groupBystringThe grouping dimension used: tenant, pool, or user
dataStartstring or nullThe oldest daily bucket present in the ledger (across all retained data visible to your tenant scope, not just the query window). When dataStart is later than from, the requested range extends before the retention horizon and the period's leading edge is incomplete.
groupsarrayOne entry per group key (per tenant for groupBy=tenant, per tenant and pool for pool, per tenant and user for user) with non-zero activity in the period. Sorted by engineMs descending.
groups[].tenantstringTenant id
groups[].poolstring or nullPool key; null when groupBy=tenant
groups[].usernamestring or nullUsername; null when groupBy is not user
groups[].statementsintegerTotal statements in the period
groups[].errorsintegerStatements whose status is not ok or denied (transient, permanent, no-node, no-pool, pin-lost)
groups[].deniedintegerStatements whose status is denied (blocked by the ACL validator)
groups[].engineMsintegerSummed execution time in milliseconds
groups[].daysarrayPer-day breakdown, one entry per UTC day bucket with non-zero statements
groups[].days[].daystringDay bucket start as ISO-8601 UTC instant
groups[].days[].statementsintegerStatement count for that day
groups[].days[].errorsintegerError count for that day
groups[].days[].engineMsintegerSummed engine time for that day in milliseconds

Note that per-day entries carry errors but not denied. The denied total is available at the group level only.

CSV column contract

The admin UI's Download CSV button produces a client-side CSV. There is no server-side CSV endpoint. The column order depends on the active groupBy:

groupByColumns
tenanttenant,statements,errors,denied,engine_ms
pooltenant,pool,statements,errors,denied,engine_ms
usertenant,user,statements,errors,denied,engine_ms

The absent dimension columns (pool, user) are omitted for dimensions that are not the active grouping. The filename is qod-usage-<from-date>-<to-date>.csv.

Retention

The manager runs an hourly background purge that deletes daily rollup rows older than QOD_USAGE_RETENTION_DAYS:

Env varDefaultEffect
QOD_USAGE_RETENTION_DAYS400Delete daily rollup buckets older than N days; 0 = keep forever

The default of 400 covers a full billing year (365 days) plus a safety margin. The purge runs on the same hourly tick as the audit and history purges; in HA mode it runs only on the singleton leader.

When the requested from date is before the retention horizon, the API returns whatever buckets remain and sets dataStart to the oldest bucket present. The UI marks this truncated edge with a note "Data starts YYYY-MM-DD (older buckets purged)."

Feeding external billing

The JSON API is the integration surface for external billing systems. A typical monthly billing pipeline:

# Extract a closed billing month (half-open interval, UTC)
qod --json usage --from 2026-06-01T00:00:00Z --to 2026-07-01T00:00:00Z --group-by user \
| jq -r '["tenant","user","statements","errors","denied","engine_ms"],
(.groups[] | [.tenant, (.username // ""), .statements, .errors, .denied, .engineMs])
| @csv'

Use the per-day days arrays if you need sub-monthly granularity (for example, to detect spikes or to reconcile a partial month in a multi-system environment).

Rows and bytes transferred are not metered. Metering them would require counting Arrow batches on the hot statement path and is out of scope.

Scoping rules

The scoping rules mirror the statement history endpoints:

  • Superusers and static-key callers see all tenants. The tenant and pool filter parameters work as passed.
  • Tenant admins are pinned to their manageable tenants. A ?tenant= parameter is accepted but silently intersected with the admin's own tenants; specifying another tenant's name does not return an error and does not reveal its existence.

The none off switch

Set QOD_TELEMETRY_STORE=none to disable all telemetry:

  • Every rollup write is a no-op, so no daily buckets are created.
  • GET /api/usage returns an empty groups list.
  • The rollup job, hourly purge duties, and journal fiber are not started.
  • The Usage entry is hidden from the admin UI navigation. A deep link to the Usage page shows a "telemetry is disabled" message.

The postgres and none values are the only accepted values for QOD_TELEMETRY_STORE. Any other value is refused at startup.