Metrics
Declare metrics with defineMetric, roll them up into metric_points or read small tables directly, and read series and KPIs with a previous-range comparison.
metrics turns a table into time series. A metric counts rows, or sums or
averages a column, per time bucket and per dimension, in the metric's time
zone. Large tables roll up into metric_points; small ones are read directly.
series() and kpi() return the shapes chart and stat-card components read,
and each read checks a permission in the tenant.
better-supabase sql add metricsimport {
createMetrics,
defineMetric,
metricsSink,
toChartData,
} from "better-supabase/blocks/metrics";The module needs tenant and access. It works with jobs for the periodic
rollup and with Usage, whose events can be a metric's
source.
Define metrics
Define each metric once, in a file both the config and the app import, so the SQL and the client agree on keys and dimensions:
import { defineMetric } from "better-supabase/blocks/metrics";
export const metrics = [
defineMetric({
key: "orders",
table: "public.orders",
measure: "count",
timeColumn: "placed_at",
dimensions: ["channel"],
tz: "Europe/Amsterdam",
rollup: ["hour", "day"],
}),
defineMetric({
key: "revenue",
table: "public.orders",
measure: { sum: "total" },
timeColumn: "placed_at",
where: { status: "paid" },
rollup: false,
unit: "EUR",
}),
defineMetric({
key: "api_calls",
table: "better_supabase.usage_events",
measure: { sum: "quantity" },
timeColumn: "recorded_at",
where: { meter: "api_calls" },
permission: "metrics.manage",
}),
] as const;import { metrics } from "./src/metrics";
sql: {
modules: {
metrics: { options: { metrics, window: "3 days" } },
},
},| Key | Default | What it sets |
|---|---|---|
key | required | The metric's name, lowercase with underscores |
table | required | The source table, as table or schema.table |
measure | required | "count", { sum: column } or { avg: column } |
timeColumn | required | The timestamp that places a row in a bucket |
dimensions | none | Columns a read can filter or group by |
tenant | organization_id | The tenant column |
tz | UTC | The IANA time zone buckets start in |
where | none | Column to value; only matching rows count |
rollup | ["day"] | The grains stored in metric_points, or false to read the table every time |
permission | metrics.read | The key a read checks in the tenant |
label | none | A display name definitions() returns |
unit | none | A display unit such as EUR |
window (default 3 days) is how far back the periodic rollup
re-aggregates, so late and edited rows land in their buckets.
Rollups
A stored grain answers that grain and every coarser one it divides: hourly
points sum into days, weeks and months, daily points into weeks and months.
A read at a grain no stored grain divides, such as hour for a daily metric,
reads the table.
metrics_rollup rebuilds the buckets of a window: it deletes the stored
points from the aligned start of the window to the aligned end and inserts
them again from the table, under an advisory lock per metric. It is service
only. Run it on a schedule with jobs:
export const GET = jobs.drainRoute({
secret: process.env.CRON_SECRET,
handlers: { "metrics.rollup": metrics.job },
});To keep charts current between runs, relay the events that add rows to
metricsSink. It re-aggregates the window the batch's events fall in, per
metric:
outbox.relay(
"metrics",
metricsSink(serviceMetrics, {
on: { "order.placed": ["orders", "revenue"] },
}),
);The sink reads each event's time. When rows sit at another time than the
event, such as time logged for a past day, pass time, a function that
returns the timestamp to bucket by, or undefined to keep the event's own.
Time tracking exports
timeEntryMetricTime for its entries, and
orders exports orderMetrics with the events
that feed it.
Read series and KPIs
createMetrics({ transport, metrics }) types the metric keys and each
metric's dimensions from the definitions.
const metrics = createMetrics({ transport, metrics: definitions });
const range = {
from: Temporal.Instant.from("2026-10-01T00:00:00Z"),
until: Temporal.Instant.from("2026-11-01T00:00:00Z"),
};
const series = await metrics
.series("orders", range, {
organizationId,
grain: "day",
groupBy: "channel",
compare: "previous",
})
.orThrow();
const revenue = await metrics
.kpi("revenue", range, { organizationId, compare: "previous" })
.orThrow();
// { metric: "revenue", value: 1840, count: 23, previous: 1600, change: 0.15, trend: "up" }A series has one point per bucket of [from, until), with empty buckets as
zero (or null for an average over no rows). compare: "previous" reads the
range of the same length just before, bucket for bucket. filter keeps rows
whose dimensions equal the given values.
| Hint | When |
|---|---|
METRIC_FORBIDDEN | The caller lacks the metric's permission in the tenant |
METRIC_UNKNOWN | No metric has this key |
METRIC_DIMENSION_UNKNOWN | A filter or groupBy names a column that isn't a dimension |
METRIC_GRAIN_INVALID | The grain isn't hour, day, week or month |
METRIC_RANGE_INVALID | from is not before until |
METRIC_RANGE_TOO_LARGE | The series would have more than 1000 buckets |
definitions() lists the declared metrics with their label, unit, measure,
dimensions, stored grains and time zone, for a metric picker.
Usage meters
usage records each increment in usage_events, so a meter is a metric over
that table with where: { meter }, as api_calls above. Give it a stricter
permission when the numbers are internal.
With ReUI
toChartData turns a series into the rows Recharts reads: date, value,
previous with a comparison, and one key per group with groupBy. With the
ReUI (shadcn) chart components:
"use client";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import type { MetricSeries } from "better-supabase/blocks/metrics";
import { toChartData } from "better-supabase/blocks/metrics";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
const day = new Intl.DateTimeFormat("en", { month: "short", day: "numeric" });
export function OrdersChart({ series }: { series: MetricSeries }) {
const data = toChartData(series, {
format: (bucket) => day.format(new Date(bucket.epochMilliseconds)),
});
return (
<ChartContainer
config={{
value: { label: "Orders", color: "var(--chart-1)" },
previous: { label: "Previous period", color: "var(--chart-2)" },
}}
>
<AreaChart data={[...data]}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area dataKey="previous" stroke="var(--color-previous)" fill="none" />
<Area
dataKey="value"
stroke="var(--color-value)"
fill="var(--color-value)"
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
);
}A kpi() result fills a stat card: value, and change with trend for
the badge.
Hooks
before_metric_rollup and after_metric_rollup run around each metric's
rollup with its key and window. createMetrics takes hooks around its
methods, as every block does.
Last updated on