feat(usage): per-request Axiom telemetry pipeline (gateway + upstream attribution)#3403
Conversation
) PR-0 of the Axiom usage-telemetry stack. Pure infra change: no telemetry emission yet, only the signature plumbing required for ctx.waitUntil to exist on the hot path. - createDomainGateway returns (req, ctx) instead of (req) - rewriteToSebuf propagates ctx to its target gateway - 5 alias callsites updated to pass ctx through - ~30 [rpc].ts callsites unchanged (export default createDomainGateway(...)) Pattern reference: api/notification-channels.ts:166. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
server/_shared/usage-identity.ts
- buildUsageIdentity: pure function, consumes already-resolved gateway state.
- Static ENTERPRISE_KEY_TO_CUSTOMER map (explicit, reviewable in code).
- Does not re-verify JWTs or re-validate API keys.
server/_shared/usage.ts
- buildRequestEvent / buildUpstreamEvent: allowlisted-primitive builders only.
Never accept Request/Response — additive field leaks become structurally
impossible.
- emitUsageEvents → ctx.waitUntil(sendToAxiom). Direct fetch, 1.5s timeout,
no retry, gated by USAGE_TELEMETRY=1 and AXIOM_API_TOKEN.
- Sliding-window circuit breaker (5% over 5min, min 20 samples). Trips with
one structured console.error; subsequent drops are 1%-sampled console.warn.
- Header derivers reuse Vercel/CF headers for request_id, region, country,
reqBytes; ua_hash null unless USAGE_UA_PEPPER is set (no stable
fingerprinting).
- Dev-only x-usage-telemetry response header for 2-second debugging.
server/_shared/auth-session.ts
- New resolveClerkSession returning { userId, orgId } in one JWT verify so
customer_id can be Clerk org id without a second pass. resolveSessionUserId
kept as back-compat wrapper.
No emission wiring yet — that lands in the next commit (gateway request
event + 403 + 429).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Wires the request-event side of the Axiom usage-telemetry stack. Behind USAGE_TELEMETRY=1 — no-op when the env var is unset. Emit points (each builds identity from accumulated gateway state): - origin_403 disallowed origin → reason=origin_403 - API access subscription required (403) - legacy bearer 401 / 403 / 401-without-bearer - entitlement check fail-through - endpoint rate-limit 429 → reason=rate_limit_429 - global rate-limit 429 → reason=rate_limit_429 - 405 method not allowed - 404 not found - 304 etag match (resolved cache tier) - 200 GET with body (resolved cache tier, real res_bytes) - streaming / non-GET-200 final return (res_bytes best-effort) Identity inputs (UsageIdentityInput): - sessionUserId / clerkOrgId from new resolveClerkSession (one JWT verify) - isUserApiKey + userApiKeyCustomerRef from validateUserApiKey result - enterpriseApiKey when keyCheck.valid + non-wm_ wmKey present - widgetKey from x-widget-key header (best-effort) - tier captured opportunistically from existing getEntitlements calls Header derivers reuse Vercel/CF metadata (x-vercel-id, x-vercel-ip-country, cf-ipcountry, content-length, sentry-trace) — no new geo lookup, no new crypto on the hot path. ua_hash null unless USAGE_UA_PEPPER is set. Dev-only x-usage-telemetry response header (ok | degraded | off) attached on the response paths for 2-second debugging in non-production. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Closes the upstream-attribution side of the Axiom usage-telemetry stack
without requiring leaf-handler changes (per koala's review).
server/_shared/usage.ts
- AsyncLocalStorage-backed UsageScope: gateway sets it once per request,
fetch helpers read from it lazily. Defensive import — if the runtime
rejects node:async_hooks, scope helpers degrade to no-ops and the
request event is unaffected.
- runWithUsageScope(scope, fn) / getUsageScope() exports.
server/gateway.ts
- Wraps matchedHandler in runWithUsageScope({ ctx, requestId, customerId,
route, tier }) so deep fetchers can attribute upstream calls without
threading state through every handler signature.
server/_shared/redis.ts
- cachedFetchJsonWithMeta accepts opts.usage = { provider, operation? }.
Only the provider label is required to opt in — request_id / customer_id
/ route / tier flow implicitly from UsageScope.
- Emits on the fresh path only (cache hits don't emit; the inbound
request event already records cache_status).
- cache_status correctly distinguishes 'miss' vs 'neg-sentinel' by
construction, matching NEG_SENTINEL handling.
- Telemetry never throws — failures are swallowed in the lazy-import
catch, sink itself short-circuits on USAGE_TELEMETRY=0.
server/_shared/fetch-json.ts
- New optional { provider, operation } in FetchJsonOptions. Same
opt-in-by-provider model as cachedFetchJsonWithMeta. Auto-derives host
from URL. Reads body via .text() so response_bytes is recorded
(best-effort; chunked responses still report 0).
Net result: any handler that uses fetchJson or cachedFetchJsonWithMeta
gets full per-customer upstream attribution by adding two fields to the
options bag. No signature changes anywhere else.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- ctx is now optional on the createDomainGateway handler signature so direct callers (tests, non-Vercel paths) no longer crash on emit - legacy premium bearer-token routes (resilience, shipping-v2) propagate session.userId into the usage accumulator so successful requests are attributed instead of emitting as anon - after checkEntitlement allows a tier-gated route, re-read entitlements (Redis-cached + in-flight coalesced) to populate usage.tier so analyze-stock & co. emit the correct tier rather than 0 - domain extraction now skips a leading vN segment, so /api/v2/shipping/* records domain="shipping" instead of "v2" Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…guide - tests/usage-telemetry-emission.test.mts stubs globalThis.fetch to capture the Axiom ingest POST body and asserts the four review-flagged fields end-to-end through the gateway: domain on /api/v2/<svc>/* (was "v2"), customer_id on legacy premium bearer success (was null/anon), tier on entitlement-gated success via the Convex fallback path (was 0), plus a ctx-optional regression guard - server/__tests__/usage-identity.test.ts unit-tests the pure buildUsageIdentity() resolver across every auth_kind branch, tier coercion, and the secret-handling invariant (raw enterprise key never lands in any output field) - docs/architecture/usage-telemetry.md is the operator + dev guide: field reference, architecture, configuration, failure modes, local workflow, eight Axiom APL recipes, and runbooks for adding fields / new gateway return paths Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR implements a per-request Axiom telemetry pipeline wired into The previously-flagged P1 nested- Confidence Score: 5/5Safe to merge — no P0/P1 findings in this review round; the previously-flagged P1 nested-waitUntil is correctly fixed. All findings in this review are P2. The P1 nested-waitUntil from the prior round is properly resolved via server/_shared/usage.ts — two new P2s (deriveRequestId fallback and getScopeStore concurrency) Important Files Changed
Sequence DiagramsequenceDiagram
participant V as Vercel Edge (ctx)
participant G as createDomainGateway
participant ALS as AsyncLocalStorage (UsageScope)
participant H as matchedHandler
participant F as fetchJson / cachedFetchJsonWithMeta
participant A as Axiom (wm_api_usage)
V->>G: handler(req, ctx)
G->>G: auth resolution → usage:UsageIdentityInput
G->>ALS: runWithUsageScope({ctx, requestId, customerId, route, tier})
ALS->>H: handlerCall(request)
H->>F: fetchJson(url, {provider, operation})
F->>F: finally: ctx.waitUntil(sendToAxiom([upstream event]))
Note right of F: registered during request phase ✓
F-->>H: result
H-->>G: Response
G->>G: emitRequest(status, reason, cacheTier)
G->>V: ctx.waitUntil(IIFE)
Note right of G: IIFE awaits deriveUaHash then awaits deliverUsageEvents directly (no nested waitUntil) ✓
V->>A: POST /v1/datasets/wm_api_usage/ingest (upstream event)
V->>A: POST /v1/datasets/wm_api_usage/ingest (request event)
Reviews (3): Last reviewed commit: "fix(usage): address koala #3403 review —..." | Re-trigger Greptile |
Promise.all(pending) snapshotted the array at call time, missing the inner ctx.waitUntil(sendToAxiom(...)) that emitUsageEvents pushes after the outer drain begins. Tests passed only because the fetch spy resolved in an earlier microtask tick. Replace with a quiescence loop so the helper survives any future async in the emit path. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Greptile review follow-up@greptileai thanks for the review — second-passed every item against the code and ran an independent Codex audit ( ImplementedP2 — You and Codex both flagged this. Not carried forward (with rationale)@koala73 — flagging the items we consciously decided not to act on, so this is auditable later: P1 — Nested P2 — P2 — 32-bit FNV-1a "corrupts upstream per-customer attribution" P2 — Uninitialized Verification
|
Aggregated review — telemetry pipelineCombining my walkthrough, @SebastienMelki's PR-body Greptile-watch list, the Greptile comment, and findings from a fresh local checkout. Two new P1s and two new P2s vs. what Greptile surfaced. The PR is sound architecturally and safe to merge in Phase 1 (telemetry dark), but the two P1s and one of the P2s need to land before flipping P1 — Nested
|
…idget-key validation, neg-sentinel status, auth_* reasons P1 - Collapse nested ctx.waitUntil at all 3 emit sites (gateway.ts emitRequest, fetch-json.ts, redis.ts emitUpstreamFromHook). Export sendToAxiom and call it directly inside the outer waitUntil so Edge runtimes don't drop the delivery promise after the response phase. - Validate X-Widget-Key against WIDGET_AGENT_KEY before populating usage.widgetKey so unauthenticated callers can't spoof per-customer attribution. P2 - Emit on OPTIONS preflight (new 'preflight' RequestReason). - Gate cachedFetchJsonWithMeta upstreamStatus=200 on result != null so the neg-sentinel branch no longer reports as a successful upstream call. - Extend RequestReason with auth_401/auth_403/tier_403 and replace reason:'ok' on every auth/tier-rejection emit path. - Replace 32-bit FNV-1a with a two-round XOR-folded 64-bit variant in hashKeySync (collision space matters once widget-key adoption grows). Verification - tests/usage-telemetry-emission.test.mts — 6/6 - tests/premium-stock-gateway.test.mts + tests/gateway-cdn-origin-policy.test.mts — 15/15 - npx vitest run server/__tests__/usage-identity.test.ts — 13/13 - npx tsc --noEmit clean Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Round-2 review fixes — pushed in 5883976@koala73 @greptileai — addressed the full set from the aggregated review. Recap: P1 — fixed
P2 — fixed
Verification
P3 — not in this commit
Ready for re-review. |
…step (#3404) scripts/vercel-ignore.sh skipped any preview deploy where VERCEL_GIT_PULL_REQUEST_ID was empty. Vercel only populates that var on fresh PR-aware webhook events; manual "Redeploy" / "Redeploy without cache" from the dashboard, and some integration edge cases, leave it empty even on commits attached to an open PR. The merge-base diff against origin/main below it is already the authoritative "touched anything web-relevant" check and is strictly stronger. Repro: PR #3403 commit 24d511e on feat/usage-telemetry — five api/ + server/ files clearly modified, build canceled at line 18 before the path diff ran. Local replay with PR_ID unset now exits 1 (build).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ooting Temporary — to be reverted once Axiom delivery is confirmed working in preview. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Sync UsageCacheTier with the local CacheTier in gateway.ts (main added 'live' in PR #3402 — synthetic merge with main was failing typecheck:api). - Revert temporary unconditional debug logs in sendToAxiom now that Axiom delivery is verified end-to-end on preview (event landed with all fields populated, including the new auth_401 reason from the koala #3403 fix). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
@koala73 — ready for final review. Round-2 review fixes verified end-to-end on previewAll P1/P2 items from your aggregated review are landed in d68c089. Pushed an Axiom token to the preview env, hit `/api/market/v1/list-market-quotes` from a logged-in browser, and the event arrived as expected. Per the screenshots I shared with you separately: ```json Confirms three things at once:
What changed since the round-2 push
CI
Telemetry is currently scoped to the `feat/usage-telemetry` branch in preview only. Production env vars (`USAGE_TELEMETRY`, `AXIOM_API_TOKEN`) are unset, so emission stays dark on prod regardless of merge timing — Phase 1 contract. |
Round-2 verification — all P1/P2 fixes confirmedRe-pulled
CI still green (typecheck / biome / unit / markdown). PR description's deploy-phasing claims now match the code. Residual non-blockers (file as follow-ups, don't block this PR)
Ship recommendationLGTM for the full deploy plan, not just Phase 1.
Round-2 fixes are surgical and the architecture is unchanged. Nice turnaround. |
Round-3 review — three new findings, two block Phase 2/3Re-verified against P1 —
|
| Call site | Helper | provider passed? |
|---|---|---|
server/worldmonitor/intelligence/v1/get-company-enrichment.ts:125, 146, 174, 194 |
fetchJson |
❌ |
server/worldmonitor/intelligence/v1/get-risk-scores.ts |
fetchJson |
❌ |
server/worldmonitor/intelligence/v1/list-company-signals.ts |
fetchJson |
❌ |
server/worldmonitor/news/v1/summarize-article.ts:119 |
cachedFetchJsonWithMeta |
❌ |
server/worldmonitor/infrastructure/v1/list-service-statuses.ts:315 |
cachedFetchJsonWithMeta |
❌ |
PR description Phase-3 success criteria says "Top-N customer_id should match known top accounts" and the docs advertise per-customer × per-provider cost attribution as a primary deliverable — neither is achievable until call sites are instrumented. The upstream event table will be empty in Axiom.
Fix: either (a) instrument each provider-tagged fetch in this PR — at minimum the LLM, GitHub, SEC, HN, intelligence, and infrastructure paths above; or (b) demote upstream emission from a "what this gives us" bullet to a "scaffolding ready, instrumentation TODO" follow-up issue, and update docs/architecture/usage-telemetry.md accordingly so operators don't query an empty table on day one.
P2 — deriveRequestId() returns '' outside Vercel
server/_shared/usage.ts:197-199:
export function deriveRequestId(req: Request): string {
return req.headers.get('x-vercel-id') ?? '';
}Empty string breaks every join key in the docs:
request × upstreamjoin onrequest_idcollapses every non-Vercel request into one bucket.- Local dev (
vercel devdoes setx-vercel-id, but plainnodetest harness doesn't), Railway, Tauri, MCP, internal-cron — all collide on''. - The test recorder for the
tier=2path bypasses Vercel headers entirely; their telemetry rows would all sharerequest_id=''.
Fix:
export function deriveRequestId(req: Request): string {
return (
req.headers.get('x-request-id') ??
req.headers.get('x-vercel-id') ??
crypto.randomUUID()
);
}crypto.randomUUID() is available in Edge, Node ≥19, and is in the Web Crypto spec. Existing Vercel calls keep the same id; non-Vercel emits become joinable.
Combined ship state
| Phase | Blocker? |
|---|---|
| Phase 1 — merge dark | None. Telemetry off, P1s inert. |
| Phase 2 — Preview emit | P1 widget leak (rotates secret to Axiom every preview hit). P2 request_id (test rows + Railway rows would all collide). |
| Phase 3 — Prod emit | Both P1s. Upstream-table-empty makes the dashboards in usage-telemetry.md mostly non-functional. |
Recommend:
- Fix P1 widget customer_id (one-line: replace
input.widgetKeywithhashKeySync(input.widgetKey)or a constant). - Fix P2 request_id fallback chain.
- Decide on upstream P1: instrument the 7+ call sites in this PR (preferred — keeps the PR's stated value intact) or scope it to a follow-up and adjust docs/PR description.
- Rotate
WIDGET_AGENT_KEYafter the widget leak fix lands, given the preview env hadUSAGE_TELEMETRY=1+AXIOM_API_TOKENset during the recent verification commits.
Implements
docs/brainstorms/2026-04-24-axiom-api-observability-requirements.md— emits one structured event per inbound request (and one per upstream fetch) into Axiom datasetwm_api_usage, with stable customer/tier/cache attribution. Telemetry is fire-and-forget viactx.waitUntil, never on the request-critical path.What this gives us
customer_id,principal_id,auth_kind,tierresolved at the gateway from already-validated auth state. Raw API/widget keys are FNV-hashed before emission.cache_tier, exposing whereRPC_CACHE_TIERshould be calibrated.fetchJson/cachedFetchJsonWithMetaemitupstreamevents tagged with the inboundcustomer_id+routevia AsyncLocalStorage, so per-customer x per-provider call volume is reconstructible without threading hooks through every handler.sentry_trace_idon every event.Code map
server/gateway.tsserver/_shared/usage-identity.tsserver/_shared/usage.tsserver/_shared/cached-fetch.ts,server/_shared/fetch-json.tsdocs/architecture/usage-telemetry.mdSafety properties
USAGE_TELEMETRYunset -> emission no-ops.AXIOM_API_TOKENunset -> events build butfetchis skipped.AbortControllertimeout, no retry, 5%/5min sliding-window circuit breaker. All drop reasons surface as 1%-sampledconsole.warn.createDomainGateway()returns(req, ctx?) => Promise<Response>—ctxis optional and the emit guards onctx?.waitUntil. Existinghandler(req)callers (non-Vercel paths + tests) are unchanged.Request/Response/ untyped objects in builder signatures, so future field additions can't leak by structural impossibility.Codex review history
dbcea4d045b16c71usage-identity.test.ts; P3: doc misstated widget customer_id and origin_kind valuesTests
tests/usage-telemetry-emission.test.mts— new, stubsglobalThis.fetchto capture the Axiom POST body and assertsdomain,customer_id,auth_kind,tierend-to-end through the gateway. 6 cases including the entitlement-gatedtier=2success path (Convex fallback stubbed) and a ctx-optional invariant guard.server/__tests__/usage-identity.test.ts— new, 13 unit tests covering everyauth_kindbranch in the pure resolver, tier coercion, and the secret-handling invariant.tests/premium-stock-gateway.test.mts,tests/gateway-cdn-origin-policy.test.mts— exercise the gateway withoutctx, locking in the "telemetry must not break direct callers" invariant.server/__tests__/entitlement-check.test.ts— unchanged; still passes.Test plan
npm run typecheck:allnpx tsx --test tests/usage-telemetry-emission.test.mts tests/premium-stock-gateway.test.mts tests/gateway-cdn-origin-policy.test.mts(21 tests)npx vitest run server/__tests__/usage-identity.test.ts server/__tests__/entitlement-check.test.ts(21 tests)handler(req)callers still resolve cleanly with telemetry onDeployment plan
Phased rollout — emission is dark by default, so each step is independently revertable.
Phase 1 — merge, telemetry off. Merge with neither env var set. Identity-resolution + scope code paths run, but
emitUsageEventsis a no-op. Watch Vercel p50/p95 + Sentry for 24h to confirm no regression from the resolver work itself.Phase 2 — Axiom dataset + token. Create dataset
wm_api_usageand an Ingest token scoped to it. SetUSAGE_TELEMETRY=1+AXIOM_API_TOKEN=xaat-...on Preview only. Trigger a preview deploy, hit ~5 representative endpoints (anon, bearer, API-key, premium, v2), then in Axiom:Verify
domainpopulated correctly for both/api/<svc>/v1/*and/api/v2/<svc>/*,customer_idpopulated on bearer + API-key,auth_kinddistribution plausible,tiernon-zero for paid bearer calls.Phase 3 — production. Set the two env vars on Production, redeploy.
x-usage-telemetryheader on staging (okvsdegraded); watch Vercel logs for[usage-telemetry] droprate.customer_idshould match known top accounts.Rollback. Single knob: unset
USAGE_TELEMETRY(or set to0) in Vercel env, redeploy. In-flight isolates drain on next cycle. No data migration, no schema cleanup. If only Axiom is unhealthy, no action — the circuit breaker auto-trips on >5% failure ratio.Greptile watch list
Things likely to be flagged that are intentional:
as typeof fetchcast in test infra — structural-but-not-nominal match forfetch, not a runtime concern.waitUntil: () => {}fallback ingateway.ts— intentional no-op for the no-ctxpath insiderunWithUsageScope. Alternative was makingUsageScope.ctxoptional and threading?.everywhere downstream.getEntitlements()call —_inFlightMap + Redis cache make the second call cheap once the first resolves. Comment in code explains.wm_api_usagedataset name — operator config lives in env (AXIOM_API_TOKEN); dataset name is a code-level decision documented atusage.ts:18.Math.random() < 0.01for sampled drop logs — intentional sampling cap to keep Vercel logs from drowning in telemetry-failure noise.principal_idinstead of SHA-256 — non-cryptographic, only used to avoid logging raw key material. SHA-256 is async; gateway hot path is sync. Documented atusage-identity.ts:90.Request/Response— documented invariant from the brainstorm spec (allowlist-only fields, no structural leakage).emitRequestduplicated at every return path instead of a singlefinally— each return has differentstatus/reason/cacheTier/resBytessemantics.Real concerns worth scrutiny:
gateway.tssize + cyclomatic complexity — adds ~140 lines to an already-long file. New identity-accumulator + emit pattern is the right shape, but extraction (e.g., a separateresolveAuth(usage, request)helper) is worth considering for a follow-up.runWithUsageScopeswallowsAsyncLocalStorageimport failure silently. Documented as deliberate degradation for older Edge runtimes; worth a sanity-check that current Vercel Edge actually exposesnode:async_hooks.upstreamevent payload.requestevents are now fully assertion-verified, butupstreamevents still rely on inspection. Acceptable for this PR; file a follow-up to add similar payload tests forcached-fetch.ts/fetch-json.ts.getEntitlements()failure on the new tier-population branch is silently swallowed — if Convex is down,usage.tierstaysnull(becomes0at emit). Request is still allowed (already passedcheckEntitlement); telemetry row will look "free". Acceptable per the documented "fail-soft for telemetry, fail-hard for auth" split.globalThis.fetchand forwards via closure to the original.node:testruns files sequentially by default but suites within a file are parallel — current tests don't conflict, but a future test author should know.Docs
docs/architecture/usage-telemetry.md— single operator + dev guide covering field reference, architecture, configuration, failure modes, local dev workflow, eight Axiom APL recipes (per-customer volume, latency, premium/free mix, cache-tier calibration, anon abuse, upstream cost per customer, cache hit ratio, Sentry join, telemetry health), and runbooks for adding fields / new gateway return paths.@koala73 — flagging you for review per project norms. Happy to iterate on the deploy-phasing or split this into smaller PRs if you'd prefer.
🤖 Generated with Claude Code