---
title: Analytics
description: The per-tenant, RLS-scoped customer analytics port — event ingest, a structured cube query, and a raw-SQL escape hatch, all reachable through f.Analytics().
---

<Callout type="info">
**This is tenant-facing analytics.** `f.Analytics()` is scoped to the caller's
own tenant like every other fabriq port — a tenant's own application queries
its own data. It is a completely different capability from the operator-only
[Analytics sink](/docs/fabriq/(operations)/analytics-sink) (`POST /admin/analytics/query`, ADR
0013), which deliberately co-locates a redacted slice of **every** tenant's
data in one shared store for fleet-wide operator reporting. The two share no
table, DSN, consumer group, or query path — see
[ADR 0014](/docs/fabriq/reference/decisions) for the full boundary.
</Callout>

The analytics plane is for a tenant's own product analytics: custom event
tracking (`"order_placed"`, `"button_clicked"`) plus aggregation over the
tenant's own opt-in domain entities — through one typed surface, isolated to
that tenant exactly like every other fabriq port.

```go
type AnalyticsQuerier interface {
	Track(ctx context.Context, events []AnalyticsEvent) error
	Query(ctx context.Context, q AnalyticsQuery, into any) error
	QueryRaw(ctx context.Context, into any, sql string, args ...any) error
}
```

Reach it through `f.Analytics()`.

## Enabling it

Analytics is off by default. Turn it on with one config flag — there is no
separate DSN to configure, because the store lives inside the tenant's own
database/schema/shard, alongside every other tenant table:

```yaml
insights:
  enabled: true
```

```go
cfg := fabriq.Config{
	// ...
	Insights: fabriq.InsightsConfig{Enabled: true},
}
```

With `Insights.Enabled: false` (the default), `f.Analytics()` degrades every
method to `fabriq.ErrStoreNotConfigured`, the same discipline every other
optional port follows.

Migration `202607100031` (`insights`) creates the two tables this plane uses
— `fabriq_insights_events` and `fabriq_insights_facts` — with the same
row-level-security policies as every other in-tenant table. Turning on
`Insights.Enabled` does not by itself start the domain-projection consumer
(`proj:insights`); that also requires at least one registered entity to opt
in via `InsightsSpec` (see [Opting a domain entity in](#opting-a-domain-entity-in)
below). Enabling `Insights` with no entity marked logs a warning: nothing
would flow into any tenant's store — `Track`/`Query`/`QueryRaw` still work
for schemaless events regardless.

## Ingesting events with Track

`Track` is a bulk, fire-and-forget ingest path — like `TSQuerier.BulkWrite`,
it bypasses the outbox entirely (no event, no aggregate) and writes straight
into the tenant's `fabriq_insights_events` table:

```go
type AnalyticsEvent struct {
	Name     string         // "order_placed", "button_clicked"
	At       time.Time      // event time
	Props    map[string]any // arbitrary dimensions/measures -> JSONB
	DedupKey string         // optional: idempotent retries
}
```

```go
err := f.Analytics().Track(ctx, []query.AnalyticsEvent{
	{
		Name: "order_placed",
		At:   time.Now(),
		Props: map[string]any{
			"amount":   42.50,
			"status":   "paid",
			"category": "widgets",
		},
	},
})
```

`Props` is schemaless — no registration is required to start tracking a new
event name or a new prop key. `DedupKey`, when set, makes a retry safe: a
second `Track` call with the same `(tenant, DedupKey)` is silently ignored
(a partial unique index enforces this; `NULL` dedup keys never conflict, so
most events need not set one at all). Dedup keys are **tenant-wide**: they are
deduplicated across the whole tenant, not per scope, so two different scopes
that reuse the same `DedupKey` collide and only the first event lands. Choose
keys that are unique within the tenant (e.g. prefix them with the scope).

## Querying with the cube

`Query` runs a structured aggregation — measures grouped by dimensions,
optionally bucketed over time — without hand-writing SQL:

```go
type AnalyticsQuery struct {
	Source        string        // event name, InsightsSpec entity name, or MetricSpec name
	Measures      []Measure     // at least one (omit when Source names a metric)
	Dimensions    []string      // group-by keys (omit when Source names a metric)
	TimeBucket    time.Duration // 0 = no time grouping; overrides a metric's DefaultBucket
	Filter        Where         // reuses the relational Where vocabulary
	Having        Where         // post-aggregation filter over measure output aliases
	From, To      time.Time     // bound the event time window
	OrderBy       string
	Limit, Offset int
}
```

A worked example — total revenue and order count, by status, bucketed by
day, for orders over $10 in the last 30 days:

```go
type row struct {
	Status string  `json:"status"`
	Bucket string  `json:"bucket"`
	Total  float64 `json:"sum_amount"`
	Count  int64   `json:"count"`
}

var rows []row
err := f.Analytics().Query(ctx, query.AnalyticsQuery{
	Source:     "order_placed",
	Measures:   []query.Measure{
		{Kind: query.MeasureSum, Field: "amount"}, // -> column "sum_amount"
		{Kind: query.MeasureCount},                // -> column "count"
	},
	Dimensions: []string{"status"},
	TimeBucket: 24 * time.Hour,
	Filter:     query.Where{query.Gt("amount", 10)},
	From:       time.Now().AddDate(0, 0, -30),
}, &rows)
```

Each result row carries one column per dimension (named after the dimension
key), one `bucket` column when `TimeBucket > 0`, and one column per measure
(named `<kind>_<field>`, e.g. `sum_amount`, or `count` for `MeasureCount` —
override with `Measure.As`). `Query` scans into `*[]T` (a struct or a
`map[string]any`), the same map-native scanning every dynamic-entity read in
fabriq uses.

Available measure kinds: `MeasureCount`, `MeasureSum`, `MeasureAvg`,
`MeasureMin`, `MeasureMax`, `MeasureCountDistinct`, `MeasurePercentile`.

`Filter` reuses the same `Where` vocabulary as every other query port
(`Eq`/`In`/`Gt`/`Or`/…). Range comparisons (`Gt`/`Gte`/`Lt`/`Lte`) against a
**numeric** Go value compare numerically; against a **string** value they
compare lexicographically (the prop is stored as JSONB text under the hood) —
by design, since dimensions can be either numbers or strings.

### Percentile measures

```go
var rows []struct {
	Status string  `json:"status"`
	P95    float64 `json:"p95_latencyMs"`
}
err := f.Analytics().Query(ctx, query.AnalyticsQuery{
	Source: "request_completed",
	Measures: []query.Measure{
		{Kind: query.MeasurePercentile, Field: "latencyMs", Percentile: 0.95}, // -> "p95_latencyMs"
	},
	Dimensions: []string{"route"},
}, &rows)
```

`Percentile` is a fraction in `(0, 1)` — `0.95` for p95, `0.5` for the
median. The default output column is `pNN_field` (the percentile × 100,
rounded, then the field name — `0.95` + `latencyMs` becomes `p95_latencyMs`);
set `Measure.As` to override. Percentiles are **non-additive** — a stored p95
can't be recombined from partial p95s the way a sum or count can — so a
purely live `Query` always recomputes one from scratch over every matching
row. A percentile measure declared on a materialized `MetricSpec` *can* still
be accelerated, via an approximate t-digest sketch — see
[Materialized rollups](#materialized-rollups).

### Having: filtering on aggregated output

`Having` filters aggregated rows by a measure's **output alias** — the same
`Where` vocabulary as `Filter`, evaluated after aggregation instead of
before it:

```go
err := f.Analytics().Query(ctx, query.AnalyticsQuery{
	Source: "order_placed",
	Measures: []query.Measure{
		{Kind: query.MeasureCount, As: "orders"},
	},
	Dimensions: []string{"status"},
	Having:     query.Where{query.Gt("orders", 100)},
}, &rows)
```

Only statuses with more than 100 orders come back. `Having` works over any
measure's output alias, including a `MeasurePercentile` alias — e.g.
`query.Where{query.Gt("p95_latencyMs", 500)}` keeps only groups whose p95
latency exceeds 500ms.

### Querying projected facts (Source = entity name)

Set `Source` to an entity name that declared `registry.InsightsSpec` (see
[Opting a domain entity in](#opting-a-domain-entity-in)) to aggregate that
entity's projected facts — the rows the `proj:insights` consumer already
wrote to `fabriq_insights_facts` — instead of customer events:

```go
err := f.Analytics().Query(ctx, query.AnalyticsQuery{
	Source:     "order", // entity name, not an event name
	Measures:   []query.Measure{{Kind: query.MeasureSum, Field: "amount"}},
	Dimensions: []string{"status", "region"},
}, &rows)
```

`Measures` and `Dimensions` must be columns declared in that entity's
`InsightsSpec` (`Measures`/`Dimensions`) — a column the entity didn't
declare is rejected before the query runs.

### Invoking a declared metric by name

Set `Source` to a `registry.MetricSpec.Name` to reuse its declared
`Measures`/`Dimensions`/`DefaultBucket` — the caller still controls
`Filter`, `From`/`To`, `OrderBy`, `Limit`/`Offset`, and `Having`:

```go
err := f.Analytics().Query(ctx, query.AnalyticsQuery{
	Source: "daily_revenue", // registry.MetricSpec.Name
	Filter: query.Where{query.Eq("status", "paid")},
	From:   time.Now().AddDate(0, 0, -7),
}, &rows)
```

Passing explicit `Measures` or `Dimensions` alongside a metric `Source` is a
query-time **error** — a metric owns its shape, the caller owns filtering.

### Known limitations of Query

<Callout type="warn">
- **Rollups are event-sourced only.** `MetricSpec.Rollup` can only be set on a
  metric whose `Source` is a schemaless event — a metric sourced from a
  registered entity's projected facts (`Source` names an entity with
  `InsightsSpec`) can't be materialized; querying it always takes the live
  path over `fabriq_insights_facts`.
- **One source per query.** `Query.Source` names exactly one event, entity,
  or metric — there's no way to join customer events with projected facts
  (or facts from two different entities) in a single cube `Query`. Reach
  for `QueryRaw` for cross-source aggregation. This also means rollup
  acceleration never spans sources: it only ever applies to the one
  materialized metric a query names.
- **The remote/gRPC transport doesn't carry `AnalyticsQuerier` yet.**
  Analytics only works against an in-process `f.Analytics()`; the
  `fabriq+grpc://` transport (ADR 0009) returns `ErrNotImplemented` for
  `Track`/`Query`/`QueryRaw` today.
- **Materialized rollups aren't supported under schema-per-tenant mode.**
  The runtime rollup-table DDL doesn't stamp `search_path` for the tenant's
  schema — the same gap dynamic-entity managed DDL has. Under that tenancy
  mode, leave `MetricSpec.Rollup` unset and query the metric live.
</Callout>

See [ADR 0014](/docs/fabriq/reference/decisions) for the full,
authoritative gaps list.

## QueryRaw: the SQL escape hatch

For aggregations the cube can't express — window functions, complex joins,
reading `fabriq_insights_facts` directly — `QueryRaw` runs a read-only,
RLS-scoped SQL statement:

```go
var out []struct {
	Entity string `json:"entity"`
	Count  int64  `json:"count"`
}
err := f.Analytics().QueryRaw(ctx, &out, `
	SELECT entity, count(*) AS count
	FROM fabriq_insights_facts
	WHERE NOT deleted
	GROUP BY entity
`)
```

`QueryRaw` runs inside a genuine Postgres `READ ONLY` transaction (so any
data-modifying statement fails at the database, not just at a precheck) and
is additionally guarded by a statement precheck that rejects writes, DDL,
and file-access functions before the query ever reaches Postgres. It is
tenant-stamped the same way every other in-tenant query is — `tenant_id`
and `scope_id` are `SET LOCAL` on the transaction, so RLS scopes every read
to the caller's own tenant regardless of what the SQL text says.

## Opting a domain entity in

Beyond custom events, a tenant's existing domain entities can be projected
into the analytics store so their history is aggregatable too. Mark an
entity with `registry.InsightsSpec`:

```go
r.Register(registry.EntitySpec{
	Name: "order",
	// ...
	Insights: &registry.InsightsSpec{
		Measures:   []string{"amount"},          // aggregatable numeric columns
		Dimensions: []string{"status", "region"}, // group-by columns
	},
})
```

Deny-by-default: an entity with no `Insights` field (the zero value, `nil`)
is never projected. A spec naming neither `Measures` nor `Dimensions` is
rejected at registration time — a marked-but-empty spec would silently
project nothing, which is worse than a startup error. Every named column
must be a real column of the entity's binding, also checked at registration.

Unlike the operator sink's `AnalyticsSpec`, **there is no `Include`/`Hash`
redaction** on `InsightsSpec` — the projected payload keeps exactly the
declared measure and dimension columns, unredacted, because this data never
leaves the tenant's own database.

Once at least one entity carries an `InsightsSpec` (and `Insights.Enabled`
is true, and Redis is configured), the `proj:insights` consumer starts
automatically alongside the worker or catalog sweeper: on every change to an
opted-in entity, a version-gated `Fact` lands in that tenant's own
`fabriq_insights_facts` table. Redelivery is always safe — the upsert only
applies when the incoming version is newer than what's stored.

### Declaring named metrics (optional)

For typed, reusable cube queries, declare a `MetricSpec` alongside
`Insights`:

```go
r.Register(registry.EntitySpec{
	Name: "order",
	// ...
	Insights: &registry.InsightsSpec{
		Measures:   []string{"amount"},
		Dimensions: []string{"status"},
	},
	Metrics: []registry.MetricSpec{
		{
			Name:          "daily_revenue",
			Source:        "order_placed",
			Measures:      []registry.MetricMeasure{{Kind: "sum", Field: "amount"}},
			Dimensions:    []string{"status"},
			DefaultBucket: 24 * time.Hour,
		},
	},
})
```

A `MetricSpec` validates at registration (a non-empty `Name`, at least one
`MetricMeasure`, and every non-`count` measure field is a real column) and
makes the metric discoverable, typed metadata. It **is** callable by name:
pass `Query.Source = "daily_revenue"` and the cube expands it into the
declared measures/dimensions/bucket, as shown in
[Invoking a declared metric by name](#invoking-a-declared-metric-by-name).
`MetricMeasure.Kind` may be any of `count`/`sum`/`avg`/`min`/`max`/
`count_distinct`/`percentile` — a percentile measure on a `MetricSpec` sets
`MetricMeasure.Percentile` the same way `Measure.Percentile` works inline on
an `AnalyticsQuery`.

An event-sourced metric (`Source` names no registered entity) can also opt
into background materialization — see [Materialized rollups](#materialized-rollups)
below.

## Materialized rollups

Any event-sourced `MetricSpec` can opt into background materialization by
setting `Rollup`. A maintainer job pre-aggregates the metric's raw events
into per-bucket rollup rows, and `Query` transparently accelerates itself
against them — no change to the calling code:

```go
Metrics: []registry.MetricSpec{
	{
		Name:          "daily_revenue",
		Source:        "order_placed",
		Measures:      []registry.MetricMeasure{{Kind: "sum", Field: "amount"}},
		Dimensions:    []string{"status"},
		DefaultBucket: 24 * time.Hour,
		Rollup: &registry.RollupSpec{
			Bucket:       time.Hour,        // rollup grain; required, must be > 0
			SealGrace:    10 * time.Minute, // delay before a bucket seals (0 = 5m default)
			RerollWindow: 2 * time.Hour,    // trailing recompute for late arrivals (0 = 2×Bucket default)
		},
	},
},
```

`Rollup` is `nil` by default — a metric stays live-only (the phase 1/2a
behavior) unless it opts in. Materialization is **event-sourced only**:
`Rollup.Bucket` must be greater than zero, and registration fails if
`Source` names a registered entity instead of a schemaless event — a
projected-facts metric can never be materialized.

Querying it is unchanged:

```go
// Same call as any other named metric — the cube decides, transparently,
// whether to serve it from the rollup or compute it live.
err := f.Analytics().Query(ctx, query.AnalyticsQuery{
	Source:     "daily_revenue",
	TimeBucket: 24 * time.Hour, // a clean multiple of the 1h rollup grain
	Filter:     query.Where{query.Eq("status", "paid")},
	From:       time.Now().AddDate(0, 0, -30),
}, &rows)
```

### Transparent and always fresh

A `Query` against a materialized metric is served by **stitching** the
sealed rollup with a **live tail**: buckets recent enough that the
maintainer hasn't sealed them yet are computed live, directly over
`fabriq_insights_events`, and combined with the pre-aggregated sealed rows
in the same result. The live tail always covers events up to the query's
`To` bound, so the stitched result is exactly as current as a fully-live
query — just faster, since most of the range is already pre-aggregated.
One caveat: `RerollWindow` is the late-arrival exactness horizon — an event
backdated into a bucket that's already sealed *and* has fallen outside the
metric's `RerollWindow` is never absorbed into the rollup, so a
rollup-served result can under-count such very-late arrivals relative to a
fully-live query over the same range.

### Exact vs. approximate

- **Additive measures** — `count`, `sum`, `avg`, `min`, `max` — roll up
  **exactly**: the stitched result is identical to what a fully-live query
  would compute.
- **`count_distinct` and `percentile`** roll up **approximately**, backed by
  `timescaledb_toolkit` sketches — a HyperLogLog for `count_distinct`, a
  t-digest for `percentile`. The sealed rollup and the live tail build their
  sketch at the same size (a 1024-register HyperLogLog, a 100-bucket
  t-digest) so they combine correctly via the toolkit's `rollup()`
  aggregate. A 1024-register HyperLogLog carries roughly a 3% expected
  standard error for `count_distinct`; the t-digest stays accurate to a few
  percent for `percentile` on typical distributions.
- The pure-live path — a non-materialized metric, a schemaless event
  queried directly, projected facts, or any query the rollup can't serve
  (see below) — always computes exactly, sketches included.

### Compatibility and fallback

A query is served from the rollup only when its shape lines up with what's
stored:

- `TimeBucket` must be a positive multiple of the metric's `Rollup.Bucket`
  grain (an hourly rollup can serve 1h/2h/6h/24h buckets, never anything
  finer than an hour).
- Every requested dimension and every `Filter` column must be one of the
  metric's declared `Dimensions` — a rollup row has no column for anything
  else.

Any other query shape falls through to the exact live path automatically,
rather than erroring — materialization only ever accelerates a query, it
never changes what a query is allowed to ask for. The same fallback applies
before the maintainer has ever sealed a bucket for the metric (no watermark
yet): the very first `Query` against a freshly materialized metric is served
live, exactly like an unmaterialized one, until the next maintainer pass.

### Maintenance

A leader-elected background job, `rollup:insights`, incrementally seals and
aggregates completed buckets, per tenant, for every materialized metric. It
runs once `Insights.Enabled` is true and at least one metric declares
`Rollup` — nothing to configure beyond that. Its cadence defaults to one
pass a minute; override it with `forgeext.WithRollupInterval(d)` when wiring
the extension.

### Requirements and limits

- `count_distinct`/`percentile` measures in a rollup require the
  `timescaledb_toolkit` Postgres extension (bundled in the
  `timescale/timescaledb-ha:pg*-all` images). A materialized metric with a
  sketch measure fails loudly at boot — before any table is created — if
  the toolkit isn't installable in the target database; additive-only
  rollups never touch the toolkit at all.
- **Materialized rollups aren't supported under schema-per-tenant
  consolidation mode.** The runtime rollup-table DDL doesn't stamp
  `search_path` for the tenant's schema — the same gap dynamic-entity
  managed DDL has. Use another tenancy mode for a metric that needs
  materialization, or leave `Rollup` unset and query it live.

<Cards>
  <Card title="ADR 0014" href="/docs/fabriq/reference/decisions">The design decisions and the hard boundary vs. the operator sink.</Card>
  <Card title="Analytics sink" href="/docs/fabriq/(operations)/analytics-sink">The operator-only, cross-tenant counterpart (ADR 0013).</Card>
  <Card title="Registry" href="/docs/fabriq/(concepts)/registry">EntitySpec and how opt-in specs like InsightsSpec are validated.</Card>
  <Card title="Tenancy" href="/docs/fabriq/(concepts)/tenancy">The RLS and structural-stamping layers this plane inherits.</Card>
</Cards>
