Runtime usage
The usage index is where Apiome keeps what it learns from live traffic: how often each operation of a project is called, with what outcome, how fast, and by which consumer. Usage-weighted diff verdicts, drift reports and adoption charts all read it.
The index is built as counters, never as a request log. It stores numbers per operation and per hour, not the requests themselves, so it can never become a store of credentials or customer data.
The index, its retention and the ingest endpoint that fills it are available. The ready-made sources that call the endpoint for you (an OpenTelemetry receiver, a HAR upload, gateway logs) and creating a source from the app or the CLI come in later releases. Until then, a script or a log shipper can send usage events directly.
What the index stores
Every observed call is reduced at the edge to an apiome.usage.event/v1 event and then counted.
The counters are kept per project, source, environment, operation, status code, published version
and consumer:
| Counter | What it is |
|---|---|
| Calls and errors | How many calls there were, and how many answered 4xx or 5xx |
| Latency | A histogram with bounds 5, 10, 25, 50, 100, 250, 500, 1,000, 2,500, 5,000 and 10,000 ms, plus the total and the slowest call |
| Posture | How many calls carried a credential, carried a version marker or used HTTPS, and for how many the source could not tell |
| Bytes | Request and response body sizes |
| Fields | How often each field (a JSON Pointer such as /items/0/id) was present in a request or response, when the source reports field presence |
| Unmatched paths | Traffic on paths no published version declares, by inferred template (/users/{param}), with first and last seen |
| Ingest ledger | One row per batch a source sends: how many events were accepted, sampled out, unmatched or rejected |
| Unattributed clients | Per day, how many calls no binding recognised came from each client: the first word of its scrubbed user agent (checkout-web), or the HMAC its consumer_hint carried. These are the suggested consumers |
An operation is named the way the classified diff names it, for example
/paths/~1pets~1{petId}/get, so usage joins consumer contracts and the
blast radius directly.
What it never stores
| Data | Rule |
|---|---|
| Header names and values | Never. Only whether a credential header was present survives the edge |
| Request and response bodies | Never by default. A source may opt in to a small redacted sample (below) |
| Query parameter values | Never. Names are counted as fields (/parameters/query:<name>) when the source reports field presence; values are dropped before the event is built |
| Raw paths | Never. A path such as /users/alice@example.com is templated before it is counted |
| Client IP addresses and locations | Never. No column can hold one |
| Consumer identity | Only an HMAC computed at the edge, never the key, token or header it came from. For a call no binding recognises, the first word of its scrubbed user agent is kept too, and only when it is a plain name (letters, digits, ., _, -, at most 64 characters) that does not look like a credential |
The schema enforces this: no table has a column that can hold an address, free text or a body. Every text column is a short, pattern-checked identifier, so a header or a query string is refused.
Opt-in samples
A source can keep a redacted sample of exchanges for debugging. Samples are off by default and bounded on every side:
- at most 5% of calls, and only until an end date at most 7 days ahead;
- each sample is reduced by the same redactor as mock proxy capture, which removes credential headers, credential-shaped fields, tokens and personal data such as emails, then re-scanned for credentials, and not stored at all if anything is still flagged;
- at most 16 KiB each, and deleted after 7 days on every plan.
An event carries its exchange in a sample field. A batch that sends one while the source's sample
window is closed is refused with 422 usage-body-not-accepted.
Send usage events
Every source sends its traffic through one endpoint:
POST /v1/tenants/{tenant}/usage/ingest
Before you start
-
A usage source. Each source belongs to one project and sets its environment (
prodby default), its sampling rate, whether it reports field presence, and its sample window. Creating sources from the app or the CLI comes in a later release. Until then, an operator creates one in the database and passes its id to whoever runs the source:INSERT INTO apiome.usage_source (tenant_id, project_id, kind, environment, sampling_rate)SELECT p.tenant_id, p.id, 'otlp', 'prod', 1FROM apiome.projects p WHERE p.slug = 'payments-api'RETURNING id; -
A key with the
usage:ingestscope. Create a workspace API key with the Usage source preset (see API keys). That key can post usage batches and call nothing else. A full-access key also works. A signed-in session and agent keys are refused.
The request
Send a JSON batch of at most 5,000 events and 2 MiB. To compress it, send
Content-Encoding: gzip; the 2 MiB limit applies before and after decompression. Name the source
with the X-Apiome-Usage-Source header or with source_id in the body.
curl -X POST https://api.apiome.dev/v1/tenants/northwind/usage/ingest \
-H "X-API-Key: $APIOME_USAGE_KEY" \
-H "X-Apiome-Usage-Source: 6f1c9a52-4a5e-4c1b-9d0e-1f2a3b4c5d6e" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"observed_at": "2026-10-18T14:03:12.481Z",
"method": "GET",
"path": "/pets/42?limit=10",
"status": 200,
"latency_ms": 38,
"scheme": "https",
"auth_present": true,
"user_agent": "checkout-web/4.2"
}
]
}'
Each event is an apiome.usage.event/v1 event. observed_at (with a time zone), method, path,
status and latency_ms are required. request_bytes, response_bytes, scheme, host,
auth_present, version_hint, user_agent, consumer_hint and fields are optional.
environment and source_id default to the source's. An event with an unknown key, or a value out
of range (for example a latency_ms above 2,147,483,647), is rejected on its own. The rest of the batch still counts.
What happens to each event
The pipeline runs these five steps in order. An event stops at the first step that refuses it.
- Tier cap. Your plan's Events per day (table above). A batch that
would take the workspace over today's cap is refused whole with
429 usage-quota-exceeded.retryAt(and theRetry-Afterheader) says when the cap resets, at 00:00 UTC. The batch is still written to the ledger, as rejected. - Head sampling. A source with a sampling rate below 1 keeps a fixed share of events. The
choice hashes the source,
observed_at, the path and the status, so a retried batch keeps the same events. Counts are scaled up by the inverse rate, and the response saysbasis: sampled. - Scrubbing. Every string the index could keep is cleaned first:
- a path segment that looks like a credential (a known key or token shape, a bearer token, a
random-looking string), an email address or a UUID becomes
{param}, so/users/alice@example.com/ordersis counted as/users/{param}/orders; - the query string is split off before the event is built: parameter names are kept and values are discarded;
- the user agent is scrubbed of credentials and emails, then cut to 256 characters;
- a field pointer that names a credential is dropped.
- a path segment that looks like a credential (a known key or token shape, a bearer token, a
random-looking string), an email address or a UUID becomes
- Template matching. The path is matched against the project's published versions, newest
first, the same way Import Pact file resolves a Pact file:
/pets/42lands onGET /pets/{petId}. A match counts toward that operation and version. Traffic that no published version declares, including an operation that only a draft declares, is counted as unmatched under an inferred template, where numbers, UUIDs and hashes become{param}. With field presence on, the call's query names andfieldsare counted too. A matched call is then attributed to a consumer when one of the project's bindings recognises it; otherwise it is counted under anonymous. It is never dropped. - Ledger. One ledger row per batch records how many events were accepted, sampled out, unmatched or rejected, and how many strings scrubbing rewrote. The response echoes it.
Scrubbing always applies, even when your workspace's import secret-scrub policy is set to warn only: that setting covers documents you upload, not tokens that came along in live traffic.
The response
{
"batchId": "0c4e…",
"sourceId": "6f1c9a52-4a5e-4c1b-9d0e-1f2a3b4c5d6e",
"receivedAt": "2026-10-18T14:03:15.002Z",
"received": 1, "accepted": 1, "attributed": 1, "sampledOut": 0, "unmatched": 0, "rejected": 0,
"scrubFindings": 0,
"basis": "complete", "samplingRate": 1.0,
"samples": {"stored": 0, "dropped": 0},
"rejections": [], "rejectionsTruncated": false
}
attributed counts the accepted events a consumer binding recognised. The rest of accepted is
anonymous. rejections lists up to 100 rejected events by index and reason, never by content:
| Reason | Meaning |
|---|---|
invalid-event | The event does not match apiome.usage.event/v1 |
source-mismatch | The event names a different source_id from the batch |
environment-reserved | mock is reserved for the mock runtime's own source |
template-unstorable | The path's template is longer than 1,024 characters, or has a character the index cannot store |
day-finalized | The event's UTC day is already final and takes no new counts |
quota-exceeded | The batch was over the daily cap |
Errors
| Status | Code | When |
|---|---|---|
400 | usage-batch-invalid, usage-source-required | The body is not a batch, the encoding is not gzip, or no source is named |
403 | usage-ingest-key-required, usage-source-disabled | Not an API key, or the source is switched off |
404 | usage-source-not-found | No such source in this workspace |
413 | usage-batch-too-large | Over 2 MiB or 5,000 events |
422 | usage-body-not-accepted | A sample was sent while the source's sample window is closed |
429 | usage-quota-exceeded | Over today's events-per-day cap |
Attribute traffic to consumers
Traffic carries headers, API keys, JWT claims and user agents, not the consumer handles of the
consumer registry. A binding tells the index how to
recognise a consumer, so a share such as "this change affects billing-service at 38%" can name
it. Add bindings from a project's Consumers page (Bindings and traffic) or with the API.
| Kind | Name | Value |
|---|---|---|
| Header | The header, for example x-client-id | The header's value for this consumer |
| API key | The header carrying the key, for example x-api-key | The consumer's key |
| JWT claim | The claim, for example azp | The claim's value for this consumer |
| User agent | A pattern such as billing-service/* (* and ? are wildcards, case is ignored) | None |
A bound value is never stored. The value is accepted once, when the binding is created, and hashed (HMAC-SHA256 under a key derived for your workspace) before anything is written. No page or response shows it again, not even as a hash. A user-agent pattern must name at least three literal characters, so it cannot match every caller.
How a call is matched. An event's consumer_hint ({"kind": "api_key", "hmac": "…"}) is
compared with the hashed bindings first. If none matches, its scrubbed user agent is compared with
the patterns, most specific first. A call that matches nothing is counted under the reserved
consumer anonymous, so no traffic is ever dropped and no share silently loses its unattributed
part. The handle anonymous cannot be used for a real consumer.
One value names one consumer. A workspace can bind a kind, name and value only once
(409 consumer-binding-exists). Two bindings of the same consumer, such as an API key and a user
agent, add up to that one consumer. A consumer can hold up to 50 bindings.
Deleting a binding stops it matching from the next batch on. Calls it already attributed stay attributed to the consumer, so reports about the past do not change.
Declared vs observed
For each consumer, Apiome compares the operations its contract declares with the operations its bindings show it calls, by UTC day over the last 30 days (up to 395):
| Side | Meaning |
|---|---|
| Declared and used | In the contract and called |
| Declared, unused | In the contract, never called in the window: a stale declaration, or a consumer whose traffic is not bound yet |
| Used, undeclared | Called, but not in the contract: the dependency a breaking-change check would otherwise miss |
A consumer with no contract reports everything it calls as used, undeclared. “Add to contract” on one of those operations stores a new contract revision: the current declaration plus that operation. The report reads the daily counters, so a call appears after the next usage sweep rolls its day up.
Bindings with the API
All four calls take the consumer_contracts permissions of the consumer registry:
| Call | Permission |
|---|---|
GET /v1/tenants/{t}/projects/{p}/consumers/{c}/bindings | consumer_contracts:view (add ?include_deleted=true for deleted bindings) |
POST /v1/tenants/{t}/projects/{p}/consumers/{c}/bindings | consumer_contracts:create |
DELETE /v1/tenants/{t}/projects/{p}/consumers/{c}/bindings/{id} | consumer_contracts:delete |
GET /v1/tenants/{t}/projects/{p}/consumers/{c}/runtime?days=30 | consumer_contracts:view. Use anonymous as {c} for unattributed traffic |
curl -X POST https://api.apiome.dev/v1/tenants/northwind/projects/pets/consumers/billing-service/bindings \
-H "Authorization: Bearer $APIOME_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind": "api_key", "name": "x-api-key", "value": "ak_live_…"}'
The consumer list (GET …/consumers) carries each consumer's counts in runtime, over
?runtime_days= (default 30), and consumer.runtime_last_seen_at says when traffic was last
attributed to it.
| Status | Code | When |
|---|---|---|
400 | consumer-binding-invalid | A malformed header or claim name, or a user-agent pattern that is too broad |
404 | consumer-binding-not-found | No live binding with that id on this consumer |
409 | consumer-binding-exists, consumer-binding-limit | The value is already bound in this workspace, or the consumer has 50 bindings |
503 | consumer-binding-key-unavailable | The server has no APIOME_LINKED_ACCOUNT_TOKEN_ENCRYPTION_KEYS, which the workspace key is derived from |
How long it is kept
Hourly counters are rolled up into daily counters, and unattributed client counts are kept as long as the daily counters. The plan's seat limits set how long each is kept (see Licenses):
| Plan | Hourly counters | Daily counters, fields, unmatched paths and ledger | Events per day |
|---|---|---|---|
| Free | 7 days | 90 days | 100,000 |
| Paid | 30 days | 395 days | 10,000,000 |
| Sponsor | 90 days | 730 days | 100,000,000 |
Hourly counters are deleted only after their day has been rolled up and finalized, so the daily numbers always match what they were built from. A day is finalized a few hours after it ends (6 by default). After that it is never recomputed and takes no new counts.
For operators
The REST API runs the usage sweep in the background. Each run rolls up open days, finalizes closed ones, deletes what is past retention, then refreshes the dependency graph's runtime edges. Only one replica sweeps at a time.
| Variable | Default | Effect |
|---|---|---|
APIOME_USAGE_SWEEP_INTERVAL_SECONDS | 3600 | Time between sweeps; 0 turns the background sweep off |
APIOME_USAGE_FINALIZE_GRACE_HOURS | 6 | Hours after a UTC day ends before it is final. Must exceed the longest delay between a call and its ingest |
APIOME_USAGE_SWEEP_BATCH_SIZE | 10000 | The most rows each sweep deletes per table; the rest go on the next run |
To run one sweep yourself, for example from cron with the background sweep off:
docker compose exec rest python -m app.usage_sweep --grace-hours 6 --batch-size 10000
It prints what it did as JSON, or {"skipped": true} when another replica is already sweeping.
Where next
- API keys — the
usage:ingestscope - Dependency graph
- Projects → Consumers
- Licenses