You probably don't need a metrics database
Every service on the platform emits operational counts: API calls, flag evaluations, config resolutions, log-level changes. The moment you have those, somebody wants a dashboard, and the reflexive move is to stand up a metrics stack — a time-series database, an ingestion pipeline, dashboards, retention policies, and a new thing that can page you at night.
We put ours in a Postgres table. One table.
The single metric table
CREATE TABLE metric (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
account_id UUID NOT NULL,
service TEXT NOT NULL,
environment TEXT,
name TEXT NOT NULL,
dimensions JSONB NOT NULL DEFAULT '{}',
value NUMERIC NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
The name column uses dotted names — flags.evaluation.count, config.resolution.count, logging.level_change.count — the same hierarchy-in-a-string trick as Java packages and CloudWatch namespaces. The dimensions JSONB column carries per-metric attributes (which flag, which config, which environment), and queries can filter on them. And period_start/period_end define the window a record covers, which brings us to the design decision that makes the single table survivable.
Aggregate before you send
We don’t store raw events. Ever. Each sending service accumulates counts in memory and flushes a batch to POST /api/v1/metrics/bulk every 60 seconds, or when the buffer hits 1,000 records. The aggregation happens in the sender: instead of one record per evaluation, the flags service sends one record per flag per environment per minute. “Flag checkout-v2 was evaluated 847 times in production in the minute ending 15:23:00.”
That single choice is why this works. Storage cost is proportional to records, not events, so a flag costs at most 1,440 rows per environment a day, whether it’s evaluated a thousand times or a hundred million. And the query side never has to aggregate raw events, because the stored records already are aggregates — rollups are sums over sums.
Fire and forget, and mean it
Metric writes must never make an API call slower or less reliable. If the metrics service is down, flags still evaluate and config still resolves. Each service pushes metric events onto an in-memory queue — non-blocking — and a background thread flushes the queue on its own schedule. The request path never waits.
Which raises the obvious question: what happens when a flush fails and those counts just evaporate? For telemetry, nothing happens. That’s the point of the design, and it’s also its sharpest edge, so it comes with a rule: nothing billing-critical reads from the metrics service. Billable counts come from the database of record, where writes are transactional and losing data is not a design feature. The metrics service is for operational telemetry, not financial accounting. Fire-and-forget is a great property in a pipeline right up until an invoice depends on it.
The rollup query API
GET /api/v1/metrics?
name=flags.evaluation.count&
granularity=hour&
start=2026-05-01T00:00:00Z&
end=2026-05-08T00:00:00Z
That returns hourly sums for the window. Granularities run from raw (per stored record) through minute, hour, day, week, and month; finer costs more aggregation work, coarser returns faster. Dimension filters like dimensions[flag_key]=checkout-v2 narrow to one flag, and multiple filters AND together.
The developer console’s usage dashboard sits on this API: evaluations over time by flag, resolutions by config, level changes by logger, API calls by endpoint. It’s all there the moment the SDK is integrated, because the platform emits these metrics itself — nobody has to instrument anything.
Opting out
There’s a disable_telemetry option, and we didn’t bury it. Some customers — privacy-conscious ones, or ones in regulated industries — want the platform recording as little about their operational patterns as possible. Turn it off and the SDKs and services stop emitting metric events for the account. You lose the usage dashboards; you gain the certainty that nobody is keeping a record of your minute-by-minute flag traffic.
What the one table can’t do
Counters and sums, it handles. Percentiles, it does not: p95 latency needs histograms or a sketch structure like t-digest, and this schema has neither. The day we need latency percentiles out of it, we’ll have that argument then.
It’s also a historical view: the query API reads stored records, so a dashboard that wants your evaluation rate refreshed every second is out of scope until we stream metrics over a WebSocket. And high-cardinality dimension filters lean on a JSONB GIN index, which works, but a hot dimension pattern would eventually earn itself a real column.
None of these gaps has hurt yet. When one does, the fix is an extension to a boring schema in a database we already run — not a migration off a metrics platform we married in year one.