Fabriq
1.x
Docs/Fabriq/Analytics
Open

Reading18 min
Updated2 Aug 2026
Sourcev1/(data-planes)/analytics.mdx
Note

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: true
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 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

Warning
  • 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.

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: &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:

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. 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: &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:

// 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 measurescount, 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.