Skip to content

feat(web): funnel breakdowns, time to convert and a fuller steps table - #2586

Open
Makisuo wants to merge 7 commits into
feat/product-analytics-pagefrom
t3/polish-product-analytics-funnels
Open

Makisuo wants to merge 7 commits into
feat/product-analytics-pagefrom
t3/polish-product-analytics-funnels

Conversation

@Makisuo

@Makisuo Makisuo commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

The Funnels tab on Product analytics answered "how many reached each step". This makes it answer the questions that come next: which channel converts, how long it takes, is it getting better, and where the people who left went.

  • Break down by: where the session came from (referrer, UTM source, medium, campaign, country, site) or an event property the org sends, picked from a list. A table shows each group's entered, converted and conversion, with the difference from everyone in points. Up to 20 groups.
  • Stat rail: entered and conversion are compared with the window before. A new "Time to convert" tile shows the median from the first step to the last, and the time 90% finish within.
  • Steps table: people dropped, p50 and p90 from the previous step, and the top three things leavers did next with their share. It now shows in both views; before, Bars had no timing or leavers.
  • Bars view: one hue for a funnel's steps, with bars sized to the panel. Before, each step had its own colour and the bars were thin lines in a tall panel. A dashboard funnel fed by a group-by query keeps its series colours.
  • The stat tile's hint now sits beside its label, and a tile with a delta no longer sits lower than its neighbours.

Under it: the timing query also answers the time since step 1, and a breakdown can ask for more than the widget's six groups (breakdownLimit, 1 to 20).

Screenshots

Break down by UTM source (dark, light)

breakdown
breakdown light

Drop-off view: before, after

before
after

Bars view: before, after

before
after

Dashboard funnel tiles (query-set funnels keep their colours, bars fill the tile)

tiles

Verification

  • bunx vitest run src/page/product-analytics src/page/dashboards src/page/kit src/lab in apps/web: 852 pass. New scene tests cover:
    • the funnel read again for the window before, counts only, and the change in points;
    • a breakdown asking for 20 groups and each group compared with everyone;
    • picking a breakdown writing it to the URL, None clearing it, and an unknown value ignored;
    • the time to convert and the leavers' shares.
  • apps/cli/test/funnel-scenarios.test.ts (chDB): 79 pass, with the time since step 1 checked against the reference on every example funnel and on random histories.
  • packages/query-engine 814 pass, packages/query-model, packages/web-data, packages/backend dashboards and apps/api v2 dashboards tests pass.
  • Typecheck for web, views, query-engine, query-model, domain, backend, web-data, cli. oxlint (both configs), ui:index:check, ui:patterns:check and the UI baseline are clean.
  • In a browser (/lab/product-analytics?state=funnels-breakdown, funnels, funnels-bars and /lab/widgets), dark and light.

Not done yet: a preview run on real data.

Stack: #2171 → #2172 → this PR. The diff also shows #2584's commit until that merges.

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Devin Review

…nverting attempt correctly

A funnel that repeats a step was satisfied by a single event, two rows in one
millisecond converted in either order, and time between steps and the leavers
list followed only a person's first attempt (and returned nothing when step 1
was a session step).

Every funnel query now orders rows by one key (microseconds and Seq), reads
times and leavers from the attempt the count is based on, and treats a mobile
screen view as a page step. A scenario suite runs the compiled SQL on chDB
against a TypeScript reference funnel over four example products, hand-built
edge cases and random histories.
The Funnels tab gains a breakdown (where the session came from, or an event
property the org sends) with a table of each group's conversion against
everyone, a stat rail that compares the window with the one before and shows
how long the funnel takes, and a steps table with the people dropped, p50 and
p90 between steps and the top three things leavers did next.

The timing query also answers the time since step 1, a breakdown can ask for
up to twenty groups, and a product-event funnel draws its bars in one hue,
sized to the panel.
@maple-review-bot

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 72cb2270-7ae3-4dac-ab48-e16cad6799bc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +574 to +580
const stepSubject = (step: FunnelStep): string => {
switch (step.kind) {
case "event":
return `event:${step.eventName}`
case "page":
return `page:${step.pagePath}`
case "session":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 One page view counts as two steps

For an event step followed by a matching page step, stepsCanShareRow misses their overlap. windowFunnel then counts one view twice, inflating conversions while timing and leavers use distinct rows.

Learn more

The funnel uses windowFunnel only when no row can satisfy two steps; overlapping steps instead use walkQuery, which consumes each row once. An event step matches EventName regardless of Kind, while eventStepCondition also matches a page step on navigation or screen rows. The subject classifier assigns different prefixes to those steps, so it overlooks this overlap and sends their counts through windowFunnel. Timing and leavers always use the distinct-row walk, so their results can disagree with the counts.

Example: A single navigation row has EventName = '$pageview' and PagePath = '/pricing'. For steps event('$pageview') → page('/pricing'), the count reaches step 2 using that one row, although no second row exists.

Recommended fix: Make stepsCanShareRow detect cross-kind overlaps between event names and page steps, or use the distinct-row walk whenever an event and page step can match the same row. Add a chDB case with one matching navigation row and compare counts, timing, and leavers.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +305 to 308
: `median · 90% within ${fmtSpan(last.sinceStartP90Ms)}`,
}),
statRailItem(h, {
eyebrow: "Biggest drop",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Empty conversions display a timing claim

When nobody converts, stats receives zero-valued timing and displays median · 90% within –. The timing query returns zeroes for empty steps, so the intended nobody converted message never appears.

(Refers to this code)

Learn more

The timing query emits a zero-valued timing row even when nobody reached a step. Those zeroes are carried into the funnel row through funnelStepDetails. The display checks for an absent value rather than checking whether anyone converted, so the no-conversion case renders a statement about the 90th percentile with no duration.

Example: For signup_started → signup_completed, 12 people enter and zero complete. The tile shows median · 90% within – instead of nobody converted.

Recommended fix: Gate the final-step timing value and subline on converted > 0, not only on whether the timing fields are present. Keep the existing one-step state separate.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment on lines +300 to +305
subline:
last?.sinceStartP90Ms === undefined
? hasSteps
? "nobody converted"
: "one step"
: `median · 90% within ${fmtSpan(last.sinceStartP90Ms)}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Time to convert shows “median · 90% within –” when nobody converted

F1 · Warning · correctness

A step nobody reached still answers a timing row of zeros (productEventsFunnelTimingRowSchema: "a step nobody reached answers 0, never a missing row"), so last.sinceStartP90Ms is 0, never undefined. On a funnel with no conversions the tile renders value “–” and subline “median · 90% within –”, and the “nobody converted” copy never renders.

Decide the state from the last step's count (`converted === 0`) instead of testing `sinceStartP90Ms === undefined`, which the query never sends.
Suggested change
subline:
last?.sinceStartP90Ms === undefined
? hasSteps
? "nobody converted"
: "one step"
: `median · 90% within ${fmtSpan(last.sinceStartP90Ms)}`,
subline: !hasSteps
? "one step"
: converted === 0
? "nobody converted"
: `median · 90% within ${fmtSpan(last?.sinceStartP90Ms ?? 0)}`,
🤖 Prompt to fix with an AI agent
In `apps/web/src/page/product-analytics/view-funnels.ts:300-305`: `Time to convert` shows “median · 90% within –” when nobody converted.

A step nobody reached still answers a timing row of zeros (`productEventsFunnelTimingRowSchema`: "a step nobody reached answers 0, never a missing row"), so `last.sinceStartP90Ms` is `0`, never `undefined`. On a funnel with no conversions the tile renders value “–” and subline “median · 90% within –”, and the “nobody converted” copy never renders.

Replace those lines with:

			subline: !hasSteps
				? "one step"
				: converted === 0
					? "nobody converted"
					: `median · 90% within ${fmtSpan(last?.sinceStartP90Ms ?? 0)}`,

Verify the problem exists at that location before changing it, and keep the fix to those lines.

…tics

"Filter by property" on the Steps panel shows a where line under each event
step (plan = "pro"). It runs on Enter or once typing pauses, a clause that
does not parse yet keeps the filter that is running, and turning it off clears
the filters. The line moves into the step builder kit, so the dashboard editor
and the page draw the same one.

A one-row where editor is one line tall again: its min-height went to the
wrapper instead of the textarea, so it kept the two-row minimum. A filtered
step's label names its filter.
Sessions carry a UTM medium and campaign, where a visitor came from tilts how
far they get, and signup_completed, onboarding_connected and plan_started carry
a property. A preview or local stack now has something to break a funnel down
by and to filter a step on.
How to build a funnel on Product analytics, what Count by and the window mean,
how to read the result, breakdowns, step filters, repeated steps, and server
and mobile steps.
…h no events

Review findings on the funnel queries:

- Rows with the same millisecond and Seq (two server events from one request)
  were converted by the count but not by the walk, and made time between steps
  negative. Tied rows now sort in step order everywhere, and the chain walks by
  position, so the count, the times and the leavers agree.
- An event step on $pageview or $screen beside a page step let one view count
  for both. Those funnels now take the walk.
- A visitor whose session matched step 1 and who sent no event was missing
  from the leavers. They count under nothing after.
- The order key is milliseconds and Seq modulo 2^20, so a long session no
  longer reverses two rows across Seq 1024.
- A window longer than 366 days is refused instead of overflowing.
@maple-review-bot

maple-review-bot Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 6/10 · needs attention. Held at 6 because a correctness warning is open.
1 warning −1 · 1 note · tests covered · risk medium · 1/1 new units observable

Adds a breakdown table, previous-window deltas, time to convert and a fuller steps table to the Funnels tab, plus a per-person walk that replaces windowFunnel for repeated steps. Well tested, but the time-to-convert fix from the last pass does not work.

  • chainQuery reads each step's order key from walkQuery, adding sinceStartP50Ms/sinceStartP90Ms
  • funnelBreakdownParamsOf asks for 20 groups via the new breakdownLimit widget param
  • funnelParamsOf always sets details: true, so both views fetch timing and leavers
  • The stats rail compares entered and conversion with the previous window

Still open from earlier reviews

d1fd638 · What was checked, coverage and telemetry details are on the Maple / review check. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant