docs/architecture/usage-telemetry.md
Operator + developer guide to the gateway's per-request usage telemetry pipeline.
Implements the requirements in docs/brainstorms/2026-04-24-axiom-api-observability-requirements.md.
Every inbound API request that hits createDomainGateway() emits one structured
event to Axiom describing who called what, how it was authenticated,
what it cost, and how it was served. Deep fetch helpers
(fetchJson, cachedFetchJsonWithMeta) emit a second event type per upstream
call so customer × provider attribution is reconstructible.
It is observability only — never on the request-critical path. The whole
sink runs inside ctx.waitUntil(...) with a 1.5s timeout, no retries, and a
circuit breaker that trips on 5% failure ratio over a 5-minute window.
Two event types in dataset wm_api_usage:
request (one per inbound request)| Field | Example | Notes |
|---|---|---|
event_type | "request" | |
request_id | "req_xxx" | from x-request-id or generated |
route | /api/market/v1/analyze-stock | |
domain | "market" | strips leading vN for /api/v2/<svc>/… |
method, status | "GET", 200 | |
duration_ms | 412 | wall-clock at the gateway |
req_bytes, res_bytes | response counted only on 200/304 GET | |
customer_id | Clerk user ID, org ID, enterprise slug, or widget key | null only for anon |
principal_id | user ID or hash of API/widget key | never the raw secret |
auth_kind | clerk_jwt | user_api_key | enterprise_api_key | widget_key | anon | |
tier | 0 free / 1 pro / 2 api / 3 enterprise | 0 if unknown |
cache_tier | fast | medium | slow | slow-browser | static | daily | no-store | only on 200/304 |
ip | "203.0.113.7" | Cloudflare client IP only when the edge-proof header is valid; otherwise Vercel's peer IP |
country | "US" | Cloudflare client country only when edge transit is proven; otherwise Vercel connection country |
ip_city, ip_region | "Johannesburg", "WC" | Vercel connection/edge geography, not verified client location |
execution_region | "iad1" | Vercel execution region |
execution_plane | "vercel-edge" | |
origin_kind | api-key | oauth | browser-same-origin | browser-cross-origin | null | derived from headers by deriveOriginKind() — mcp and internal-cron exist in the OriginKind type for upstream/future use but are not currently emitted on the request path |
ua_hash | SHA-256 of the UA | hashed so PII doesn't land in Axiom |
sentry_trace_id | "abc123…" | join key into Sentry |
reason | ok | origin_403 | rate_limit_429 | rate_limit_429_endpoint | rate_limit_429_global | rate_limit_429_direct_llm | rate_limit_degraded | preflight | auth_401 | auth_403 | tier_403 | Scoped 429 reasons identify the rejecting limiter directly; auth_* distinguishes auth-rejection paths from genuine successes when filtering on status alone is ambiguous |
upstream (one per outbound fetch from a request handler)| Field | Example |
|---|---|
request_id | links back to the parent |
provider, host | "yahoo-finance", "query1.finance.yahoo.com" |
operation | logical op name set by the helper |
status, duration_ms, request_bytes, response_bytes | |
cache_status | miss | fresh | stale-while-revalidate | neg-sentinel |
customer_id, route, tier | inherited from the inbound request via AsyncLocalStorage |
A non-exhaustive list — copy-paste APL queries are in the Analysis section below.
RPC_CACHE_TIER)auth_kind=anon × country × route)upstream join request on request_id)execution_region × route) ┌─────────────────────────────────────────────────────┐
│ Vercel Edge handler │
│ │
request ──► │ createDomainGateway() │
│ auth resolution → usage:UsageIdentityInput │
│ runWithUsageScope({ ctx, customerId, route, … }) │
│ └─ user handler ── fetchJson / cachedFetch... ─┼─► upstream
│ (reads scope, emits │ API
│ upstream event) │
│ emitRequest(...) at every return point ──────────┼────► Axiom
│ └─ ctx.waitUntil(emitUsageEvents(...)) │ wm_api_usage
└─────────────────────────────────────────────────────┘
Code map:
| Concern | File |
|---|---|
| Gateway emit points + identity accumulator | server/gateway.ts |
| Identity resolver (pure) | server/_shared/usage-identity.ts |
| Event shapes, builders, Axiom sink, breaker, ALS scope | server/_shared/usage.ts |
| Upstream-event emission from fetch helpers | server/_shared/cached-fetch.ts, server/_shared/fetch-json.ts |
Key invariants:
Request, Response, or untyped objects, so future field additions can't
leak by structural impossibility.emitRequest() fires at every gateway return path — origin block,
OPTIONS, 401/403/404/405, rate-limit 429, ETag 304, success 200, error 500.
Adding a new return path requires adding the emit, or telemetry coverage
silently regresses.principal_id is a hash for secret-bearing auth (API key, widget key)
so raw secrets never land in Axiom.console.warn.Two env vars control the pipeline. Both are independent of every other system.
| Var | Required for | Behavior when missing |
|---|---|---|
USAGE_TELEMETRY | Emission | Set to 1 to enable. Anything else → emission is a no-op (zero network calls, zero allocations of the event payload). |
AXIOM_API_TOKEN | Delivery | Events build but sendToAxiom short-circuits to a 1%-sampled [usage-telemetry] drop { reason: 'no-token' } warning. |
Vercel project setup:
wm_api_usage (the constant in
server/_shared/usage.ts:18; rename if you want a different name).xaat-… value.USAGE_TELEMETRY=1
AXIOM_API_TOKEN=xaat-...
The paid-plan notification scanner reads usage from the same observability surfaces but is not on the request path. It runs as a Convex internal cron and writes only compact rollups/notices to Convex; raw request logs stay in Axiom and raw limiter counters stay in Redis.
| Source | Env vars | Used for | Missing behavior |
|---|---|---|---|
| Axiom query API | AXIOM_QUERY_TOKEN or AXIOM_API_TOKEN (AXIOM_QUERY_URL optional) | API daily requests and API sustained burst rollups from wm_api_usage | Scanner reports missing_axiom_query_token; it does not assume zero usage |
| Upstash Redis | UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN | Existing Pro MCP daily counters | Scanner reports missing_upstash_credentials; it does not assume zero usage |
| MCP limiter-hit telemetry | mcp.rate_limit_hit log event from api/mcp/auth.ts | MCP sustained burst notices | No notice is emitted without durable hit buckets |
| Resend | RESEND_API_KEY, optional PLAN_LIMIT_EMAIL_FROM | Customer notification emails | Notice remains pending/failed; hard enforcement readiness stays blocked |
Operators can inspect the hard-enforcement preflight with the internal Convex query:
npx convex run apiPlanLimitNotices:getEnforcementReadiness
Treat ready: false as a stop sign for paid-plan hard enforcement. The readiness report blocks on stale sources, failed or pending email, and self-serve upgrade gaps such as API Starter users who need API Business while API Business is not currently checkout-enabled.
| Scenario | Behavior |
|---|---|
USAGE_TELEMETRY unset | emit is a no-op, identity object is still built but discarded |
USAGE_TELEMETRY=1, no token | event built, fetch skipped, sampled warn |
| Axiom returns non-2xx | recordSample(false), sampled warn |
| Axiom timeout (>1.5s) | AbortController aborts, sampled warn |
| ≥5% failure ratio over 5min (≥20 samples) | breaker trips → all sends short-circuit until ratio recovers |
Direct gateway caller passes no ctx | emit is a no-op (the ctx?.waitUntil guard) |
There is no in-code feature flag separate from the env vars. To disable in
production: set USAGE_TELEMETRY=0 (or unset it) and redeploy. Existing
in-flight requests drain on the next isolate cycle.
Just run the dev server with neither env var set. Hit any route. The path is fully exercised — only the Axiom POST is skipped.
vercel dev
curl http://localhost:3000/api/seismology/v1/list-earthquakes
In any non-production build, the response carries an x-usage-telemetry
header. Use it as a wiring check:
curl -sI http://localhost:3000/api/seismology/v1/list-earthquakes | grep -i x-usage
# x-usage-telemetry: off # USAGE_TELEMETRY unset
# x-usage-telemetry: ok # enabled, breaker closed
# x-usage-telemetry: degraded # breaker tripped — Axiom is failing
USAGE_TELEMETRY=1 AXIOM_API_TOKEN=xaat-... vercel dev
curl http://localhost:3000/api/market/v1/list-market-quotes?symbols=AAPL
Then in Axiom:
['wm_api_usage']
| where _time > ago(2m)
| project _time, route, status, customer_id, auth_kind, tier, duration_ms
Three suites cover the pipeline:
server/__tests__/usage-identity.test.ts cover the
pure buildUsageIdentity() resolver across every auth_kind branch.tests/usage-telemetry-emission.test.mts
stubs globalThis.fetch to capture the Axiom POST body and asserts the
domain, customer_id, auth_kind, and tier fields end-to-end through
the gateway.tests/premium-stock-gateway.test.mts and
tests/gateway-cdn-origin-policy.test.mts exercise the gateway without a
ctx argument, locking in the "telemetry must not break direct callers"
invariant.Run them:
npx tsx --test tests/usage-telemetry-emission.test.mts \
tests/premium-stock-gateway.test.mts \
tests/gateway-cdn-origin-policy.test.mts
npx vitest run server/__tests__/usage-identity.test.ts
All queries assume dataset wm_api_usage. Adjust time windows as needed.
['wm_api_usage']
| where event_type == "request" and _time > ago(24h)
| summarize requests = count(),
errors_5xx = countif(status >= 500),
errors_4xx = countif(status >= 400 and status < 500),
p95_ms = percentile(duration_ms, 95)
by customer_id
| order by requests desc
['wm_api_usage']
| where event_type == "request" and _time > ago(1h)
| summarize p50 = percentile(duration_ms, 50),
p95 = percentile(duration_ms, 95),
n = count()
by route
| where n > 50
| order by p95 desc
['wm_api_usage']
| where event_type == "request" and _time > ago(24h)
| extend tier_bucket = case(tier >= 2, "api+ent", tier == 1, "pro", "free/anon")
| summarize n = count() by route, tier_bucket
| evaluate pivot(tier_bucket, sum(n))
| order by route asc
RPC_CACHE_TIER['wm_api_usage']
| where event_type == "request" and status == 200 and method == "GET" and _time > ago(24h)
| summarize n = count() by route, cache_tier
| evaluate pivot(cache_tier, sum(n))
| order by route asc
A route dominated by slow-browser that should be CDN-cached is a hint to
add an entry to RPC_CACHE_TIER in server/gateway.ts.
['wm_api_usage']
| where event_type == "request" and auth_kind == "anon" and _time > ago(1h)
| summarize n = count() by route, country
| where n > 100
| order by n desc
['wm_api_usage']
| where event_type == "upstream" and _time > ago(24h)
| summarize calls = count(),
response_bytes_mb = sum(response_bytes) / 1024.0 / 1024.0,
p95_ms = percentile(duration_ms, 95)
by customer_id, provider
| order by calls desc
['wm_api_usage']
| where event_type == "upstream" and _time > ago(24h)
| summarize n = count() by provider, cache_status
| evaluate pivot(cache_status, sum(n))
| extend hit_ratio = (fresh + coalesce(['stale-while-revalidate'], 0)) * 1.0 / (fresh + miss + coalesce(['stale-while-revalidate'], 0))
| order by hit_ratio asc
When Sentry surfaces an exception, copy its trace ID and:
['wm_api_usage']
| where sentry_trace_id == "<paste from Sentry>"
…to see the exact request envelope (route, customer, latency, cache outcome).
['wm_api_usage']
| where _time > ago(1h)
| summarize events_per_min = count() by bin(_time, 1m)
| order by _time asc
A drop to zero with no corresponding traffic drop = breaker tripped or
Vercel/Axiom integration broken — pair it with the [usage-telemetry] drop
warns in Vercel logs to find the cause.
RequestEvent (or UpstreamEvent) in
server/_shared/usage.ts.buildRequestEvent /
buildUpstreamEvent) — only allowlisted primitives, no untyped objects.usage accumulator
in gateway.ts. Otherwise plumb it through the builder call sites.If you add a new return new Response(...) inside createDomainGateway(),
you must call emitRequest(status, reason, cacheTier, resBytes?) immediately
before it. Telemetry coverage is enforced by code review, not lint. The
reason field uses the existing RequestReason union — extend it if the
return represents a new failure class.