File size: 17,639 Bytes
d705bb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# Usage telemetry (Axiom)

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`.

---

## What it is

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.

## What you get out of it

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 |

## What it answers

A non-exhaustive list β€” copy-paste APL queries are in the **Analysis** section below.

- Per-customer request volume, p50/p95 latency, error rate
- Per-route premium-vs-free traffic mix
- CDN cache-tier distribution per route (calibrate `RPC_CACHE_TIER`)
- Top-of-funnel for noisy abusers (`auth_kind=anon` Γ— `country` Γ— `route`)
- Upstream provider cost per customer (`upstream` join `request` on `request_id`)
- Bearer-vs-API-key vs anon ratio per premium route
- Region heatmaps (`execution_region` Γ— `route`)

---

## Architecture

```
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚                  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:

1. **Builders accept allowlisted primitives only** β€” they never accept
   `Request`, `Response`, or untyped objects, so future field additions can't
   leak by structural impossibility.
2. **`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.
3. **`principal_id` is a hash for secret-bearing auth** (API key, widget key)
   so raw secrets never land in Axiom.
4. **Telemetry failure must not affect API availability or latency** β€” sink is
   fire-and-forget with timeout + breaker; any error path drops the event with
   a 1%-sampled `console.warn`.

---

## Configuration

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:

1. Axiom β†’ create dataset **`wm_api_usage`** (the constant in
   `server/_shared/usage.ts:18`; rename if you want a different name).
2. Axiom β†’ Settings β†’ API Tokens β†’ create an **Ingest** token scoped to that
   dataset. Copy the `xaat-…` value.
3. Vercel β†’ Project β†’ Settings β†’ Environment Variables, add for the desired
   environments (Production / Preview):
   ```
   USAGE_TELEMETRY=1
   AXIOM_API_TOKEN=xaat-...
   ```
4. Redeploy. Axiom infers schema from the first events β€” no upfront schema
   work needed.

### Plan-limit scanner configuration

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:

```sh
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.

### Failure modes (deploy-with-Axiom-down is safe)

| 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)        |

### Kill switch

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.

---

## Local development & testing

### Smoke test without Axiom

Just run the dev server with neither env var set. Hit any route. The path is
fully exercised β€” only the Axiom POST is skipped.

```sh
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:

```sh
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
```

### End-to-end with a real Axiom dataset

```sh
USAGE_TELEMETRY=1 AXIOM_API_TOKEN=xaat-... vercel dev
curl http://localhost:3000/api/market/v1/list-market-quotes?symbols=AAPL
```

Then in Axiom:

```kusto
['wm_api_usage']
| where _time > ago(2m)
| project _time, route, status, customer_id, auth_kind, tier, duration_ms
```

### Automated tests

Three suites cover the pipeline:

1. **Identity unit tests** β€” `server/__tests__/usage-identity.test.ts` cover the
   pure `buildUsageIdentity()` resolver across every `auth_kind` branch.
2. **Gateway emit assertions** β€” `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.
3. **Auth-path regression tests** β€” `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:

```sh
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
```

---

## Analysis recipes (Axiom APL)

All queries assume dataset `wm_api_usage`. Adjust time windows as needed.

### Per-customer request volume + error rate

```kusto
['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
```

### p50 / p95 latency per route

```kusto
['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
```

### Premium vs free traffic mix per route

```kusto
['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
```

### CDN cache-tier mix per route β€” calibrates `RPC_CACHE_TIER`

```kusto
['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`.

### Anonymous abuse hotspots

```kusto
['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
```

### Upstream cost per customer (provider attribution)

```kusto
['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
```

### Cache hit ratio per provider (correctness signal)

```kusto
['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
```

### Sentry Γ— Axiom join

When Sentry surfaces an exception, copy its trace ID and:

```kusto
['wm_api_usage']
| where sentry_trace_id == "<paste from Sentry>"
```

…to see the exact request envelope (route, customer, latency, cache outcome).

### Telemetry health watch

```kusto
['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.

---

## Adding new telemetry fields

1. Add the field to `RequestEvent` (or `UpstreamEvent`) in
   `server/_shared/usage.ts`.
2. Extend the corresponding builder (`buildRequestEvent` /
   `buildUpstreamEvent`) β€” only allowlisted primitives, no untyped objects.
3. If the value comes from gateway state, set it on the `usage` accumulator
   in `gateway.ts`. Otherwise plumb it through the builder call sites.
4. Axiom auto-discovers the new column on the next ingest. No schema migration.
5. Update this doc's field table.

## Adding a new gateway return path

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.