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

Source: https://bettersupabase.com/docs/blocks/metrics

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

```bash
better-supabase sql add metrics
```

```ts
import {
  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](/docs/blocks/usage), whose events can be a metric's
source.

## Define metrics [#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:

```ts title="src/metrics.ts"
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;
```

```ts title="better-supabase.config.ts"
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 [#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`:

```ts title="app/api/jobs/drain/route.ts"
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:

```ts
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](/docs/blocks/time-tracking#metrics) exports
`timeEntryMetricTime` for its entries, and
[orders](/docs/blocks/orders#metrics) exports `orderMetrics` with the events
that feed it.

## Read series and KPIs [#read-series-and-kpis]

`createMetrics({ transport, metrics })` types the metric keys and each
metric's dimensions from the definitions.

```ts
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-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 [#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:

```tsx title="components/orders-chart.tsx"
"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 [#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.