advanced

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

Customer dashboards (embedded)

A dozen chart queries per page load, p95 under 2s, refreshed on a schedule — cacheability matters more than freshness.

Analyst power users

Exploratory group-bys and long ranges; will absolutely construct the 40-million-row query, and need a legitimate path to run it.

Customer data teams

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

Metric

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.

Query

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).

QueryJob

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.

ResultSet

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

OperationPurposeDesign notes
GET /metricsThe 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 /queriesSubmit 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/estimateCost 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}/rowsPage 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/recentThe 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 /exportsBulk 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

CodeStatusWhenRetryable
INVALID_QUERY400Unknown metric, dimension not allowed for that metric, malformed time range — the catalog is the authority, and `details` points into it.no
QUERY_TOO_EXPENSIVE422Estimated 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_LARGE422Range × 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_EXHAUSTED429The 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_EXPIRED410Fetching a ResultSet past its 48h retention. `410` with the original query embedded, so any client can re-submit mechanically.no
JOB_FAILED200Not 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.

A structured query object against a published metric catalog — never raw SQL, never arbitrary aggregation.
Reason · Everything else depends on being able to *analyze* a query before running it: cost estimation, tenant scoping, caching, validation. SQL strings make each of those a parsing research project; a schema makes them table lookups.
Alternative · A SQL dialect or PromQL-style language (vastly more expressive).
Trade-off · Expressiveness ceiling is real — some questions need the export path plus the customer's own tools. Chosen anyway: an analytics API that can't bound its own work doesn't stay up.
One submission endpoint; the *server* chooses sync (`200`) vs async (`202`) by estimated cost.
Reason · Callers can't know where the threshold sits (it moves with load and data volume), and a separate "fast endpoint" would just be the one everyone overloads. The estimate endpoint exists for callers who need to know beforehand.
Alternative · Caller-chosen endpoints: /queries/sync and /queries/async.
Trade-off · Every client must handle both response shapes from day one — enforced early by making even trivial queries return 202 occasionally in sandbox, so the async path can't rot untested (Long-Running Operations: 202 and the Job Resource).
Admission control by pre-execution cost estimate, with the estimate exposed as an endpoint.
Reason · Timeouts are damage *after* the fact — the scan already happened. Estimation from metadata (partition sizes, dimension cardinalities) rejects the 40M-group query in 20ms, and exposing it converts a wall into feedback.
Alternative · Run everything with kill-at-timeout plus per-tenant concurrency caps.
Trade-off · Estimates are wrong sometimes in both directions — a runtime circuit breaker still backs the gate, and the docs are honest that estimation is heuristic.
Results are immutable ResultSets with 48h retention, paginated by cursor, separate from jobs.
Reason · Retained results make pagination stable (the data can't move), retries free (re-read, not re-compute), and the 9am dashboard stampede cacheable (same query hash → same result within its freshness window).
Alternative · Stream results once, keep nothing.
Trade-off · Storage cost and a stated retention clause callers must respect — the RESULT_EXPIRED error with the embedded original query is the pressure valve.
Compute budgets per tenant (quota) layered on top of request rate limits, each with its own error.
Reason · Rate limits bound *requests*; one request here can cost a million times another. Budgets bound *work* — the actual scarce resource — and per-tenant isolation is what stops a neighbor's analyst from eating the 9am dashboard capacity.
Alternative · Concurrency caps per tenant only.
Trade-off · Budgets need metering, a visible balance (/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 Schedule resource 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 formula field; 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).

Lessons behind this design