Report builder

A report is a grid of blocks. Each block runs one query and draws one thing.

The terms here are the terms the interface uses, because both come from the same validation schema in app/Reports/schema.ts. A test asserts this page lists exactly the values that schema accepts, so a new measure cannot appear in the product without appearing here.

Blocks

KindDraws
lineA measure over time
areaThe same, filled, for one dominant series
barA measure across a dimension, or over coarse time
donutComposition of a total across a dimension
big_numberOne number, and whether it moved
tableThe underlying rows, when the shape is not a chart
funnelConversion across ordered steps
heatmapA measure across two dimensions
textProse. The only kind that runs no query

The query

Every block except text carries a query with these parts.

events

The event names the block reads. Empty means every event in the project. Names come from the taxonomy or are your own.

measure

What to compute:

MeasureMeans
countRows. The default, and what a count of events means
count_uniqueDistinct user_key. Unique visitors, active users, buyers
sumSum of the named field. Revenue, duration, quantity
avgMean of the named field. Order value, session length
minSmallest value of the named field
maxLargest value of the named field

sum, avg, min and max require a field. count and count_unique ignore one.

field

Which value the measure reads. One of name, user_key, session_key, value, currency, occurred_at, or properties.<key> for anything in your own property bag.

This is an allowlist rather than a pattern. These names reach a query builder, and "anything that looks like an identifier" is how a column name becomes an injection point the day somebody builds the query with a template string.

dimension

Group the result into series by a field, using the same set as above. Bounded by limit with an explicit other bucket: when two hundred tags become the top ten, the report says how many went into Other rather than letting a reader assume the tail does not exist.

filters

Narrow what the block counts. Each filter is a field, an operator and usually a value:

OperatorMeans
isEqual to
is_notNot equal to
containsSubstring
starts_withPrefix
gtGreater than
ltLess than
existsThe field is present
not_existsThe field is absent

exists and not_exists carry no value, because the test is presence.

grain

How time is bucketed: hour, day, week or month.

compare

Compare against the previous period of equal length. When there was no previous period, the block says so rather than inventing a percentage. This is how dashboards end up claiming a four thousand percent rise, and it is always because the previous period was zero.

limit

Top N series when a dimension is set. The rest become Other, and the count is stated.

steps

Ordered event names, for funnel blocks only. A funnel measures people, not events, so when data is sampled the sampling keeps whole subjects: somebody is either wholly in the sample or wholly out. Sampling events independently makes every conversion rate noise, because the steps stop belonging to the same people.

The grid

Twelve columns. Drag a block to move it, its corner to resize, and everything has a keyboard path. Blocks push each other out of the way while you hold one, and the server settles the layout before saving, so two blocks can never share a cell. That last part is on the server deliberately: a layout agreed by one browser is a layout that disagrees with itself when two people edit at once.

Draft and published

Edits go to a draft. The published version is what a teammate opens and what a share link serves, and it does not change until you publish. A half finished experiment is never what somebody else walks into.

Every block explains itself

Each block carries one sentence derived from the query it actually runs, not from a label somebody typed. Two charts both called Revenue can count different things, and a reader deserves to know which one is in front of them before they quote a number in a meeting.

Report filters

A whole report can be narrowed from its URL:

/reports/my-report?f=properties.plan:is:pro

The form is f=field:operator:value, repeated, up to five. It lives in the query string and nowhere else, so a narrowed view is linkable: paste the URL into a conversation and the reader sees what you saw.

Operators here are limited to is, is_not and contains. A malformed filter is dropped rather than erroring, so a link with one bad filter still shows the report. Funnels are left alone, because quietly narrowing their steps would change what the conversion rate measures without saying so.