Skip to main content

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.

What is available today

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:

CounterWhat it is
Calls and errorsHow many calls there were, and how many answered 4xx or 5xx
LatencyA 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
PostureHow many calls carried a credential, carried a version marker or used HTTPS, and for how many the source could not tell
BytesRequest and response body sizes
FieldsHow 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 pathsTraffic on paths no published version declares, by inferred template (/users/{param}), with first and last seen
Ingest ledgerOne row per batch a source sends: how many events were accepted, sampled out, unmatched or rejected
Unattributed clientsPer 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​

DataRule
Header names and valuesNever. Only whether a credential header was present survives the edge
Request and response bodiesNever by default. A source may opt in to a small redacted sample (below)
Query parameter valuesNever. Names are counted as fields (/parameters/query:<name>) when the source reports field presence; values are dropped before the event is built
Raw pathsNever. A path such as /users/alice@example.com is templated before it is counted
Client IP addresses and locationsNever. No column can hold one
Consumer identityOnly 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 (prod by 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', 1
    FROM apiome.projects p WHERE p.slug = 'payments-api'
    RETURNING id;
  • A key with the usage:ingest scope. 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.

  1. 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 the Retry-After header) says when the cap resets, at 00:00 UTC. The batch is still written to the ledger, as rejected.
  2. 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 says basis: sampled.
  3. 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/orders is 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.
  4. 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/42 lands on GET /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 and fields are 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.
  5. 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:

ReasonMeaning
invalid-eventThe event does not match apiome.usage.event/v1
source-mismatchThe event names a different source_id from the batch
environment-reservedmock is reserved for the mock runtime's own source
template-unstorableThe path's template is longer than 1,024 characters, or has a character the index cannot store
day-finalizedThe event's UTC day is already final and takes no new counts
quota-exceededThe batch was over the daily cap

Errors​

StatusCodeWhen
400usage-batch-invalid, usage-source-requiredThe body is not a batch, the encoding is not gzip, or no source is named
403usage-ingest-key-required, usage-source-disabledNot an API key, or the source is switched off
404usage-source-not-foundNo such source in this workspace
413usage-batch-too-largeOver 2 MiB or 5,000 events
422usage-body-not-acceptedA sample was sent while the source's sample window is closed
429usage-quota-exceededOver 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.

KindNameValue
HeaderThe header, for example x-client-idThe header's value for this consumer
API keyThe header carrying the key, for example x-api-keyThe consumer's key
JWT claimThe claim, for example azpThe claim's value for this consumer
User agentA 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):

SideMeaning
Declared and usedIn the contract and called
Declared, unusedIn the contract, never called in the window: a stale declaration, or a consumer whose traffic is not bound yet
Used, undeclaredCalled, 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:

CallPermission
GET /v1/tenants/{t}/projects/{p}/consumers/{c}/bindingsconsumer_contracts:view (add ?include_deleted=true for deleted bindings)
POST /v1/tenants/{t}/projects/{p}/consumers/{c}/bindingsconsumer_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=30consumer_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.

StatusCodeWhen
400consumer-binding-invalidA malformed header or claim name, or a user-agent pattern that is too broad
404consumer-binding-not-foundNo live binding with that id on this consumer
409consumer-binding-exists, consumer-binding-limitThe value is already bound in this workspace, or the consumer has 50 bindings
503consumer-binding-key-unavailableThe 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):

PlanHourly countersDaily counters, fields, unmatched paths and ledgerEvents per day
Free7 days90 days100,000
Paid30 days395 days10,000,000
Sponsor90 days730 days100,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.

VariableDefaultEffect
APIOME_USAGE_SWEEP_INTERVAL_SECONDS3600Time between sweeps; 0 turns the background sweep off
APIOME_USAGE_FINALIZE_GRACE_HOURS6Hours after a UTC day ends before it is final. Must exceed the longest delay between a call and its ingest
APIOME_USAGE_SWEEP_BATCH_SIZE10000The 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​