> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

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

> How a write-side onResponse hook buffers per-request telemetry rows into Redis, a worker drains them into a partitioned metrics table, and the Developer API analytics console reads back aggregates — and why page-view traffic, self-observation, and known-Plumbing paths are deliberately left out.

# 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](/concepts/request-logs-model) and
[stats surface map](/concepts/observability-stats-surface) 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](/concepts/api-request-guard-pipeline): 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](/concepts/api-request-guard-pipeline) —
  the 20-guard chain this plugin sits in, with status codes and
  ordering rules.
* [The stats surface map](/concepts/observability-stats-surface) — when
  to read `/api/v1/stats` widget counters vs `/api/v1/analytics`
  roll-ups vs this pipeline.
* [Request Logs model](/concepts/request-logs-model) — the sibling
  surface over Cloud Logging that serves the per-request drill-down
  view this pipeline aggregates.
