Skip to main content

Per-tenant request metrics: how the Developer API analytics feed works

Developer → API Analytics answers “what did my API traffic look like” at per-endpoint, per-API-key, p50/p95-latency, and error-rate resolution — aggregates no log viewer computes. Behind it is a deliberately isolated write pipeline: a write-side hook buffers one telemetry row per request into Redis, a worker drains that queue into a dedicated api_request_metrics table, and five read endpoints serve the console from Redis-cached aggregates over that table. Request Logs and Insights are neighbours to this surface, not parts of it — the request-logs model and stats surface map respectively — this page owns the metric-emitting write pipeline.

1. What the metric plugin actually does

On every completed request, a hook fires after the response is sent and builds one row in a per-process in-memory buffer. Responses do not wait for the push — emission is fire-and-forget, so response latency never depends on Redis. A timer drains the buffer into a single shared Redis list every few seconds; a worker tick then bulk-inserts those rows into the api_request_metrics table every 30 seconds. Each row carries:
  • organization_id + api_key_id (attribution; null key id for dashboard session traffic),
  • method, path_pattern, status_code, duration_ms, request_id,
  • client_ip (the by-key panel’s “Last IP” for dashboard rows, which have no API-key record to read),
  • provider + cost_minor — populated by deep plumbing that tags the HTTP request id when a message send resolves its provider or debits the wallet, so the same request carries its provider class name and per-request spend for the per-key spend column.
A request whose context didn’t populate (pre-auth short-circuit: a 429 from the global rate-limit, a denylist hit, a policy-scan flag) recovers attribution from the API-key cache so its error row still lands against the right key. Rows with no resolvable tenant context are deliberately skipped rather than metered under a wrong or empty namespace — the absence is expected for unauth traffic.

2. Which requests fire metrics, and which deliberately don’t

The plugin splits three universes of traffic on the same finished request stream:
  • Programmatic API traffic (api_key_id non-null) — what you called with a dv_ key. This is the primary dataset the per-key panels and the wallboard “API Requests (24h)” tile show.
  • Dashboard session traffic (api_key_id null) — browser traffic via your session, metered with a null key id so it lands in the “Dashboard / session” attribution bucket instead of a named key.
  • Traffic that never fires a metric — the platform health endpoints (/health, /ready, /metrics), the console’s own read endpoints, and first-party realtime-transport paths (presence heartbeats, SSE token mints, the two event streams). Metering self-polling or keepalive traffic would fold page-view tubes into your API numbers, so the pipeline names these explicitly instead of drawing a blanket “anything authenticated counts” line. The console’s read endpoints also exclude any historical self-observation rows from before the write-side skip shipped — belt-and-braces so a pre-skip row doesn’t leak into a fresh window either.
The same separation decides which counters the Insights “API Requests (24h)” tile and the wallboard tile of the same name show: Insights deliberately counts all traffic (most tenants have no key-scoped calls, so key-only reads 0), the wallboard deliberately counts key-scoped traffic only (it re-polls its own page forever, so all-traffic would mostly count the page itself).

3. How the Developer API analytics console consumes it

Developer → API Analytics pages in five read endpoints under /api/v1/developers/api-analytics, all Redis-cached STALE-WHILE-REVALIDATE reads over api_request_metrics — the console polls them together, so one refresh cycle serves cards, endpoints table, and percentiles at once:
  • GET /developers/api-analytics/overview — totals, error rate, top endpoints, p50/p95 over a trailing window.
  • GET /developers/api-analytics/endpoints — per-endpoint breakdown.
  • GET /developers/api-analytics/timeseries — request volume over time.
  • GET /developers/api-analytics/rate-limits — the 429 panels.
  • GET /developers/api-analytics/by-key — per-key attribution: ok / error count, latency, last IP, spend.
Because attribution, provider, and cost are resolved on the WRITE side (and skipped rows are named explicitly), the console reads back aggregates over one flat table rather than correlating joins at query time — which is why the per-key spend column and the error-rate card stay cheap enough to poll.

4. How the plugin composes with its neighbours in the guard chain

The metric plugin is guard 14 of 20 in the ordered guard chain: it registers immediately after the request logger (platform access logging) and the Sentry/base-metrics plugins, and just before idempotency, the policy scan, and the error-envelope wrapper. That order matters three ways:
  • It sits before auth, so it must tolerate a missing context. Most requests arrive attributed; a 429/403 short-circuit recovers from the API-key cache instead. Any resolution failure counters to the pipeline-drop metrics rather than throwing into response handling — the hook always returns the request to the chain.
  • It reads the same typed request.ctx the sibling logger/errors/logger guards read, so a rename of the context shape fails at compile time in every observability plugin at once rather than silently mis-attributing rows.
  • It shares the pipeline-drop/enqueued counter names with the worker that drains the queue, so dropped by stage,reason / enqueued points at the lossy stage (Redis unavailable, queue cap, push failure, buffer overflow) instead of reporting only a warn log.

5. Adding a new skip or extending a guard

The skip list is the pipeline’s ONE naming place — don’t scatter ad-hoc exclusions inline. When a new guard or surface needs to exclude itself (a new poller, a new health path, a new transport), follow in order:
  1. Decide whether to even register a skip. A poll that emits rows with a genuine operator intent (a widget you opened) stays metered; the skip list only names traffic emitted on a timer, a platform probe, or the metrics surface itself. The line it draws is operator intent (a page you opened) vs transport noise (a tab that exists).
  2. Name the path as an exported constant next to the existing skip-list constants, then check it in the pipeline’s single predicate — the pipeline exports its predicate precisely so the skip contract can be pinned without booting the API.
  3. Pair the write-side skip with the read-side exclusion in the api-analytics controller, which already filters historical rows from before your skip shipped so a 30d window is correct immediately. The skip and the filter MUST share the same exported constant so the two never drift.
  4. Extend the existing paired test for the skip contract — this pipeline’s finished skips are all pinned so a regression is caught by a failing pin rather than a misreading dashboard.
The same discipline extends a guard: name exported constants for any tunable (queue depths, buffer sizes, skip prefixes), buffer rather than hold I/O, and make failure self-guarding with a counted drop rather than a thrown error — response latency must never depend on observability.

See also

  • API request guard pipeline — the 20-guard chain this plugin sits in, with status codes and ordering rules.
  • The stats surface map — when to read /api/v1/stats widget counters vs /api/v1/analytics roll-ups vs this pipeline.
  • Request Logs model — the sibling surface over Cloud Logging that serves the per-request drill-down view this pipeline aggregates.