API Metrics: Rate, Errors, Duration, Sizes
Four signals per endpoint — request rate, error rate by class, duration percentiles, payload sizes — labeled by route template, method and status class. The craft is in the labels: one high-cardinality label like user_id can melt the metrics system that was supposed to watch everything else.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The four signals, per endpoint
For an API the useful minimum is RED plus sizes: Rate (requests/sec), Errors (rate by status class, at least 4xx vs 5xx — they mean opposite things: 5xx is your bug, a 4xx surge is usually a consumer's), Duration (latency as percentiles, never averages), and Sizes (request and response payload percentiles, the early-warning system for Payload Size: 20KB, 200KB, 5MB drift). Each is labeled by route *template*, method, and status class — enough dimensions to isolate "POST /payments is slow" without enough to explode.
Percentiles are non-negotiable because latency distributions are violently skewed: an endpoint with a 30ms average can hide a 4-second p99, and the p99 is where your biggest customer lives. Track p50, p95, p99 per endpoint; and remember percentiles do not average across instances or windows — aggregate histograms, not precomputed percentiles, or your dashboard is fiction. Averages answer "how much capacity"; percentiles answer "what did users experience"; the contract's promises (see Consistency as a Contract Clause, SLOs) are made in percentiles.
Sizes are the habitually-skipped fourth signal, and the cheapest regression detector an API can have: p99 response size creeping up release-over-release is a contract getting fatter, visible weeks before a consumer complains — and a request-size histogram is how you learn what real callers send before you tighten a limit (see Large Requests and Documented Limits).
http_requests_total {route="/projects/{id}/tasks", method="GET", status_class="2xx"}
http_request_duration_seconds{route="/projects/{id}/tasks", method="GET"} # histogram → p50/p95/p99
http_response_bytes {route="/projects/{id}/tasks", method="GET"} # histogram
http_request_bytes {route="/projects/{id}/tasks", method="POST"} # histogram
# route is the TEMPLATE, never the raw path
# status_class (2xx/4xx/5xx) — full code only where clients branch (429, 409)Cardinality is the budget you spend with labels
A time-series system stores one series per unique label combination, and series are what you pay for — in memory, storage, and query time. The arithmetic is brutal: 50 routes × 5 methods × 3 status classes × 20 instances is 15,000 series, comfortable anywhere. Add one label with unbounded values — user_id on a service with 100,000 users — and you have asked for 1.5 *billion* potential series. The metrics system does not gracefully degrade; it OOMs, throttles, or silently drops data, and the monitoring for every other service on it goes down with yours.
The classic cardinality bombs are all tempting: raw URL paths (every /projects/8231/tasks becomes its own series — always label by the route template), user or tenant ids ("we want per-customer dashboards"), API keys, request ids (never — that is what logs are for), and unbounded error strings. The rule: a label's value set must be small, bounded and known in advance. Per-customer questions are real, but they belong in logs and traces (which store events, not series) or in a deliberate top-N/tiered design — e.g. a tier label with five values, or exact per-tenant metrics for your ten largest contracts only.
1http_requests_total{2 path="/projects/8231/tasks", # unbounded: one series per id3 user="u_88412", # 100k users → 100k multiplier4 error="timeout after 3000ms…", # free text → infinite5 status="500"6}7# potential series: routes × users × error strings × …8# outcome: TSDB OOM; every team's dashboards go dark1http_requests_total{2 route="/projects/{id}/tasks", # ~50 templates3 method="GET", # ≤ 74 status_class="5xx" # 3 (+ named codes: 429, 409)5}6# ~50 × 7 × 5 × instances ≈ 10⁴ series: survivable for years7# per-user, per-request detail → logs, joined on request_idBoth record the same traffic. The left one answers slightly richer questions for a week, then takes the monitoring platform down. Cardinality is a hard budget: spend it on dimensions with small, fixed value sets, and route unbounded detail to logs where events are cheap.
Metrics as the contract's scoreboard
API metrics earn their keep when they measure the *promises*. If the contract says p99 < 300ms and 99.95% availability, the SLO dashboard must compute exactly those numbers per endpoint, and alerting should fire on burn rate against them — not on raw CPU or a global error count that mixes your checkout with your health checks. Contract-specific clauses deserve contract-specific metrics: 429 rates tell you whether The Rate-Limit Contract limits are sized right; Idempotency-Replayed counts show how often retries actually happen; deprecated-endpoint request counts are the burn-down chart for Deprecation as a Process, Not a Label.
Two boundaries keep the system honest. Metrics tell you *that* and *how much*, never *which one* or *why* — the moment you need a specific failing request, you pivot to logs via Request IDs: The Contract's Correlation Clause; resist the urge to make metrics carry event-level detail, because that urge is exactly the cardinality bomb. And measure at the layer consumers experience: instrumenting only inside the service misses queueing at the gateway and TLS at the edge — the The Gateway as Policy Boundary layer sees what consumers feel, service-side metrics explain it (a persistent gap between the two *is* a finding).
| Question | Metrics | Logs | Traces |
|---|---|---|---|
| Is /payments erroring more than promised? | Yes — error rate vs SLO, by route | No — too slow to aggregate at query time | No — sampled |
| Which request failed for customer X at 14:47? | No — and adding user labels to try is the bomb | Yes — filter on request_id / principal | If sampled in |
| Why is p99 slow — which hop? | Locates the endpoint and the when | Per-hop timing if logged | Yes — this is the trace's job |
| Is response size drifting release-over-release? | Yes — size histograms per route | Possible but expensive | No |
Key points
- Instrument RED + sizes per endpoint: rate, errors by class, duration percentiles, payload size histograms.
- Label by route template, method and status class — never raw paths, user ids, keys or free text.
- Cardinality is multiplicative: one unbounded label turns 10⁴ series into 10⁹ and takes the monitoring platform down for everyone.
- Percentiles over averages, aggregated from histograms — a 30ms average happily hides a 4s p99.
- 4xx and 5xx are different stories: one is usually the consumer's bug, the other is always yours; alert on them differently.
- Metrics say *that* and *how much*; logs (joined on Request IDs: The Contract's Correlation Clause) say *which one*; traces say *where inside*. Do not make one carry another's job.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → dashboards: instruments a global request count and average latency; everything is green.
- 2Consumers → API: the p99 on one endpoint quietly triples; the average moves 2ms; no alert fires.
- 3Team → metrics: reacting to a big customer's complaint, adds
user_idand rawpathlabels to "see everything". - 4TSDB → org: series count goes vertical; the metrics platform OOMs and every team's alerting goes dark during the debugging of an unrelated incident.
- 5Team → logs: strips the labels back out, and the per-customer question is finally answered where it always lived — in logs, joined on request id.
- Silent SLO violations: averages and global counters stay green while specific endpoints and specific percentiles break promises.
- A cardinality explosion is a monitoring outage — the blast radius is every service sharing the metrics platform, at exactly the moment someone was watching a graph.
- On-call decisions degrade: without per-endpoint, per-class signals, every page starts with twenty minutes of orientation instead of ninety seconds of triage.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Standardize the four signals and their label set (route template, method, status class) in shared middleware so every service emits identically shaped metrics.
- • Enforce a label-cardinality policy mechanically: allowlisted label keys, bounded value sets, CI or ingestion-time rejection for raw paths and ids.
- • Define SLOs per endpoint in the same terms as the contract's promises, and alert on burn rate against them rather than raw thresholds.
- • Emit histograms, not precomputed percentiles, so aggregation across instances stays mathematically valid.
- • Watch the watcher: series count and ingestion rate per service, with alerts on growth spikes — a cardinality bomb announces itself in minutes.
- • The gateway-vs-service duration gap per route: a widening gap means queueing or edge latency that service-side metrics cannot see.
- • p99/p50 ratios for duration and size: distribution skew is the earliest signal that one class of caller is having a much worse day than the median.
- • New metrics and new bounded labels add compatibly; renaming or re-labeling breaks every dashboard and alert downstream — treat the metric schema as an internal API with its own [[backward-compatibility]] discipline.
- • When per-tenant visibility becomes a product requirement, evolve deliberately: a bounded `tier` label, exact series for the top N contracts, or a separate analytics pipeline — never `tenant_id` on the hot path.
- • Deprecation metrics (requests per deprecated route per consumer) turn [[consumer-driven-evolution]] from guesswork into a burn-down chart.
- • Bounded labels genuinely answer fewer questions — the long tail of per-customer, per-request curiosity is deliberately pushed to logs, which are slower to aggregate.
- • Histograms cost more than counters (buckets × series), and good bucket boundaries require knowing your latency range in advance; bad buckets quantize away the truth.
- • Per-endpoint SLOs and burn-rate alerting take real setup and tuning; the alternative — a global error threshold — is one line and wrong.