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 (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 for the full boundary.
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.
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 it01
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:
insights:
enabled: truecfg := 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
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 Track02
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:
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
}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 cube03
Query runs a structured aggregation — measures grouped by dimensions,
optionally bucketed over time — without hand-writing SQL:
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:
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
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.
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:
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) to aggregate that
entity's projected facts — the rows the proj:insights consumer already
wrote to fabriq_insights_facts — instead of customer events:
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:
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
Rollups are event-sourced only.
MetricSpec.Rollupcan only be set on a metric whoseSourceis a schemaless event — a metric sourced from a registered entity's projected facts (Sourcenames an entity withInsightsSpec) can't be materialized; querying it always takes the live path overfabriq_insights_facts.One source per query.
Query.Sourcenames 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 cubeQuery. Reach forQueryRawfor 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
AnalyticsQuerieryet. Analytics only works against an in-processf.Analytics(); thefabriq+grpc://transport (ADR 0009) returnsErrNotImplementedforTrack/Query/QueryRawtoday.Materialized rollups aren't supported under schema-per-tenant mode. The runtime rollup-table DDL doesn't stamp
search_pathfor the tenant's schema — the same gap dynamic-entity managed DDL has. Under that tenancy mode, leaveMetricSpec.Rollupunset and query the metric live.
See ADR 0014 for the full, authoritative gaps list.
QueryRaw: the SQL escape hatch04
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:
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 in05
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:
r.Register(registry.EntitySpec{
Name: "order",
// ...
Insights: ®istry.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:
r.Register(registry.EntitySpec{
Name: "order",
// ...
Insights: ®istry.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.
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
below.
Materialized rollups06
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:
Metrics: []registry.MetricSpec{
{
Name: "daily_revenue",
Source: "order_placed",
Measures: []registry.MetricMeasure{{Kind: "sum", Field: "amount"}},
Dimensions: []string{"status"},
DefaultBucket: 24 * time.Hour,
Rollup: ®istry.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:
// 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_distinctandpercentileroll up approximately, backed bytimescaledb_toolkitsketches — a HyperLogLog forcount_distinct, a t-digest forpercentile. 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'srollup()aggregate. A 1024-register HyperLogLog carries roughly a 3% expected standard error forcount_distinct; the t-digest stays accurate to a few percent forpercentileon 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:
TimeBucketmust be a positive multiple of the metric'sRollup.Bucketgrain (an hourly rollup can serve 1h/2h/6h/24h buckets, never anything finer than an hour).Every requested dimension and every
Filtercolumn must be one of the metric's declaredDimensions— 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/percentilemeasures in a rollup require thetimescaledb_toolkitPostgres extension (bundled in thetimescale/timescaledb-ha:pg*-allimages). 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_pathfor the tenant's schema — the same gap dynamic-entity managed DDL has. Use another tenancy mode for a metric that needs materialization, or leaveRollupunset and query it live.