SQL Primitives

Metrics And KPIs

Versioned SQL metrics with KPI checks, a bitemporal run model, rolling baselines, and a durable verdict-stamped history — built into every rvbbit table.

Metrics are a small BI layer that comes with rvbbit tables — and you never have to use it. A metric is a row in a plain table and a SELECT. A KPI is that plus one more SELECT that returns a boolean. There is no separate metric store, no metric DSL, and no service to run. If you define one, you get systematic, versioned reporting (with experimental time-travel queries) over the rvbbit tables you already have. If you drop the extension, you keep every definition and every recorded observation as ordinary Postgres rows. All your data, no lock-in.

Two Time Axes#

Classic metrics tools struggle with three things: latency, "as of now vs. as of then," and the metric definition shifting over time. The metrics layer's core contribution is the third axis: it stores definitions as plain, append-versioned rows, so every run is parameterized by two independent axes:

Axis Controlled by Means
def-time p_def_as_of (a created_at filter) which version of the definition
data-time p_data_as_of (rvbbit AS OF) the data as of which moment

The def-time axis is stable: definitions are immutable, versioned rows, so "which definition" is always exact. The data-time axis rides on rvbbit time travel, which is experimental and intended primarily for audit and reproducibility — treat data-time results as best-effort and validate them before relying on them. Def-time alone (over live data) is the solid, everyday path.

SELECT * FROM rvbbit.metric('revenue', '{}'::jsonb,
    p_def_as_of  => '2025-01-01',   -- the metric as we defined it then
    p_data_as_of => now());         -- over the data as it is now

"Today's definition over last quarter's data" and "last quarter's definition over today's data" are both one call. The def-time direction is exact; the data-time direction (data_as_of in the past) is best-effort and still being validated.

Define A Metric#

define_metric appends a new version each time; definitions are never mutated. Templates use {param} for a safe literal, {param!} for raw text, and {metric:NAME} to inline another metric as a subquery.

SELECT rvbbit.define_metric(
  'revenue_by_region',
  $$SELECT region, sum(amount) AS revenue
    FROM orders
    WHERE amount >= {min}
    GROUP BY region$$,
  '{"min": 0}'::jsonb,    -- default params
  'region', 'Revenue per region', 'analytics');
SELECT * FROM rvbbit.metric('revenue_by_region', '{"min": 50}'::jsonb);
SELECT rvbbit.metric_sql('revenue_by_region', '{"min": 50}'::jsonb);  -- the exact SQL, no run
SELECT * FROM rvbbit.metric_catalog;                                   -- latest of each
SELECT * FROM rvbbit.metric_versions('revenue_by_region');            -- history

KPIs: The Check Is Part Of The Definition#

A metric becomes a KPI when its definition carries a check (the 8th argument to define_metric). The check runs against the metric's result — exposed to it as a CTE named metric — and must reduce to one row with an ok boolean (plus optional status / value / target). Thresholds are {param} tokens, so they have versioned defaults and are overridable per call.

SELECT rvbbit.define_metric(
  'daily_revenue',
  'SELECT sum(amount) AS total FROM orders',
  '{"target": 1000000}'::jsonb, 'all', 'Revenue must clear target', 'analytics',
  '{}'::jsonb,
  'SELECT total >= {target} AS ok, total AS value FROM metric');

SELECT rvbbit.check_metric('daily_revenue');
-- → {"ok": true, "value": 1250000, "status": "pass"}

Because the check lives on the versioned definition, moving a threshold makes a new version — so the verdict is auditable across def-time. Over the same data, yesterday's threshold and today's threshold can disagree, and both answers are real:

SELECT rvbbit.check_metric('daily_revenue', '{}'::jsonb, p_def_as_of => v1_time);  -- pass
SELECT rvbbit.check_metric('daily_revenue', '{}'::jsonb, p_def_as_of => now());    -- fail

A NULL ok is never treated as "pass" — a KPI over missing data does not read as healthy.

Rolling Baselines In One Line#

{metric:self.OFFSET} resolves to the metric's scalar value at a shifted data-time, so rolling, delta, and week-over-week become single tokens:

-- check: "must not shrink vs the prior snapshot"
'SELECT total >= {metric:self.-1day} AS ok, total AS value FROM metric'

-- metric body: week-over-week
'SELECT total::numeric / {metric:revenue.-7days} - 1 AS wow FROM {metric:revenue} r'

OFFSET is a signed amount + unit (-1day, -12hours, +1week, -1month) or an alias (yesterday, lastweek). Only the data-time shifts; the definition stays current. Because the shifted value is reconstructed by re-reading earlier generations via AS OF, treat these rolling/delta figures as best-effort (experimental) — they depend on the data-time path that is still being hardened.

A Durable, Verdict-Stamped History#

Live reads stay live, and earlier generations can be re-read with AS OF to approximate a historical value (experimental — see time travel). Rather than lean on that for the record of record, rvbbit materializes a durable log of what was reported: (value, verdict, threshold-version, def-time, data-time, generation, trigger). It outlives generation reaping and records the KPI verdict as decided — so the authoritative history is the materialized log, not a replayed reconstruction.

The default cadence is not a clock — compaction is the trigger. A new generation enqueues itself (if a metric depends on the table) and materialize_tick() (a pg_cron heartbeat) drains it, materializing each dependent metric at the generation's commit time. One observation per (metric, generation), aligned to the data's own heartbeat. With the Temporal Mirror, each sync run becomes one observation automatically.

SELECT data_generation, value->0->>'total' AS total, status, trigger
FROM rvbbit.metric_history('daily_revenue');

SELECT rvbbit.set_materialize('daily_revenue', p_on_compaction => true);
SELECT rvbbit.schedule_materialize_tick('* * * * *');   -- or call materialize_tick() yourself

Observations are immutable — they are the record of what you reported. "What it would have been under a newer definition" stays a live query.

Materialize Everything#

materialize_tick() only touches metrics whose tables just compacted. To take one timestamped snapshot of every metric on demand — the natural shape for a scheduled batch — use materialize_all_metrics. It records one observation per metric at a single captured def/data timestamp (a consistent snapshot), and a failing metric is reported, never aborting the rest:

SELECT * FROM rvbbit.materialize_all_metrics();                  -- everything, now
SELECT * FROM rvbbit.materialize_all_metrics(p_category => 'Finance');
-- → one row per metric: (metric_name, observation_id, status, error)
rvbbit.materialize_all_metrics(
    p_category    text DEFAULT NULL,
    p_subcategory text DEFAULT NULL,
    p_def_as_of   timestamptz DEFAULT NULL,   -- NULL = now()
    p_data_as_of  timestamptz DEFAULT NULL,   -- NULL = now(); set for a backfill
    p_trigger     text DEFAULT 'bulk'
) RETURNS TABLE(metric_name text, observation_id bigint, status text, error text)

Schedule it as a maintenance job:

SELECT cron.schedule('rvbbit_materialize_all', '0 * * * *',
                     $$SELECT rvbbit.materialize_all_metrics()$$);

A broken metric or check (e.g. an unbound {param}) is isolated — that one metric comes back as its own result row with status = 'error' and the error message in error (and no observation written for it), while every other metric in the batch still materializes. One typo never wedges a batch. Give every {param} a default in the metric's params to be safe.

Categories#

Metrics, cubes, and alerts share one optional two-level taxonomy. Categories organize the catalog and drive the p_category filter on materialize_all_metrics:

SELECT rvbbit.set_category('metric', 'daily_revenue', 'Finance', 'Revenue');
SELECT name, category, subcategory FROM rvbbit.metric_catalog WHERE category = 'Finance';

Dimensional Metrics#

A metric defined over a cube can be sliced by the cube's dimensions without redefining it — declare the source in labels, then call metric_by:

-- A scalar headline over a cube.
SELECT rvbbit.define_metric('pipeline_value',
    'SELECT sum(amount) AS pipeline_value FROM cubes.opportunities',
    '{}', 'scalar', 'Total open + won pipeline', 'sales',
    '{"cube_source": "cubes.opportunities"}'::jsonb);

SELECT * FROM rvbbit.metric('pipeline_value');     -- [{"pipeline_value": 5000000}]
SELECT * FROM rvbbit.metric_by('pipeline_value', ARRAY['stage_name', 'region']);
-- [{"stage_name": "Closed Won", "region": "AMER", "pipeline_value": 2000000}, ...]

What can you slice by?

SELECT * FROM rvbbit.metric_dimensions('pipeline_value');   -- groupable columns + semantics
SELECT * FROM rvbbit.cube_dimensions('cubes.opportunities') WHERE groupable;

Boards & Monitoring#

metric_board pivots the observation log into a time-bucketed grid for trend charts; breaching_kpis lists the KPIs failing right now; metric_lineage shows which tables a metric reads (impact analysis):

SELECT * FROM rvbbit.metric_board(p_metrics => ARRAY['daily_revenue'], p_bucket => 'day');

SELECT metric_name, status, verdict, observed_at FROM rvbbit.breaching_kpis();

SELECT rvbbit.metric_lineage('pipeline_value');   -- {"cubes.opportunities"}

A breaching KPI is the natural trigger for an alert ({"kind": "metric", "metric": "daily_revenue"}).

Function Reference#

Function
define_metric(name, sql, params, grain, description, owner, labels, check)int append a version (KPI if check set)
metric(name, params, def_as_of, data_as_of)SETOF jsonb run across both axes
metric_sql(name, params, def_as_of)text the composed SQL, no run
preview_metric_sql(draft_sql, params, def_as_of)text compose an unsaved draft
check_metric(name, params, def_as_of, data_as_of)jsonb the KPI verdict (NULL if not a KPI)
materialize_metric(name, params, def_as_of, data_as_of, generation, trigger)bigint append one observation
materialize_all_metrics(category, subcategory, def_as_of, data_as_of, trigger)TABLE snapshot every metric, now
materialize_tick(max)int drain the compaction queue (pg_cron)
metric_history(name, limit)TABLE the durable series
metric_board(metrics, from, to, bucket)SETOF jsonb time-bucketed grid for trends
metric_by(name, slice[], params, def_as_of, data_as_of)SETOF jsonb slice a cube-backed metric
metric_dimensions(name) / cube_dimensions(cube)TABLE groupable dimensions
breaching_kpis()TABLE KPIs currently failing
metric_lineage(name)text[] tables the metric reads
set_materialize(name, on_compaction, cron, enabled)void per-metric materialize policy
set_category('metric', name, category, subcategory)void tag for organization + filtering

Tables and views — all plain and SELECT-able: rvbbit.metric_defs, rvbbit.metric_catalog (latest def per metric + category/subcategory), rvbbit.metric_observations (the immutable log: value, verdict, metric_version, def_as_of, data_as_of, data_generation, trigger), rvbbit.metric_materialize, rvbbit.metric_dependencies.

In Data Rabbit, a Metrics folder adds three apps — a Catalog, a Creator (with a live resolved-SQL preview and verdict badge), and an Inspector that runs a metric across both axes with a results grid, a pass/fail verdict, and a materialized Trend.