Case Study: Analytics API
Customer-facing analytics over event data: time ranges, group-bys, metrics — powering dashboards and data exports for thousands of tenants on one warehouse.
An analytics API sells arbitrary computation over large data and must survive doing so. The naive contract — "send any query, get all rows" — dies twice: once when a caller groups by user_id over a year and the response is 40 million rows, and again when twelve dashboards refresh at 9am and each fires warehouse scans. The contract's job is to make cost visible, bounded, and shaped before execution: a structured query object instead of free-form SQL, admission control with an explainable cost estimate, cursors on every result, and a hard split between interactive queries (small, synchronous, cacheable) and heavy queries (async jobs with retained results — The Async Job Pattern). The recurring move: never let "how much work is this?" be discovered during the work.
Consumers
A dozen chart queries per page load, p95 under 2s, refreshed on a schedule — cacheability matters more than freshness.
Exploratory group-bys and long ranges; will absolutely construct the 40-million-row query, and need a legitimate path to run it.
Scheduled bulk extraction into their own warehouse — completeness and resumability over latency; latency is irrelevant at 2am.
Requirements
- • Query metrics (counts, sums, percentiles) over event data with time ranges, granularity, group-by dimensions, and filters.
- • Small queries answer synchronously; expensive ones run as jobs with progress, cancellation, and retained, re-fetchable results.
- • No caller can issue unbounded work: every query has a computable cost, a budget, and an answer *before* execution about whether it fits.
- • Results paginate — a group-by can legally produce millions of groups and must never arrive as one response body.
- • Tenant isolation is absolute: no query, however malformed, reads across tenants; one tenant's load cannot starve another's.
- • The docs state freshness (events queryable within 5 minutes) and result retention (48h) as contract clauses, not folklore.
Resources
A named, tenant-visible measure (`active_users`, `event_count`) with a defined aggregation and allowed dimensions — published as a catalog. Callers compose *from the catalog*, not from raw SQL: the catalog is simultaneously the feature list and the security boundary.
A structured request object: metrics, time range, granularity, group_by, filters, limit. Structured (not a SQL string) because the API must *reason* about it — estimate cost, validate dimensions, rewrite for caching — and you cannot reliably reason about arbitrary SQL ([[graphql-costs]] is the same lesson in another syntax).
The async execution of an expensive Query: `queued` → `running` → `succeeded` | `failed` | `canceled`, with progress, cost accounting, and a pointer to results. Exists so long work has an address — something to poll, cancel, and bill.
The output, stored for 48h and read through its own paginated endpoint. Separating results from jobs means fetching page 300 doesn't depend on the job machinery, and retention is a property of the *data*, stated plainly.
Operations
| Operation | Purpose | Design notes |
|---|---|---|
| GET /metrics | The catalog: available metrics, their dimensions, granularities, and per-metric constraints. | Machine-readable capability discovery — integrators build against the catalog instead of trial-and-erroring queries into 422s. |
| POST /queries | Submit a query; the API decides sync vs async. | The pivotal contract move: cost below the sync threshold → 200 with inline first page; above it → `202` with a QueryJob. POST despite being a read, because query objects blow past URL limits and deserve a body (POST: More Than Create) — Cache-Control semantics are recovered via a query-hash cache key server-side. |
| POST /queries/estimate | Cost preview without execution: estimated scan, group cardinality, sync/async verdict, budget impact. | Turns admission control from a wall into a negotiation — dashboards use it to pre-flight, analysts use it to trim a range before committing. |
| GET /query-jobs/{id} | Job status, progress fraction, cost so far, and — when done — the results reference. | Poll target with Retry-After hints that grow with queue depth, so a thousand dashboards don't hot-poll a busy queue. |
| DELETE /query-jobs/{id} | Cancel a running job. | Returns 202: cancellation of distributed work is itself asynchronous — the contract says "cancel *requested*", and the job's terminal state says whether it won the race. Cost accrued before cancellation is still billed, and documented as such. |
| GET /result-sets/{id}/rows | Page through results. | Cursor-based with a limit cap of 10,000 rows per page. Results are immutable once written, so cursors here are trivially stable — the easy case of pagination, earned by freezing the data first (Cursor Pagination: An Opaque Bookmark, Not a Position). |
| GET /queries/recent | The tenant's recent queries and jobs with cost. | Self-service accountability: when a tenant asks "why did we hit our budget?", the answer is a list they can read, not a support ticket. |
| POST /exports | Bulk extraction of raw or aggregated data to object storage. | A separate contract from queries — different SLO, different budget pool, file-based delivery — because "give me everything" is a legitimate need that must not be met by paginating a query to death (Unbounded Collections: The Anti-Pattern With a Fuse). |
Error contract
| Code | Status | When | Retryable |
|---|---|---|---|
| INVALID_QUERY | 400 | Unknown metric, dimension not allowed for that metric, malformed time range — the catalog is the authority, and `details` points into it. | no |
| QUERY_TOO_EXPENSIVE | 422 | Estimated cost exceeds even the async budget. Body carries the estimate, the ceiling, and the biggest cost driver (`group_by: user_id ≈ 4.1M groups`) — a rejection that teaches. | no |
| TIME_RANGE_TOO_LARGE | 422 | Range × granularity exceeds the per-metric limit (e.g. minute-level over 2 years). Named separately from general expense because the fix — coarser granularity — is specific and suggestible. | no |
| BUDGET_EXHAUSTED | 429 | The tenant's compute budget for the period is spent. `Retry-After` points at budget reset; distinct from request-rate limiting because the remedy (wait/upgrade) differs from "slow down" ([[quotas-vs-rate-limits]]). | after delay |
| RESULT_EXPIRED | 410 | Fetching a ResultSet past its 48h retention. `410` with the original query embedded, so any client can re-submit mechanically. | no |
| JOB_FAILED | 200 | Not an HTTP error: `GET /query-jobs/{id}` returns `200` with `status: "failed"` and a structured reason — the *request* about the job succeeded; the job is the thing that failed ([[async-job-pattern]]). | no |
Decision log
Decision → reason → alternative → trade-off. The alternative is part of the record.
/queries/sync and /queries/async.202 occasionally in sandbox, so the async path can't rot untested (Long-Running Operations: 202 and the Job Resource).RESULT_EXPIRED error with the embedded original query is the pressure valve./queries/recent), and commercial tiering conversations — an org cost, not just a technical one (The Rate-Limit Contract).How it evolves
- • New metrics and dimensions are catalog rows, not API changes — the contract's whole surface was designed so the *data* vocabulary grows without the *protocol* moving (Backward Compatibility: The Real Rules).
- • Scheduled queries reuse QueryJob wholesale: a
Scheduleresource that submits on cron and delivers via the existing webhook events — the async plumbing built for admission control turns out to be the feature. - • Derived/computed metrics (customer-defined formulas over catalog metrics) arrive as a new catalog entry type with a
formulafield; the estimator prices them by expansion, so admission control absorbs the feature instead of being bypassed by it. - • Streaming freshness (5min → seconds) narrows only the freshness clause; because freshness was a documented number rather than an implied "immediately", tightening it is a release note, not a migration (Consistency as a Contract Clause).