From c0b6fbc7300848ef45aaa9d20e26a7acfb85d442 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 02:04:54 +0100 Subject: [PATCH 1/8] fix(funnels): count repeated steps, same-millisecond order and the converting 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. --- .github/workflows/ci.yml | 2 +- apps/cli/test/funnel-scenarios-support.ts | 630 ++++++++++++++++ apps/cli/test/funnel-scenarios.test.ts | 698 ++++++++++++++++++ apps/cli/test/funnel-scenarios.ts | 364 +++++++++ docs/product-events-funnels.md | 38 +- .../src/__sql_baseline__/catalog.sql | 184 ++--- .../ch/queries/product-events-paths.test.ts | 10 +- .../src/ch/queries/product-events-paths.ts | 7 +- .../src/ch/queries/product-events.test.ts | 47 +- .../src/ch/queries/product-events.ts | 307 +++++--- packages/query-model/src/funnel.ts | 2 +- 11 files changed, 2069 insertions(+), 220 deletions(-) create mode 100644 apps/cli/test/funnel-scenarios-support.ts create mode 100644 apps/cli/test/funnel-scenarios.test.ts create mode 100644 apps/cli/test/funnel-scenarios.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efdc8d5670..8729d618b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -537,7 +537,7 @@ jobs: MAPLE_REQUIRE_LIBCHDB: "1" MAPLE_TELEMETRY: off run: | - for file in tenant-isolation scoped-delete local-service-map-rollup checkpoint-merge-freeze local-store-traces-rekey local-query-guard local-query-guard.fuzz; do + for file in tenant-isolation scoped-delete local-service-map-rollup checkpoint-merge-freeze local-store-traces-rekey local-query-guard local-query-guard.fuzz funnel-scenarios; do bun test --timeout 30000 "test/$file.test.ts" done diff --git a/apps/cli/test/funnel-scenarios-support.ts b/apps/cli/test/funnel-scenarios-support.ts new file mode 100644 index 0000000000..9c62afca85 --- /dev/null +++ b/apps/cli/test/funnel-scenarios-support.ts @@ -0,0 +1,630 @@ +// Shared by the funnel scenario suite: a small product-analytics simulation, its inserts into a +// chDB store, and a reference funnel written in plain TypeScript that the compiled SQL must match. + +import * as CH from "@maple/query-engine/ch" +import { normalizeSqlForClickHouseClient } from "@maple/query-engine/execution" +import type { Chdb } from "@maple/local-server/chdb" +import { decodeJsonEachRow } from "@maple/local-server/chdb-rows" +import { Array as Arr, Option, Order, Record as Rec, Result, String as Str } from "effect" + +export const MINUTE_MS = 60_000 +export const HOUR_MS = 3_600_000 +export const DAY_MS = 86_400_000 + +/** Midnight 25 days back: inside `session_replays`' 30-day TTL for the whole 24-day range. */ +export const BASE_MS = Math.floor((Date.now() - 25 * DAY_MS) / DAY_MS) * DAY_MS +export const RANGE_DAYS = 24 + +export interface SimEvent { + /** Milliseconds after {@link BASE_MS}. */ + readonly atMs: number + /** Sub-millisecond part, 0..999: backends stamp finer than a browser does. */ + readonly micro: number + readonly seq: number + readonly source: "browser" | "server" | "mobile" + readonly sessionId: string + readonly visitorId: string + readonly userId: string + readonly kind: "navigation" | "custom" | "screen" + readonly eventName: string + readonly host: string + readonly pagePath: string + readonly attributes: Readonly> +} + +export interface SimSession { + readonly sessionId: string + readonly visitorId: string + readonly userId: string + readonly startMs: number + readonly referrerHost: string + readonly utmSource: string + readonly utmMedium: string + readonly utmCampaign: string + readonly country: string + readonly host: string + readonly deviceType: string + readonly browserName: string +} + +export interface Dataset { + readonly orgId: string + readonly events: ReadonlyArray + readonly sessions: ReadonlyArray +} + +export const event = (fields: Partial & Pick): SimEvent => ({ + micro: 0, + seq: 0, + source: "browser", + sessionId: "", + visitorId: "", + userId: "", + kind: "custom", + host: "", + pagePath: "", + attributes: {}, + ...fields, +}) + +export const session = ( + fields: Partial & Pick, +): SimSession => ({ + visitorId: "", + userId: "", + referrerHost: "", + utmSource: "", + utmMedium: "", + utmCampaign: "", + country: "", + host: "", + deviceType: "desktop", + browserName: "Chrome", + ...fields, +}) + +/** mulberry32: a seeded generator, so every scenario is the same dataset on every run. */ +export const seeded = (seed: number) => { + let state = seed >>> 0 + const next = (): number => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + return { + next, + int: (max: number): number => Math.floor(next() * max), + chance: (probability: number): boolean => next() < probability, + pick: ]>(items: T): T[number] => + items[Math.floor(next() * items.length)] ?? items[0], + } +} +export type Rng = ReturnType + +// Inserts + +const quote = (value: string): string => + `'${Str.replaceAll("'", "\\'")(Str.replaceAll("\\", "\\\\")(value))}'` + +const pad = (value: number, width: number): string => Str.padStart(width, "0")(String(value)) + +/** `YYYY-MM-DD HH:MM:SS.ffffff` in UTC. */ +export const chTimestamp = (offsetMs: number, micro = 0): string => + `${Str.replace("T", " ")(Str.slice(0, 23)(new Date(BASE_MS + offsetMs).toISOString()))}${pad(micro, 3)}` + +const mapLiteral = (attributes: Readonly>): string => + `map(${Arr.join( + Arr.flatMap(Rec.toEntries(attributes), ([key, value]) => [quote(key), quote(value)]), + ", ", + )})` + +const INSERT_CHUNK = 2_000 + +export const insertDataset = (db: Chdb, dataset: Dataset): void => { + const org = quote(dataset.orgId) + Arr.forEach(Arr.chunksOf(dataset.events, INSERT_CHUNK), (chunk) => { + const rows = Arr.map( + chunk, + (row) => + `(${org}, ${quote(chTimestamp(row.atMs, row.micro))}, ${quote(row.source)}, ${quote(row.sessionId)}, ${row.seq}, ${quote(row.visitorId)}, ${quote(row.userId)}, ${quote(row.kind)}, ${quote(row.eventName)}, ${quote(row.host)}, ${quote(row.pagePath)}, ${mapLiteral(row.attributes)})`, + ) + db.exec( + `INSERT INTO product_events (OrgId, Timestamp, Source, SessionId, Seq, VisitorId, UserId, Kind, EventName, Host, PagePath, Attributes) VALUES ${Arr.join(rows, ", ")}`, + ) + }) + Arr.forEach(Arr.chunksOf(dataset.sessions, INSERT_CHUNK), (chunk) => { + const rows = Arr.map( + chunk, + (row) => + `(${org}, ${quote(row.sessionId)}, ${quote(chTimestamp(row.startMs))}, 'ended', ${quote(row.userId)}, 1, ${quote(row.visitorId)}, ${quote(row.referrerHost)}, ${quote(row.utmSource)}, ${quote(row.utmMedium)}, ${quote(row.utmCampaign)}, ${quote(row.country)}, ${quote(row.host)}, ${quote(row.deviceType)}, ${quote(row.browserName)})`, + ) + db.exec( + `INSERT INTO session_replays (OrgId, SessionId, StartTime, Status, UserId, Version, VisitorId, ReferrerHost, UtmSource, UtmMedium, UtmCampaign, Country, Host, DeviceType, BrowserName) VALUES ${Arr.join(rows, ", ")}`, + ) + }) +} + +// Running the compiled SQL + +export interface Range { + readonly fromMs: number + readonly toMs: number +} + +export const FULL_RANGE: Range = { fromMs: 0, toMs: RANGE_DAYS * DAY_MS } + +const params = (orgId: string, range: Range) => ({ + orgId, + startTime: Str.slice(0, 19)(chTimestamp(range.fromMs)), + endTime: Str.slice(0, 19)(chTimestamp(range.toMs)), +}) + +const decodeFunnel = decodeJsonEachRow(CH.productEventsFunnelRowSchema) +const decodeBreakdown = decodeJsonEachRow(CH.productEventsFunnelBreakdownRowSchema) +const decodeTiming = decodeJsonEachRow(CH.productEventsFunnelTimingRowSchema) +const decodeLeavers = decodeJsonEachRow(CH.productEventsFunnelLeaversRowSchema) + +const run = (db: Chdb, sql: string): string => db.query(normalizeSqlForClickHouseClient(sql)) + +export const sqlFunnel = ( + db: Chdb, + orgId: string, + opts: CH.ProductEventsFunnelOpts, + range: Range = FULL_RANGE, +): ReadonlyArray => + Arr.map( + decodeFunnel( + run( + db, + CH.compileUnsafe(Result.getOrThrow(CH.productEventsFunnelQuery(opts)), params(orgId, range)) + .sql, + ), + ), + (row) => row.count, + ) + +type GroupCounts = readonly [group: string, counts: ReadonlyArray] +export type BreakdownCounts = ReadonlyArray + +const byEnteredThenName: Order.Order = Order.combine( + Order.flip(Order.mapInput(Order.Number, (entry: GroupCounts) => entry[1][0] ?? 0)), + Order.mapInput(Order.String, (entry: GroupCounts) => entry[0]), +) + +export const sqlBreakdown = ( + db: Chdb, + orgId: string, + opts: CH.ProductEventsFunnelBreakdownOpts, + range: Range = FULL_RANGE, +): BreakdownCounts => { + const rows = decodeBreakdown( + run( + db, + CH.compileUnsafe( + Result.getOrThrow(CH.productEventsFunnelBreakdownQuery(opts)), + params(orgId, range), + ).sql, + ), + ) + // Keyed with a prefix so the empty group is still a key. + const byGroup = Arr.groupBy(rows, (row) => `g:${row.group}`) + return Arr.sort( + Arr.map(Rec.toEntries(byGroup), ([group, groupRows]): GroupCounts => [ + Str.slice(2)(group), + Arr.map( + Arr.sort( + groupRows, + Order.mapInput(Order.Number, (row: (typeof rows)[number]) => row.step), + ), + (row) => row.count, + ), + ]), + byEnteredThenName, + ) +} + +export interface StepTiming { + readonly step: number + readonly p50Ms: number + readonly p90Ms: number +} + +export const sqlTiming = ( + db: Chdb, + orgId: string, + opts: CH.ProductEventsFunnelOpts, + range: Range = FULL_RANGE, +): ReadonlyArray => + decodeTiming( + run( + db, + CH.compileUnsafe(Result.getOrThrow(CH.productEventsFunnelTimingQuery(opts)), params(orgId, range)) + .sql, + ), + ) + +type Leaver = readonly [step: number, next: string, count: number] +export type Leavers = ReadonlyArray + +export const sqlLeavers = ( + db: Chdb, + orgId: string, + opts: CH.ProductEventsFunnelOpts, + range: Range = FULL_RANGE, +): Leavers => + Arr.map( + decodeLeavers( + run( + db, + CH.compileUnsafe( + Result.getOrThrow(CH.productEventsFunnelLeaversQuery(opts)), + params(orgId, range), + ).sql, + ), + ), + (row): Leaver => [row.step, row.next, row.count], + ) + +// The reference funnel +// +// The intended semantics, independent of the SQL: rows ordered by (timestamp, seq). An attempt +// starts at a step-1 row and takes, for each later step, the first later row that matches and is +// still within the window of the start, so one row serves one step. A person's level is their +// deepest attempt, and their chain is the earliest attempt that gets that deep. + +type Filters = NonNullable + +/** The visitor's first linked user, as `identity_links` resolves it. */ +const linksOf = (dataset: Dataset): ReadonlyMap => { + const first = new Map() + Arr.forEach(dataset.sessions, (row) => { + if (row.visitorId === "" || row.userId === "") return + const current = first.get(row.visitorId) + if (current === undefined || row.startMs < current.startMs) + first.set(row.visitorId, { userId: row.userId, startMs: row.startMs }) + }) + return new Map(Arr.map([...first], ([visitorId, link]) => [visitorId, link.userId])) +} + +const keyOf = ( + keyBy: CH.FunnelKeyBy, + row: { readonly sessionId: string; readonly visitorId: string; readonly userId: string }, + links: ReadonlyMap, +): string => { + switch (keyBy) { + case "session": + return row.sessionId + case "visitor": + return row.visitorId + case "user": + return row.userId + case "person": + return row.userId !== "" ? row.userId : (links.get(row.visitorId) ?? row.visitorId) + } +} + +const SESSION_FIELD = { + referrerHost: "referrerHost", + utmSource: "utmSource", + utmMedium: "utmMedium", + utmCampaign: "utmCampaign", + country: "country", + host: "host", +} as const satisfies Record + +const inRange = (atMs: number, range: Range): boolean => atMs >= range.fromMs && atMs <= range.toMs + +const sessionMatches = (row: SimSession, filters: Filters): boolean => + (filters.country === undefined || row.country === filters.country) && + (filters.referrerHost === undefined || row.referrerHost === filters.referrerHost) && + (filters.utmSource === undefined || row.utmSource === filters.utmSource) && + (filters.utmMedium === undefined || row.utmMedium === filters.utmMedium) && + (filters.utmCampaign === undefined || row.utmCampaign === filters.utmCampaign) && + (filters.deviceType === undefined || row.deviceType === filters.deviceType) && + (filters.browserName === undefined || row.browserName === filters.browserName) + +const FILTER_FIELDS = [ + "country", + "referrerHost", + "utmSource", + "utmMedium", + "utmCampaign", + "deviceType", + "browserName", +] as const + +const hasFilter = (filters: Filters): boolean => + Arr.some(FILTER_FIELDS, (field) => filters[field] !== undefined) + +const isView = (row: SimEvent): boolean => row.kind === "navigation" || row.kind === "screen" + +/** What a row is called in a leavers list: the page or screen for a view, else the event name. */ +const displayName = (row: SimEvent): string => + isView(row) && row.pagePath !== "" ? row.pagePath : row.eventName + +const stepMatches = (step: CH.FunnelStep, row: SimEvent): boolean => { + switch (step.kind) { + case "event": + return ( + row.eventName === step.eventName && + Arr.every( + Rec.toEntries(step.attributeEquals ?? {}), + ([key, value]) => row.attributes[key] === value, + ) + ) + case "page": + return ( + isView(row) && + row.pagePath === step.pagePath && + (step.host === undefined || row.host === step.host) + ) + case "session": + return false + } +} + +interface FunnelRow { + readonly key: string + /** Microseconds after the base: the order rows are walked in, with `seq` breaking ties. */ + readonly order: number + readonly seq: number + readonly atMs: number + readonly matches: ReadonlyArray + readonly name: string + readonly dimension: string +} + +const byOrder: Order.Order = Order.combine( + Order.mapInput(Order.Number, (row: FunnelRow) => row.order), + Order.mapInput(Order.Number, (row: FunnelRow) => row.seq), +) + +export interface PersonResult { + readonly key: string + readonly level: number + /** The step rows of the person's chain, in step order. */ + readonly chain: ReadonlyArray + readonly group: string +} + +/** The attempt starting at `rows[start]`: each later step's first qualifying row. */ +const attemptFrom = ( + rows: ReadonlyArray, + start: number, + stepCount: number, + windowMicro: number, +): ReadonlyArray => { + const first = rows[start] + if (first === undefined) return [] + const chain: Array = [first] + let at = start + for (let step = 1; step < stepCount; step++) { + const next = Arr.findFirstIndex( + rows, + (row, index) => + index > at && row.matches[step] === true && row.order <= first.order + windowMicro, + ) + if (Option.isNone(next)) break + at = next.value + const row = rows[at] + if (row !== undefined) chain.push(row) + } + return chain +} + +const walk = ( + rows: ReadonlyArray, + stepCount: number, + windowMicro: number, +): ReadonlyArray => + Arr.reduce(rows, [] as ReadonlyArray, (best, row, index) => { + if (row.matches[0] !== true) return best + const attempt = attemptFrom(rows, index, stepCount, windowMicro) + return attempt.length > best.length ? attempt : best + }) + +const dimensionOfEvent = ( + breakdownBy: CH.FunnelBreakdownBy | undefined, + row: SimEvent, + sessionsById: ReadonlyMap, + range: Range, +): string => { + if (breakdownBy === undefined) return "" + if (Str.startsWith("attribute:")(breakdownBy)) + return row.attributes[Str.slice("attribute:".length)(breakdownBy)] ?? "" + if (breakdownBy === "host") return row.host + const owner = sessionsById.get(row.sessionId) + if (owner === undefined || !inRange(owner.startMs, range)) return "" + return owner[SESSION_FIELD[breakdownBy as CH.FunnelSessionDimension]] +} + +export const referencePersons = ( + dataset: Dataset, + opts: CH.ProductEventsFunnelOpts, + range: Range = FULL_RANGE, + breakdownBy?: CH.FunnelBreakdownBy, +): ReadonlyArray => { + const links = linksOf(dataset) + const filters = opts.filters ?? {} + const sessionsById = new Map(Arr.map(dataset.sessions, (row) => [row.sessionId, row])) + const population = hasFilter(filters) + ? new Set( + Arr.map( + Arr.filter( + dataset.sessions, + (row) => inRange(row.startMs, range) && sessionMatches(row, filters), + ), + (row) => keyOf(opts.keyBy, row, links), + ), + ) + : undefined + const admitted = (key: string): boolean => key !== "" && (population === undefined || population.has(key)) + + const eventRows = Arr.flatMap(dataset.events, (row): ReadonlyArray => { + const key = keyOf(opts.keyBy, row, links) + const matches = Arr.map(opts.steps, (step) => stepMatches(step, row)) + if (!inRange(row.atMs, range) || !admitted(key) || !Arr.some(matches, (matched) => matched)) return [] + return [ + { + key, + order: row.atMs * 1000 + row.micro, + seq: row.seq, + atMs: row.atMs, + matches, + name: displayName(row), + dimension: dimensionOfEvent(breakdownBy, row, sessionsById, range), + }, + ] + }) + const first = opts.steps[0] + const entryRows = + first?.kind === "session" + ? Arr.flatMap(dataset.sessions, (row): ReadonlyArray => { + const key = keyOf(opts.keyBy, row, links) + if (!inRange(row.startMs, range) || !admitted(key)) return [] + if (row[SESSION_FIELD[first.dimension]] !== first.value) return [] + const isSessionDimension = + breakdownBy !== undefined && !Str.startsWith("attribute:")(breakdownBy) + return [ + { + key, + order: row.startMs * 1000, + seq: 0, + atMs: row.startMs, + matches: Arr.map(opts.steps, (_, index) => index === 0), + name: "$session_entry", + dimension: isSessionDimension + ? row[SESSION_FIELD[breakdownBy as CH.FunnelSessionDimension]] + : "", + }, + ] + }) + : [] + + const byKey = Arr.groupBy([...entryRows, ...eventRows], (row) => row.key) + return Arr.map(Rec.toEntries(byKey), ([key, rows]): PersonResult => { + const sorted = Arr.sort(rows, byOrder) + const chain = walk(sorted, opts.steps.length, opts.windowSeconds * 1_000_000) + return { + key, + level: chain.length, + chain, + group: Option.match( + Arr.findFirst(sorted, (row) => row.dimension !== ""), + { onNone: () => "", onSome: (row) => row.dimension }, + ), + } + }) +} + +const countsOf = (persons: ReadonlyArray, stepCount: number): ReadonlyArray => + Arr.makeBy(stepCount, (index) => Arr.filter(persons, (person) => person.level >= index + 1).length) + +export const referenceFunnel = ( + dataset: Dataset, + opts: CH.ProductEventsFunnelOpts, + range: Range = FULL_RANGE, +): ReadonlyArray => countsOf(referencePersons(dataset, opts, range), opts.steps.length) + +export const referenceBreakdown = ( + dataset: Dataset, + opts: CH.ProductEventsFunnelBreakdownOpts, + range: Range = FULL_RANGE, +): BreakdownCounts => { + const persons = referencePersons(dataset, opts, range, opts.breakdownBy) + const groups = Arr.groupBy(persons, (person) => person.group) + return Arr.take( + Arr.sort( + Arr.map(Rec.toEntries(groups), ([group, members]): GroupCounts => [ + group, + countsOf(members, opts.steps.length), + ]), + byEnteredThenName, + ), + opts.limit ?? 10, + ) +} + +/** ClickHouse's `quantile` over a small set: linear interpolation between the neighbours. */ +const quantile = (values: ReadonlyArray, level: number): number => { + if (values.length === 0) return 0 + const sorted = Arr.sort(values, Order.Number) + const position = level * (sorted.length - 1) + const low = Math.floor(position) + const lower = sorted[low] ?? 0 + const upper = sorted[Math.min(low + 1, sorted.length - 1)] ?? lower + return lower + (upper - lower) * (position - low) +} + +export const referenceTiming = ( + dataset: Dataset, + opts: CH.ProductEventsFunnelOpts, + range: Range = FULL_RANGE, +): ReadonlyArray => { + const persons = referencePersons(dataset, opts, range) + return Arr.makeBy(opts.steps.length - 1, (index) => { + const step = index + 2 + const spans = Arr.flatMap(persons, (person) => { + const to = person.chain[step - 1] + const from = person.chain[step - 2] + return to === undefined || from === undefined ? [] : [to.atMs - from.atMs] + }) + return { step, p50Ms: quantile(spans, 0.5), p90Ms: quantile(spans, 0.9) } + }) +} + +export const referenceLeavers = ( + dataset: Dataset, + opts: CH.ProductEventsFunnelOpts, + range: Range = FULL_RANGE, + perStep = 6, +): Leavers => { + const links = linksOf(dataset) + const persons = referencePersons(dataset, opts, range) + const eventsByKey = Arr.groupBy( + Arr.filter(dataset.events, (row) => inRange(row.atMs, range)), + (row) => keyOf(opts.keyBy, row, links), + ) + const nexts = Arr.flatMap(persons, (person) => { + const last = person.chain[person.level - 1] + if (last === undefined || person.level >= opts.steps.length) return [] + const later = Arr.sort( + Arr.filter( + eventsByKey[person.key] ?? [], + (row) => + row.atMs * 1000 + row.micro > last.order || + (row.atMs * 1000 + row.micro === last.order && row.seq > last.seq), + ), + Order.combine( + Order.mapInput(Order.Number, (row: SimEvent) => row.atMs * 1000 + row.micro), + Order.mapInput(Order.Number, (row: SimEvent) => row.seq), + ), + ) + const next = later[0] + return [ + { + step: person.level + 1, + next: next === undefined ? "" : displayName(next), + }, + ] + }) + const counted = Arr.map( + Rec.toEntries(Arr.groupBy(nexts, (entry) => `${entry.step}:${entry.next}`)), + ([, members]): Leaver => [members[0].step, members[0].next, members.length], + ) + const byCountThenName: Order.Order = Order.combine( + Order.flip(Order.mapInput(Order.Number, (entry: Leaver) => entry[2])), + Order.mapInput(Order.String, (entry: Leaver) => entry[1]), + ) + return Arr.flatMap(Arr.sort(Arr.dedupe(Arr.map(counted, (entry) => entry[0])), Order.Number), (step) => + Arr.take( + Arr.sort( + Arr.filter(counted, (entry) => entry[0] === step), + byCountThenName, + ), + perStep, + ), + ) +} diff --git a/apps/cli/test/funnel-scenarios.test.ts b/apps/cli/test/funnel-scenarios.test.ts new file mode 100644 index 0000000000..a5e23ef515 --- /dev/null +++ b/apps/cli/test/funnel-scenarios.test.ts @@ -0,0 +1,698 @@ +// Product-event funnels against a real ClickHouse engine (chDB), over example products. +// +// Every expectation comes from the reference funnel in `funnel-scenarios-support.ts`, a plain +// TypeScript walk of the same rows. The compiled SQL has to agree with it person for person, so a +// funnel that double-counts, loses the row order inside a millisecond or reports a time for a +// chain it did not count fails here on realistic data rather than in someone's dashboard. + +import { afterAll, beforeAll, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type * as CH from "@maple/query-engine/ch" +import { Array as Arr } from "effect" +import schemaSql from "@maple/local-server/schema/local-schema.sql" with { type: "text" } +import { Chdb } from "@maple/local-server/chdb" +import { describeWithChdb } from "./libchdb-test-support" +import { contentSite, mobileOnboarding, saasSignup, shopCheckout } from "./funnel-scenarios" +import { + type Dataset, + DAY_MS, + event, + HOUR_MS, + insertDataset, + MINUTE_MS, + referenceBreakdown, + referenceFunnel, + referenceLeavers, + referenceTiming, + seeded, + session, + type SimEvent, + sqlBreakdown, + sqlFunnel, + sqlLeavers, + sqlTiming, +} from "./funnel-scenarios-support" + +type Opts = CH.ProductEventsFunnelOpts + +const ev = (eventName: string, attributeEquals?: Record): CH.FunnelStep => + attributeEquals === undefined + ? { kind: "event", eventName } + : { kind: "event", eventName, attributeEquals } +const page = (pagePath: string, host?: string): CH.FunnelStep => + host === undefined ? { kind: "page", pagePath } : { kind: "page", pagePath, host } + +const KEYS: ReadonlyArray = ["person", "visitor", "user", "session"] + +// Hand-built cases: one person each, in their own org, so a wrong count names its cause. + +const user = (userId: string, rows: ReadonlyArray) => + Arr.map(rows, ([atMs, eventName, seq]) => + event({ atMs, eventName, userId, source: "server", seq: seq ?? 0 }), + ) + +const CASES = { + // A did the event once, B twice, C three times. + repeats: [ + ...user("once", [[1_000, "search"]]), + ...user("twice", [ + [1_000, "search"], + [5_000, "search"], + ]), + ...user("thrice", [ + [1_000, "search"], + [5_000, "search"], + [9_000, "search"], + ]), + ], + // Two rows in one millisecond, told apart by `seq` alone. + sameMillisecond: [ + ...user("in_order", [ + [1_000, "cart", 0], + [1_000, "pay", 1], + ]), + ...user("reversed", [ + [1_000, "pay", 0], + [1_000, "cart", 1], + ]), + ], + // The second step lands exactly on the one-hour window, and one millisecond past it. + windowEdge: [ + ...user("on_edge", [ + [0, "start"], + [HOUR_MS, "finish"], + ]), + ...user("past_edge", [ + [0, "start"], + [HOUR_MS + 1, "finish"], + ]), + ], + // Gave up, came back three hours later and went all the way: only the second run fits an hour. + restart: user("returner", [ + [0, "start"], + [10 * MINUTE_MS, "middle"], + [3 * HOUR_MS, "start"], + [3 * HOUR_MS + 5 * MINUTE_MS, "middle"], + [3 * HOUR_MS + 20 * MINUTE_MS, "finish"], + [3 * HOUR_MS + 21 * MINUTE_MS, "settings_opened"], + ]), + // Later steps before the first one do not count. + outOfOrder: [ + ...user("backwards", [ + [1_000, "finish"], + [2_000, "middle"], + [3_000, "start"], + ]), + ...user("forwards", [ + [1_000, "start"], + [2_000, "middle"], + [3_000, "finish"], + ]), + ], + // One account firing the same event four thousand times, then checking out. + heavyRepeats: [ + ...Arr.makeBy(4_000, (index) => + event({ atMs: index * 1_000, eventName: "search", userId: "crawler", source: "server" }), + ), + event({ atMs: 4_000 * 1_000, eventName: "checkout", userId: "crawler", source: "server" }), + ], + // Names and values that would break a query built by string concatenation. + awkwardNames: [ + event({ + atMs: 1_000, + userId: "poet", + source: "server", + eventName: 'it\'s "quoted" \\ back', + attributes: { "plan's": "pro' OR '1'='1" }, + }), + event({ atMs: 2_000, userId: "poet", source: "server", eventName: "bestellt · 注文 🎉" }), + event({ + atMs: 1_000, + userId: "plain", + source: "server", + eventName: 'it\'s "quoted" \\ back', + attributes: { "plan's": "free" }, + }), + ], +} satisfies Record> + +const caseDataset = (name: keyof typeof CASES): Dataset => ({ + orgId: `case_${name}`, + events: CASES[name], + sessions: [], +}) + +// A person who arrives from a campaign, signs up in a second session, and is filtered by country. +const ACQUISITION: Dataset = { + orgId: "case_acquisition", + sessions: [ + session({ sessionId: "a1", startMs: 0, visitorId: "ann", utmSource: "twitter", country: "DE" }), + session({ sessionId: "a2", startMs: DAY_MS, visitorId: "ann", userId: "u_ann", country: "DE" }), + session({ sessionId: "b1", startMs: HOUR_MS, visitorId: "bob", utmSource: "twitter", country: "US" }), + session({ + sessionId: "c1", + startMs: 2 * HOUR_MS, + visitorId: "cy", + utmSource: "newsletter", + country: "DE", + }), + ], + events: [ + event({ + atMs: 1_000, + sessionId: "a1", + visitorId: "ann", + kind: "navigation", + eventName: "$pageview", + pagePath: "/", + host: "acme.dev", + }), + event({ + atMs: DAY_MS + 1_000, + sessionId: "a2", + visitorId: "ann", + userId: "u_ann", + eventName: "signup_started", + }), + event({ + atMs: DAY_MS + 60_000, + userId: "u_ann", + source: "server", + eventName: "plan_started", + attributes: { plan: "pro" }, + }), + event({ + atMs: HOUR_MS + 1_000, + sessionId: "b1", + visitorId: "bob", + kind: "navigation", + eventName: "$pageview", + pagePath: "/", + host: "acme.dev", + }), + event({ atMs: HOUR_MS + 9_000, sessionId: "b1", visitorId: "bob", eventName: "signup_started" }), + event({ + atMs: 2 * HOUR_MS + 1_000, + sessionId: "c1", + visitorId: "cy", + kind: "navigation", + eventName: "$pageview", + pagePath: "/", + host: "acme.dev", + }), + ], +} + +describeWithChdb("product event funnels over example products", () => { + let root = "" + let db: Chdb + const saas = saasSignup() + const shop = shopCheckout() + const mobile = mobileOnboarding() + const content = contentSite() + const random = randomDatasets(60) + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "maple-funnel-scenarios-")) + db = Chdb.open({ dataDir: join(root, "data"), schemaSql }) + Arr.forEach( + [ + saas, + shop, + mobile, + content, + ACQUISITION, + ...Arr.map( + [ + "repeats", + "sameMillisecond", + "windowEdge", + "restart", + "outOfOrder", + "awkwardNames", + "heavyRepeats", + ] as const, + caseDataset, + ), + ...Arr.map(random, (entry) => entry.dataset), + ], + (dataset) => insertDataset(db, dataset), + ) + }) + + afterAll(() => { + db?.close() + if (root) rmSync(root, { recursive: true, force: true }) + }) + + const counts = (dataset: Dataset, opts: Opts) => sqlFunnel(db, dataset.orgId, opts) + const byUser = (steps: ReadonlyArray, windowSeconds = 3_600): Opts => ({ + steps, + keyBy: "user", + windowSeconds, + }) + + test("a step that repeats needs the event that many times", () => { + const dataset = caseDataset("repeats") + expect(counts(dataset, byUser([ev("search"), ev("search")]))).toEqual([3, 2]) + expect(counts(dataset, byUser([ev("search"), ev("search"), ev("search")]))).toEqual([3, 2, 1]) + }) + + test("rows in the same millisecond convert only in their recorded order", () => { + expect(counts(caseDataset("sameMillisecond"), byUser([ev("cart"), ev("pay")]))).toEqual([2, 1]) + }) + + test("the window includes its last millisecond and nothing after", () => { + expect(counts(caseDataset("windowEdge"), byUser([ev("start"), ev("finish")]))).toEqual([2, 1]) + }) + + test("a later attempt converts when the first one ran out of time", () => { + const opts = byUser([ev("start"), ev("middle"), ev("finish")]) + expect(counts(caseDataset("restart"), opts)).toEqual([1, 1, 1]) + // The times are the converting attempt's: 5 minutes, then 15, not a span from the abandoned one. + expect(sqlTiming(db, "case_restart", opts)).toEqual([ + { step: 2, p50Ms: 5 * MINUTE_MS, p90Ms: 5 * MINUTE_MS }, + { step: 3, p50Ms: 15 * MINUTE_MS, p90Ms: 15 * MINUTE_MS }, + ]) + }) + + test("where a leaver went is read after the attempt that got furthest", () => { + const opts = byUser([ev("start"), ev("finish"), ev("never_happens")]) + expect(sqlLeavers(db, "case_restart", opts)).toEqual([[3, "settings_opened", 1]]) + }) + + test("steps done before the first one do not count", () => { + expect(counts(caseDataset("outOfOrder"), byUser([ev("start"), ev("middle"), ev("finish")]))).toEqual([ + 2, 1, 1, + ]) + }) + + test("thousands of repeats of one event stay cheap to walk", () => { + const opts = byUser([ev("search"), ev("search"), ev("search"), ev("checkout")]) + expect(counts(caseDataset("heavyRepeats"), opts)).toEqual([1, 1, 1, 1]) + // The earliest attempt that still fits the hour starts 3,600 s before the checkout: + // three searches a second apart, then the wait for the checkout. + expect(sqlTiming(db, "case_heavyRepeats", opts)).toEqual([ + { step: 2, p50Ms: 1_000, p90Ms: 1_000 }, + { step: 3, p50Ms: 1_000, p90Ms: 1_000 }, + { step: 4, p50Ms: 3_598_000, p90Ms: 3_598_000 }, + ]) + }) + + test("quotes, backslashes and non-Latin names match literally", () => { + const dataset = caseDataset("awkwardNames") + const quoted = 'it\'s "quoted" \\ back' + expect(counts(dataset, byUser([ev(quoted), ev("bestellt · 注文 🎉")]))).toEqual([2, 1]) + expect(counts(dataset, byUser([ev(quoted, { "plan's": "pro' OR '1'='1" })]))).toEqual([1]) + }) + + test("a campaign visit stitches to the signup in a later session and the server-side plan", () => { + const steps: ReadonlyArray = [ + { kind: "session", dimension: "utmSource", value: "twitter" }, + ev("signup_started"), + ev("plan_started", { plan: "pro" }), + ] + const week = 7 * 86_400 + expect(counts(ACQUISITION, { steps, keyBy: "person", windowSeconds: week })).toEqual([2, 2, 1]) + // Ann's signup is a day after her campaign visit: outside an hour, and in another session. + expect(counts(ACQUISITION, { steps, keyBy: "person", windowSeconds: 3_600 })).toEqual([2, 1, 0]) + expect(counts(ACQUISITION, { steps, keyBy: "session", windowSeconds: week })).toEqual([2, 1, 0]) + expect( + counts(ACQUISITION, { steps, keyBy: "person", windowSeconds: week, filters: { country: "DE" } }), + ).toEqual([1, 1, 1]) + }) + + // The example products: every funnel below must match the reference exactly. + + const FUNNELS: ReadonlyArray<{ + readonly name: string + readonly dataset: Dataset + readonly opts: Omit + }> = [ + { + name: "SaaS: landing to paid plan", + dataset: saas, + opts: { + steps: [ + page("/", "acme.dev"), + ev("signup_started"), + ev("signup_completed"), + ev("first_trace_received"), + ev("plan_started"), + ], + windowSeconds: 14 * 86_400, + }, + }, + { + name: "SaaS: signup to connected within a day", + dataset: saas, + opts: { + steps: [ + ev("signup_started"), + ev("signup_completed"), + ev("onboarding_step_completed", { step: "connect" }), + ], + windowSeconds: 86_400, + }, + }, + { + name: "SaaS: launch campaign to a startup plan", + dataset: saas, + opts: { + steps: [ + { kind: "session", dimension: "utmCampaign", value: "launch-week" }, + page("/pricing"), + ev("signup_started"), + ev("plan_started", { plan: "startup" }), + ], + windowSeconds: 30 * 86_400, + }, + }, + { + name: "SaaS: German visitors only", + dataset: saas, + opts: { + steps: [page("/"), page("/pricing"), ev("signup_started")], + windowSeconds: 7 * 86_400, + filters: { country: "DE" }, + }, + }, + { + name: "SaaS: mobile Safari from the newsletter", + dataset: saas, + opts: { + steps: [page("/"), ev("signup_started"), ev("signup_completed")], + windowSeconds: 7 * 86_400, + filters: { utmSource: "newsletter", deviceType: "mobile", browserName: "Safari" }, + }, + }, + { + name: "Shop: view to order in one sitting", + dataset: shop, + opts: { + steps: [ + ev("product_viewed"), + ev("add_to_cart"), + ev("checkout_started"), + ev("payment_submitted"), + ev("order_completed"), + ], + windowSeconds: 3_600, + }, + }, + { + name: "Shop: browsed three products before buying", + dataset: shop, + opts: { + steps: [ + ev("product_viewed"), + ev("product_viewed"), + ev("product_viewed"), + ev("add_to_cart"), + ev("order_completed"), + ], + windowSeconds: 7 * 86_400, + }, + }, + { + name: "Shop: shoes paid by card", + dataset: shop, + opts: { + steps: [ + ev("product_viewed", { category: "shoes" }), + ev("add_to_cart", { category: "shoes" }), + ev("payment_submitted", { method: "card" }), + ev("order_completed", { method: "card" }), + ], + windowSeconds: 86_400, + }, + }, + { + name: "Shop: checkout page then the checkout event in the same millisecond", + dataset: shop, + opts: { + steps: [page("/checkout"), ev("checkout_started"), ev("payment_submitted")], + windowSeconds: 3_600, + }, + }, + { + name: "Mobile: first open to a logged workout", + dataset: mobile, + opts: { + steps: [ + ev("app_opened"), + ev("permission_answered", { answer: "granted" }), + ev("account_created"), + ev("workout_logged"), + ], + windowSeconds: 7 * 86_400, + }, + }, + { + name: "Mobile: welcome screen to the home screen", + dataset: mobile, + opts: { + steps: [page("Welcome"), page("Permissions"), ev("account_created"), page("Home")], + windowSeconds: 86_400, + }, + }, + { + name: "Mobile: came back for a second workout", + dataset: mobile, + opts: { + steps: [ev("account_created"), ev("workout_logged"), ev("workout_logged")], + windowSeconds: 14 * 86_400, + }, + }, + { + name: "Content: pricing on any host to signup", + dataset: content, + opts: { steps: [page("/pricing"), page("/signup"), ev("signup_started")], windowSeconds: 3_600 }, + }, + { + name: "Content: pricing on the marketing host only", + dataset: content, + opts: { + steps: [page("/pricing", "acme.dev"), page("/signup", "acme.dev"), ev("signup_started")], + windowSeconds: 3_600, + }, + }, + { + name: "Content: from Hacker News", + dataset: content, + opts: { + steps: [ + { kind: "session", dimension: "referrerHost", value: "news.ycombinator.com" }, + page("/pricing"), + page("/signup"), + ], + windowSeconds: 86_400, + }, + }, + ] + + for (const funnel of FUNNELS) { + for (const keyBy of KEYS) { + test(`${funnel.name}, counted by ${keyBy}`, () => { + const opts: Opts = { ...funnel.opts, keyBy } + const expected = referenceFunnel(funnel.dataset, opts) + expect(counts(funnel.dataset, opts)).toEqual([...expected]) + if (opts.steps.length < 2) return + const timing = sqlTiming(db, funnel.dataset.orgId, opts) + Arr.forEach(referenceTiming(funnel.dataset, opts), (step, index) => { + expect(timing[index]?.step).toBe(step.step) + expect(timing[index]?.p50Ms ?? Number.NaN).toBeCloseTo(step.p50Ms, 3) + expect(timing[index]?.p90Ms ?? Number.NaN).toBeCloseTo(step.p90Ms, 3) + }) + expect(sqlLeavers(db, funnel.dataset.orgId, opts)).toEqual([ + ...referenceLeavers(funnel.dataset, opts), + ]) + }) + } + } + + test("the example funnels are not empty: people enter each and fewer finish", () => { + const entered = Arr.map(FUNNELS, (funnel) => { + const result = counts(funnel.dataset, { ...funnel.opts, keyBy: "person" }) + return { name: funnel.name, first: result[0] ?? 0, last: result[result.length - 1] ?? 0 } + }) + expect(Arr.filter(entered, (funnel) => funnel.first === 0)).toEqual([]) + expect(Arr.filter(entered, (funnel) => funnel.last >= funnel.first)).toEqual([]) + }) + + const BREAKDOWNS: ReadonlyArray<{ + readonly name: string + readonly dataset: Dataset + readonly opts: CH.ProductEventsFunnelBreakdownOpts + }> = [ + { + name: "SaaS signups by UTM source", + dataset: saas, + opts: { + steps: [page("/"), ev("signup_started"), ev("signup_completed")], + keyBy: "person", + windowSeconds: 14 * 86_400, + breakdownBy: "utmSource", + }, + }, + { + name: "SaaS signups by country, top three", + dataset: saas, + opts: { + steps: [page("/"), ev("signup_started")], + keyBy: "visitor", + windowSeconds: 7 * 86_400, + breakdownBy: "country", + limit: 3, + }, + }, + { + name: "SaaS plans by plan attribute", + dataset: saas, + opts: { + steps: [ev("plan_started")], + keyBy: "user", + windowSeconds: 3_600, + breakdownBy: "attribute:plan", + }, + }, + { + name: "Shop checkout by category", + dataset: shop, + opts: { + steps: [ev("product_viewed"), ev("add_to_cart"), ev("checkout_started")], + keyBy: "session", + windowSeconds: 3_600, + breakdownBy: "attribute:category", + }, + }, + { + name: "Shop checkout by referrer from a campaign entry", + dataset: shop, + opts: { + steps: [ + { kind: "session", dimension: "utmSource", value: "twitter" }, + ev("add_to_cart"), + ev("order_completed"), + ], + keyBy: "person", + windowSeconds: 7 * 86_400, + breakdownBy: "referrerHost", + }, + }, + { + name: "Content signups by host", + dataset: content, + opts: { + steps: [page("/pricing"), page("/signup")], + keyBy: "visitor", + windowSeconds: 3_600, + breakdownBy: "host", + }, + }, + { + name: "Mobile accounts by sign-in method", + dataset: mobile, + opts: { + steps: [ev("account_created"), ev("workout_logged")], + keyBy: "visitor", + windowSeconds: 14 * 86_400, + breakdownBy: "attribute:method", + }, + }, + ] + + for (const breakdown of BREAKDOWNS) { + test(`breakdown: ${breakdown.name}`, () => { + const expected = referenceBreakdown(breakdown.dataset, breakdown.opts) + expect(expected.length).toBeGreaterThan(1) + expect(sqlBreakdown(db, breakdown.dataset.orgId, breakdown.opts)).toEqual( + Arr.map(expected, ([group, groupCounts]) => [group, [...groupCounts]] as const), + ) + }) + } + + test("random small histories agree with the reference under every key and window", () => { + const mismatches = Arr.flatMap(random, ({ dataset, funnels }) => + Arr.flatMap(funnels, (opts) => { + const expected = referenceFunnel(dataset, opts) + const actual = sqlFunnel(db, dataset.orgId, opts) + return Arr.every(expected, (count, index) => count === actual[index]) && + expected.length === actual.length + ? [] + : [{ org: dataset.orgId, opts, expected, actual, events: dataset.events }] + }), + ) + expect(mismatches.slice(0, 2)).toEqual([]) + }) + + test("random small histories give the reference's times and leavers", () => { + const round = (timing: ReadonlyArray<{ step: number; p50Ms: number; p90Ms: number }>) => + Arr.map(timing, (step) => [step.step, Math.round(step.p50Ms), Math.round(step.p90Ms)]) + const mismatches = Arr.flatMap(random, ({ dataset, funnels }) => + Arr.flatMap( + Arr.filter(funnels, (opts: Opts) => opts.steps.length >= 2), + (opts: Opts) => { + const expected = { + timing: round(referenceTiming(dataset, opts)), + leavers: referenceLeavers(dataset, opts), + } + const actual = { + timing: round(sqlTiming(db, dataset.orgId, opts)), + leavers: sqlLeavers(db, dataset.orgId, opts), + } + return JSON.stringify(expected) === JSON.stringify(actual) + ? [] + : [{ org: dataset.orgId, opts, expected, actual, events: dataset.events }] + }, + ), + ) + expect(mismatches.slice(0, 2)).toEqual([]) + }) +}) + +/** Small random histories over a three-letter alphabet, each in its own org, with funnels to ask of it. */ +function randomDatasets( + count: number, +): ReadonlyArray<{ readonly dataset: Dataset; readonly funnels: ReadonlyArray }> { + const rng = seeded(97) + const names = ["a", "b", "c"] as const + return Arr.makeBy(count, (index) => { + const orgId = `random_${index}` + const people = 2 + rng.int(4) + const used = new Set() + const events = Arr.flatMap(Arr.range(1, people), (person) => { + const userId = rng.chance(0.7) ? `u${person}` : "" + const visitorId = userId === "" || rng.chance(0.5) ? `v${person}` : "" + const sessionId = visitorId === "" ? "" : `s${person}` + return Arr.makeBy(1 + rng.int(9), (seq) => { + // A distinct millisecond per row: ties are the hand-built cases' job. + let atMs = rng.int(4 * HOUR_MS) + while (used.has(atMs)) atMs += 1 + used.add(atMs) + return event({ + atMs, + seq, + userId, + visitorId, + sessionId, + source: sessionId === "" ? "server" : "browser", + eventName: rng.pick(names), + attributes: rng.chance(0.3) ? { tier: rng.pick(["x", "y"] as const) } : {}, + }) + }) + }) + const funnels = Arr.makeBy(4, (): Opts => ({ + steps: Arr.makeBy(1 + rng.int(4), () => + rng.chance(0.2) + ? ev(rng.pick(names), { tier: rng.pick(["x", "y"] as const) }) + : ev(rng.pick(names)), + ), + keyBy: rng.pick(["person", "visitor", "user", "session"] as const), + windowSeconds: rng.pick([60, 600, 3_600, 86_400] as const), + })) + return { dataset: { orgId, events, sessions: [] }, funnels } + }) +} diff --git a/apps/cli/test/funnel-scenarios.ts b/apps/cli/test/funnel-scenarios.ts new file mode 100644 index 0000000000..d895af58bf --- /dev/null +++ b/apps/cli/test/funnel-scenarios.ts @@ -0,0 +1,364 @@ +// Example products for the funnel scenario suite. Each is a seeded simulation of how people move +// through a product, shaped to hit a different part of the funnel query: identity stitching, +// server-side steps, repeated events, restarts, attribute filters, page steps and session steps. + +import { Array as Arr } from "effect" +import { + DAY_MS, + type Dataset, + event, + HOUR_MS, + MINUTE_MS, + type Rng, + seeded, + session, + type SimEvent, + type SimSession, +} from "./funnel-scenarios-support" + +const REFERRALS = [ + { referrerHost: "google.com", utmSource: "", utmMedium: "", utmCampaign: "" }, + { referrerHost: "news.ycombinator.com", utmSource: "", utmMedium: "", utmCampaign: "" }, + { referrerHost: "t.co", utmSource: "twitter", utmMedium: "social", utmCampaign: "launch-week" }, + { referrerHost: "", utmSource: "newsletter", utmMedium: "email", utmCampaign: "november" }, + { referrerHost: "", utmSource: "", utmMedium: "", utmCampaign: "" }, +] as const + +const COUNTRIES = ["US", "US", "US", "DE", "DE", "GB", "FR", "IN"] as const +const DEVICES = [ + { deviceType: "desktop", browserName: "Chrome" }, + { deviceType: "desktop", browserName: "Firefox" }, + { deviceType: "mobile", browserName: "Safari" }, +] as const + +/** Collects one dataset's rows and hands out a per-session sequence. */ +const recorder = (orgId: string) => { + const events: Array = [] + const sessions: Array = [] + const seqBySession = new Map() + const nextSeq = (sessionId: string): number => { + const seq = seqBySession.get(sessionId) ?? 0 + seqBySession.set(sessionId, seq + 1) + return seq + } + return { + track: (fields: Parameters[0]) => { + events.push( + event({ ...fields, seq: fields.sessionId === undefined ? 0 : nextSeq(fields.sessionId) }), + ) + }, + open: (fields: Parameters[0]) => { + sessions.push(session(fields)) + }, + done: (): Dataset => ({ orgId, events, sessions }), + } +} + +const spread = (rng: Rng, days: number): number => rng.int(days * DAY_MS) +const pause = (rng: Rng, minMs: number, maxMs: number): number => minMs + rng.int(maxMs - minMs) + +/** + * A B2B SaaS signup: anonymous marketing visits on `acme.dev`, a signup that the backend confirms + * with a user id only, onboarding in the app under the same visitor, and a plan that starts days + * later from a billing webhook. Some people visit twice before signing up, and some use two browsers. + */ +export const saasSignup = (orgId = "scenario_saas"): Dataset => { + const rng = seeded(11) + const data = recorder(orgId) + Arr.forEach(Arr.range(1, 320), (n) => { + const visitorId = `v${n}` + const referral = rng.pick(REFERRALS) + const device = rng.pick(DEVICES) + const country = rng.pick(COUNTRIES) + const marketing = (sessionId: string, startMs: number, entry: typeof referral): number => { + data.open({ sessionId, startMs, visitorId, host: "acme.dev", country, ...device, ...entry }) + const browser = { sessionId, visitorId, host: "acme.dev" } as const + let atMs = startMs + data.track({ ...browser, atMs, kind: "navigation", eventName: "$pageview", pagePath: "/" }) + if (rng.chance(0.6)) { + atMs += pause(rng, 5_000, 2 * MINUTE_MS) + data.track({ + ...browser, + atMs, + kind: "navigation", + eventName: "$pageview", + pagePath: "/pricing", + }) + } + if (rng.chance(0.25)) { + atMs += pause(rng, 5_000, MINUTE_MS) + data.track({ + ...browser, + atMs, + kind: "navigation", + eventName: "$pageview", + pagePath: "/docs", + }) + } + return atMs + } + + let startMs = spread(rng, 10) + let atMs = marketing(`s${n}a`, startMs, referral) + // A quarter leave and come back days later, direct, before they decide. + if (rng.chance(0.25)) { + startMs += pause(rng, DAY_MS, 4 * DAY_MS) + atMs = marketing(`s${n}b`, startMs, REFERRALS[4]) + } + if (!rng.chance(0.42)) return + const sessionId = `s${n}-signup` + atMs += pause(rng, 10_000, 3 * MINUTE_MS) + data.open({ sessionId, startMs: atMs, visitorId, host: "acme.dev", country, ...device }) + data.track({ + sessionId, + visitorId, + host: "acme.dev", + atMs, + eventName: "signup_started", + pagePath: "/signup", + }) + if (!rng.chance(0.7)) return + + const userId = `u${n}` + const plan = rng.pick(["free", "free", "startup", "scale"] as const) + atMs += pause(rng, 20_000, 4 * MINUTE_MS) + // The backend knows the user, not the browser: no visitor and no session on these rows. + data.track({ atMs, micro: rng.int(1000), source: "server", userId, eventName: "signup_completed" }) + + const appSession = `s${n}-app` + atMs += pause(rng, 2_000, 30_000) + data.open({ + sessionId: appSession, + startMs: atMs, + visitorId, + userId, + host: "app.acme.dev", + country, + ...device, + }) + const app = { sessionId: appSession, visitorId, userId, host: "app.acme.dev" } as const + data.track({ ...app, atMs, kind: "navigation", eventName: "$pageview", pagePath: "/onboarding" }) + if (rng.chance(0.75)) { + atMs += pause(rng, 30_000, 20 * MINUTE_MS) + data.track({ + ...app, + atMs, + eventName: "onboarding_step_completed", + attributes: { step: "connect" }, + }) + if (rng.chance(0.8)) { + atMs += pause(rng, MINUTE_MS, 3 * HOUR_MS) + data.track({ + atMs, + micro: rng.int(1000), + source: "server", + userId, + eventName: "first_trace_received", + }) + } + } + // One in eight continues on a second browser: another visitor that links to the same user. + if (rng.chance(0.125)) { + const other = `v${n}-laptop` + const laptop = `s${n}-laptop` + atMs += pause(rng, HOUR_MS, DAY_MS) + data.open({ + sessionId: laptop, + startMs: atMs, + visitorId: other, + userId, + host: "app.acme.dev", + country, + }) + data.track({ + sessionId: laptop, + visitorId: other, + userId, + host: "app.acme.dev", + atMs, + eventName: "dashboard_created", + }) + } + if (plan !== "free" && rng.chance(0.6)) { + atMs += pause(rng, 2 * HOUR_MS, 9 * DAY_MS) + data.track({ + atMs, + micro: rng.int(1000), + source: "server", + userId, + eventName: "plan_started", + attributes: { plan }, + }) + } + }) + return data.done() +} + +/** + * A shop checkout: people view several products, add to cart, start checkout and pay; the order is + * confirmed server-side. Many abandon and start over the next day, so one person runs the funnel + * more than once, and guests check out under a visitor id only. + */ +export const shopCheckout = (orgId = "scenario_shop"): Dataset => { + const rng = seeded(23) + const data = recorder(orgId) + const categories = ["shoes", "jackets", "bags"] as const + const methods = ["card", "card", "paypal", "klarna"] as const + Arr.forEach(Arr.range(1, 260), (n) => { + const visitorId = `shopper${n}` + const userId = rng.chance(0.4) ? `account${n}` : "" + const device = rng.pick(DEVICES) + const country = rng.pick(COUNTRIES) + const referral = rng.pick(REFERRALS) + const attempts = rng.chance(0.3) ? 2 : 1 + let startMs = spread(rng, 12) + Arr.forEach(Arr.range(1, attempts), (attempt) => { + const sessionId = `shop${n}-${attempt}` + data.open({ + sessionId, + startMs, + visitorId, + userId, + host: "shop.example", + country, + ...device, + ...referral, + }) + const browser = { sessionId, visitorId, userId, host: "shop.example" } as const + let atMs = startMs + const views = 1 + rng.int(5) + const category = rng.pick(categories) + Arr.forEach(Arr.range(1, views), () => { + atMs += pause(rng, 3_000, 90_000) + data.track({ ...browser, atMs, eventName: "product_viewed", attributes: { category } }) + }) + startMs += pause(rng, 20 * HOUR_MS, 3 * DAY_MS) + if (!rng.chance(0.55)) return + atMs += pause(rng, 3_000, 60_000) + data.track({ ...browser, atMs, eventName: "add_to_cart", attributes: { category } }) + if (!rng.chance(0.6)) return + atMs += pause(rng, 5_000, 5 * MINUTE_MS) + data.track({ + ...browser, + atMs, + kind: "navigation", + eventName: "$pageview", + pagePath: "/checkout", + }) + data.track({ ...browser, atMs, eventName: "checkout_started" }) + if (!rng.chance(0.65)) return + const method = rng.pick(methods) + atMs += pause(rng, 20_000, 6 * MINUTE_MS) + data.track({ ...browser, atMs, eventName: "payment_submitted", attributes: { method } }) + if (!rng.chance(0.9)) return + // Guests have no account: the order row carries the visitor id the checkout sent along. + atMs += pause(rng, 300, 4_000) + data.track({ + atMs, + micro: rng.int(1000), + source: "server", + visitorId: userId === "" ? visitorId : "", + userId, + eventName: "order_completed", + attributes: { method, coupon: rng.chance(0.2) ? "WELCOME10" : "" }, + }) + }) + }) + return data.done() +} + +/** + * A mobile app's onboarding, sent straight to the events endpoint: screens and taps under an + * install id, with the user id appearing once an account exists. No browser sessions at all. + */ +export const mobileOnboarding = (orgId = "scenario_mobile"): Dataset => { + const rng = seeded(37) + const data = recorder(orgId) + Arr.forEach(Arr.range(1, 240), (n) => { + const visitorId = `install-${n}` + const base = { source: "mobile", visitorId } as const + let atMs = spread(rng, 14) + const screen = (name: string, userId = "") => { + atMs += pause(rng, 1_500, 40_000) + data.track({ ...base, userId, atMs, kind: "screen", eventName: "$screen", pagePath: name }) + } + data.track({ + ...base, + atMs, + eventName: "app_opened", + attributes: { platform: rng.pick(["ios", "android"]) }, + }) + screen("Welcome") + if (!rng.chance(0.8)) return + screen("Permissions") + atMs += pause(rng, 1_000, 20_000) + data.track({ + ...base, + atMs, + eventName: "permission_answered", + attributes: { permission: "notifications", answer: rng.chance(0.6) ? "granted" : "denied" }, + }) + if (!rng.chance(0.7)) return + const userId = `member${n}` + atMs += pause(rng, 5_000, 2 * MINUTE_MS) + data.track({ + ...base, + userId, + atMs, + eventName: "account_created", + attributes: { method: rng.pick(["apple", "email"]) }, + }) + screen("Home", userId) + if (!rng.chance(0.5)) return + atMs += pause(rng, MINUTE_MS, 2 * DAY_MS) + data.track({ ...base, userId, atMs, eventName: "workout_logged" }) + if (rng.chance(0.4)) { + atMs += pause(rng, DAY_MS, 5 * DAY_MS) + data.track({ ...base, userId, atMs, eventName: "workout_logged" }) + } + }) + return data.done() +} + +/** + * A docs site and its app on two hosts that share paths: the same `/pricing` exists on both, so a + * page step without a host and one with a host must count differently. + */ +export const contentSite = (orgId = "scenario_content"): Dataset => { + const rng = seeded(41) + const data = recorder(orgId) + const hosts = ["docs.acme.dev", "acme.dev"] as const + Arr.forEach(Arr.range(1, 200), (n) => { + const visitorId = `reader${n}` + const host = rng.pick(hosts) + const sessionId = `read${n}` + let atMs = spread(rng, 8) + const referral = rng.pick(REFERRALS) + data.open({ sessionId, startMs: atMs, visitorId, host, country: rng.pick(COUNTRIES), ...referral }) + const view = (pagePath: string, on: string = host) => { + atMs += pause(rng, 2_000, 3 * MINUTE_MS) + data.track({ + sessionId, + visitorId, + host: on, + atMs, + kind: "navigation", + eventName: "$pageview", + pagePath, + }) + } + view(host === "docs.acme.dev" ? "/quickstart" : "/") + if (rng.chance(0.5)) view("/pricing") + if (rng.chance(0.3)) view("/pricing", "acme.dev") + if (rng.chance(0.35)) { + view("/signup", "acme.dev") + if (rng.chance(0.5)) { + atMs += pause(rng, 5_000, 2 * MINUTE_MS) + data.track({ sessionId, visitorId, host: "acme.dev", atMs, eventName: "signup_started" }) + } + } + }) + return data.done() +} + +export const SCENARIOS = { saasSignup, shopCheckout, mobileOnboarding, contentSite } as const diff --git a/docs/product-events-funnels.md b/docs/product-events-funnels.md index 871df24282..94343f4467 100644 --- a/docs/product-events-funnels.md +++ b/docs/product-events-funnels.md @@ -465,10 +465,9 @@ the previous step. When the variant is set the route params carry `details: true` and the funnel route also runs `productEventsFunnelTimingQuery` (p50/p90 milliseconds between consecutive steps) and `productEventsFunnelLeaversQuery` (the first event after the last step a leaver -reached, top 6 per step, `''` = nothing followed). Both walk each person's -chain from the first step-1 event: a first-occurrence approximation of the -chain `windowFunnel` counted, so a person the funnel counts at level N can -occasionally have no step-N timestamp and is left out of the quantiles. The +reached, top 6 per step, `''` = nothing followed). Both read each person's +chain, described under "How a funnel is counted" below, so the times and the +leavers belong to the same attempt the count does. The extras ride on the same `{ name, value }` rows as optional per-step fields (`p50Ms`, `p90Ms`, `leavers`), so a reader that only knows the bars ignores them. With a `breakdownBy` the route returns the grouped rows only: the drop-off view then draws one @@ -491,3 +490,34 @@ The hop rows are a CTE so the ranking and both sides of the fold join read them once. The benchmark catalog (`packages/query-engine/src/benchmark/builders.ts`) carries fixtures for both, and `web-analytics-parity.clickhouse.e2e.test.ts` runs them against a real ClickHouse. + +## How a funnel is counted (2026-11-14) + +Every funnel query orders a person's rows by one key, `orderKey` in +`packages/query-engine/src/ch/queries/product-events.ts`: microseconds times 1024, plus +`Seq % 1024`. `Seq` is what tells a page load from the `track()` call it fires in the same +millisecond; server rows carry `Seq` 0 and order by their own clock. Before this key, counts +ordered by millisecond only, so two rows in one millisecond converted in either order. + +- **An attempt** starts at a step-1 row and takes, for each later step, the first later row that + matches and is still inside the window of the start. One row serves one step. +- **The level** is the person's deepest attempt. That is `windowFunnel` whenever no row can match + two steps. A funnel that repeats a step (`product_viewed`, `product_viewed`, `add_to_cart`) is + read from `walkQuery` instead: plain `windowFunnel` lets one row satisfy both repeats, and its + `strict_once` mode, which forbids that, took 31 s on 72,000 rows where the walk takes 26 ms. +- **The chain** is the earliest attempt that gets that deep. A first try that ran out of time gives + way to the later one that converted. Time between steps and "where the leavers went" are both + read from the chain, so a funnel never reports a time for people it did not count. This replaced + a walk from the person's first step-1 event, which dropped everyone whose first attempt expired + and returned nothing at all when step 1 was a session step. +- **A page step** matches a browser page view or a mobile screen view (`Kind` `navigation` or + `screen`); both are named by `PagePath`. Paths and leavers name a screen the same way. + +`apps/cli/test/funnel-scenarios.test.ts` holds this definition as a plain TypeScript reference +funnel and runs the compiled SQL against it on chDB: four example products (a SaaS signup with +server-side steps, a shop checkout with restarts, a mobile onboarding, a two-host content site), +hand-built edge cases and random histories. It needs no Docker; run it with +`bun test test/funnel-scenarios.test.ts` in `apps/cli`. + +On 2.9M rows and 200,000 people (chDB, five steps): counts 30 ms, time between steps 80 ms (118 ms +before), leavers 122 ms (133 ms before), a funnel with a repeated step 45 ms. diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index f17df9fdd5..5c1e24332c 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -4267,7 +4267,7 @@ SELECT ORDER BY bucket ASC, groupName ASC FORMAT JSON --- builder:product-events-paths:productEventsPathsQuery:after-person [ca42de6b] +-- builder:product-events-paths:productEventsPathsQuery:after-person [387f0bb9] SELECT mapped.hop AS hop, mapped.fromNode AS fromNode, @@ -4300,7 +4300,7 @@ SELECT toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, e.Seq AS seq, e.Kind AS kind, - if(e.Kind = 'navigation', e.PagePath, e.EventName) AS name, + if((e.Kind IN ('navigation', 'screen') AND e.PagePath != ''), e.PagePath, e.EventName) AS name, toUInt8(e.EventName = 'signup_completed') AS isAnchor FROM product_events AS e LEFT JOIN (SELECT @@ -4392,7 +4392,7 @@ SELECT ORDER BY hop ASC, count DESC, fromNode ASC, toNode ASC FORMAT JSON --- builder:product-events-paths:productEventsPathsQuery:before-session-pages-filtered [59eef311] +-- builder:product-events-paths:productEventsPathsQuery:before-session-pages-filtered [a4e7b20b] SELECT mapped.hop AS hop, mapped.fromNode AS fromNode, @@ -4425,8 +4425,8 @@ SELECT toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, e.Seq AS seq, e.Kind AS kind, - if(e.Kind = 'navigation', e.PagePath, e.EventName) AS name, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS isAnchor + if((e.Kind IN ('navigation', 'screen') AND e.PagePath != ''), e.PagePath, e.EventName) AS name, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS isAnchor FROM product_events AS e WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' @@ -4512,11 +4512,11 @@ SELECT AND product_events.EventName = 'signup_started' GROUP BY sessionId) GROUP BY key) - AND ((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') + AND ((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') GROUP BY key) AS a ON r.key = a.key WHERE r.ts <= a.anchorTs AND r.ts >= a.anchorTs - 3600000 - AND (r.isAnchor = 1 OR r.kind = 'navigation')) AS path_events + AND (r.isAnchor = 1 OR r.kind IN ('navigation', 'screen'))) AS path_events GROUP BY key) AS per_person WHERE anchorIdx > 0) AS sequences) AS edges WHERE tupleElement(edge, 1) <= 2 @@ -4655,7 +4655,7 @@ SELECT LIMIT 50 FORMAT JSON --- builder:product-events:productEventsFunnelBreakdownQuery:attribute-session-step [c28317af] +-- builder:product-events:productEventsFunnelBreakdownQuery:attribute-session-step [b0b5b3fb] SELECT groups.group AS group, arrayJoin([1, 2, 3, 4]) AS step, @@ -4666,13 +4666,12 @@ SELECT countIf(level >= 1) AS entered FROM (SELECT funnel_events.key AS key, - windowFunnel(604800000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1, funnel_events.s4 = 1) AS level, - argMinIf(funnel_events.dim, funnel_events.ts, funnel_events.dim != '') AS group + windowFunnel(619315200000000)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level, + argMinIf(funnel_events.dim, funnel_events.ord, funnel_events.dim != '') AS group FROM ( SELECT s.UserId AS key, - toUInt64(toUnixTimestamp64Milli(s.StartTime)) AS ts, - 0 AS seq, + plus(multiply(toUInt64(toUnixTimestamp64Micro(s.StartTime)), 1024), modulo(0, 1024)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, @@ -4687,10 +4686,9 @@ SELECT UNION ALL SELECT e.UserId AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - e.Seq AS seq, + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, 0 AS s1, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, toUInt8(e.EventName = 'signup_completed') AS s3, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s4, e.Attributes['plan'] AS dim @@ -4698,7 +4696,7 @@ SELECT WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND e.UserId != '' ) AS funnel_events GROUP BY key) AS levels @@ -4708,7 +4706,7 @@ SELECT ORDER BY group ASC, step ASC FORMAT JSON --- builder:product-events:productEventsFunnelBreakdownQuery:session-dimension [f210b7b0] +-- builder:product-events:productEventsFunnelBreakdownQuery:session-dimension [99914415] SELECT groups.group AS group, arrayJoin([1, 2, 3]) AS step, @@ -4719,13 +4717,12 @@ SELECT countIf(level >= 1) AS entered FROM (SELECT funnel_events.key AS key, - windowFunnel(604800000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1) AS level, - argMinIf(funnel_events.dim, funnel_events.ts, funnel_events.dim != '') AS group + windowFunnel(619315200000000)(ord, s1 = 1, s2 = 1, s3 = 1) AS level, + argMinIf(funnel_events.dim, funnel_events.ord, funnel_events.dim != '') AS group FROM (SELECT multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - e.Seq AS seq, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3, coalesce(sd.Value, '') AS dim @@ -4752,7 +4749,7 @@ SELECT WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) != '') AS funnel_events GROUP BY key) AS levels GROUP BY group @@ -4761,7 +4758,7 @@ SELECT ORDER BY group ASC, step ASC FORMAT JSON --- builder:product-events:productEventsFunnelLeaversQuery:visitor-filtered [95bb693c] +-- builder:product-events:productEventsFunnelLeaversQuery:visitor-filtered [7dc23b3d] SELECT hops.step AS step, tupleElement(hop, 1) AS next, @@ -4771,7 +4768,7 @@ SELECT arrayJoin(head) AS hop FROM (SELECT counted.step AS step, - arraySlice(arrayReverseSort(x -> x.2, groupArray(tuple(next, count))), 1, 6) AS head + arraySlice(arraySort(x -> (-toInt64(x.2), x.1), groupArray(tuple(next, count))), 1, 6) AS head FROM (SELECT nexts.step AS step, nexts.next AS next, @@ -4779,11 +4776,11 @@ SELECT FROM (SELECT e.key AS key, d.step AS step, - argMinIf(e.name, e.ts, e.ts > d.tLast) AS next + argMinIf(e.name, e.ord, e.ord > d.lastOrd) AS next FROM (SELECT e.VisitorId AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - if(e.Kind = 'navigation', e.PagePath, e.EventName) AS name + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + if((e.Kind IN ('navigation', 'screen') AND e.PagePath != ''), e.PagePath, e.EventName) AS name FROM product_events AS e WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' @@ -4828,29 +4825,40 @@ SELECT INNER JOIN (SELECT chain.key AS key, chain.level + 1 AS step, - arrayElement([t1, t2, t3], level) AS tLast + arrayElement([o1, o2, o3], level) AS lastOrd FROM (SELECT - chain_events.key AS key, - chain_events.level AS level, - tupleElement(arrayFirst(x -> x.3 = 1, evs), 1) AS t1, - tupleElement(arrayFirst(x -> t1 > 0 AND x.4 = 1 AND x.1 >= t1 AND x.1 <= t1 + 604800000, evs), 1) AS t2, - tupleElement(arrayFirst(x -> t2 > 0 AND x.5 = 1 AND x.1 >= t2 AND x.1 <= t1 + 604800000, evs), 1) AS t3 + walked.key AS key, + walked.level AS level, + if(level >= 1, tupleElement(arrayFirst(x -> x.2 = 1 AND x.1 + 619315200000000 >= reachedAt, evs), 1), toUInt64(0)) AS o1, + if(level >= 2, tupleElement(arrayFirst(x -> x.3 = 1 AND x.1 > o1, evs), 1), toUInt64(0)) AS o2, + if(level >= 3, tupleElement(arrayFirst(x -> x.4 = 1 AND x.1 > o2, evs), 1), toUInt64(0)) AS o3 + FROM (SELECT + person_events.key AS key, + person_events.evs AS evs, + arrayFill(v -> v > 0, arrayMap(x -> if(x.2 = 1, x.1, toUInt64(0)), evs)) AS a1, + arrayMap((x, p) -> if(x.3 = 1 AND p > 0 AND x.1 <= p + 619315200000000, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a1), toUInt64(0))) AS b2, + arrayFill(v -> v > 0, b2) AS a2, + arrayMap((x, p) -> if(x.4 = 1 AND p > 0 AND x.1 <= p + 619315200000000, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a2), toUInt64(0))) AS b3, + arrayFill(v -> v > 0, b3) AS a3, + arrayFirst(v -> v > 0, a1) AS f1, + tupleElement(arrayFirst((x, b) -> b > 0, evs, b2), 1) AS f2, + tupleElement(arrayFirst((x, b) -> b > 0, evs, b3), 1) AS f3, + (f1 > 0) + (f2 > 0) + (f3 > 0) AS level, + greatest(f1, f2, f3) AS reachedAt FROM (SELECT funnel_events.key AS key, - windowFunnel(604800000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1) AS level, - arraySort(x -> (x.1, x.2), groupArray(tuple(ts, seq, s1, s2, s3))) AS evs + arraySort(groupArray(tuple(ord, s1, s2, s3))) AS evs FROM (SELECT e.VisitorId AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - e.Seq AS seq, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 FROM product_events AS e WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND e.VisitorId != '' AND e.VisitorId IN (SELECT session_replays.VisitorId AS key @@ -4888,17 +4896,16 @@ SELECT AND product_events.EventName = 'signup_started' GROUP BY sessionId) GROUP BY key)) AS funnel_events - GROUP BY key) AS chain_events) AS chain + GROUP BY key) AS person_events) AS walked) AS chain WHERE chain.level >= 1 - AND chain.level < 3 - AND arrayElement([t1, t2, t3], level) > 0) AS d ON e.key = d.key + AND chain.level < 3) AS d ON e.key = d.key GROUP BY key, step) AS nexts GROUP BY step, next) AS counted GROUP BY step) AS ranked) AS hops ORDER BY step ASC, count DESC, next ASC FORMAT JSON --- builder:product-events:productEventsFunnelQuery:person [877c57db] +-- builder:product-events:productEventsFunnelQuery:person [7616752e] SELECT arrayJoin([1, 2, 3]) AS step, arrayElement(totals.counts, step) AS count @@ -4906,12 +4913,11 @@ SELECT [countIf(level >= 1), countIf(level >= 2), countIf(level >= 3)] AS counts FROM (SELECT funnel_events.key AS key, - windowFunnel(604800000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1) AS level + windowFunnel(619315200000000)(ord, s1 = 1, s2 = 1, s3 = 1) AS level FROM (SELECT multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - e.Seq AS seq, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 FROM product_events AS e @@ -4929,13 +4935,13 @@ SELECT WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) != '') AS funnel_events GROUP BY key) AS levels) AS totals ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelQuery:session-key [118d45ba] +-- builder:product-events:productEventsFunnelQuery:session-key [f32e490d] SELECT arrayJoin([1, 2, 3]) AS step, arrayElement(totals.counts, step) AS count @@ -4943,25 +4949,24 @@ SELECT [countIf(level >= 1), countIf(level >= 2), countIf(level >= 3)] AS counts FROM (SELECT funnel_events.key AS key, - windowFunnel(1800000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1) AS level + windowFunnel(1843200000000)(ord, s1 = 1, s2 = 1, s3 = 1) AS level FROM (SELECT e.SessionId AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - e.Seq AS seq, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 FROM product_events AS e WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND e.SessionId != '') AS funnel_events GROUP BY key) AS levels) AS totals ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelQuery:session-step-filtered [7bae7ef8] +-- builder:product-events:productEventsFunnelQuery:session-step-filtered [51fcc352] SELECT arrayJoin([1, 2, 3, 4]) AS step, arrayElement(totals.counts, step) AS count @@ -4969,12 +4974,11 @@ SELECT [countIf(level >= 1), countIf(level >= 2), countIf(level >= 3), countIf(level >= 4)] AS counts FROM (SELECT funnel_events.key AS key, - windowFunnel(604800000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1, funnel_events.s4 = 1) AS level + windowFunnel(619315200000000)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level FROM ( SELECT multiIf(s.UserId != '', s.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), s.VisitorId) AS key, - toUInt64(toUnixTimestamp64Milli(s.StartTime)) AS ts, - 0 AS seq, + plus(multiply(toUInt64(toUnixTimestamp64Micro(s.StartTime)), 1024), modulo(0, 1024)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, @@ -5046,10 +5050,9 @@ SELECT UNION ALL SELECT multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - e.Seq AS seq, + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, 0 AS s1, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, toUInt8(e.EventName = 'signup_completed') AS s3, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s4 FROM product_events AS e @@ -5067,7 +5070,7 @@ SELECT WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) != '' AND multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) IN (SELECT multiIf(s.UserId != '', s.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), s.VisitorId) AS key @@ -5121,7 +5124,7 @@ SELECT ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelQuery:visitor-session-step [59b281c2] +-- builder:product-events:productEventsFunnelQuery:visitor-session-step [97d46614] SELECT arrayJoin([1, 2, 3, 4]) AS step, arrayElement(totals.counts, step) AS count @@ -5129,12 +5132,11 @@ SELECT [countIf(level >= 1), countIf(level >= 2), countIf(level >= 3), countIf(level >= 4)] AS counts FROM (SELECT funnel_events.key AS key, - windowFunnel(3600000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1, funnel_events.s4 = 1) AS level + windowFunnel(3686400000000)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level FROM ( SELECT s.VisitorId AS key, - toUInt64(toUnixTimestamp64Milli(s.StartTime)) AS ts, - 0 AS seq, + plus(multiply(toUInt64(toUnixTimestamp64Micro(s.StartTime)), 1024), modulo(0, 1024)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, @@ -5148,46 +5150,56 @@ SELECT UNION ALL SELECT e.VisitorId AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - e.Seq AS seq, + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, 0 AS s1, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, toUInt8(e.EventName = 'signup_completed') AS s3, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s4 FROM product_events AS e WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND e.VisitorId != '' ) AS funnel_events GROUP BY key) AS levels) AS totals ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelTimingQuery:person [82b59764] +-- builder:product-events:productEventsFunnelTimingQuery:person [bb4dad6c] SELECT arrayJoin([2, 3]) AS step, arrayElement(totals.p50s, step) AS p50Ms, arrayElement(totals.p90s, step) AS p90Ms FROM (SELECT - [0, ifNotFinite(quantileIf(0.5)(toFloat64(t2 - t1), level >= 2 AND t2 > 0), 0), ifNotFinite(quantileIf(0.5)(toFloat64(t3 - t2), level >= 3 AND t3 > 0), 0)] AS p50s, - [0, ifNotFinite(quantileIf(0.9)(toFloat64(t2 - t1), level >= 2 AND t2 > 0), 0), ifNotFinite(quantileIf(0.9)(toFloat64(t3 - t2), level >= 3 AND t3 > 0), 0)] AS p90s + [0, ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3), 0)] AS p50s, + [0, ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3), 0)] AS p90s + FROM (SELECT + walked.key AS key, + walked.level AS level, + if(level >= 1, tupleElement(arrayFirst(x -> x.2 = 1 AND x.1 + 619315200000000 >= reachedAt, evs), 1), toUInt64(0)) AS o1, + if(level >= 2, tupleElement(arrayFirst(x -> x.3 = 1 AND x.1 > o1, evs), 1), toUInt64(0)) AS o2, + if(level >= 3, tupleElement(arrayFirst(x -> x.4 = 1 AND x.1 > o2, evs), 1), toUInt64(0)) AS o3 FROM (SELECT - chain_events.key AS key, - chain_events.level AS level, - tupleElement(arrayFirst(x -> x.3 = 1, evs), 1) AS t1, - tupleElement(arrayFirst(x -> t1 > 0 AND x.4 = 1 AND x.1 >= t1 AND x.1 <= t1 + 604800000, evs), 1) AS t2, - tupleElement(arrayFirst(x -> t2 > 0 AND x.5 = 1 AND x.1 >= t2 AND x.1 <= t1 + 604800000, evs), 1) AS t3 + person_events.key AS key, + person_events.evs AS evs, + arrayFill(v -> v > 0, arrayMap(x -> if(x.2 = 1, x.1, toUInt64(0)), evs)) AS a1, + arrayMap((x, p) -> if(x.3 = 1 AND p > 0 AND x.1 <= p + 619315200000000, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a1), toUInt64(0))) AS b2, + arrayFill(v -> v > 0, b2) AS a2, + arrayMap((x, p) -> if(x.4 = 1 AND p > 0 AND x.1 <= p + 619315200000000, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a2), toUInt64(0))) AS b3, + arrayFill(v -> v > 0, b3) AS a3, + arrayFirst(v -> v > 0, a1) AS f1, + tupleElement(arrayFirst((x, b) -> b > 0, evs, b2), 1) AS f2, + tupleElement(arrayFirst((x, b) -> b > 0, evs, b3), 1) AS f3, + (f1 > 0) + (f2 > 0) + (f3 > 0) AS level, + greatest(f1, f2, f3) AS reachedAt FROM (SELECT funnel_events.key AS key, - windowFunnel(604800000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1) AS level, - arraySort(x -> (x.1, x.2), groupArray(tuple(ts, seq, s1, s2, s3))) AS evs + arraySort(groupArray(tuple(ord, s1, s2, s3))) AS evs FROM (SELECT multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) AS key, - toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, - e.Seq AS seq, - toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, + plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 FROM product_events AS e @@ -5205,9 +5217,9 @@ SELECT WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) != '') AS funnel_events - GROUP BY key) AS chain_events) AS chain) AS totals + GROUP BY key) AS person_events) AS walked) AS chain) AS totals ORDER BY step ASC FORMAT JSON diff --git a/packages/query-engine/src/ch/queries/product-events-paths.test.ts b/packages/query-engine/src/ch/queries/product-events-paths.test.ts index 53bd7b0720..cef4b45827 100644 --- a/packages/query-engine/src/ch/queries/product-events-paths.test.ts +++ b/packages/query-engine/src/ch/queries/product-events-paths.test.ts @@ -85,12 +85,16 @@ describe("productEventsPathsQuery", () => { params, ) // No identity join on a session key, so the columns go unprefixed. - expect(sql).toContain("if(e.Kind = 'navigation', e.PagePath, e.EventName) AS name") + expect(sql).toContain( + "if((e.Kind IN ('navigation', 'screen') AND e.PagePath != ''), e.PagePath, e.EventName) AS name", + ) // The anchor row is exempt from its own kind and name filters. expect(sql).toContain( - "(r.isAnchor = 1 OR (r.kind = 'navigation' AND r.name NOT IN ('heartbeat', '/')))", + "(r.isAnchor = 1 OR (r.kind IN ('navigation', 'screen') AND r.name NOT IN ('heartbeat', '/')))", + ) + expect(sql).toContain( + "(e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev'", ) - expect(sql).toContain("(e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev'") expect(sql).not.toContain("identity_links") }) diff --git a/packages/query-engine/src/ch/queries/product-events-paths.ts b/packages/query-engine/src/ch/queries/product-events-paths.ts index e6c6baf071..013496e033 100644 --- a/packages/query-engine/src/ch/queries/product-events-paths.ts +++ b/packages/query-engine/src/ch/queries/product-events-paths.ts @@ -109,15 +109,16 @@ function validate(opts: ProductEventsPathsOpts): ProductEventsFunnelError | unde return undefined } -/** The include filter over a projected `kind` column; `undefined` for "all". */ +/** The include filter over a projected `kind` column; `undefined` for "all". Pages cover mobile screens. */ function includeCondition(kind: CH.Expr, include: PathsInclude): CH.Condition | undefined { + const views = ["navigation", "screen"] switch (include) { case "all": return undefined case "events": - return kind.neq("navigation") + return CH.notInList(kind, views) case "pages": - return kind.eq("navigation") + return CH.inList(kind, views) } } diff --git a/packages/query-engine/src/ch/queries/product-events.test.ts b/packages/query-engine/src/ch/queries/product-events.test.ts index e242f2941a..67603e58d8 100644 --- a/packages/query-engine/src/ch/queries/product-events.test.ts +++ b/packages/query-engine/src/ch/queries/product-events.test.ts @@ -62,12 +62,11 @@ describe("productEventsFunnelQuery", () => { ), params, ) - // The window is passed in the timestamp's unit — epoch milliseconds — since - // windowFunnel does not accept DateTime64. + // The window is in the order key's unit: 3600 s as microseconds, times 1024. + expect(sql).toContain("windowFunnel(3686400000000)(ord, s1 = 1, s2 = 1, s3 = 1) AS level") expect(sql).toContain( - "windowFunnel(3600000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1) AS level", + "plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord", ) - expect(sql).toContain("toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts") expect(sql).toContain("[countIf(level >= 1), countIf(level >= 2), countIf(level >= 3)] AS counts") expect(sql).toContain("arrayJoin([1, 2, 3]) AS step") expect(sql).toContain("arrayElement(totals.counts, step) AS count") @@ -82,14 +81,14 @@ describe("productEventsFunnelQuery", () => { params, ) expect(sql).toContain( - "toUInt8(((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1", + "toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1", ) expect(sql).toContain("toUInt8(e.EventName = 'signup_completed') AS s2") expect(sql).toContain( "toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3", ) expect(oneLine(sql)).toContain( - "AND (((e.Kind = 'navigation' AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup'))", + "AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup'))", ) }) @@ -151,16 +150,14 @@ describe("productEventsFunnelQuery", () => { ) // The session branch: s1 = 1, every other step 0, at the session's StartTime. expect(oneLine(sql)).toContain( - "SELECT s.VisitorId AS key, toUInt64(toUnixTimestamp64Milli(s.StartTime)) AS ts, 0 AS seq, 1 AS s1, 0 AS s2, 0 AS s3, 0 AS s4 FROM session_replays AS s", + "SELECT s.VisitorId AS key, plus(multiply(toUInt64(toUnixTimestamp64Micro(s.StartTime)), 1024), modulo(0, 1024)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, 0 AS s4 FROM session_replays AS s", ) expect(sql).toContain("AND s.ReferrerHost = 'news.ycombinator.com'") // The events branch never satisfies the session step. expect(oneLine(sql)).toContain( - "SELECT e.VisitorId AS key, toUInt64(toUnixTimestamp64Milli(e.Timestamp)) AS ts, e.Seq AS seq, 0 AS s1,", - ) - expect(sql).toContain( - "windowFunnel(86400000)(funnel_events.ts, funnel_events.s1 = 1, funnel_events.s2 = 1, funnel_events.s3 = 1, funnel_events.s4 = 1) AS level", + "SELECT e.VisitorId AS key, plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, 0 AS s1,", ) + expect(sql).toContain("windowFunnel(88473600000000)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level") }) it("has no session_replays branch without a session step", () => { @@ -256,7 +253,7 @@ describe("productEventsFunnelBreakdownQuery", () => { const flat = oneLine(compiled.sql) expect(flat).toContain("Attributes['plan'] AS dim") expect(flat).toContain( - "argMinIf(funnel_events.dim, funnel_events.ts, funnel_events.dim != '') AS group", + "argMinIf(funnel_events.dim, funnel_events.ord, funnel_events.dim != '') AS group", ) expect(flat).toContain("countIf(level >= 1) AS entered") expect(flat).toContain("GROUP BY group ORDER BY entered DESC, group ASC LIMIT 5") @@ -371,21 +368,24 @@ describe("productEventNamesQuery", () => { // every event of the population back onto the chain to find what came next. describe("funnel drop-off details", () => { - it("walks each step's first event in chain order and takes p50/p90 of the gaps", () => { + it("walks the chain from the start that reached the deepest step and takes p50/p90 of the gaps", () => { const { sql } = compileUnsafe( Result.getOrThrow( productEventsFunnelTimingQuery({ steps: STEPS, keyBy: "person", windowSeconds: 3_600 }), ), params, ) - expect(sql).toContain("arraySort(x -> (x.1, x.2), groupArray(tuple(ts, seq, s1, s2, s3))) AS evs") - expect(sql).toContain("tupleElement(arrayFirst(x -> x.3 = 1, evs), 1) AS t1") - // A step is only found once the previous one was. + expect(sql).toContain("arraySort(groupArray(tuple(ord, s1, s2, s3))) AS evs") + // The first step-1 row still inside the window when the deepest step was first reached. + expect(sql).toContain("arrayFirst(x -> x.2 = 1 AND x.1 + 3686400000000 >= reachedAt, evs)") + // Each later step is the first matching row after the previous step's. + expect(sql).toContain("arrayFirst(x -> x.3 = 1 AND x.1 > o1, evs)") + expect(sql).toContain( + "quantileIf(0.5)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2)", + ) expect(sql).toContain( - "arrayFirst(x -> t1 > 0 AND x.4 = 1 AND x.1 >= t1 AND x.1 <= t1 + 3600000, evs), 1) AS t2", + "quantileIf(0.9)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3)", ) - expect(sql).toContain("quantileIf(0.5)(toFloat64(t2 - t1), level >= 2 AND t2 > 0)") - expect(sql).toContain("quantileIf(0.9)(toFloat64(t3 - t2), level >= 3 AND t3 > 0)") expect(sql).toContain("arrayJoin([2, 3]) AS step") expect(sql).toContain("arrayElement(totals.p50s, step) AS p50Ms") }) @@ -399,13 +399,14 @@ describe("funnel drop-off details", () => { ) // oxlint-disable-next-line test-slop/no-sql-text-pin -- steps are 1-based, the chain level 0-based expect(sql).toContain("level + 1 AS step") - expect(sql).toContain("arrayElement([t1, t2, t3], level) AS tLast") + expect(sql).toContain("arrayElement([o1, o2, o3], level) AS lastOrd") expect(sql).toContain("WHERE chain.level >= 1") expect(sql).toContain("AND chain.level < 3") - expect(sql).toContain("AND arrayElement([t1, t2, t3], level) > 0") - expect(sql).toContain("argMinIf(e.name, e.ts, e.ts > d.tLast) AS next") + expect(sql).toContain("argMinIf(e.name, e.ord, e.ord > d.lastOrd) AS next") // No identity join on a visitor key, so the branch's columns go unprefixed. - expect(sql).toContain("if(e.Kind = 'navigation', e.PagePath, e.EventName) AS name") + expect(sql).toContain( + "if((e.Kind IN ('navigation', 'screen') AND e.PagePath != ''), e.PagePath, e.EventName) AS name", + ) expect(sql).toContain("groupArray(tuple(next, count))), 1, 6) AS head") expect(sql).toContain("ORDER BY step ASC, count DESC, next ASC") }) diff --git a/packages/query-engine/src/ch/queries/product-events.ts b/packages/query-engine/src/ch/queries/product-events.ts index d9be6fb2de..e6bc70f732 100644 --- a/packages/query-engine/src/ch/queries/product-events.ts +++ b/packages/query-engine/src/ch/queries/product-events.ts @@ -2,8 +2,8 @@ // // `product_events` is the one time-sorted table every product event lands in: // browser page views and `track()` calls via the `session_events` MV, backend -// and mobile events via `POST /v1/events`. Funnels step over it with -// `windowFunnel`, grouped by a per-person key, and every query here keeps the +// and mobile events via `POST /v1/events`. Funnels step over it per person, in +// `orderKey` order (docs/product-events-funnels.md), and every query here keeps the // page-level filter surface (`ProductEventsFilters`) so the `/analytics` // sidebar narrows a funnel exactly the way it narrows the page-view panels. // @@ -55,6 +55,25 @@ export function epochMs(ts: CH.Expr): CH.Expr { return CH.toUInt64(compileFnCall("toUnixTimestamp64Milli", ts)) } +/** How many {@link orderKey} units one millisecond spans. */ +const ORDER_UNITS_PER_MS = 1_024_000 + +/** + * A row's place in its person's history, as one unsigned integer: microseconds * 1024 + + * (`Seq` % 1024). `Seq` is the tiebreak inside one timestamp (a page load and the `track()` it + * fires share a millisecond), and server rows, which carry `Seq` 0, order by their own clock. + * `windowFunnel` and the chain walk both read this key, so they cannot disagree about order. + */ +function orderKey(ts: CH.Expr, seq: CH.Expr): CH.Expr { + const micros = CH.toUInt64(compileFnCall("toUnixTimestamp64Micro", ts)) + return CH.compileTypedFnCall( + "plus", + T.uint64.schema, + CH.compileTypedFnCall("multiply", T.uint64.schema, micros, CH.lit(1024)), + CH.compileTypedFnCall("modulo", T.uint64.schema, seq, CH.lit(1024)), + ) +} + // argMinIf(value, orderBy, cond) — the `value` on the earliest row matching // `cond`. Returns one of its inputs unchanged, so it decodes as that input does. export function argMinIf(value: CH.Expr, orderBy: CH.Expr, cond: CH.Condition): CH.Expr { @@ -82,8 +101,8 @@ export type FunnelSessionDimension = * * - `event`: a `track()` (or direct-ingested) event by name, optionally * narrowed by `Attributes[k] = v` for every entry of `attributeEquals`. - * - `page`: a page view of `pagePath` (`Kind = 'navigation'`), optionally on - * one `host`. + * - `page`: a browser page view or a mobile screen view of `pagePath`, optionally + * on one `host`. * - `session`: "started a session with this acquisition dimension" — the * referral / campaign entry point. Only valid as step 1: it is evaluated * against `session_replays` and enters the funnel as a synthetic @@ -301,6 +320,13 @@ function sessionDimensionColumn($: ReplaysAccessor, dimension: FunnelSessionDime } } +/** A browser page view or a mobile screen view: both are named by `PagePath`. */ +function isPageView( + $: EventsAccessor | OpenJoinAccessor, +): CH.Condition { + return CH.inList($.Kind, ["navigation", "screen"]) +} + /** The step's predicate over a `product_events` row. `session` steps never match an event row. */ export function eventStepCondition($: EventsAccessor, step: FunnelStep): CH.Condition | undefined { switch (step.kind) { @@ -312,7 +338,7 @@ export function eventStepCondition($: EventsAccessor, step: FunnelStep): CH.Cond return cond } case "page": { - const cond = $.Kind.eq("navigation").and($.PagePath.eq(step.pagePath)) + const cond = isPageView($).and($.PagePath.eq(step.pagePath)) return step.host === undefined ? cond : cond.and($.Host.eq(step.host)) } case "session": @@ -445,7 +471,7 @@ function eventsBranch(plan: FunnelPlan): FunnelBranch { return cond ? flag(cond) : CH.lit(0) }) const d = dimExpr($) - const row = { key, ts: epochMs($.Timestamp), seq: $.Seq, ...flags } + const row = { key, ord: orderKey($.Timestamp, $.Seq), ...flags } return d ? { ...row, dim: d } : row }) .where(($) => { @@ -492,8 +518,8 @@ function sessionEntryBranch(plan: FunnelPlan, step: Extract { const key = personKey(keyBy, $, keyBy === "person" ? $[LINK_ALIAS] : undefined) const flags = stepFlags(opts.steps, (_, index) => CH.lit(index === 0 ? 1 : 0)) - // A session entry has no row order of its own; 0 sorts it first inside its millisecond. - const row = { key, ts: epochMs($.StartTime), seq: CH.lit(0), ...flags } + // A session entry has no row order of its own; 0 sorts it first inside its timestamp. + const row = { key, ord: orderKey($.StartTime, CH.lit(0)), ...flags } if (dim === undefined) return row // An attribute breakdown has no value on a session row; the events // branch supplies it. A session dimension is read straight off the row. @@ -518,21 +544,19 @@ function sessionEntryBranch(plan: FunnelPlan, step: Extract GROUP BY key` — one row per person with the - * deepest step they reached in order within the window. + * The rows a funnel reads: every step-matching event, plus one entry row per matching session + * when step 1 is a session step. * * A funnel whose ONLY step is a `session` step has no event predicate at all, so * the events branch would have nothing to filter on and would read every - * `product_events` row in range to project `s1..sN` as zeros — rows that can + * `product_events` row in range to project `s1..sN` as zeros: rows that can * never reach a level. That funnel is answered by the session-entry branch * alone, which is the state the step builder is in the moment step 1 becomes a * session step, so the branch is dropped rather than scanned. */ -function levelsQuery(plan: FunnelPlan) { - const { opts } = plan - const hasEventStep = Arr.some(opts.steps, (step) => step.kind !== "session") - const source = plan.sessionStep +function funnelSource(plan: FunnelPlan) { + const hasEventStep = Arr.some(plan.opts.steps, (step) => step.kind !== "session") + return plan.sessionStep ? hasEventStep ? fromUnion( unionAll(sessionEntryBranch(plan, plan.sessionStep), eventsBranch(plan)), @@ -540,22 +564,135 @@ function levelsQuery(plan: FunnelPlan) { ) : fromQuery(sessionEntryBranch(plan, plan.sessionStep), "funnel_events") : fromQuery(eventsBranch(plan), "funnel_events") +} + +/** The conversion window in {@link orderKey} units, exact past 2^53. */ +const windowUnits = (opts: ProductEventsFunnelOpts): string => + (BigInt(Math.round(opts.windowSeconds * 1000)) * BigInt(ORDER_UNITS_PER_MS)).toString() + +/** What a step matches on, ignoring its attribute and host narrowing: two steps sharing it can match one row. */ +const stepSubject = (step: FunnelStep): string => { + switch (step.kind) { + case "event": + return `event:${step.eventName}` + case "page": + return `page:${step.pagePath}` + case "session": + return "session" + } +} - return source +/** Two steps could be satisfied by the same row, as in a funnel that repeats a step. */ +const stepsCanShareRow = (steps: ReadonlyArray): boolean => + new Set(Arr.map(steps, stepSubject)).size < steps.length + +/** + * `SELECT key, [, first dim AS group] FROM GROUP BY key`: one row per + * person with the deepest step they reached in order within the window. + * + * The level is `windowFunnel` when every row can serve one step at most. A funnel that repeats a + * step (`search` then `search`) is different: `windowFunnel` lets a single `search` row satisfy + * both conditions, and its `strict_once` mode, which forbids that, is quadratic in a person's + * rows. Those funnels read the level from {@link walkQuery}, where a row advances one step. + */ +function levelsQuery(plan: FunnelPlan) { + const { opts } = plan + if (stepsCanShareRow(opts.steps)) { + return fromQuery(walkQuery(plan), "walked").select(($) => { + const row = { key: $.key, level: $.level } + if (plan.breakdownBy === undefined) return row + // Not `$.group`: the walk projects it only for a breakdown, so it is not on the accessor's type. + return { ...row, group: CH.dynamicColumn("group", T.string) } + }) + } + const conditions = Arr.join( + Arr.map(opts.steps, (_, index) => `${stepColumn(index)} = 1`), + ", ", + ) + return funnelSource(plan) + .select(($) => { + const row = { + key: $.key as CH.Expr, + level: CH.rawExpr(`windowFunnel(${windowUnits(opts)})(ord, ${conditions})`, T.uint8), + } + if (plan.breakdownBy === undefined) return row + const dim = $.dim as CH.Expr + return { ...row, group: argMinIf(dim, $.ord as CH.Expr, dim.neq("")) } + }) + .groupBy("key") +} + +/** + * `SELECT key, evs, level, reachedAt [, group]`: each person's funnel rows as one sorted array, + * how deep they got, and the order key at which they first got there. + * + * It is `windowFunnel`'s own bookkeeping, one step at a time over the whole array so the engine + * vectorizes it. `aK[i]` is the start of the latest attempt that has reached step K by row `i`; + * `bK[i]` is that start on the rows that moved an attempt onto step K, else 0. A row reads the + * state from before it (`aK` shifted by one), so it moves an attempt one step and never two. + * + * Raw SQL, because the builder has no lambdas; every name it uses is a column projected here. + * Tuple element 1 is `ord`, element k + 1 is step k's flag. + */ +function walkQuery(plan: FunnelPlan) { + const { opts } = plan + const n = opts.steps.length + const window = windowUnits(opts) + const flags = Arr.join( + Arr.map(opts.steps, (_, index) => stepColumn(index)), + ", ", + ) + const perPerson = funnelSource(plan) .select(($) => { - const ts = $.ts as CH.Expr - const conditions = Arr.map(opts.steps, (_, index) => - ($[stepColumn(index)] as CH.Expr).eq(1), - ) const row = { key: $.key as CH.Expr, - level: CH.windowFunnel(opts.windowSeconds * 1000)(ts, ...conditions), + // Tuples sort by their first element, the order key. + evs: CH.untypedExpr(`arraySort(groupArray(tuple(ord, ${flags})))`), } if (plan.breakdownBy === undefined) return row const dim = $.dim as CH.Expr - return { ...row, group: argMinIf(dim, ts, dim.neq("")) } + return { ...row, group: argMinIf(dim, $.ord as CH.Expr, dim.neq("")) } }) .groupBy("key") + + const later = Arr.makeBy(n - 1, (index) => index + 2) + const firsts = Arr.makeBy(n, (index) => `f${index + 1}`) + return fromQuery(perPerson, "person_events").select(($) => { + const row = { + key: $.key, + evs: $.evs, + a1: CH.untypedExpr("arrayFill(v -> v > 0, arrayMap(x -> if(x.2 = 1, x.1, toUInt64(0)), evs))"), + ...Rec.fromEntries( + Arr.flatMap(later, (step) => [ + [ + `b${step}`, + CH.untypedExpr( + `arrayMap((x, p) -> if(x.${step + 1} = 1 AND p > 0 AND x.1 <= p + ${window}, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a${step - 1}), toUInt64(0)))`, + ), + ], + [`a${step}`, CH.untypedExpr(`arrayFill(v -> v > 0, b${step})`)], + ]), + ), + f1: CH.untypedExpr("arrayFirst(v -> v > 0, a1)"), + ...Rec.fromEntries( + Arr.map(later, (step) => [ + `f${step}`, + CH.untypedExpr(`tupleElement(arrayFirst((x, b) -> b > 0, evs, b${step}), 1)`), + ]), + ), + level: CH.rawExpr( + Arr.join( + Arr.map(firsts, (column) => `(${column} > 0)`), + " + ", + ), + T.uint16, + ), + // Steps are reached in order, so the deepest one's first time is the largest. + reachedAt: CH.rawExpr(n === 1 ? "f1" : `greatest(${Arr.join(firsts, ", ")})`, T.uint64), + } + if (plan.breakdownBy === undefined) return row + return { ...row, group: CH.dynamicColumn("group", T.string) } + }) } /** `[countIf(level >= 1), …, countIf(level >= N)]` over a levels row set. */ @@ -811,16 +948,13 @@ export function productEventTraceSamplesQuery( // Funnel drop-off details: time between steps, and where the leavers went. // -// Both read the same per-person chain: the funnel's `level` (windowFunnel, as -// above) plus the timestamp of each step's first qualifying event, walked in -// order from the first step-1 event within the window. That walk is a first- -// occurrence approximation of the chain `windowFunnel` counted — it can start -// at a later step-1 event than the walk does — so a person the funnel counts at -// level N can occasionally have no step-N timestamp; such persons are left out -// of the timing quantiles (guarded by `t_n > 0`) rather than contributing a -// negative duration. The counts stay the funnel's own. - -/** Every event of a funnel's population in range, as `(key, ts, name)` — the rows a leaver went on to. */ +// Both read each person's chain: the attempt the funnel counted, as the order key of the row that +// satisfied each step. An attempt starts at a step-1 row and takes, for every later step, the +// first row after the previous one that matches and is still inside the window. A person's chain +// is the earliest attempt that gets as deep as any of theirs, so a first try that ran out of time +// gives way to the later one that converted, and the level is the number the funnel counts. + +/** Every event of a funnel's population in range, as `(key, ord, name)`: the rows a leaver went on to. */ function personEventsBranch(plan: FunnelPlan) { const { opts, filters } = plan const keyBy = opts.keyBy @@ -833,7 +967,7 @@ function personEventsBranch(plan: FunnelPlan) { return base .select(($) => ({ key: personKey(keyBy, $, keyBy === "person" ? $[LINK_ALIAS] : undefined), - ts: epochMs($.Timestamp), + ord: orderKey($.Timestamp, $.Seq), name: eventDisplayName($), })) .where(($) => { @@ -850,66 +984,47 @@ function personEventsBranch(plan: FunnelPlan) { }) } -/** What a row is called in a path or a leavers list: the page for a page view, else the event name. */ +/** What a row is called in a path or a leavers list: the page or screen for a view, else the event name. */ export function eventDisplayName( $: EventsAccessor | OpenJoinAccessor, ): CH.Expr { - return CH.if_($.Kind.eq("navigation"), $.PagePath, $.EventName) + return CH.if_(isPageView($).and($.PagePath.neq("")), $.PagePath, $.EventName) } -const stepTimeColumn = (index: number) => `t${index + 1}` +const stepOrderColumn = (index: number) => `o${index + 1}` /** - * `SELECT key, level, t1, …, tN FROM (per-person sorted events)` — the funnel - * level beside the epoch-ms timestamp of each step's first event in chain - * order (0 when the walk never reached it). + * `SELECT key, level, o1, …, oN`: per person, the funnel level and the order key of each step's + * row in their chain (0 for a step the chain did not reach). * - * The walk is raw SQL over the per-person `evs` array — `arrayFirst` with a - * lambda referencing the previous step's alias — because the builder has no - * lambda support; every column it names is one this query projects itself. + * The chain's start is the first step-1 row still inside the window when the deepest step was + * first reached ({@link walkQuery}'s `reachedAt`): an earlier start had expired by then, and no + * later one is needed. From there each step is the first matching row after the previous one, + * which costs one scan of the person's rows per step. */ function chainQuery(plan: FunnelPlan) { const { opts } = plan - const windowMs = opts.windowSeconds * 1000 - const perPerson = fromQuery(eventsBranch(plan), "funnel_events") - .select(($) => { - const ts = $.ts as CH.Expr - const conditions = Arr.map(opts.steps, (_, index) => - ($[stepColumn(index)] as CH.Expr).eq(1), - ) - const tuple = Arr.join( - ["ts", "seq", ...Arr.map(opts.steps, (_, index) => stepColumn(index))], - ", ", - ) - return { - key: $.key as CH.Expr, - level: CH.windowFunnel(windowMs)(ts, ...conditions), - // `Seq` breaks ties inside a millisecond, as the table's sorting key does. - evs: CH.untypedExpr(`arraySort(x -> (x.1, x.2), groupArray(tuple(${tuple})))`), - } - }) - .groupBy("key") - - return fromQuery(perPerson, "chain_events").select(($) => { - const times = Rec.fromEntries( + const window = windowUnits(opts) + return fromQuery(walkQuery(plan), "walked").select(($) => ({ + key: $.key, + level: $.level, + ...Rec.fromEntries( Arr.map(opts.steps, (_, index) => { - // Tuple element index: 1 is `ts`, 2 is `seq`, k + 3 is the flag of step k (0-based). - const flagIndex = index + 3 - // A step only counts once the previous one was found: with `t_{k-1}` - // still 0 the `>=` would match any row and hand the quantiles a - // duration measured from the epoch. - const walk = - index === 0 - ? `x.${flagIndex} = 1` - : `${stepTimeColumn(index - 1)} > 0 AND x.${flagIndex} = 1 AND x.1 >= ${stepTimeColumn(index - 1)} AND x.1 <= t1 + ${windowMs}` + const step = index + 1 + const row = + step === 1 + ? `x.2 = 1 AND x.1 + ${window} >= reachedAt` + : `x.${step + 1} = 1 AND x.1 > ${stepOrderColumn(index - 1)}` return [ - stepTimeColumn(index), - CH.rawExpr(`tupleElement(arrayFirst(x -> ${walk}, evs), 1)`, T.uint64), + stepOrderColumn(index), + CH.rawExpr( + `if(level >= ${step}, tupleElement(arrayFirst(x -> ${row}, evs), 1), toUInt64(0))`, + T.uint64, + ), ] }), - ) - return { key: $.key, level: $.level, ...times } - }) + ), + })) } function validateDetails(opts: ProductEventsFunnelOpts): ProductEventsFunnelError | undefined { @@ -951,9 +1066,9 @@ export function productEventsFunnelTimingQuery( `[0, ${Arr.join( Array.from({ length: n - 1 }, (_, i) => { const step = i + 2 - const to = stepTimeColumn(step - 1) - const fromCol = stepTimeColumn(step - 2) - return `ifNotFinite(quantileIf(${q})(toFloat64(${to} - ${fromCol}), level >= ${step} AND ${to} > 0), 0)` + const to = `intDiv(${stepOrderColumn(step - 1)}, ${ORDER_UNITS_PER_MS})` + const fromStep = `intDiv(${stepOrderColumn(step - 2)}, ${ORDER_UNITS_PER_MS})` + return `ifNotFinite(quantileIf(${q})(toFloat64(${to} - ${fromStep}), level >= ${step}), 0)` }), ", ", )}]` @@ -984,13 +1099,13 @@ export const productEventsFunnelLeaversRowSchema = Schema.Struct({ }) export type ProductEventsFunnelLeaversOutput = typeof productEventsFunnelLeaversRowSchema.Type -/** Rows kept per step in the leavers list — the chart draws four and folds the rest. */ +/** Rows kept per step in the leavers list: the chart draws four and folds the rest. */ export const FUNNEL_LEAVERS_PER_STEP = 6 /** * Where the drop-offs went: for each step 2..N, the first event after the last * step a leaver did reach, counted by name, most common first. `next = ''` - * means no later event in range — the person simply stopped. + * means no later event in range: the person simply stopped. * * Reads every event of the population (not just step matches) once, joined to * the per-person chain on the key; bounded to the top rows per step. @@ -1004,8 +1119,8 @@ export function productEventsFunnelLeaversQuery( const first = opts.steps[0] const plan: FunnelPlan = { opts, filters, sessionStep: first?.kind === "session" ? first : undefined } const n = opts.steps.length - const timeColumns = Arr.join( - Arr.map(opts.steps, (_, index) => stepTimeColumn(index)), + const orderColumns = Arr.join( + Arr.map(opts.steps, (_, index) => stepOrderColumn(index)), ", ", ) @@ -1013,15 +1128,9 @@ export function productEventsFunnelLeaversQuery( .select(($) => ({ key: $.key, step: ($.level as CH.Expr).add(1), - tLast: CH.rawExpr(`arrayElement([${timeColumns}], level)`, T.uint64), + lastOrd: CH.rawExpr(`arrayElement([${orderColumns}], level)`, T.uint64), })) - .where(($) => [ - ($.level as CH.Expr).gte(1), - ($.level as CH.Expr).lt(n), - // A person the funnel counts at this level but whose first-occurrence - // walk never reached it has no "last step" instant to look after. - CH.rawCond(`arrayElement([${timeColumns}], level) > 0`), - ]) + .where(($) => [($.level as CH.Expr).gte(1), ($.level as CH.Expr).lt(n)]) const nexts = fromQuery(personEventsBranch(plan), "e") .innerJoinQuery(dropped, "d", (e, d) => e.key.eq(d.key)) @@ -1030,8 +1139,8 @@ export function productEventsFunnelLeaversQuery( step: $.d.step as CH.Expr, next: argMinIf( $.name as CH.Expr, - $.ts, - ($.ts as CH.Expr).gt($.d.tLast as CH.Expr), + $.ord, + ($.ord as CH.Expr).gt($.d.lastOrd as CH.Expr), ), })) .groupBy("key", "step") @@ -1040,13 +1149,13 @@ export function productEventsFunnelLeaversQuery( .select(($) => ({ step: $.step, next: $.next, count: CH.count() })) .groupBy("step", "next") - // Top rows per step without `LIMIT BY`: fold each step's rows into an array, - // take the head, and explode it again. + // Top rows per step without `LIMIT BY`: fold each step's rows into an array, sort it by count + // (ties by name, so the cut is the same on every run), take the head, and explode it again. const ranked = fromQuery(counted, "counted") .select(($) => ({ step: $.step, head: CH.untypedExpr( - `arraySlice(arrayReverseSort(x -> x.2, groupArray(tuple(next, count))), 1, ${FUNNEL_LEAVERS_PER_STEP})`, + `arraySlice(arraySort(x -> (-toInt64(x.2), x.1), groupArray(tuple(next, count))), 1, ${FUNNEL_LEAVERS_PER_STEP})`, ), })) .groupBy("step") diff --git a/packages/query-model/src/funnel.ts b/packages/query-model/src/funnel.ts index 923965db44..43b21a68b8 100644 --- a/packages/query-model/src/funnel.ts +++ b/packages/query-model/src/funnel.ts @@ -31,7 +31,7 @@ export const FunnelEventStep = Schema.Struct({ eventName: Schema.String, attributeEquals: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }) -/** A page view of `pagePath`, optionally on one `host`. */ +/** A page view, or a mobile screen view, of `pagePath`, optionally on one `host`. */ export const FunnelPageStep = Schema.Struct({ kind: Schema.Literal("page"), pagePath: Schema.String, From 92c436ed8a9fb58e70bc464f5386d6a9b1f64ba4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 02:19:49 +0100 Subject: [PATCH 2/8] feat(web): funnel breakdowns, time to convert and a fuller steps table 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. --- apps/cli/test/funnel-scenarios-support.ts | 23 +- apps/cli/test/funnel-scenarios.test.ts | 27 +- apps/web/src/lab/entries/product-analytics.ts | 90 ++++- .../src/page/dashboards/kit/tiles-chart.ts | 15 +- apps/web/src/page/kit/stat-rail.ts | 27 +- .../web/src/page/product-analytics/command.ts | 6 + .../src/page/product-analytics/definition.ts | 25 +- .../web/src/page/product-analytics/message.ts | 11 +- apps/web/src/page/product-analytics/model.ts | 18 + .../product-analytics.scene.test.ts | 97 ++++- .../src/page/product-analytics/requests.ts | 55 ++- apps/web/src/page/product-analytics/update.ts | 28 +- .../page/product-analytics/view-funnels.ts | 336 ++++++++++++++++-- apps/web/src/route/search.ts | 2 + .../dashboards/route-endpoint-plans.ts | 8 +- packages/domain/src/http/query-engine.ts | 15 +- .../src/__sql_baseline__/catalog.sql | 10 +- .../src/ch/queries/product-events.ts | 28 +- packages/query-model/src/funnel.test.ts | 12 +- packages/query-model/src/funnel.ts | 31 +- packages/views/src/charts/funnel-chart.ts | 27 +- .../web-data/src/warehouse/product-events.ts | 6 +- 22 files changed, 770 insertions(+), 127 deletions(-) diff --git a/apps/cli/test/funnel-scenarios-support.ts b/apps/cli/test/funnel-scenarios-support.ts index 9c62afca85..6e2a7fbcff 100644 --- a/apps/cli/test/funnel-scenarios-support.ts +++ b/apps/cli/test/funnel-scenarios-support.ts @@ -229,6 +229,8 @@ export interface StepTiming { readonly step: number readonly p50Ms: number readonly p90Ms: number + readonly sinceStartP50Ms: number + readonly sinceStartP90Ms: number } export const sqlTiming = ( @@ -566,12 +568,21 @@ export const referenceTiming = ( const persons = referencePersons(dataset, opts, range) return Arr.makeBy(opts.steps.length - 1, (index) => { const step = index + 2 - const spans = Arr.flatMap(persons, (person) => { - const to = person.chain[step - 1] - const from = person.chain[step - 2] - return to === undefined || from === undefined ? [] : [to.atMs - from.atMs] - }) - return { step, p50Ms: quantile(spans, 0.5), p90Ms: quantile(spans, 0.9) } + const spansFrom = (from: number) => + Arr.flatMap(persons, (person) => { + const to = person.chain[step - 1] + const start = person.chain[from] + return to === undefined || start === undefined ? [] : [to.atMs - start.atMs] + }) + const previous = spansFrom(step - 2) + const start = spansFrom(0) + return { + step, + p50Ms: quantile(previous, 0.5), + p90Ms: quantile(previous, 0.9), + sinceStartP50Ms: quantile(start, 0.5), + sinceStartP90Ms: quantile(start, 0.9), + } }) } diff --git a/apps/cli/test/funnel-scenarios.test.ts b/apps/cli/test/funnel-scenarios.test.ts index a5e23ef515..d4b84302e5 100644 --- a/apps/cli/test/funnel-scenarios.test.ts +++ b/apps/cli/test/funnel-scenarios.test.ts @@ -29,6 +29,7 @@ import { seeded, session, type SimEvent, + type StepTiming, sqlBreakdown, sqlFunnel, sqlLeavers, @@ -46,6 +47,10 @@ const page = (pagePath: string, host?: string): CH.FunnelStep => const KEYS: ReadonlyArray = ["person", "visitor", "user", "session"] +/** One person's span is both quantiles. */ +const both = (ms: number) => ({ p50Ms: ms, p90Ms: ms }) +const sinceStart = (ms: number) => ({ sinceStartP50Ms: ms, sinceStartP90Ms: ms }) + // Hand-built cases: one person each, in their own org, so a wrong count names its cause. const user = (userId: string, rows: ReadonlyArray) => @@ -273,8 +278,8 @@ describeWithChdb("product event funnels over example products", () => { expect(counts(caseDataset("restart"), opts)).toEqual([1, 1, 1]) // The times are the converting attempt's: 5 minutes, then 15, not a span from the abandoned one. expect(sqlTiming(db, "case_restart", opts)).toEqual([ - { step: 2, p50Ms: 5 * MINUTE_MS, p90Ms: 5 * MINUTE_MS }, - { step: 3, p50Ms: 15 * MINUTE_MS, p90Ms: 15 * MINUTE_MS }, + { step: 2, ...both(5 * MINUTE_MS), ...sinceStart(5 * MINUTE_MS) }, + { step: 3, ...both(15 * MINUTE_MS), ...sinceStart(20 * MINUTE_MS) }, ]) }) @@ -295,9 +300,9 @@ describeWithChdb("product event funnels over example products", () => { // The earliest attempt that still fits the hour starts 3,600 s before the checkout: // three searches a second apart, then the wait for the checkout. expect(sqlTiming(db, "case_heavyRepeats", opts)).toEqual([ - { step: 2, p50Ms: 1_000, p90Ms: 1_000 }, - { step: 3, p50Ms: 1_000, p90Ms: 1_000 }, - { step: 4, p50Ms: 3_598_000, p90Ms: 3_598_000 }, + { step: 2, ...both(1_000), ...sinceStart(1_000) }, + { step: 3, ...both(1_000), ...sinceStart(2_000) }, + { step: 4, ...both(3_598_000), ...sinceStart(3_600_000) }, ]) }) @@ -505,6 +510,8 @@ describeWithChdb("product event funnels over example products", () => { expect(timing[index]?.step).toBe(step.step) expect(timing[index]?.p50Ms ?? Number.NaN).toBeCloseTo(step.p50Ms, 3) expect(timing[index]?.p90Ms ?? Number.NaN).toBeCloseTo(step.p90Ms, 3) + expect(timing[index]?.sinceStartP50Ms ?? Number.NaN).toBeCloseTo(step.sinceStartP50Ms, 3) + expect(timing[index]?.sinceStartP90Ms ?? Number.NaN).toBeCloseTo(step.sinceStartP90Ms, 3) }) expect(sqlLeavers(db, funnel.dataset.orgId, opts)).toEqual([ ...referenceLeavers(funnel.dataset, opts), @@ -629,8 +636,14 @@ describeWithChdb("product event funnels over example products", () => { }) test("random small histories give the reference's times and leavers", () => { - const round = (timing: ReadonlyArray<{ step: number; p50Ms: number; p90Ms: number }>) => - Arr.map(timing, (step) => [step.step, Math.round(step.p50Ms), Math.round(step.p90Ms)]) + const round = (timing: ReadonlyArray) => + Arr.map(timing, (step) => [ + step.step, + Math.round(step.p50Ms), + Math.round(step.p90Ms), + Math.round(step.sinceStartP50Ms), + Math.round(step.sinceStartP90Ms), + ]) const mismatches = Arr.flatMap(random, ({ dataset, funnels }) => Arr.flatMap( Arr.filter(funnels, (opts: Opts) => opts.steps.length >= 2), diff --git a/apps/web/src/lab/entries/product-analytics.ts b/apps/web/src/lab/entries/product-analytics.ts index ac8236970c..afe16ab202 100644 --- a/apps/web/src/lab/entries/product-analytics.ts +++ b/apps/web/src/lab/entries/product-analytics.ts @@ -5,7 +5,14 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import type { PageContext } from "../../page/contract" import type { Search } from "../../page/product-analytics/definition" import { Message } from "../../page/product-analytics/message" -import { type EventRow, type FunnelRow, Model, type PathsRow } from "../../page/product-analytics/model" +import { + type AttributeKeyRow, + type EventRow, + type FunnelGroupRow, + type FunnelRow, + Model, + type PathsRow, +} from "../../page/product-analytics/model" import { init as pageInit, update as pageUpdate } from "../../page/product-analytics/update" import { view as pageView } from "../../page/product-analytics/view" import type { Breakdowns } from "../../page/kit/analytics-filter-rail" @@ -20,6 +27,7 @@ const Scenario = Schema.Literals([ "events-loading", "funnels", "funnels-bars", + "funnels-breakdown", "funnels-new", "funnels-error", "paths", @@ -51,31 +59,81 @@ const PREVIOUS: ReadonlyArray = Arr.map(EVENTS, (row) => ({ count: Math.round(row.count * 0.89), })) +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + const FUNNEL: ReadonlyArray = [ { name: "signup_started", value: 1_206 }, { name: "signup_completed", value: 742, - p50Ms: 190_000, - p90Ms: 1_420_000, - leavers: [{ name: "docs_opened", count: 210 }], + p50Ms: 3 * MINUTE, + p90Ms: 24 * MINUTE, + sinceStartP50Ms: 3 * MINUTE, + sinceStartP90Ms: 24 * MINUTE, + leavers: [ + { name: "docs_opened", count: 210 }, + { name: "", count: 152 }, + { name: "/pricing", count: 64 }, + { name: "/", count: 38 }, + ], }, { name: "onboarding_connected", value: 388, - p50Ms: 6_720_000, - p90Ms: 86_000_000, - leavers: [{ name: "", count: 190 }], + p50Ms: 112 * MINUTE, + p90Ms: 26 * HOUR, + sinceStartP50Ms: 2 * HOUR, + sinceStartP90Ms: 27 * HOUR, + leavers: [ + { name: "", count: 190 }, + { name: "docs_opened", count: 96 }, + { name: "query_run", count: 41 }, + ], }, { name: "plan_started", value: 61, - p50Ms: 187_000_000, - p90Ms: 520_000_000, - leavers: [{ name: "dashboard_viewed", count: 140 }], + p50Ms: 2 * DAY + 3 * HOUR, + p90Ms: 6 * DAY, + sinceStartP50Ms: 2 * DAY + 6 * HOUR, + sinceStartP90Ms: 6 * DAY + 8 * HOUR, + leavers: [ + { name: "dashboard_created", count: 140 }, + { name: "query_run", count: 88 }, + { name: "", count: 61 }, + ], }, ] +/** The week before: a little less traffic, converting worse. */ +const PREVIOUS_FUNNEL: ReadonlyArray = [ + { name: "signup_started", value: 1_104 }, + { name: "signup_completed", value: 655 }, + { name: "onboarding_connected", value: 318 }, + { name: "plan_started", value: 47 }, +] + +const GROUPS: ReadonlyArray]> = [ + ["google", [412, 268, 131, 14]], + ["(none)", [388, 231, 124, 19]], + ["newsletter", [164, 121, 82, 21]], + ["twitter", [142, 71, 29, 3]], + ["producthunt", [64, 33, 14, 3]], + ["linkedin", [36, 18, 8, 1]], +] + +const FUNNEL_BY_SOURCE: ReadonlyArray = Arr.flatMap(GROUPS, ([group, counts]) => + Arr.map(FUNNEL, (row, index) => ({ name: row.name, value: counts[index] ?? 0, group })), +) + +const ATTRIBUTE_KEYS: ReadonlyArray = [ + { attributeKey: "plan", usageCount: 742 }, + { attributeKey: "method", usageCount: 742 }, + { attributeKey: "source", usageCount: 388 }, +] + const PATHS: ReadonlyArray = [ { hop: 1, fromNode: "signup_completed", toNode: "onboarding_viewed", count: 410 }, { hop: 1, fromNode: "signup_completed", toNode: "dashboard_viewed", count: 160 }, @@ -138,6 +196,9 @@ const BREAKDOWNS: Breakdowns = { ], } +/** A paid plan starts days after signup, so the fixture funnel runs with the 7-day window. */ +const WITHIN_SECONDS = 7 * 24 * 3600 + const SIGNUP_TO_PAID: Search["steps"] = Arr.map(FUNNEL, (row) => ({ kind: "event", eventName: row.name })) const searchOf = (scenario: Scenario): Search => { @@ -148,9 +209,11 @@ const searchOf = (scenario: Scenario): Search => { return {} case "funnels": case "funnels-error": - return { tab: "funnels", steps: SIGNUP_TO_PAID } + return { tab: "funnels", steps: SIGNUP_TO_PAID, within: WITHIN_SECONDS } case "funnels-bars": - return { tab: "funnels", steps: SIGNUP_TO_PAID, view: "bars" } + return { tab: "funnels", steps: SIGNUP_TO_PAID, within: WITHIN_SECONDS, view: "bars" } + case "funnels-breakdown": + return { tab: "funnels", steps: SIGNUP_TO_PAID, within: WITHIN_SECONDS, breakdown: "utmSource" } case "funnels-new": return { tab: "funnels" } case "paths": @@ -181,6 +244,9 @@ const init = (flags: typeof Flags.Type) => { data: AsyncData.Failure({ error: toLoadFailure("The warehouse timed out.") }), } : loaded(FUNNEL), + previousFunnel: loaded(PREVIOUS_FUNNEL), + funnelBreakdown: loaded(FUNNEL_BY_SOURCE), + attributeKeys: loaded(ATTRIBUTE_KEYS), paths: loaded(PATHS), } return { model } diff --git a/apps/web/src/page/dashboards/kit/tiles-chart.ts b/apps/web/src/page/dashboards/kit/tiles-chart.ts index 3a7f4c3c32..af7ad6690a 100644 --- a/apps/web/src/page/dashboards/kit/tiles-chart.ts +++ b/apps/web/src/page/dashboards/kit/tiles-chart.ts @@ -24,7 +24,8 @@ import type { TileContext, TileWidget } from "./tiles" import { frame } from "./tiles-frame" import { isServiceChart, serviceChart, serviceLegend } from "./tiles-service" import { chartFrameClass } from "@maple/views/ui/chart-frame" -import { Array as Arr, Record as Rec } from "effect" +import { Array as Arr, Predicate, Record as Rec } from "effect" +import { dataSourceEndpoint, PRODUCT_EVENTS_FUNNEL_ENDPOINT } from "@maple/widgets/dashboard" // Each chart branch hands its chart only the settings it declares. // TODO(chart-axes): line/area/bar thresholds, log scale and soft bounds are not drawn yet. @@ -83,6 +84,17 @@ const toPrimitiveRows = (rows: Rows): ReadonlyArray => ), ) +/** + * The funnel's bars are ordered steps of one population, not categories: it reads the product-event + * funnel route. A share strips the endpoint, so there any route-fed funnel counts. + */ +const isStepFunnel = (dataSource: unknown): boolean => { + const endpoint = dataSourceEndpoint(dataSource) + return endpoint === null + ? Predicate.hasProperty(dataSource, "kind") && dataSource.kind === "route" + : endpoint === PRODUCT_EVENTS_FUNNEL_ENDPOINT +} + const skeletonOf = (category: ChartCategory, widget: Widget): ChartSkeletonVariant => category === "funnel" && widget.display.funnel?.variant === "dropoff" ? "funnel-dropoff" : category @@ -260,6 +272,7 @@ const chartView = (h: HtmlBuilder, ctx: TileContext, widget: Widget, re className, unit: display.unit, showStepPercent: display.funnel?.showStepPercent, + isSingleHue: isStepFunnel(widget.dataSource), } return display.funnel?.variant === "dropoff" ? h.submodel({ ...common, view: FunnelDropoffChart.view, viewInputs: inputs }) diff --git a/apps/web/src/page/kit/stat-rail.ts b/apps/web/src/page/kit/stat-rail.ts index cf076bf660..d97898ade1 100644 --- a/apps/web/src/page/kit/stat-rail.ts +++ b/apps/web/src/page/kit/stat-rail.ts @@ -114,19 +114,30 @@ export const statRailItem = (h: HtmlBuilder, options: StatRailItemOptions< h.div( [h.Class("flex items-baseline justify-between gap-3")], [ + // The hint stays beside its label when a delta takes the corner. h.span( + [h.Class("flex min-w-0 items-center gap-1.5")], [ - h.Class( - cn( - "truncate text-2xs font-medium", - isSelected ? "text-primary" : "text-muted-foreground", - ), + h.span( + [ + h.Class( + cn( + "truncate text-2xs font-medium", + isSelected ? "text-primary" : "text-muted-foreground", + ), + ), + ], + [options.eyebrow], ), + ...(options.eyebrowTooltip === undefined + ? [] + : [infoHint(h, options.eyebrowTooltip)]), ], - [options.eyebrow], ), - ...(options.eyebrowTooltip === undefined ? [] : [infoHint(h, options.eyebrowTooltip)]), - ...(corner === undefined ? [] : [h.span([h.Class("shrink-0 whitespace-nowrap")], [corner])]), + // Centred, not on the baseline: a delta's arrow would otherwise make its tile's header taller. + ...(corner === undefined + ? [] + : [h.span([h.Class("shrink-0 self-center leading-none whitespace-nowrap")], [corner])]), ], ), h.div( diff --git a/apps/web/src/page/product-analytics/command.ts b/apps/web/src/page/product-analytics/command.ts index 9dbebfc473..45ae2fc4ed 100644 --- a/apps/web/src/page/product-analytics/command.ts +++ b/apps/web/src/page/product-analytics/command.ts @@ -1,5 +1,6 @@ import { ProductEventsFunnelWidgetParams, ProductEventsPathsWidgetParams } from "@maple/query-model" import { + getProductEventAttributeKeys, getProductEventNames, getProductEventsFunnelWidget, getProductEventsPathsWidget, @@ -61,6 +62,11 @@ export const FetchFunnel = define( { ...Window, ...ProductEventsFunnelWidgetParams.fields }, (data) => getProductEventsFunnelWidget({ data }).pipe(Effect.map((response) => response.data)), ) +export const FetchAttributeKeys = define( + "FetchProductAnalyticsAttributeKeys", + { ...Window, limit: Schema.optional(Schema.Number) }, + (data) => getProductEventAttributeKeys({ data }).pipe(Effect.map((response) => response.data)), +) export const FetchPaths = define( "FetchProductAnalyticsPaths", { ...Window, ...ProductEventsPathsWidgetParams.fields }, diff --git a/apps/web/src/page/product-analytics/definition.ts b/apps/web/src/page/product-analytics/definition.ts index a6747ee47d..2911b50734 100644 --- a/apps/web/src/page/product-analytics/definition.ts +++ b/apps/web/src/page/product-analytics/definition.ts @@ -1,7 +1,9 @@ import { DEFAULT_PATHS_BRANCHES, DEFAULT_PATHS_DEPTH, + FUNNEL_BREAKDOWN_MAX_LIMIT, FUNNEL_POPULATION_FILTER_FIELDS, + type FunnelBreakdownBy, type FunnelKeyBy, type FunnelPopulationFilters, type FunnelStep, @@ -48,6 +50,7 @@ export type FunnelDefinition = Readonly<{ keyBy: FunnelKeyBy windowSeconds: number variant: FunnelVariant + breakdownBy: FunnelBreakdownBy | undefined }> export const funnelOf = (search: Search): FunnelDefinition => ({ @@ -58,9 +61,13 @@ export const funnelOf = (search: Search): FunnelDefinition => ({ ? search.within : DEFAULT_FUNNEL_WINDOW_SECONDS, variant: search.view ?? "dropoff", + breakdownBy: search.breakdown, }) -/** The funnel's widget params, or `null` until a step has something to match on. */ +/** + * The funnel's widget params, or `null` until a step has something to match on. Both views show + * the steps table, so the time between steps and the leavers are always asked for. + */ export const funnelParamsOf = (search: Search): ProductEventsFunnelWidgetParams | null => { const funnel = funnelOf(search) const steps = Arr.map(completedSteps(funnel.steps), trimmed) @@ -70,10 +77,24 @@ export const funnelParamsOf = (search: Search): ProductEventsFunnelWidgetParams steps, keyBy: funnel.keyBy, windowSeconds: funnel.windowSeconds, - details: funnel.variant === "dropoff", + details: true, } } +/** The same funnel with counts only: what the previous period is compared on. */ +export const funnelCountsParamsOf = (search: Search): ProductEventsFunnelWidgetParams | null => { + const params = funnelParamsOf(search) + return params === null ? null : { ...params, details: false } +} + +/** The funnel split by its breakdown, as many groups as the query answers; `null` without one. */ +export const funnelBreakdownParamsOf = (search: Search): ProductEventsFunnelWidgetParams | null => { + const params = funnelCountsParamsOf(search) + const breakdownBy = funnelOf(search).breakdownBy + if (params === null || breakdownBy === undefined) return null + return { ...params, breakdownBy, breakdownLimit: FUNNEL_BREAKDOWN_MAX_LIMIT } +} + const clamp = (value: number | undefined, fallback: number, max: number): number => value === undefined || !Number.isFinite(value) ? fallback : Math.min(max, Math.max(1, Math.round(value))) diff --git a/apps/web/src/page/product-analytics/message.ts b/apps/web/src/page/product-analytics/message.ts index 508f4b3456..2bee2a5a1d 100644 --- a/apps/web/src/page/product-analytics/message.ts +++ b/apps/web/src/page/product-analytics/message.ts @@ -22,7 +22,16 @@ export const RequestMode = Schema.Literals(["Load", "Refresh"]) export type RequestMode = typeof RequestMode.Type /** Every Keyed slot of the page, by its Model field. */ -export const SlotName = Schema.Literals(["breakdowns", "events", "previousEvents", "funnel", "paths"]) +export const SlotName = Schema.Literals([ + "breakdowns", + "events", + "previousEvents", + "funnel", + "previousFunnel", + "funnelBreakdown", + "attributeKeys", + "paths", +]) export type SlotName = typeof SlotName.Type /** A population filter: a `session_replays` dimension, one value each. */ diff --git a/apps/web/src/page/product-analytics/model.ts b/apps/web/src/page/product-analytics/model.ts index f1249b75ee..ac40ff1fce 100644 --- a/apps/web/src/page/product-analytics/model.ts +++ b/apps/web/src/page/product-analytics/model.ts @@ -33,10 +33,24 @@ export const FunnelRow = Schema.Struct({ value: Schema.Number, p50Ms: Schema.optionalKey(Schema.Number), p90Ms: Schema.optionalKey(Schema.Number), + sinceStartP50Ms: Schema.optionalKey(Schema.Number), + sinceStartP90Ms: Schema.optionalKey(Schema.Number), leavers: Schema.optionalKey(Schema.Array(Schema.Struct({ name: Schema.String, count: Schema.Number }))), }) export type FunnelRow = typeof FunnelRow.Type +/** One step of one breakdown group, in the query's group rank. */ +export const FunnelGroupRow = Schema.Struct({ + name: Schema.String, + value: Schema.Number, + group: Schema.String, +}) +export type FunnelGroupRow = typeof FunnelGroupRow.Type + +/** A `track()` property key in use, for the breakdown picker. */ +export const AttributeKeyRow = Schema.Struct({ attributeKey: Schema.String, usageCount: Schema.Number }) +export type AttributeKeyRow = typeof AttributeKeyRow.Type + /** `PathsWidgetRow`: one hop between two nodes. */ export const PathsRow = Schema.Struct({ hop: Schema.Number, @@ -56,6 +70,10 @@ export const Model = Schema.Struct({ events: KeyedSchema(Schema.Array(EventRow), LoadFailure), previousEvents: KeyedSchema(Schema.Array(EventRow), LoadFailure), funnel: KeyedSchema(Schema.Array(FunnelRow), LoadFailure), + /** The same funnel over the window before this one, counts only. */ + previousFunnel: KeyedSchema(Schema.Array(FunnelRow), LoadFailure), + funnelBreakdown: KeyedSchema(Schema.Array(FunnelGroupRow), LoadFailure), + attributeKeys: KeyedSchema(Schema.Array(AttributeKeyRow), LoadFailure), paths: KeyedSchema(Schema.Array(PathsRow), LoadFailure), /** The funnel steps as edited; the URL catches up on a pick, or once typing pauses. */ steps: Schema.Array(StepDraft), diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index eeec7ef078..f3febc336e 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -3,6 +3,7 @@ import { Array as Arr, Option, Result, Schema } from "effect" import { fromString } from "foldkit/url" import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" import * as Chart from "@maple/views/charts/chart" +import * as Select from "@maple/views/ui/select" import { MeasureChartContainer } from "@maple/views/charts/chart-mounts" import { describe, expect, test } from "vitest" import { StepEdit } from "../kit/funnel-steps" @@ -11,7 +12,7 @@ import type { PageContext } from "../contract" import type { Search } from "./definition" import { Message, type SlotName } from "./message" import type { EventRow, FunnelRow, Model } from "./model" -import { init, routeChanged, update } from "./update" +import { BREAKDOWN_SELECT_ID, init, routeChanged, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -66,9 +67,34 @@ const EVENTS: ReadonlyArray = [ const FUNNEL: ReadonlyArray = [ { name: "signup_started", value: 1_206 }, - { name: "signup_completed", value: 742, p50Ms: 41_000, leavers: [{ name: "docs_opened", count: 120 }] }, + { + name: "signup_completed", + value: 742, + p50Ms: 41_000, + p90Ms: 600_000, + sinceStartP50Ms: 41_000, + sinceStartP90Ms: 600_000, + leavers: [ + { name: "docs_opened", count: 116 }, + { name: "", count: 232 }, + ], + }, ] +const measured = Mount.resolve( + MeasureChartContainer, + Chart.Message.ResizedChartContainer({ width: 800, height: 320 }), +) + +const pickBreakdown = (model: Model, value: string) => + pageUpdate( + model, + Message.GotSelectMessage({ + id: BREAKDOWN_SELECT_ID, + message: Select.Message.SelectedItem({ index: 0, value }), + }), + ) + const SIGNUP: Search["steps"] = [ { kind: "event", eventName: "signup_started" }, { kind: "event", eventName: "signup_completed" }, @@ -303,17 +329,74 @@ describe("product analytics", () => { ) }) - test("a funnel's answer draws its conversion, biggest drop and what leavers did", () => { + test("a funnel's answer draws its conversion, how long it takes and where the leavers went", () => { const model = settled(resolved({ tab: "funnels", steps: SIGNUP }).model, "funnel", FUNNEL) scene( { update: pageUpdate, view }, given(model), - Mount.resolve( - MeasureChartContainer, - Chart.Message.ResizedChartContainer({ width: 800, height: 320 }), - ), + measured, expectView(text("Biggest drop")).toExist(), + expectView(text("median · 90% within 10m")).toExist(), + // 464 dropped before signup_completed: 116 opened the docs, 232 did nothing else. expectView(text("docs_opened")).toExist(), + expectView(text("25%")).toExist(), + expectView(text("nothing after")).toExist(), + expectView(text("50%")).toExist(), ) }) + + test("the funnel is read again for the window before, counts only, and compared with it", () => { + const { commands, model } = resolved({ tab: "funnels", steps: SIGNUP }) + const runs = Arr.filter(commands ?? [], (command) => command.name === "FetchProductAnalyticsFunnel") + expect(Arr.map(runs, (run) => run.args)).toMatchObject([ + { slot: "funnel", input: { startTime: window.startTime, details: true } }, + { + slot: "previousFunnel", + input: { startTime: "2026-10-20 00:00:00", endTime: window.startTime, details: false }, + }, + ]) + // 61.5% now against 50% the week before. + const compared = settled(settled(model, "funnel", FUNNEL), "previousFunnel", [ + { name: "signup_started", value: 1_000 }, + { name: "signup_completed", value: 500 }, + ]) + scene({ update: pageUpdate, view }, given(compared), measured, expectView(text("11.5 pts")).toExist()) + }) + + test("a breakdown splits the funnel into up to twenty groups and compares each with everyone", () => { + const { commands, model } = resolved({ tab: "funnels", steps: SIGNUP, breakdown: "utmSource" }) + const split = Arr.findFirst( + commands ?? [], + (command) => + command.name === "FetchProductAnalyticsFunnel" && command.args?.slot === "funnelBreakdown", + ) + expect(Option.getOrUndefined(split)?.args).toMatchObject({ + input: { breakdownBy: "utmSource", breakdownLimit: 20, details: false }, + }) + const answered = settled(settled(model, "funnel", FUNNEL), "funnelBreakdown", [ + { name: "signup_started", value: 400, group: "newsletter" }, + { name: "signup_completed", value: 300, group: "newsletter" }, + { name: "signup_started", value: 806, group: "google" }, + { name: "signup_completed", value: 442, group: "google" }, + ]) + scene( + { update: pageUpdate, view }, + given(answered), + measured, + expectView(text("By UTM source")).toExist(), + // The newsletter converts 75% against 61.5% overall; google 54.8%. + expectView(text("75%")).toExist(), + expectView(text("13.5 pts")).toExist(), + expectView(text("6.7 pts")).toExist(), + ) + }) + + test("picking a breakdown writes it to the URL, and None clears it", () => { + const model = resolved({ tab: "funnels", steps: SIGNUP }).model + expect(searchOf(pickBreakdown(model, "attribute:plan").outMessage).breakdown).toBe("attribute:plan") + expect(pickBreakdown(model, "not-a-dimension").outMessage).toBeUndefined() + const split = resolved({ tab: "funnels", steps: SIGNUP, breakdown: "country" }).model + expect(searchOf(pickBreakdown(split, "").outMessage)).toMatchObject({ tab: "funnels", steps: SIGNUP }) + expect(searchOf(pickBreakdown(split, "").outMessage).breakdown).toBeUndefined() + }) }) diff --git a/apps/web/src/page/product-analytics/requests.ts b/apps/web/src/page/product-analytics/requests.ts index 1f693e3b18..1ca1754bae 100644 --- a/apps/web/src/page/product-analytics/requests.ts +++ b/apps/web/src/page/product-analytics/requests.ts @@ -6,8 +6,15 @@ import { previousWindow } from "../analytics/previous-window" import type { TimeWindow } from "../../data/time-range" import type { Resources } from "../../platform/resources" import type { PageContext } from "../contract" -import { FetchBreakdowns, FetchEventNames, FetchFunnel, FetchPaths } from "./command" -import { funnelParamsOf, pathsParamsOf, populationOf, tabOf } from "./definition" +import { FetchAttributeKeys, FetchBreakdowns, FetchEventNames, FetchFunnel, FetchPaths } from "./command" +import { + funnelBreakdownParamsOf, + funnelCountsParamsOf, + funnelParamsOf, + pathsParamsOf, + populationOf, + tabOf, +} from "./definition" import type { Message, SlotName } from "./message" import type { Model } from "./model" @@ -17,6 +24,8 @@ type Cmd = Command.Command const BREAKDOWN_LIMIT = 50 export const EVENTS_LIMIT = 200 +/** Property keys the breakdown picker offers. */ +const ATTRIBUTE_KEYS_LIMIT = 30 type Request = Readonly<{ slot: SlotName; fn: string; input: object; command: (key: string) => Cmd }> @@ -48,21 +57,39 @@ const plan = (model: Model, window: TimeWindow): ReadonlyArray => { events("previousEvents", previousWindow(startTime, endTime)), ] case "funnels": { - const params = funnelParamsOf(model.search) - const input = params === null ? null : { startTime, endTime, ...params } + const funnel = ( + slot: "funnel" | "previousFunnel" | "funnelBreakdown", + range: TimeWindow, + params: ReturnType, + ): ReadonlyArray => { + if (params === null) return [] + const input = { ...range, ...params } + return [ + { + slot, + fn: "getProductEventsFunnelWidget", + input, + command: (key) => FetchFunnel({ slot, key, input }), + }, + ] + } + const keysInput = { startTime, endTime, limit: ATTRIBUTE_KEYS_LIMIT } return [ rail, events("events", window), - ...(input === null - ? [] - : [ - { - slot: "funnel" as const, - fn: "getProductEventsFunnelWidget", - input, - command: (key: string) => FetchFunnel({ slot: "funnel", key, input }), - }, - ]), + { + slot: "attributeKeys", + fn: "getProductEventAttributeKeys", + input: keysInput, + command: (key) => FetchAttributeKeys({ slot: "attributeKeys", key, input: keysInput }), + }, + ...funnel("funnel", window, funnelParamsOf(model.search)), + ...funnel( + "previousFunnel", + previousWindow(startTime, endTime), + funnelCountsParamsOf(model.search), + ), + ...funnel("funnelBreakdown", window, funnelBreakdownParamsOf(model.search)), ] } case "paths": { diff --git a/apps/web/src/page/product-analytics/update.ts b/apps/web/src/page/product-analytics/update.ts index f21bf28787..72128bd090 100644 --- a/apps/web/src/page/product-analytics/update.ts +++ b/apps/web/src/page/product-analytics/update.ts @@ -1,4 +1,4 @@ -import { FUNNEL_MAX_STEPS, FUNNEL_POPULATION_FILTER_FIELDS } from "@maple/query-model" +import { FUNNEL_MAX_STEPS, FUNNEL_POPULATION_FILTER_FIELDS, FunnelBreakdownBy } from "@maple/query-model" import { Array as Arr, Option, Result, Schema, Struct } from "effect" import { Command } from "foldkit" import * as Chart from "@maple/views/charts/chart" @@ -25,7 +25,7 @@ import * as TimeRangePicker from "../kit/time-range-picker" import { CompleteReloadSpin, ResolveWindow, WaitForTypingPause } from "./command" import { anchorNameOf, pathsOf, type Search, toAnchor, toFunnelStep } from "./definition" import { FilterKey, Message, type RequestMode, SlotName } from "./message" -import { EventRow, FunnelRow, type Model, PathsRow } from "./model" +import { AttributeKeyRow, EventRow, FunnelGroupRow, FunnelRow, type Model, PathsRow } from "./model" import { requestSlots } from "./requests" type Return = PageReturn @@ -38,6 +38,9 @@ const SLOT_SCHEMAS = { events: Schema.Array(EventRow), previousEvents: Schema.Array(EventRow), funnel: Schema.Array(FunnelRow), + previousFunnel: Schema.Array(FunnelRow), + funnelBreakdown: Schema.Array(FunnelGroupRow), + attributeKeys: Schema.Array(AttributeKeyRow), paths: Schema.Array(PathsRow), } satisfies Record @@ -150,6 +153,20 @@ const withFilter = (search: Search, key: FilterKey, value: string | undefined): [key]: value === "" ? undefined : value, }) +/** The breakdown select's id among the page's selects. */ +export const BREAKDOWN_SELECT_ID = "funnel-breakdown" + +const decodeBreakdown = Schema.decodeUnknownOption(FunnelBreakdownBy) + +/** `""` clears the breakdown; anything that is not a dimension or `attribute:` is ignored. */ +const pickBreakdown = (model: Model, value: string): Return => + value === "" + ? navigate(model, { ...withDrafts(model), breakdown: undefined }, true) + : Option.match(decodeBreakdown(value), { + onNone: () => ({ model }), + onSome: (breakdown) => navigate(model, { ...withDrafts(model), breakdown }, true), + }) + // ENTRY POINTS export const init = (route: { readonly search: Search }, context: PageContext): Return => { @@ -160,6 +177,9 @@ export const init = (route: { readonly search: Search }, context: PageContext): events: Keyed.idle(), previousEvents: Keyed.idle(), funnel: Keyed.idle(), + previousFunnel: Keyed.idle(), + funnelBreakdown: Keyed.idle(), + attributeKeys: Keyed.idle(), paths: Keyed.idle(), ...draftsOf(route.search), typingVersion: 0, @@ -292,6 +312,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re const lifted = lift(model, next.commands, (m) => Message.GotSelectMessage({ id, message: m }), { selects: { ...model.selects, [id]: next.model }, }) + if (id === BREAKDOWN_SELECT_ID && next.outMessage !== undefined) { + const picked = pickBreakdown(lifted.model, next.outMessage.value) + return { ...picked, commands: [...(lifted.commands ?? []), ...(picked.commands ?? [])] } + } const target = next.outMessage === undefined ? Option.none() : stepEditOf(id, next.outMessage.value) if (Option.isNone(target)) return lifted diff --git a/apps/web/src/page/product-analytics/view-funnels.ts b/apps/web/src/page/product-analytics/view-funnels.ts index f66685ea99..188772cc25 100644 --- a/apps/web/src/page/product-analytics/view-funnels.ts +++ b/apps/web/src/page/product-analytics/view-funnels.ts @@ -1,6 +1,12 @@ -import { FUNNEL_MAX_STEPS, type FunnelKeyBy, type FunnelVariant } from "@maple/query-model" +import { + FUNNEL_MAX_STEPS, + FUNNEL_SESSION_DIMENSION_LABEL, + type FunnelBreakdownBy, + type FunnelKeyBy, + type FunnelVariant, +} from "@maple/query-model" import { formatNumber, formatPercent } from "@maple/ui/lib/format" -import { Array as Arr, Option } from "effect" +import { Array as Arr, Option, String as Str } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type * as Chart from "@maple/views/charts/chart" @@ -8,8 +14,13 @@ import * as FunnelChart from "@maple/views/charts/funnel-chart" import * as FunnelDropoffChart from "@maple/views/charts/funnel-dropoff-chart" import { fmtSpan } from "@maple/views/charts/funnel-dropoff-steps" import { button } from "@maple/views/ui/button" +import { delta } from "@maple/views/ui/delta" import { errorState } from "@maple/views/ui/error-state" +import { meter } from "@maple/views/ui/meter" import { panel, panelChartBody, panelHeader } from "@maple/views/ui/panel" +import * as Select from "@maple/views/ui/select" +import { type SelectEntry, view as selectView } from "@maple/views/ui/select-view" +import { skeletonList } from "@maple/views/ui/skeleton" import { hstack, vstack } from "@maple/views/ui/stack" import { numericTableCell, @@ -21,12 +32,17 @@ import { tableRow, } from "@maple/views/ui/table" import { typography } from "@maple/views/ui/typography" -import { FUNNEL_KEY_BY_OPTIONS, FUNNEL_WINDOW_OPTIONS } from "../dashboards/kit/funnel-definition" +import { + FUNNEL_KEY_BY_OPTIONS, + FUNNEL_SESSION_DIMENSIONS, + FUNNEL_WINDOW_OPTIONS, +} from "../dashboards/kit/funnel-definition" import { stepBuilder } from "../kit/funnel-steps" import { statRail, statRailItem, statRailLoading } from "../kit/stat-rail" import { funnelOf, funnelParamsOf } from "./definition" import { Message } from "./message" -import { type FunnelRow, isCustomEvent, type Model } from "./model" +import { type FunnelGroupRow, type FunnelRow, isCustomEvent, type Model } from "./model" +import { BREAKDOWN_SELECT_ID } from "./update" import { controlsBar, countHeader, @@ -52,6 +68,79 @@ const WINDOWS = Arr.map(FUNNEL_WINDOW_OPTIONS, (option) => ({ label: option.label, })) +const ATTRIBUTE_PREFIX = "attribute:" + +/** What a breakdown splits by, as its panel title reads: "UTM source", or the property's key. */ +const breakdownLabel = (breakdownBy: FunnelBreakdownBy): string => + Option.match( + Arr.findFirst(FUNNEL_SESSION_DIMENSIONS, (dimension) => dimension === breakdownBy), + { + onNone: () => Str.slice(ATTRIBUTE_PREFIX.length)(breakdownBy), + onSome: (dimension) => FUNNEL_SESSION_DIMENSION_LABEL[dimension], + }, + ) + +/** None, where the session came from, then the event properties this org sends. */ +const breakdownEntries = ( + model: Model, + current: FunnelBreakdownBy | undefined, +): ReadonlyArray => { + const keys = Arr.map( + Option.getOrElse(AsyncData.getData(model.attributeKeys.data), () => []), + (row) => `${ATTRIBUTE_PREFIX}${row.attributeKey}`, + ) + // A property from a shared link stays listed even when this window no longer sends it. + const properties = + current !== undefined && Str.startsWith(ATTRIBUTE_PREFIX)(current) && !keys.includes(current) + ? [current, ...keys] + : keys + return [ + { _tag: "Item", item: { value: "", label: "None" } }, + { + _tag: "Group", + label: "Session", + items: Arr.map(FUNNEL_SESSION_DIMENSIONS, (dimension) => ({ + value: dimension, + label: FUNNEL_SESSION_DIMENSION_LABEL[dimension], + })), + }, + ...(properties.length === 0 + ? [] + : [ + { + _tag: "Group" as const, + label: "Event property", + items: Arr.map(properties, (value) => ({ + value, + label: Str.slice(ATTRIBUTE_PREFIX.length)(value), + })), + }, + ]), + ] +} + +const breakdownSelect = (h: H, model: Model, current: FunnelBreakdownBy | undefined): Html => { + const value = current ?? "" + const state = + model.selects[BREAKDOWN_SELECT_ID] ?? Select.init(BREAKDOWN_SELECT_ID, { value, selectedIndex: null }) + return hstack(h, { gap: "2" }, [ + typography(h, { size: "xs", tone: "muted", className: "shrink-0" }, ["Break down by"]), + h.submodel({ + slotId: `product-analytics-select-${BREAKDOWN_SELECT_ID}`, + model: { ...state, value }, + view: selectView, + toParentMessage: (message) => Message.GotSelectMessage({ id: BREAKDOWN_SELECT_ID, message }), + viewInputs: { + entries: breakdownEntries(model, current), + size: "sm", + className: "w-40 min-w-0", + ariaLabel: "Break down by", + toView: (parts) => h.div([h.Class("contents")], parts), + }, + }), + ]) +} + const editor = (h: H, model: Model): Html => { const funnel = funnelOf(model.search) return panel(h, { className: "shrink-0" }, [ @@ -89,6 +178,7 @@ const editor = (h: H, model: Model): Html => { options: WINDOWS, onChange: (seconds) => Message.PickedWindow({ seconds: Number(seconds) }), }), + breakdownSelect(h, model, funnel.breakdownBy), hstack(h, { className: "ml-auto" }, [ labelledSegment(h, { label: "View", @@ -161,20 +251,58 @@ const biggestDrop = (rows: ReadonlyArray): Readonly<{ name: string; s }, ) -const stats = (h: H, rows: ReadonlyArray): Html => { +const conversionOf = (rows: ReadonlyArray<{ readonly value: number }>): number | null => { + const entered = rows[0]?.value ?? 0 + return entered === 0 ? null : (rows.at(-1)?.value ?? 0) / entered +} + +const points = (magnitude: number): string => `${(magnitude * 100).toFixed(1)} pts` + +/** The same funnel over the window before, once it answered with people in it. */ +const previousOf = (model: Model): ReadonlyArray | undefined => { + const rows = Option.getOrUndefined(AsyncData.getData(model.previousFunnel.data)) + return rows === undefined || (rows[0]?.value ?? 0) === 0 ? undefined : rows +} + +const stats = (h: H, model: Model, rows: ReadonlyArray, keyBy: FunnelKeyBy): Html => { const entered = rows[0]?.value ?? 0 const last = rows.at(-1) + const converted = last?.value ?? 0 + const conversion = conversionOf(rows) ?? 0 const drop = biggestDrop(rows) + const previous = previousOf(model) + const previousConversion = previous === undefined ? null : conversionOf(previous) + const hasSteps = rows.length > 1 return statRail(h, { columns: 4 }, [ - statRailItem(h, { eyebrow: "Entered", value: formatNumber(entered), subline: "reached step 1" }), statRailItem(h, { - eyebrow: "Converted", - value: formatNumber(last?.value ?? 0), - subline: last === undefined ? undefined : `reached ${last.name}`, + eyebrow: "Entered", + value: formatNumber(entered), + delta: + previous === undefined + ? undefined + : delta(h, { current: entered, previous: previous[0]?.value ?? 0 }), + subline: `${countNoun(keyBy, entered)} reached step 1`, }), statRailItem(h, { eyebrow: "Conversion", - value: formatPercent(entered === 0 ? 0 : (last?.value ?? 0) / entered), + eyebrowTooltip: "The share of those who entered that reached the last step inside the window.", + value: formatPercent(conversion), + delta: + previousConversion === null || !hasSteps + ? undefined + : delta(h, { ratio: conversion - previousConversion, format: points }), + subline: last === undefined ? undefined : `${formatNumber(converted)} reached ${last.name}`, + }), + statRailItem(h, { + eyebrow: "Time to convert", + eyebrowTooltip: "From the first step to the last, for those who reached the last step.", + value: last?.sinceStartP50Ms === undefined ? "–" : fmtSpan(last.sinceStartP50Ms), + subline: + last?.sinceStartP90Ms === undefined + ? hasSteps + ? "nobody converted" + : "one step" + : `median · 90% within ${fmtSpan(last.sinceStartP90Ms)}`, }), statRailItem(h, { eyebrow: "Biggest drop", @@ -190,31 +318,55 @@ const chart = (h: H, model: Model, rows: ReadonlyArray, isDropoff: bo model: model.chart, toParentMessage: (message: Chart.Message) => Message.GotChartMessage({ message }), } - const inputs = { data: rows, className: "h-80" } + // The bars need a row per step; the drop-off columns want the height. + const height = isDropoff ? "h-80" : rows.length <= 3 ? "h-44" : rows.length <= 6 ? "h-60" : "h-80" + const inputs = { data: rows, className: height, isSingleHue: true } return isDropoff ? h.submodel({ ...common, view: FunnelDropoffChart.view, viewInputs: inputs }) : h.submodel({ ...common, view: FunnelChart.view, viewInputs: inputs }) } -const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy, hasDetails: boolean): Html => { +const LEAVERS_SHOWN = 3 + +/** Where those who dropped before this step went, as shares of everyone who dropped. */ +const leaversCell = (h: H, row: FunnelRow, dropped: number): Html => { + const leavers = (row.leavers ?? []).slice(0, LEAVERS_SHOWN) + if (leavers.length === 0 || dropped <= 0) return typography(h, { tone: "muted" }, ["–"]) + return hstack( + h, + { wrap: true, className: "gap-x-3 gap-y-0.5" }, + Arr.map(leavers, (leaver) => + hstack(h, { gap: "1.5", align: "baseline", shrink: true, attributes: [h.Key(leaver.name)] }, [ + leaver.name === "" + ? typography(h, { size: "xs", tone: "muted", truncate: true }, ["nothing after"]) + : typography(h, { size: "xs", mono: true, truncate: true }, [leaver.name]), + typography(h, { size: "2xs", tone: "muted", numeric: true, className: "shrink-0" }, [ + formatPercent(Math.min(1, leaver.count / dropped)), + ]), + ]), + ), + ) +} + +const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy): Html => { const entered = rows[0]?.value ?? 0 return table(h, { variant: "bare", size: "sm", ariaLabel: "Funnel steps" }, [ tableHeader(h, [ tableRow(h, [ tableHead(h, ["Step"]), tableHead(h, [countHeader(keyBy)], "text-right"), + tableHead(h, ["Of entered"], "text-right"), tableHead(h, ["From previous"], "text-right"), - tableHead(h, ["From start"], "text-right"), - ...(hasDetails - ? [tableHead(h, ["Median time"], "text-right"), tableHead(h, ["Leavers did next"])] - : []), + tableHead(h, ["Dropped"], "text-right"), + tableHead(h, ["Time from previous"], "text-right"), + tableHead(h, ["Leavers did next"]), ]), ]), tableBody( h, Arr.map(rows, (row, index) => { const before = index === 0 ? 0 : (rows[index - 1]?.value ?? 0) - const leaver = row.leavers?.[0] + const dropped = Math.max(0, before - row.value) return tableRow( h, [ @@ -229,22 +381,31 @@ const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy, ha ]), ]), numericTableCell(h, [formatNumber(row.value)]), - numericTableCell(h, [before === 0 ? "–" : formatPercent(row.value / before)]), numericTableCell(h, [entered === 0 ? "–" : formatPercent(row.value / entered)]), - ...(hasDetails - ? [ - numericTableCell(h, [row.p50Ms === undefined ? "–" : fmtSpan(row.p50Ms)]), - tableCell(h, [ - leaver === undefined - ? "–" - : typography( - h, - { size: "xs", mono: true, tone: "muted", truncate: true }, - [leaver.name === "" ? "(nothing)" : leaver.name], - ), - ]), - ] - : []), + numericTableCell(h, [before === 0 ? "–" : formatPercent(row.value / before)]), + numericTableCell(h, [index === 0 ? "–" : formatNumber(dropped)]), + numericTableCell( + h, + row.p50Ms === undefined + ? ["–"] + : [ + fmtSpan(row.p50Ms), + ...(row.p90Ms === undefined + ? [] + : [ + typography( + h, + { size: "2xs", tone: "muted", className: "ml-1.5" }, + [`p90 ${fmtSpan(row.p90Ms)}`], + ), + ]), + ], + ), + tableCell(h, [ + index === 0 + ? typography(h, { tone: "muted" }, ["–"]) + : leaversCell(h, row, dropped), + ]), ], undefined, [h.Key(`${index}:${row.name}`)], @@ -254,6 +415,100 @@ const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy, ha ]) } +type Group = Readonly<{ name: string; entered: number; converted: number }> + +/** The grouped rows as one line per group: who entered and who reached the last step. */ +const groupsOf = (rows: ReadonlyArray): ReadonlyArray => + Arr.map(Arr.dedupe(Arr.map(rows, (row) => row.group)), (name) => { + const steps = Arr.filter(rows, (row) => row.group === name) + return { name, entered: steps[0]?.value ?? 0, converted: steps.at(-1)?.value ?? 0 } + }) + +const breakdownTable = ( + h: H, + groups: ReadonlyArray, + inputs: Readonly<{ label: string; keyBy: FunnelKeyBy; overall: number }>, +): Html => { + const best = Arr.reduce(groups, 0, (max, group) => + Math.max(max, group.entered === 0 ? 0 : group.converted / group.entered), + ) + return table(h, { variant: "bare", size: "sm", ariaLabel: `Conversion by ${inputs.label}` }, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, [inputs.label]), + tableHead(h, ["Entered"], "text-right"), + tableHead(h, ["Converted"], "text-right"), + tableHead(h, ["Conversion"], "w-2/5"), + tableHead(h, ["Vs everyone"], "text-right"), + ]), + ]), + tableBody( + h, + Arr.map(groups, (group) => { + const conversion = group.entered === 0 ? 0 : group.converted / group.entered + return tableRow( + h, + [ + tableCell(h, [typography(h, { size: "xs", truncate: true }, [group.name])]), + numericTableCell(h, [formatNumber(group.entered)]), + numericTableCell(h, [formatNumber(group.converted)]), + tableCell(h, [ + hstack(h, { gap: "2.5" }, [ + typography( + h, + { size: "xs", numeric: true, className: "w-12 shrink-0 text-right" }, + [formatPercent(conversion)], + ), + meter(h, { + value: conversion, + max: best === 0 ? 1 : best, + className: "flex-1", + fillClassName: "bg-chart-2", + }), + ]), + ]), + numericTableCell(h, [ + delta(h, { ratio: conversion - inputs.overall, format: points }), + ]), + ], + undefined, + [h.Key(group.name)], + ) + }), + ), + ]) +} + +/** The breakdown's panel: a line per group, most entered first, compared with the whole funnel. */ +const breakdown = ( + h: H, + model: Model, + breakdownBy: FunnelBreakdownBy, + inputs: Readonly<{ keyBy: FunnelKeyBy; overall: number }>, +): Html => { + const label = breakdownLabel(breakdownBy) + return panel(h, {}, [ + panelHeader(h, { + title: `By ${label}`, + scope: typography(h, { size: "2xs", tone: "muted" }, [ + `each ${countNoun(inputs.keyBy, 1)} counts under the first value seen, most entered first`, + ]), + }), + AsyncData.isRefreshing(model.funnelBreakdown.data) + ? skeletonList(h, { rows: 4, className: "p-3" }) + : AsyncData.matchData(model.funnelBreakdown.data, { + onEmpty: () => skeletonList(h, { rows: 4, className: "p-3" }), + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't split the funnel", + onRetry: Message.ClickedRetry({ slot: "funnelBreakdown" }), + }), + onData: (rows) => breakdownTable(h, groupsOf(rows), { label, ...inputs }), + }), + ]) +} + const results = (h: H, model: Model): Html => // A refetch after a Count by or view change would draw the old counts under the new labels. AsyncData.isRefreshing(model.funnel.data) @@ -267,7 +522,7 @@ const results = (h: H, model: Model): Html => onRetry: Message.ClickedRetry({ slot: "funnel" }), }), onData: (rows) => { - const { keyBy, variant } = funnelOf(model.search) + const { keyBy, variant, breakdownBy } = funnelOf(model.search) const isDropoff = variant === "dropoff" const entered = rows[0]?.value ?? 0 if (entered === 0) @@ -275,7 +530,7 @@ const results = (h: H, model: Model): Html => "Check the first step's name, widen the time range, or clear the filters.", ]) return vstack(h, { gap: "4" }, [ - stats(h, rows), + stats(h, model, rows, keyBy), panel(h, {}, [ panelHeader(h, { title: isDropoff ? "Drop-off" : "Conversion", @@ -285,10 +540,15 @@ const results = (h: H, model: Model): Html => }), panelChartBody(h, [chart(h, model, rows, isDropoff)]), ]), - panel(h, {}, [ - panelHeader(h, { title: "Steps" }), - stepsTable(h, rows, keyBy, isDropoff), - ]), + ...(breakdownBy === undefined || rows.length < 2 + ? [] + : [ + breakdown(h, model, breakdownBy, { + keyBy, + overall: conversionOf(rows) ?? 0, + }), + ]), + panel(h, {}, [panelHeader(h, { title: "Steps" }), stepsTable(h, rows, keyBy)]), ]) }, }) diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 6e1821b448..476073e76d 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -19,6 +19,7 @@ import { } from "@maple/domain/http" import { PushSourcePlatform } from "@maple/primitives" import { + FunnelBreakdownBy, FunnelKeyBy, FunnelPopulationFilters, FunnelStep, @@ -569,6 +570,7 @@ export const ProductAnalyticsSearch = Schema.Struct({ keyBy: Schema.optional(FunnelKeyBy), within: NumberParam, view: Schema.optional(FunnelVariant), + breakdown: Schema.optional(FunnelBreakdownBy), anchor: Schema.optional(PathsAnchor), direction: Schema.optional(PathsDirection), depth: NumberParam, diff --git a/packages/backend/src/services/dashboards/route-endpoint-plans.ts b/packages/backend/src/services/dashboards/route-endpoint-plans.ts index 7038a154ab..4739cfba05 100644 --- a/packages/backend/src/services/dashboards/route-endpoint-plans.ts +++ b/packages/backend/src/services/dashboards/route-endpoint-plans.ts @@ -43,7 +43,7 @@ import { import { DEFAULT_PATHS_BRANCHES, DEFAULT_PATHS_DEPTH, - FUNNEL_WIDGET_BREAKDOWN_LIMIT, + funnelBreakdownLimit, ProductEventsFunnelWidgetParams, ProductEventsPathsWidgetParams, funnelStepDetails, @@ -225,7 +225,7 @@ export const ROUTE_ENDPOINT_PLANS: RouteEndpointPlanRegistry = { const widgetParams = yield* decodeProductEventsFunnelWidgetParams(params) // No steps yet: the empty state, not a 400 from the builder. if (widgetParams.steps.length === 0) return { data: [] } - const { breakdownBy, details, ...rest } = widgetParams + const { breakdownBy, breakdownLimit, details, ...rest } = widgetParams const request = { keyBy: "person", windowSeconds: 24 * 3600, @@ -242,7 +242,7 @@ export const ROUTE_ENDPOINT_PLANS: RouteEndpointPlanRegistry = { const breakdownPayload = yield* decodeProductEventsFunnelBreakdown({ ...request, breakdownBy, - limit: FUNNEL_WIDGET_BREAKDOWN_LIMIT, + limit: funnelBreakdownLimit(breakdownLimit), }) yield* validateFunnelDefinition({ ...productEventsFunnelOpts(breakdownPayload), @@ -291,6 +291,8 @@ export const ROUTE_ENDPOINT_PLANS: RouteEndpointPlanRegistry = { step: row.step, p50Ms: row.p50Ms, p90Ms: row.p90Ms, + sinceStartP50Ms: row.sinceStartP50Ms, + sinceStartP90Ms: row.sinceStartP90Ms, })), Arr.map(leavers, (row) => ({ step: row.step, diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 9d0dce4891..98fad93455 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -3472,8 +3472,19 @@ export class ProductEventsFunnelTimingRequest extends Schema.Class( "ProductEventsFunnelTimingResponse", )({ - /** One row per step 2..N: milliseconds from the previous step, over persons who reached this one. */ - data: Schema.Array(Schema.Struct({ step: Schema.Number, p50Ms: Schema.Number, p90Ms: Schema.Number })), + /** + * One row per step 2..N, over persons who reached it: milliseconds from the previous step, and + * (`sinceStart*`) from step 1. + */ + data: Schema.Array( + Schema.Struct({ + step: Schema.Number, + p50Ms: Schema.Number, + p90Ms: Schema.Number, + sinceStartP50Ms: Schema.Number, + sinceStartP90Ms: Schema.Number, + }), + ), }) {} export class ProductEventsFunnelLeaversRequest extends Schema.Class( diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 5c1e24332c..170740d6e7 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -5166,14 +5166,18 @@ SELECT ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelTimingQuery:person [bb4dad6c] +-- builder:product-events:productEventsFunnelTimingQuery:person [61465ef0] SELECT arrayJoin([2, 3]) AS step, arrayElement(totals.p50s, step) AS p50Ms, - arrayElement(totals.p90s, step) AS p90Ms + arrayElement(totals.p90s, step) AS p90Ms, + arrayElement(totals.start50s, step) AS sinceStartP50Ms, + arrayElement(totals.start90s, step) AS sinceStartP90Ms FROM (SELECT [0, ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3), 0)] AS p50s, - [0, ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3), 0)] AS p90s + [0, ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3), 0)] AS p90s, + [0, ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o3, 1024000) - intDiv(o1, 1024000)), level >= 3), 0)] AS start50s, + [0, ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o3, 1024000) - intDiv(o1, 1024000)), level >= 3), 0)] AS start90s FROM (SELECT walked.key AS key, walked.level AS level, diff --git a/packages/query-engine/src/ch/queries/product-events.ts b/packages/query-engine/src/ch/queries/product-events.ts index e6bc70f732..4538afa290 100644 --- a/packages/query-engine/src/ch/queries/product-events.ts +++ b/packages/query-engine/src/ch/queries/product-events.ts @@ -1044,13 +1044,16 @@ export const productEventsFunnelTimingRowSchema = Schema.Struct({ /** Median milliseconds from the previous step's event to this one's, over persons who reached it. */ p50Ms: CHNumber, p90Ms: CHNumber, + /** The same from step 1's event; on the last step, the time the whole funnel took. */ + sinceStartP50Ms: CHNumber, + sinceStartP90Ms: CHNumber, }) export type ProductEventsFunnelTimingOutput = typeof productEventsFunnelTimingRowSchema.Type /** - * Time between consecutive steps: `{ step, p50Ms, p90Ms }` for steps 2..N, in - * step order, over the persons the funnel counts at that step. An empty range - * (or a step nobody reached) answers `0`, never a missing row. + * Time between steps: `{ step, p50Ms, p90Ms, sinceStartP50Ms, sinceStartP90Ms }` for steps + * 2..N, in step order, over the persons the funnel counts at that step. An empty range (or a + * step nobody reached) answers `0`, never a missing row. */ export function productEventsFunnelTimingQuery( opts: ProductEventsFunnelOpts, @@ -1062,20 +1065,25 @@ export function productEventsFunnelTimingQuery( const plan: FunnelPlan = { opts, filters, sessionStep: first?.kind === "session" ? first : undefined } const n = opts.steps.length - const quantileArray = (q: number): string => + const millis = (index: number): string => `intDiv(${stepOrderColumn(index)}, ${ORDER_UNITS_PER_MS})` + // One quantile per step 2..N of the span since the previous step, or since step 1. + const quantileArray = (q: number, since: "previous" | "start"): string => `[0, ${Arr.join( Array.from({ length: n - 1 }, (_, i) => { const step = i + 2 - const to = `intDiv(${stepOrderColumn(step - 1)}, ${ORDER_UNITS_PER_MS})` - const fromStep = `intDiv(${stepOrderColumn(step - 2)}, ${ORDER_UNITS_PER_MS})` - return `ifNotFinite(quantileIf(${q})(toFloat64(${to} - ${fromStep}), level >= ${step}), 0)` + const from = since === "start" ? millis(0) : millis(step - 2) + return `ifNotFinite(quantileIf(${q})(toFloat64(${millis(step - 1)} - ${from}), level >= ${step}), 0)` }), ", ", )}]` + const quantiles = (q: number, since: "previous" | "start") => + CH.rawExpr>(quantileArray(q, since), T.array(T.float64)) const totals = fromQuery(chainQuery(plan), "chain").select(() => ({ - p50s: CH.rawExpr>(quantileArray(0.5), T.array(T.float64)), - p90s: CH.rawExpr>(quantileArray(0.9), T.array(T.float64)), + p50s: quantiles(0.5, "previous"), + p90s: quantiles(0.9, "previous"), + start50s: quantiles(0.5, "start"), + start90s: quantiles(0.9, "start"), })) return Result.succeed( @@ -1084,6 +1092,8 @@ export function productEventsFunnelTimingQuery( step: CH.arrayJoin(CH.arrayOf(...Array.from({ length: n - 1 }, (_, i) => CH.lit(i + 2)))), p50Ms: CH.arrayElement($.p50s, CH.dynamicColumn("step")), p90Ms: CH.arrayElement($.p90s, CH.dynamicColumn("step")), + sinceStartP50Ms: CH.arrayElement($.start50s, CH.dynamicColumn("step")), + sinceStartP90Ms: CH.arrayElement($.start90s, CH.dynamicColumn("step")), })) .orderBy(["step", "asc"]) .format("JSON"), diff --git a/packages/query-model/src/funnel.test.ts b/packages/query-model/src/funnel.test.ts index 474e3f705a..b8fd4ef56f 100644 --- a/packages/query-model/src/funnel.test.ts +++ b/packages/query-model/src/funnel.test.ts @@ -68,8 +68,14 @@ describe("funnel drop-off details", () => { it("merges timing and leavers onto the step rows, by 1-based step", () => { const details = funnelStepDetails( [ - { step: 2, p50Ms: 60_000, p90Ms: 3_600_000 }, - { step: 3, p50Ms: 0, p90Ms: 0 }, + { + step: 2, + p50Ms: 60_000, + p90Ms: 3_600_000, + sinceStartP50Ms: 60_000, + sinceStartP90Ms: 3_600_000, + }, + { step: 3, p50Ms: 0, p90Ms: 0, sinceStartP50Ms: 0, sinceStartP90Ms: 0 }, ], [ { step: 2, next: "", count: 5 }, @@ -91,6 +97,8 @@ describe("funnel drop-off details", () => { value: 2, p50Ms: 60_000, p90Ms: 3_600_000, + sinceStartP50Ms: 60_000, + sinceStartP90Ms: 3_600_000, leavers: [ { name: "", count: 5 }, { name: "/docs", count: 3 }, diff --git a/packages/query-model/src/funnel.ts b/packages/query-model/src/funnel.ts index 43b21a68b8..3aee242713 100644 --- a/packages/query-model/src/funnel.ts +++ b/packages/query-model/src/funnel.ts @@ -121,7 +121,15 @@ export const FUNNEL_POPULATION_FILTER_FIELDS = [ ] as const satisfies ReadonlyArray /** Groups a dashboard funnel widget draws when broken down — one bar per group per step. */ -export const FUNNEL_WIDGET_BREAKDOWN_LIMIT = 6 +const FUNNEL_WIDGET_BREAKDOWN_LIMIT = 6 +/** The most groups a breakdown answers; mirrors `FUNNEL_BREAKDOWN_MAX_GROUPS` in the query engine. */ +export const FUNNEL_BREAKDOWN_MAX_LIMIT = 20 + +/** How many groups a breakdown asks for: the widget's six unless a reader with room asks for more. */ +export const funnelBreakdownLimit = (requested: number | undefined): number => + requested === undefined || !Number.isFinite(requested) + ? FUNNEL_WIDGET_BREAKDOWN_LIMIT + : Math.min(FUNNEL_BREAKDOWN_MAX_LIMIT, Math.max(1, Math.round(requested))) /** * The params bag of the `product_events_funnel` widget route — the stored @@ -135,6 +143,8 @@ export const ProductEventsFunnelWidgetParams = Schema.Struct({ keyBy: Schema.optional(FunnelKeyBy), windowSeconds: Schema.optional(Schema.Number), breakdownBy: Schema.optional(FunnelBreakdownBy), + /** Groups a breakdown answers, 1 to 20; a tile draws six, a table has room for more. */ + breakdownLimit: Schema.optional(Schema.Number), /** Also fetch the time between steps and where the leavers went — the drop-off view's extras. */ details: Schema.optional(Schema.Boolean), ...FunnelPopulationFilters.fields, @@ -226,18 +236,33 @@ export interface FunnelStepDetails { /** Milliseconds from the previous step's event; absent on step 1. */ readonly p50Ms?: number readonly p90Ms?: number + /** Milliseconds from step 1's event; on the last step, how long the whole funnel took. */ + readonly sinceStartP50Ms?: number + readonly sinceStartP90Ms?: number /** Most common next events of persons who stopped before this step; absent on step 1. */ readonly leavers?: ReadonlyArray } /** Query rows → per-step details keyed by 1-based step. */ export const funnelStepDetails = ( - timing: ReadonlyArray<{ readonly step: number; readonly p50Ms: number; readonly p90Ms: number }>, + timing: ReadonlyArray<{ + readonly step: number + readonly p50Ms: number + readonly p90Ms: number + readonly sinceStartP50Ms: number + readonly sinceStartP90Ms: number + }>, leavers: ReadonlyArray<{ readonly step: number; readonly next: string; readonly count: number }>, ): ReadonlyMap => { const byStep = new Map() for (const row of timing) { - byStep.set(row.step, { ...byStep.get(row.step), p50Ms: row.p50Ms, p90Ms: row.p90Ms }) + byStep.set(row.step, { + ...byStep.get(row.step), + p50Ms: row.p50Ms, + p90Ms: row.p90Ms, + sinceStartP50Ms: row.sinceStartP50Ms, + sinceStartP90Ms: row.sinceStartP90Ms, + }) } for (const row of leavers) { const current = byStep.get(row.step) ?? {} diff --git a/packages/views/src/charts/funnel-chart.ts b/packages/views/src/charts/funnel-chart.ts index 49b7763e51..b3ecf7f66e 100644 --- a/packages/views/src/charts/funnel-chart.ts +++ b/packages/views/src/charts/funnel-chart.ts @@ -22,6 +22,8 @@ export interface FunnelChartInputs { readonly className?: string | undefined readonly unit?: string | undefined readonly showStepPercent?: boolean | undefined + /** Ordered steps of one funnel draw in one hue; categories drawn as a funnel keep their own. */ + readonly isSingleHue?: boolean | undefined } type H = HtmlBuilder @@ -34,6 +36,10 @@ const ROW_MIN_H = 22 const ROW_FULL_H = ROW_MIN_H + ROW_GAP const MORE_ROW_H = 16 const GROUP_BAR_H = 8 +const BAR_MIN_H = 10 +const BAR_MAX_H = 28 +/** A row's label line and the gap under it, above its bar. */ +const ROW_LABEL_H = 14 export const PERCENT_FLOOR = { floor: 0.0005 } const LEGEND_H = 18 @@ -156,6 +162,16 @@ export const view = Submodel.defineView((mode const maxRows = height > 0 ? Math.max(1, Math.floor((height - chromeH) / rowFullH)) : stages.length const truncated = stages.length > maxRows const visible = truncated ? stages.slice(0, maxRows) : stages + // With room to spare the bars thicken, so a few steps fill a tall panel instead of floating in it. + const barH = + height > 0 + ? Math.round( + Math.min( + BAR_MAX_H, + Math.max(BAR_MIN_H, (height - chromeH) / visible.length - ROW_GAP - ROW_LABEL_H - 4), + ), + ) + : BAR_MIN_H const hiddenCount = stages.length - visible.length const hover = Option.getOrNull(model.activeKey) const hoverGroup = Option.getOrNull(model.activeGroup) @@ -226,7 +242,7 @@ export const view = Submodel.defineView((mode ), isGrouped ? groupBars(h, stage, hoverGroup, fade, inputs.unit) - : stageBar(h, stage, fade), + : stageBar(h, stage, fade, barH, inputs.isSingleHue === true), ], ) }), @@ -243,15 +259,18 @@ export const view = Submodel.defineView((mode ) }) -const stageBar = (h: H, stage: Stage, fade: number): Html => +const stageBar = (h: H, stage: Stage, fade: number, height: number, isSingleHue: boolean): Html => h.div( - [h.Class("relative h-2.5 w-full overflow-hidden rounded-xs bg-foreground/5")], + [ + h.Class("relative w-full overflow-hidden rounded-xs bg-foreground/5"), + h.Style({ height: `${height}px` }), + ], [ h.div([ h.Class(barFillClass()), h.Style({ width: `${stage.widthPct * 100}%`, - "background-color": stage.color, + "background-color": isSingleHue ? "var(--chart-2)" : stage.color, opacity: String(fade), transition: "opacity 140ms ease, width 220ms ease", }), diff --git a/packages/web-data/src/warehouse/product-events.ts b/packages/web-data/src/warehouse/product-events.ts index 2c582de45d..a426555a18 100644 --- a/packages/web-data/src/warehouse/product-events.ts +++ b/packages/web-data/src/warehouse/product-events.ts @@ -19,7 +19,7 @@ import { import { DEFAULT_PATHS_BRANCHES, DEFAULT_PATHS_DEPTH, - FUNNEL_WIDGET_BREAKDOWN_LIMIT, + funnelBreakdownLimit, ProductEventsFunnelWidgetParams, ProductEventsPathsWidgetParams, funnelStepDetails, @@ -203,7 +203,7 @@ const getProductEventsFunnelWidgetEffect = Effect.fn("QueryEngine.getProductEven // empty state rather than asking the warehouse for a definition it rejects. if (input.steps.length === 0) return { data: [] satisfies ReadonlyArray } - const { breakdownBy, details, ...rest } = input + const { breakdownBy, breakdownLimit, details, ...rest } = input const request = { ...rest, keyBy: input.keyBy ?? WIDGET_DEFAULT_KEY_BY, @@ -218,7 +218,7 @@ const getProductEventsFunnelWidgetEffect = Effect.fn("QueryEngine.getProductEven payload: new ProductEventsFunnelBreakdownRequest({ ...request, breakdownBy, - limit: FUNNEL_WIDGET_BREAKDOWN_LIMIT, + limit: funnelBreakdownLimit(breakdownLimit), }), }) }), From 79bacff21f462c8dfc2620a605bc2afe800c0665 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 02:27:04 +0100 Subject: [PATCH 3/8] feat(web): filter a funnel step by an event property on Product analytics "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. --- apps/web/src/lab/entries/product-analytics.ts | 13 +++++ .../src/page/dashboards/widget/view-steps.ts | 48 ++++++++---------- apps/web/src/page/kit/funnel-steps.ts | 38 ++++++++++++++ .../page/kit/query-builder/where-editor.ts | 2 + .../src/page/product-analytics/definition.ts | 22 ++++++-- .../web/src/page/product-analytics/message.ts | 4 ++ apps/web/src/page/product-analytics/model.ts | 2 + .../product-analytics.scene.test.ts | 50 +++++++++++++++++++ apps/web/src/page/product-analytics/update.ts | 21 ++++++-- .../page/product-analytics/view-funnels.ts | 31 +++++++++++- packages/query-model/src/funnel.test.ts | 26 ++++++++++ packages/query-model/src/funnel.ts | 12 +++-- packages/views/src/ui/INDEX.md | 2 +- 13 files changed, 230 insertions(+), 41 deletions(-) diff --git a/apps/web/src/lab/entries/product-analytics.ts b/apps/web/src/lab/entries/product-analytics.ts index afe16ab202..c74dd0ba4e 100644 --- a/apps/web/src/lab/entries/product-analytics.ts +++ b/apps/web/src/lab/entries/product-analytics.ts @@ -28,6 +28,7 @@ const Scenario = Schema.Literals([ "funnels", "funnels-bars", "funnels-breakdown", + "funnels-filter", "funnels-new", "funnels-error", "paths", @@ -212,6 +213,18 @@ const searchOf = (scenario: Scenario): Search => { return { tab: "funnels", steps: SIGNUP_TO_PAID, within: WITHIN_SECONDS } case "funnels-bars": return { tab: "funnels", steps: SIGNUP_TO_PAID, within: WITHIN_SECONDS, view: "bars" } + case "funnels-filter": + return { + tab: "funnels", + within: WITHIN_SECONDS, + steps: Arr.map(SIGNUP_TO_PAID ?? [], (step) => + step.kind === "event" && step.eventName === "signup_completed" + ? { ...step, attributeEquals: { method: "google" } } + : step.kind === "event" && step.eventName === "plan_started" + ? { ...step, attributeEquals: { plan: "startup" } } + : step, + ), + } case "funnels-breakdown": return { tab: "funnels", steps: SIGNUP_TO_PAID, within: WITHIN_SECONDS, breakdown: "utmSource" } case "funnels-new": diff --git a/apps/web/src/page/dashboards/widget/view-steps.ts b/apps/web/src/page/dashboards/widget/view-steps.ts index 088ef009cd..086a1470ea 100644 --- a/apps/web/src/page/dashboards/widget/view-steps.ts +++ b/apps/web/src/page/dashboards/widget/view-steps.ts @@ -1,16 +1,17 @@ import { parseFunnelStepFilter } from "./funnel-filters" import type { Html, HtmlBuilder } from "foldkit/html" -import { eyebrow } from "@maple/views/ui/eyebrow" -import { stepBuilder as kitStepBuilder, StepEdit, type StepDraft } from "../../kit/funnel-steps" -import { whereEditor } from "../../kit/query-builder/where-editor" +import { + stepBuilder as kitStepBuilder, + StepEdit, + stepFilterLine, + type StepDraft, +} from "../../kit/funnel-steps" import { autocompleteFor, stepField, stepFilterId } from "./where-fields" import { Message } from "./message" import type { Model } from "./model" import type { StepScope } from "../../kit/funnel-steps" import { ProductEdit } from "./product-edit" import type { Suggestion } from "./suggestions" -import { hstack, vstack } from "@maple/views/ui/stack" -import { typography } from "@maple/views/ui/typography" // The widget editor's funnel step builder: the kit's, with an attribute filter under event steps. @@ -25,7 +26,7 @@ export type StepBuilderOptions = Readonly<{ maxSteps: number }> -/** The event step's attribute filter line, its parse error under it. */ +/** The event step's attribute filter line, with the editor's suggestions. */ const filterLine = ( h: H, model: Model, @@ -35,29 +36,20 @@ const filterLine = ( ): Html => { const filterClause = step.filterClause ?? "" const parsed = parseFunnelStepFilter(filterClause) - return vstack(h, { gap: "0.5", className: "pl-6" }, [ - hstack(h, { gap: "1.5" }, [ - eyebrow(h, { className: "shrink-0" }, ["Where"]), - whereEditor(h, { - id: stepFilterId(scope, index), - value: filterClause, - ariaLabel: `Step ${index + 1} attribute filter`, - placeholder: 'plan = "pro" AND source = "cli"', - rows: 1, - className: "min-w-0 flex-1", - textareaClassName: "min-h-[28px] resize-none text-xs", - onInput: (value) => - Message.ChangedProduct({ - edit: ProductEdit.Step({ - scope, - edit: StepEdit.ChangedText({ index, field: "filterClause", value }), - }), - }), - autocomplete: autocompleteFor(model, stepField(scope, index, filterClause)), + return stepFilterLine(h, { + id: stepFilterId(scope, index), + index, + value: filterClause, + error: parsed.ok ? undefined : parsed.error, + onInput: (value) => + Message.ChangedProduct({ + edit: ProductEdit.Step({ + scope, + edit: StepEdit.ChangedText({ index, field: "filterClause", value }), + }), }), - ]), - ...(parsed.ok ? [] : [typography(h, { size: "2xs", tone: "error", as: "p" }, [parsed.error])]), - ]) + submit: { autocomplete: autocompleteFor(model, stepField(scope, index, filterClause)) }, + }) } export const stepBuilder = (h: H, model: Model, options: StepBuilderOptions): Html => diff --git a/apps/web/src/page/kit/funnel-steps.ts b/apps/web/src/page/kit/funnel-steps.ts index 9fb6f6685e..2f7feda30f 100644 --- a/apps/web/src/page/kit/funnel-steps.ts +++ b/apps/web/src/page/kit/funnel-steps.ts @@ -21,6 +21,8 @@ import * as Select from "@maple/views/ui/select" import { view as selectView, type SelectOption } from "@maple/views/ui/select-view" import { hstack, vstack } from "@maple/views/ui/stack" import { typography, typographyClass } from "@maple/views/ui/typography" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { type WhereEditorAutocomplete, whereEditor } from "./query-builder/where-editor" import { FUNNEL_SESSION_DIMENSIONS } from "../dashboards/kit/funnel-definition" // The funnel step builder: an ordered list of steps, each an event name, a page path or (first @@ -515,6 +517,42 @@ const stepRow = ( ]) } +export type StepFilterLineOptions = Readonly<{ + id: string + index: number + value: string + /** Why the clause does not parse, shown under it. */ + error: string | undefined + onInput: (value: string) => M + /** The clause's suggestions, or what Enter does when the owner offers none. */ + submit: Readonly<{ autocomplete: WhereEditorAutocomplete }> | Readonly<{ onEnter: M }> +}> + +/** An event step's property filter under its row (`plan = "pro"`), for `eventStepExtra`. */ +export const stepFilterLine = (h: HtmlBuilder, options: StepFilterLineOptions): Html => { + const editor = { + id: options.id, + value: options.value, + ariaLabel: `Step ${options.index + 1} attribute filter`, + placeholder: 'plan = "pro" AND source = "cli"', + rows: 1, + className: "min-w-0 flex-1", + textareaClassName: "min-h-[28px] resize-none text-xs", + onInput: options.onInput, + } + return vstack(h, { gap: "0.5", className: "pl-6" }, [ + hstack(h, { gap: "1.5" }, [ + eyebrow(h, { className: "shrink-0" }, ["Where"]), + "autocomplete" in options.submit + ? whereEditor(h, { ...editor, autocomplete: options.submit.autocomplete }) + : whereEditor(h, { ...editor, onEnter: options.submit.onEnter }), + ]), + ...(options.error === undefined + ? [] + : [typography(h, { size: "2xs", tone: "error", as: "p" }, [options.error])]), + ]) +} + /** One row per step, then Add step (a single anchor has neither numbers nor Add). */ export const stepBuilder = (h: HtmlBuilder, options: StepBuilderOptions): Html => { const isSingle = options.maxSteps <= 1 diff --git a/apps/web/src/page/kit/query-builder/where-editor.ts b/apps/web/src/page/kit/query-builder/where-editor.ts index 96d6bb7c66..7682de1bcc 100644 --- a/apps/web/src/page/kit/query-builder/where-editor.ts +++ b/apps/web/src/page/kit/query-builder/where-editor.ts @@ -105,6 +105,8 @@ export const whereEditor = (h: HtmlBuilder, options: WhereEditorOptions options.textareaClassName, isHighlighting && "text-transparent caret-foreground", ), + // One row means one line: without this the control keeps its two-row minimum. + controlClassName: options.rows === 1 ? "min-h-0 resize-none max-sm:min-h-0" : undefined, defaultValue: options.value, attributes: [ h.AriaLabel(options.ariaLabel), diff --git a/apps/web/src/page/product-analytics/definition.ts b/apps/web/src/page/product-analytics/definition.ts index 2911b50734..f002791d7e 100644 --- a/apps/web/src/page/product-analytics/definition.ts +++ b/apps/web/src/page/product-analytics/definition.ts @@ -16,6 +16,7 @@ import { type ProductEventsPathsWidgetParams, } from "@maple/query-model" import { Array as Arr, Option, Record as Rec, String as Str } from "effect" +import { compileFunnelStep } from "../dashboards/widget/funnel-filters" import { completedSteps, DEFAULT_FUNNEL_KEY_BY, @@ -141,13 +142,26 @@ const trimmed = (step: S): S => { } } -/** A draft step as the URL keeps it: no typed attribute filter (this page offers none). */ -export const toFunnelStep = (draft: StepDraft): FunnelStep => { +/** + * A draft step as the URL keeps it: the typed filter compiled to `attributeEquals`. A clause still + * being typed does not parse yet, so the step keeps the filter `running` has for the same event. + */ +export const toFunnelStep = (draft: StepDraft, running?: FunnelStep): FunnelStep => { if (draft.kind !== "event") return draft - const { filterClause: _filterClause, ...step } = draft - return step + const compiled = compileFunnelStep(draft) + if (compiled.ok) return compiled.value + const step: FunnelStep = { kind: "event", eventName: draft.eventName } + return running?.kind === "event" && + running.eventName === draft.eventName && + running.attributeEquals !== undefined + ? { ...step, attributeEquals: running.attributeEquals } + : step } +/** Whether any step narrows its event by a property. */ +export const hasStepFilter = (steps: ReadonlyArray): boolean => + Arr.some(steps, (step) => step.kind === "event" && (step.filterClause ?? "") !== "") + /** The paths anchor of a draft; a session step has no place in a sequence. */ export const toAnchor = (draft: StepDraft): PathsAnchor => { if (draft.kind === "page") return draft diff --git a/apps/web/src/page/product-analytics/message.ts b/apps/web/src/page/product-analytics/message.ts index 2bee2a5a1d..604f24b80a 100644 --- a/apps/web/src/page/product-analytics/message.ts +++ b/apps/web/src/page/product-analytics/message.ts @@ -67,6 +67,10 @@ export const Message = defineMessageUnion({ ChangedStep: { scope: StepScope, edit: StepEdit }, GotStepComboMessage: { id: Schema.String, message: Combobox.Message }, GotSelectMessage: { id: Schema.String, message: Select.Message }, + /** The Steps panel's "Filter by property": shows a filter line per event step, or clears them all. */ + ToggledStepFilters: {}, + /** Enter in a step's filter: run it now instead of waiting out the typing pause. */ + SubmittedStepFilter: {}, /** A typed step value held still long enough to run. */ PausedTyping: { version: Schema.Number }, PickedKeyBy: { keyBy: FunnelKeyBy }, diff --git a/apps/web/src/page/product-analytics/model.ts b/apps/web/src/page/product-analytics/model.ts index ac40ff1fce..132d37b201 100644 --- a/apps/web/src/page/product-analytics/model.ts +++ b/apps/web/src/page/product-analytics/model.ts @@ -77,6 +77,8 @@ export const Model = Schema.Struct({ paths: KeyedSchema(Schema.Array(PathsRow), LoadFailure), /** The funnel steps as edited; the URL catches up on a pick, or once typing pauses. */ steps: Schema.Array(StepDraft), + /** Each event step shows its property filter line; a funnel that has a filter always does. */ + showsStepFilters: Schema.Boolean, anchor: StepDraft, /** The steps and anchor this page last wrote to the URL, so its own navigation keeps the draft. */ committed: Schema.String, diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index f3febc336e..c10724b2f6 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -391,6 +391,56 @@ describe("product analytics", () => { ) }) + test("a step's property filter reaches the URL once it parses, and Enter does not wait", () => { + const shown = pageUpdate( + resolved({ tab: "funnels", steps: SIGNUP }).model, + Message.ToggledStepFilters(), + ) + expect(shown.outMessage).toBeUndefined() + const filter = (model: Model, value: string) => + pageUpdate( + model, + Message.ChangedStep({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 1, field: "filterClause", value }), + }), + ) + const typed = filter(shown.model, 'method = "google"') + expect(typed.outMessage).toBeUndefined() + const entered = pageUpdate(typed.model, Message.SubmittedStepFilter()) + expect(searchOf(entered.outMessage).steps).toEqual([ + { kind: "event", eventName: "signup_started" }, + { kind: "event", eventName: "signup_completed", attributeEquals: { method: "google" } }, + ]) + // Half a clause does not parse: the step keeps running with the filter that did. + const landed = routeChanged(entered.model, { search: searchOf(entered.outMessage) }, context).model + const halfway = pageUpdate( + filter(landed, 'method = "google" AND plan').model, + Message.SubmittedStepFilter(), + ) + expect(searchOf(halfway.outMessage).steps?.[1]).toEqual({ + kind: "event", + eventName: "signup_completed", + attributeEquals: { method: "google" }, + }) + scene( + { update: pageUpdate, view }, + given(halfway.model), + expectView(text('"plan" is not a key = "value" clause')).toExist(), + ) + }) + + test("a shared funnel with a filtered step opens with its filter line, and the toggle clears it", () => { + const filtered: Search["steps"] = [ + { kind: "event", eventName: "signup_started" }, + { kind: "event", eventName: "signup_completed", attributeEquals: { method: "google" } }, + ] + const model = resolved({ tab: "funnels", steps: filtered }).model + expect(model.steps[1]).toMatchObject({ filterClause: 'method = "google"' }) + const cleared = pageUpdate(model, Message.ToggledStepFilters()) + expect(searchOf(cleared.outMessage).steps).toEqual(SIGNUP) + }) + test("picking a breakdown writes it to the URL, and None clears it", () => { const model = resolved({ tab: "funnels", steps: SIGNUP }).model expect(searchOf(pickBreakdown(model, "attribute:plan").outMessage).breakdown).toBe("attribute:plan") diff --git a/apps/web/src/page/product-analytics/update.ts b/apps/web/src/page/product-analytics/update.ts index 72128bd090..5ee674eebd 100644 --- a/apps/web/src/page/product-analytics/update.ts +++ b/apps/web/src/page/product-analytics/update.ts @@ -23,7 +23,8 @@ import { routeWindowStep, snapModeOf } from "../kit/route-window" import type { PresetValue } from "../kit/time-controls" import * as TimeRangePicker from "../kit/time-range-picker" import { CompleteReloadSpin, ResolveWindow, WaitForTypingPause } from "./command" -import { anchorNameOf, pathsOf, type Search, toAnchor, toFunnelStep } from "./definition" +import { draftFromFunnelStep } from "../dashboards/widget/funnel-filters" +import { anchorNameOf, hasStepFilter, pathsOf, type Search, toAnchor, toFunnelStep } from "./definition" import { FilterKey, Message, type RequestMode, SlotName } from "./message" import { AttributeKeyRow, EventRow, FunnelGroupRow, FunnelRow, type Model, PathsRow } from "./model" import { requestSlots } from "./requests" @@ -103,14 +104,14 @@ const committedOf = (search: Search): string => encodeJson({ steps: search.steps ?? [], anchor: search.anchor ?? null }) const draftsOf = (search: Search): Pick => ({ - steps: search.steps ?? [], + steps: Arr.map(search.steps ?? [], draftFromFunnelStep), anchor: pathsOf(search).anchor, committed: committedOf(search), }) /** The search with the drafts written in, so a tab or filter change carries what is being typed. */ export const withDrafts = (model: Model): Search => { - const steps = Arr.map(model.steps, toFunnelStep) + const steps = Arr.map(model.steps, (draft, index) => toFunnelStep(draft, model.search.steps?.[index])) const anchor = toAnchor(model.anchor) return { ...model.search, @@ -182,6 +183,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): attributeKeys: Keyed.idle(), paths: Keyed.idle(), ...draftsOf(route.search), + showsStepFilters: false, typingVersion: 0, navigatedTo: null, stepCombos: {}, @@ -322,6 +324,19 @@ export const update = (model: Model, message: Message, context: PageContext): Re const step = edited(lifted.model, target.value.scope, target.value.edit, false) return { ...step, commands: [...(lifted.commands ?? []), ...(step.commands ?? [])] } }, + ToggledStepFilters: () => { + if (!model.showsStepFilters && !hasStepFilter(model.steps)) + return { model: { ...model, showsStepFilters: true } } + // Turning it off clears the filters, so nothing keeps narrowing a step out of sight. + return commit({ + ...model, + showsStepFilters: false, + steps: Arr.map(model.steps, (step) => + step.kind === "event" ? { kind: "event", eventName: step.eventName } : step, + ), + }) + }, + SubmittedStepFilter: () => commit(model), PausedTyping: ({ version }) => (version === model.typingVersion ? commit(model) : { model }), PickedKeyBy: ({ keyBy }) => navigate(model, { ...withDrafts(model), keyBy }, true), PickedWindow: ({ seconds }) => navigate(model, { ...withDrafts(model), within: seconds }, true), diff --git a/apps/web/src/page/product-analytics/view-funnels.ts b/apps/web/src/page/product-analytics/view-funnels.ts index 188772cc25..27cd8380d6 100644 --- a/apps/web/src/page/product-analytics/view-funnels.ts +++ b/apps/web/src/page/product-analytics/view-funnels.ts @@ -16,6 +16,7 @@ import { fmtSpan } from "@maple/views/charts/funnel-dropoff-steps" import { button } from "@maple/views/ui/button" import { delta } from "@maple/views/ui/delta" import { errorState } from "@maple/views/ui/error-state" +import { filterChip } from "@maple/views/ui/filter-chip" import { meter } from "@maple/views/ui/meter" import { panel, panelChartBody, panelHeader } from "@maple/views/ui/panel" import * as Select from "@maple/views/ui/select" @@ -37,9 +38,10 @@ import { FUNNEL_SESSION_DIMENSIONS, FUNNEL_WINDOW_OPTIONS, } from "../dashboards/kit/funnel-definition" -import { stepBuilder } from "../kit/funnel-steps" +import { parseFunnelStepFilter } from "../dashboards/widget/funnel-filters" +import { type StepDraft, StepEdit, stepBuilder, stepFilterLine } from "../kit/funnel-steps" import { statRail, statRailItem, statRailLoading } from "../kit/stat-rail" -import { funnelOf, funnelParamsOf } from "./definition" +import { funnelOf, funnelParamsOf, hasStepFilter } from "./definition" import { Message } from "./message" import { type FunnelGroupRow, type FunnelRow, isCustomEvent, type Model } from "./model" import { BREAKDOWN_SELECT_ID } from "./update" @@ -141,14 +143,38 @@ const breakdownSelect = (h: H, model: Model, current: FunnelBreakdownBy | undefi ]) } +/** An event step's property filter: `plan = "pro"`, run on Enter or once typing pauses. */ +const filterLine = (h: H, step: StepDraft & { kind: "event" }, index: number): Html => { + const clause = step.filterClause ?? "" + const parsed = parseFunnelStepFilter(clause) + return stepFilterLine(h, { + id: `product-analytics-step-filter-${index}`, + index, + value: clause, + error: parsed.ok ? undefined : parsed.error, + onInput: (value) => + Message.ChangedStep({ + scope: "funnel", + edit: StepEdit.ChangedText({ index, field: "filterClause", value }), + }), + submit: { onEnter: Message.SubmittedStepFilter() }, + }) +} + const editor = (h: H, model: Model): Html => { const funnel = funnelOf(model.search) + const showsFilters = model.showsStepFilters || hasStepFilter(model.steps) return panel(h, { className: "shrink-0" }, [ panelHeader(h, { title: "Steps", scope: typography(h, { size: "2xs", tone: "muted" }, [ "in order, each within the window of step 1", ]), + action: filterChip( + h, + { size: "xs", isPressed: showsFilters, onClick: Message.ToggledStepFilters() }, + ["Filter by property"], + ), }), vstack(h, { className: "p-3" }, [ stepBuilder(h, { @@ -163,6 +189,7 @@ const editor = (h: H, model: Model): Html => { onCombo: (id, message) => Message.GotStepComboMessage({ id, message }), onSelect: (id, message) => Message.GotSelectMessage({ id, message }), onEdit: (edit) => Message.ChangedStep({ scope: "funnel", edit }), + eventStepExtra: showsFilters ? (step, index) => filterLine(h, step, index) : undefined, }), ]), controlsBar(h, [ diff --git a/packages/query-model/src/funnel.test.ts b/packages/query-model/src/funnel.test.ts index b8fd4ef56f..1a0a802cc6 100644 --- a/packages/query-model/src/funnel.test.ts +++ b/packages/query-model/src/funnel.test.ts @@ -5,6 +5,7 @@ import { FUNNEL_EMPTY_GROUP_LABEL, ProductEventsPathsWidgetParams, funnelStepDetails, + funnelStepLabel, funnelWidgetBreakdownRows, funnelWidgetRows, funnelWidgetRowsWithDetails, @@ -64,6 +65,31 @@ describe("funnelWidgetBreakdownRows", () => { }) }) +describe("funnelStepLabel", () => { + it("names an event step's filter, a page's host and a session step's dimension", () => { + expect( + Arr.map( + [ + { kind: "event", eventName: "plan_started" }, + { + kind: "event", + eventName: "plan_started", + attributeEquals: { plan: "startup", seats: "5" }, + }, + { kind: "page", pagePath: "/pricing", host: "acme.dev" }, + { kind: "session", dimension: "utmSource", value: "twitter" }, + ] satisfies ReadonlyArray, + funnelStepLabel, + ), + ).toEqual([ + "plan_started", + "plan_started (plan = startup, seats = 5)", + "acme.dev/pricing", + "UTM source: twitter", + ]) + }) +}) + describe("funnel drop-off details", () => { it("merges timing and leavers onto the step rows, by 1-based step", () => { const details = funnelStepDetails( diff --git a/packages/query-model/src/funnel.ts b/packages/query-model/src/funnel.ts index 3aee242713..eda3c82028 100644 --- a/packages/query-model/src/funnel.ts +++ b/packages/query-model/src/funnel.ts @@ -1,4 +1,4 @@ -import { Schema, Array as Arr } from "effect" +import { Schema, Array as Arr, Record as Rec } from "effect" // A product-event funnel definition, as every surface that stores one keeps // it: the /analytics URL, a dashboard funnel widget, an MCP tool call, and the @@ -72,8 +72,14 @@ export const FUNNEL_SESSION_DIMENSION_LABEL = { */ export const funnelStepLabel = (step: FunnelStep): string => { switch (step.kind) { - case "event": - return step.eventName + case "event": { + // A filtered step names its filter, so two steps on one event read apart. + const filter = Arr.join( + Arr.map(Rec.toEntries(step.attributeEquals ?? {}), ([key, value]) => `${key} = ${value}`), + ", ", + ) + return filter === "" ? step.eventName : `${step.eventName} (${filter})` + } case "page": return step.host ? `${step.host}${step.pagePath}` : step.pagePath case "session": diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 0b22144c76..bf999b43cb 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -229,7 +229,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/expand-table-footer.ts` `expandTableFooter`: The foot of a capped table card: "Expand table" on the left and the row count on the right, one click target that opens the whole table in a dialog. - `apps/web/src/page/kit/facets.ts` `toggledValue`, `onlyValue`, `excludedValue`, `editFacet`, `facetChipsOf`, `facetChips`: The include/exclude arithmetic of a tri-state facet filter, as applied to a page's URL search: an empty list leaves the URL, and a value is never both included and excluded. - `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: One row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack in a card under 36rem wide (a phone, or a form besid... -- `apps/web/src/page/kit/funnel-steps.ts` `applyStepEdit`, `applyAnchorEdit`, `stepControlId`, `stepEditOf`, `stepComboUpdate`, `stepBuilder`: The funnel step builder: an ordered list of steps, each an event name, a page path or (first step only) a session dimension. +- `apps/web/src/page/kit/funnel-steps.ts` `applyStepEdit`, `applyAnchorEdit`, `stepControlId`, `stepEditOf`, `stepComboUpdate`, `stepFilterLine`, `stepBuilder`: The funnel step builder: an ordered list of steps, each an event name, a page path or (first step only) a session dimension. - `apps/web/src/page/kit/grid-list-row.ts` `gridListRow`: One `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on hover and press, positioned so a stretched row link covers it. - `apps/web/src/page/kit/guided-setup.ts` `keyFor`, `setupBlocks`, `serverKey`, `promptKey`, `frameworkIconStrip`, `frameworkPicker`, `setupAgentPrompt`, `agentPromptButton`, `connectInstructions`: The SDK setup guide: the stack picker, then the Install / Instrument / coding-agent tabs filled in with the org's ingest keys. - `apps/web/src/page/kit/health-gap-notice.ts` `healthGapOf`, `healthGapText`, `healthGapNotice`: Service health is the overview joined with firing alerts and baseline anomalies. From a897f19321a6268dbe4f3ee49bfa44bb60486ef6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 02:28:00 +0100 Subject: [PATCH 4/8] feat(demo): campaigns and event properties in the product analytics seed 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. --- .../src/services/org/demo/product.test.ts | 15 +++- .../backend/src/services/org/demo/product.ts | 74 +++++++++++++++---- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/services/org/demo/product.test.ts b/packages/backend/src/services/org/demo/product.test.ts index d1b9d4cbf5..89e432331f 100644 --- a/packages/backend/src/services/org/demo/product.test.ts +++ b/packages/backend/src/services/org/demo/product.test.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Effect, Random, String as Str } from "effect" +import { Array as Arr, Effect, Predicate, Random, String as Str } from "effect" import { describe, expect, it } from "vitest" import { generateDemoProductRows } from "./product" @@ -39,6 +39,19 @@ describe("generateDemoProductRows", () => { expect(counts[2]).toBeLessThanOrEqual(counts[1] ?? 0) }) + it("gives breakdowns something to split by: campaigns on sessions, properties on events", () => { + const campaigns = new Set(Arr.map(rows.sessionRows, (row) => row.utm_campaign)) + expect(campaigns.has("launch-week")).toBe(true) + expect(campaigns.has("")).toBe(true) + const methods = new Set( + Arr.map( + Arr.filter(rows.eventRows, (row) => row.event_name === "signup_completed"), + (row) => Predicate.hasProperty(row.attributes, "method") && row.attributes.method, + ), + ) + expect([...methods].sort()).toEqual(["email", "github", "google"]) + }) + it("keeps every event inside the window", () => { const latest = Arr.map(rows.eventRows, (row) => Date.parse(`${Str.replace(" ", "T")(String(row.timestamp))}Z`), diff --git a/packages/backend/src/services/org/demo/product.ts b/packages/backend/src/services/org/demo/product.ts index 8500b10fa5..944070c5b0 100644 --- a/packages/backend/src/services/org/demo/product.ts +++ b/packages/backend/src/services/org/demo/product.ts @@ -4,17 +4,27 @@ import { Array as Arr, Random } from "effect" // Demo browser sessions and product events: `session_replays` rows (what web analytics and the // funnel population filters read) and the `product_events` page views and track() calls inside -// them, walking a signup funnel so Product analytics has events, funnels and paths to show. +// them, walking a signup funnel so Product analytics has events, funnels and paths to show. Where +// a visitor came from tilts how far they get, and the funnel's events carry properties, so a +// breakdown by channel or by property has something to say. // Rows use the datasources' ingestion JSON (snake_case, see packages/domain/src/tinybird/datasources.ts). const HOST = "app.demo-shop.dev" +/** Where a session came from, and how much more (or less) likely its visitor is to take each step. */ const REFERRERS = [ - { referrerHost: "google.com", utmSource: "" }, - { referrerHost: "github.com", utmSource: "" }, - { referrerHost: "news.ycombinator.com", utmSource: "" }, - { referrerHost: "", utmSource: "newsletter" }, - { referrerHost: "", utmSource: "" }, + { referrerHost: "google.com", utmSource: "", utmMedium: "", utmCampaign: "", lift: 1 }, + { referrerHost: "github.com", utmSource: "", utmMedium: "", utmCampaign: "", lift: 1.2 }, + { referrerHost: "news.ycombinator.com", utmSource: "", utmMedium: "", utmCampaign: "", lift: 0.8 }, + { + referrerHost: "t.co", + utmSource: "twitter", + utmMedium: "social", + utmCampaign: "launch-week", + lift: 0.6, + }, + { referrerHost: "", utmSource: "newsletter", utmMedium: "email", utmCampaign: "november", lift: 1.5 }, + { referrerHost: "", utmSource: "", utmMedium: "", utmCampaign: "", lift: 1 }, ] as const const CLIENTS = [ @@ -26,13 +36,36 @@ const CLIENTS = [ const COUNTRIES = ["US", "US", "DE", "GB", "FR", "IN"] as const -/** Each step reached with this chance once the previous one was. Leavers drift to the side events. */ -const FUNNEL: ReadonlyArray<{ event: string; page: string; chance: number }> = [ +/** + * Each step reached with this chance once the previous one was. Leavers drift to the side events. + * `property` is the one the event carries, with the values it takes. + */ +const FUNNEL: ReadonlyArray<{ + event: string + page: string + chance: number + property?: readonly [key: string, values: readonly [string, ...ReadonlyArray]] +}> = [ { event: "signup_started", page: "/signup", chance: 0.35 }, - { event: "signup_completed", page: "/onboarding", chance: 0.65 }, - { event: "onboarding_connected", page: "/onboarding", chance: 0.55 }, + { + event: "signup_completed", + page: "/onboarding", + chance: 0.65, + property: ["method", ["google", "google", "github", "email"]], + }, + { + event: "onboarding_connected", + page: "/onboarding", + chance: 0.55, + property: ["source", ["sdk", "sdk", "collector", "cloudflare"]], + }, { event: "dashboard_created", page: "/dashboards", chance: 0.6 }, - { event: "plan_started", page: "/billing", chance: 0.2 }, + { + event: "plan_started", + page: "/billing", + chance: 0.2, + property: ["plan", ["startup", "startup", "scale"]], + }, ] const SIDE_EVENTS = [ @@ -80,7 +113,12 @@ export const generateDemoProductRows = (input: { let userId = "" const pages: Array = [] - const push = (kind: "navigation" | "custom", eventName: string, pagePath: string) => { + const push = ( + kind: "navigation" | "custom", + eventName: string, + pagePath: string, + attributes: Readonly> = {}, + ) => { atMs += 5_000 + rand() * 90_000 seq += 1 if (kind === "navigation") pages.push(pagePath) @@ -98,6 +136,7 @@ export const generateDemoProductRows = (input: { page_path: pagePath, url: `https://${HOST}${pagePath}`, service_name: "demo-frontend", + attributes, }) } const visit = (pagePath: string) => push("navigation", "$pageview", pagePath) @@ -105,7 +144,7 @@ export const generateDemoProductRows = (input: { visit("/") if (rand() < 0.5) visit("/pricing") Arr.every(FUNNEL, (step) => { - if (rand() >= step.chance) { + if (rand() >= Math.min(0.95, step.chance * referral.lift)) { if (rand() < 0.6) { const side = pick(rand, SIDE_EVENTS) visit(side.page) @@ -115,7 +154,12 @@ export const generateDemoProductRows = (input: { } visit(step.page) if (step.event === "signup_completed") userId = id("u") - push("custom", step.event, step.page) + push( + "custom", + step.event, + step.page, + step.property === undefined ? {} : { [step.property[0]]: pick(rand, step.property[1]) }, + ) return true }) @@ -146,6 +190,8 @@ export const generateDemoProductRows = (input: { referrer: referral.referrerHost === "" ? "" : `https://${referral.referrerHost}/`, referrer_host: referral.referrerHost, utm_source: referral.utmSource, + utm_medium: referral.utmMedium, + utm_campaign: referral.utmCampaign, host: HOST, entry_path: pages[0] ?? "/", exit_path: pages.at(-1) ?? "/", From ea2c4116ebecd3ff305ccc63d8c52d8a79a05590 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 02:32:17 +0100 Subject: [PATCH 5/8] docs(landing): a Funnels page for product events 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. --- .../content/docs/product-events/funnels.md | 86 +++++++++++++++++++ .../content/docs/product-events/overview.md | 1 + 2 files changed, 87 insertions(+) create mode 100644 apps/landing/src/content/docs/product-events/funnels.md diff --git a/apps/landing/src/content/docs/product-events/funnels.md b/apps/landing/src/content/docs/product-events/funnels.md new file mode 100644 index 0000000000..1ffd67e538 --- /dev/null +++ b/apps/landing/src/content/docs/product-events/funnels.md @@ -0,0 +1,86 @@ +--- +title: "Funnels" +description: "Build a funnel over product events in Maple: pick the steps, count people, visitors, users or sessions, set a conversion window, break it down by channel or event property, filter a step, and read where people drop off and how long it takes." +group: "Product Events" +order: 5 +--- + +A funnel answers one question: of the people who did the first thing, how many went on to do the next, and the next. **Product analytics → Funnels** builds one from the product events you already send. The same funnel can sit on a dashboard as a **Funnel** widget. + +## Prerequisites + +Product events from any source: the [Browser SDK](/docs/session-replay/browser-sdk), a [span you already emit](/docs/product-events/from-traces), or the [Product events API](/docs/product-events/api). Page views and mobile screen views count too. + +## Build a funnel + +1. Open **Product analytics** and pick the **Funnels** tab. +2. Add a step for each thing people do, in order. A funnel takes up to ten steps. +3. Read the result below. The funnel is in the URL, so the link you copy opens the same funnel. + +Each step is one of three kinds: + +| Kind | Matches | +| :------ | :----------------------------------------------------------------------------------------------------- | +| Event | A `track()` call or a server-side event, by name. | +| Page | A page view by path, or a mobile screen view by screen name. A page can be narrowed to one host. | +| Session | Where a session came from: referrer, UTM source, medium or campaign, country or site. First step only. | + +A session step is how you start a funnel at a campaign: "arrived from the launch campaign, then signed up, then started a plan". + +### Filter a step by a property + +Turn on **Filter by property** to narrow an event step to events that carry a value: `plan = "startup"`, or `method = "google" AND source = "cli"`. Only `=` and `AND` are supported. The filter runs when you press Enter or stop typing. + +The same event can be two steps with different filters, for example `plan_started` with `plan = "startup"` and later `plan_started` with `plan = "scale"`. + +### Repeat a step + +List the same event twice to ask who did it twice. `product_viewed`, `product_viewed`, `add_to_cart` counts people who looked at two products before adding one to the cart. One event never counts for two steps. + +## What a funnel counts + +**Count by** picks who is counted: + +| Count by | Counts | +| :------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| Person | The user id when the event has one, else the visitor. An anonymous visit and the signed-in activity of the same browser are one person. | +| Visitor | The anonymous browser or device id. | +| User | Identified users only. | +| Session | Each browser session on its own. Server-side events have no session and take no part. | + +**Within** is the conversion window: every step has to happen within that long after the first step. Pick a window that fits the funnel. A checkout fits an hour. Signup to a paid plan needs days. + +A person can try more than once. If their first try runs out of time and a later one gets further, the later one is counted. Events in the same millisecond keep the order they were recorded in. + +## Read the result + +- **Entered** and **Conversion** are compared with the same length of time before the selected range. +- **Time to convert** is the median time from the first step to the last, for the people who finished, and the time 90% of them finished within. +- **Biggest drop** names the step that loses the largest share of the people who reached the one before it. +- **Drop-off** draws a column per step: who reached it, and above it who left since the previous step. Hover a column for the time between steps and what the leavers did next. **Bars** draws one bar per step. +- The **Steps** table lists, per step, the count, the share of everyone who entered, the share of the previous step, how many dropped, the median and p90 time from the previous step, and the three most common things leavers did next. + +"Leavers did next" is the first event of a person after the last step they reached. "Nothing after" means they sent no further event in the selected range. + +## Break it down + +**Break down by** splits the funnel into groups and shows each group's conversion next to everyone's: + +- where the session came from: referrer, UTM source, medium, campaign, country or site; +- an event property your events carry, such as `plan` or `method`. + +A person is counted under the first value seen for them in the range. People with no value are grouped as `(none)`. The table lists up to 20 groups, the ones most people entered first. + +To look at one group only, use the filters on the left. They narrow the whole funnel to people with a matching session. + +## Server-side and mobile steps + +A step can come from your backend. `plan_started` usually does, because the browser leaves for the payment page. Count by **Person** and send the same `user_id` you pass to `identify()` in the browser, so the server-side step joins the visit that led to it. + +Mobile screen views are page steps: use the screen name as the path. For a funnel that starts before sign-in in a mobile app, count by **Visitor** and send the same `visitor_id` (your install id) on every event, before and after sign-in. Maple links a visitor to a user from browser sessions only. + +## Next steps + +- [Product events overview](/docs/product-events/overview) +- [Product events API](/docs/product-events/api) +- [Build dashboards](/docs/dashboards/build-dashboards) diff --git a/apps/landing/src/content/docs/product-events/overview.md b/apps/landing/src/content/docs/product-events/overview.md index 134b6641c8..7adc050f66 100644 --- a/apps/landing/src/content/docs/product-events/overview.md +++ b/apps/landing/src/content/docs/product-events/overview.md @@ -76,3 +76,4 @@ For `POST /v1/events`, a `200` response with `{"accepted": 1}` means the row was - [Product events from traces](/docs/product-events/from-traces) - [Product events API](/docs/product-events/api) - [Web analytics](/docs/product-events/web-analytics) +- [Funnels](/docs/product-events/funnels) From 9d372f3b35f794635c20441f84c9e876dc7ff3f1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 02:33:33 +0100 Subject: [PATCH 6/8] refactor(demo): the product seed draws from the demo service's Random --- packages/backend/src/services/org/DemoService.ts | 1 + packages/backend/src/services/org/demo/product.ts | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/services/org/DemoService.ts b/packages/backend/src/services/org/DemoService.ts index dd3db8c9c7..6ffc9b5d07 100644 --- a/packages/backend/src/services/org/DemoService.ts +++ b/packages/backend/src/services/org/DemoService.ts @@ -90,6 +90,7 @@ export class DemoService extends Context.Service()( orgId: tenant.orgId, hours: safeHours, visitorsPerHour: DEMO_VISITORS_PER_HOUR, + random: () => random.nextDoubleUnsafe(), }) yield* ingestAll("session_replays", sessionRows) yield* ingestAll("product_events", eventRows) diff --git a/packages/backend/src/services/org/demo/product.ts b/packages/backend/src/services/org/demo/product.ts index 944070c5b0..1ef637d018 100644 --- a/packages/backend/src/services/org/demo/product.ts +++ b/packages/backend/src/services/org/demo/product.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto" import { fmtMetricTs } from "@maple/backend/services/warehouse/metric-rows" -import { Array as Arr, Random } from "effect" +import { Array as Arr } from "effect" // Demo browser sessions and product events: `session_replays` rows (what web analytics and the // funnel population filters read) and the `product_events` page views and track() calls inside @@ -90,11 +90,10 @@ export const generateDemoProductRows = (input: { readonly hours: number readonly visitorsPerHour: number readonly nowMs?: number - /** Draws from the caller's Effect `Random`. Omitted, it is that service's default generator. */ - readonly random?: Rand + /** Draws from the caller's Effect `Random`. */ + readonly random: Rand }): DemoProductRows => { - const fallback = Random.Random.defaultValue() - const rand: Rand = input.random ?? (() => fallback.nextDoubleUnsafe()) + const rand = input.random const nowMs = input.nowMs ?? Date.now() const windowMs = input.hours * 3600_000 const visitors = Math.max(1, Math.round(input.hours * input.visitorsPerHour)) From d1fd6383ac3845434e609570e0895b5a27bc975a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 02:41:40 +0100 Subject: [PATCH 7/8] fix(funnels): tied rows, a view matched by two steps, and leavers with 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. --- apps/cli/test/funnel-scenarios-support.ts | 76 +++++---- apps/cli/test/funnel-scenarios.test.ts | 125 +++++++++++++- docs/product-events-funnels.md | 40 +++-- .../src/__sql_baseline__/catalog.sql | 158 +++++++++--------- .../src/ch/queries/product-events-paths.ts | 6 +- .../src/ch/queries/product-events.test.ts | 40 +++-- .../src/ch/queries/product-events.ts | 107 +++++++----- 7 files changed, 366 insertions(+), 186 deletions(-) diff --git a/apps/cli/test/funnel-scenarios-support.ts b/apps/cli/test/funnel-scenarios-support.ts index 6e2a7fbcff..ade8dac857 100644 --- a/apps/cli/test/funnel-scenarios-support.ts +++ b/apps/cli/test/funnel-scenarios-support.ts @@ -271,10 +271,11 @@ export const sqlLeavers = ( // The reference funnel // -// The intended semantics, independent of the SQL: rows ordered by (timestamp, seq). An attempt -// starts at a step-1 row and takes, for each later step, the first later row that matches and is -// still within the window of the start, so one row serves one step. A person's level is their -// deepest attempt, and their chain is the earliest attempt that gets that deep. +// The intended semantics, independent of the SQL: rows ordered by (millisecond, seq), and rows that +// tie on both in step order. An attempt starts at a step-1 row and takes, for each later step, the +// first later row that matches and is no more than the window after the start, so one row serves +// one step. A person's level is their deepest attempt, and their chain is the earliest attempt +// that gets that deep. type Filters = NonNullable @@ -369,19 +370,33 @@ const stepMatches = (step: CH.FunnelStep, row: SimEvent): boolean => { interface FunnelRow { readonly key: string - /** Microseconds after the base: the order rows are walked in, with `seq` breaking ties. */ - readonly order: number - readonly seq: number readonly atMs: number + readonly seq: number readonly matches: ReadonlyArray readonly name: string readonly dimension: string } -const byOrder: Order.Order = Order.combine( - Order.mapInput(Order.Number, (row: FunnelRow) => row.order), +/** A row matching an earlier step sorts first among rows of one millisecond and seq. */ +const stepRank = (row: FunnelRow): string => + Arr.join( + Arr.map(row.matches, (matched) => (matched ? "0" : "1")), + "", + ) + +const byOrder: Order.Order = Order.combineAll([ + Order.mapInput(Order.Number, (row: FunnelRow) => row.atMs), Order.mapInput(Order.Number, (row: FunnelRow) => row.seq), -) + Order.mapInput(Order.String, stepRank), +]) + +/** A breakdown that reads a session dimension, not an event attribute. */ +const sessionDimensionOf = ( + breakdownBy: CH.FunnelBreakdownBy | undefined, +): CH.FunnelSessionDimension | undefined => + Arr.findFirst(Rec.keys(SESSION_FIELD), (dimension) => dimension === breakdownBy).pipe( + Option.getOrUndefined, + ) export interface PersonResult { readonly key: string @@ -396,7 +411,7 @@ const attemptFrom = ( rows: ReadonlyArray, start: number, stepCount: number, - windowMicro: number, + windowMs: number, ): ReadonlyArray => { const first = rows[start] if (first === undefined) return [] @@ -405,8 +420,7 @@ const attemptFrom = ( for (let step = 1; step < stepCount; step++) { const next = Arr.findFirstIndex( rows, - (row, index) => - index > at && row.matches[step] === true && row.order <= first.order + windowMicro, + (row, index) => index > at && row.matches[step] === true && row.atMs <= first.atMs + windowMs, ) if (Option.isNone(next)) break at = next.value @@ -419,11 +433,11 @@ const attemptFrom = ( const walk = ( rows: ReadonlyArray, stepCount: number, - windowMicro: number, + windowMs: number, ): ReadonlyArray => Arr.reduce(rows, [] as ReadonlyArray, (best, row, index) => { if (row.matches[0] !== true) return best - const attempt = attemptFrom(rows, index, stepCount, windowMicro) + const attempt = attemptFrom(rows, index, stepCount, windowMs) return attempt.length > best.length ? attempt : best }) @@ -438,8 +452,9 @@ const dimensionOfEvent = ( return row.attributes[Str.slice("attribute:".length)(breakdownBy)] ?? "" if (breakdownBy === "host") return row.host const owner = sessionsById.get(row.sessionId) - if (owner === undefined || !inRange(owner.startMs, range)) return "" - return owner[SESSION_FIELD[breakdownBy as CH.FunnelSessionDimension]] + const dimension = sessionDimensionOf(breakdownBy) + if (owner === undefined || dimension === undefined || !inRange(owner.startMs, range)) return "" + return owner[SESSION_FIELD[dimension]] } export const referencePersons = ( @@ -471,9 +486,8 @@ export const referencePersons = ( return [ { key, - order: row.atMs * 1000 + row.micro, - seq: row.seq, atMs: row.atMs, + seq: row.seq, matches, name: displayName(row), dimension: dimensionOfEvent(breakdownBy, row, sessionsById, range), @@ -487,19 +501,15 @@ export const referencePersons = ( const key = keyOf(opts.keyBy, row, links) if (!inRange(row.startMs, range) || !admitted(key)) return [] if (row[SESSION_FIELD[first.dimension]] !== first.value) return [] - const isSessionDimension = - breakdownBy !== undefined && !Str.startsWith("attribute:")(breakdownBy) + const dimension = sessionDimensionOf(breakdownBy) return [ { key, - order: row.startMs * 1000, - seq: 0, atMs: row.startMs, + seq: 0, matches: Arr.map(opts.steps, (_, index) => index === 0), name: "$session_entry", - dimension: isSessionDimension - ? row[SESSION_FIELD[breakdownBy as CH.FunnelSessionDimension]] - : "", + dimension: dimension === undefined ? "" : row[SESSION_FIELD[dimension]], }, ] }) @@ -508,7 +518,7 @@ export const referencePersons = ( const byKey = Arr.groupBy([...entryRows, ...eventRows], (row) => row.key) return Arr.map(Rec.toEntries(byKey), ([key, rows]): PersonResult => { const sorted = Arr.sort(rows, byOrder) - const chain = walk(sorted, opts.steps.length, opts.windowSeconds * 1_000_000) + const chain = walk(sorted, opts.steps.length, opts.windowSeconds * 1_000) return { key, level: chain.length, @@ -601,17 +611,17 @@ export const referenceLeavers = ( const nexts = Arr.flatMap(persons, (person) => { const last = person.chain[person.level - 1] if (last === undefined || person.level >= opts.steps.length) return [] + // The first event after the last step's row; candidates that tie break by name. const later = Arr.sort( Arr.filter( eventsByKey[person.key] ?? [], - (row) => - row.atMs * 1000 + row.micro > last.order || - (row.atMs * 1000 + row.micro === last.order && row.seq > last.seq), + (row) => row.atMs > last.atMs || (row.atMs === last.atMs && row.seq > last.seq), ), - Order.combine( - Order.mapInput(Order.Number, (row: SimEvent) => row.atMs * 1000 + row.micro), + Order.combineAll([ + Order.mapInput(Order.Number, (row: SimEvent) => row.atMs), Order.mapInput(Order.Number, (row: SimEvent) => row.seq), - ), + Order.mapInput(Order.String, displayName), + ]), ) const next = later[0] return [ diff --git a/apps/cli/test/funnel-scenarios.test.ts b/apps/cli/test/funnel-scenarios.test.ts index d4b84302e5..00d086369c 100644 --- a/apps/cli/test/funnel-scenarios.test.ts +++ b/apps/cli/test/funnel-scenarios.test.ts @@ -117,6 +117,29 @@ const CASES = { ]), ], // One account firing the same event four thousand times, then checking out. + // A backend that sends two events in one request: same millisecond, and no `seq` to order them. + tiedServerEvents: [ + ...user("signup_first", [ + [1_000, "signup"], + [1_000, "plan"], + [9_000, "later"], + ]), + ...user("plan_first", [ + [1_000, "plan"], + [1_000, "signup"], + [9_000, "later"], + ]), + ...user("searched_twice", [ + [1_000, "search"], + [1_000, "search"], + [9_000, "later"], + ]), + ], + // A long session: the page view and the event it fires share a millisecond across `seq` 1024. + longSession: user("clicker", [ + [1_000, "cart", 1_023], + [1_000, "pay", 1_024], + ]), heavyRepeats: [ ...Arr.makeBy(4_000, (index) => event({ atMs: index * 1_000, eventName: "search", userId: "crawler", source: "server" }), @@ -149,6 +172,45 @@ const caseDataset = (name: keyof typeof CASES): Dataset => ({ sessions: [], }) +const view = (visitorId: string, atMs: number, seq: number) => + event({ + atMs, + seq, + sessionId: `s_${visitorId}`, + visitorId, + kind: "navigation", + eventName: "$pageview", + pagePath: "/pricing", + host: "acme.dev", + }) + +// One visitor saw the pricing page once, another twice. +const VIEWS: Dataset = { + orgId: "case_views", + sessions: [], + events: [view("once", 1_000, 0), view("twice", 1_000, 0), view("twice", 60_000, 1)], +} + +// Two campaign sessions starting in the same millisecond: one opens with a page view, one sends nothing. +const ENTRIES: Dataset = { + orgId: "case_entries", + sessions: [ + session({ sessionId: "e1", startMs: 5_000, visitorId: "reader", utmSource: "twitter" }), + session({ sessionId: "e2", startMs: 5_000, visitorId: "bouncer", utmSource: "twitter" }), + ], + events: [ + event({ + atMs: 5_000, + sessionId: "e1", + visitorId: "reader", + kind: "navigation", + eventName: "$pageview", + pagePath: "/", + }), + event({ atMs: 8_000, seq: 1, sessionId: "e1", visitorId: "reader", eventName: "clicked_cta" }), + ], +} + // A person who arrives from a campaign, signs up in a second session, and is filtered by country. const ACQUISITION: Dataset = { orgId: "case_acquisition", @@ -229,6 +291,8 @@ describeWithChdb("product event funnels over example products", () => { mobile, content, ACQUISITION, + VIEWS, + ENTRIES, ...Arr.map( [ "repeats", @@ -238,6 +302,8 @@ describeWithChdb("product event funnels over example products", () => { "outOfOrder", "awkwardNames", "heavyRepeats", + "tiedServerEvents", + "longSession", ] as const, caseDataset, ), @@ -288,6 +354,51 @@ describeWithChdb("product event funnels over example products", () => { expect(sqlLeavers(db, "case_restart", opts)).toEqual([[3, "settings_opened", 1]]) }) + test("two events in one millisecond with nothing to order them convert, in either order", () => { + const dataset = caseDataset("tiedServerEvents") + const converted = byUser([ev("signup"), ev("plan")]) + expect(counts(dataset, converted)).toEqual([2, 2]) + expect(sqlTiming(db, dataset.orgId, converted)).toEqual([{ step: 2, ...both(0), ...sinceStart(0) }]) + // Counted as converted, so neither is a leaver of step 2; both went on to `later`. + expect(sqlLeavers(db, dataset.orgId, byUser([ev("signup"), ev("plan"), ev("never")]))).toEqual([ + [3, "later", 2], + ]) + }) + + test("the same event twice in one millisecond is two events, with no time between them", () => { + const dataset = caseDataset("tiedServerEvents") + const twice = byUser([ev("search"), ev("search")]) + expect(counts(dataset, twice)).toEqual([1, 1]) + expect(sqlTiming(db, dataset.orgId, twice)).toEqual([{ step: 2, ...both(0), ...sinceStart(0) }]) + expect(sqlLeavers(db, dataset.orgId, byUser([ev("search"), ev("search"), ev("never")]))).toEqual([ + [3, "later", 1], + ]) + }) + + test("a long session keeps its order inside a millisecond past seq 1024", () => { + expect(counts(caseDataset("longSession"), byUser([ev("cart"), ev("pay")]))).toEqual([1, 1]) + }) + + test("one page view is not both a $pageview event step and a page step", () => { + const byVisitor = (steps: ReadonlyArray): Opts => ({ + steps, + keyBy: "visitor", + windowSeconds: 3_600, + }) + expect(counts(VIEWS, byVisitor([ev("$pageview"), page("/pricing")]))).toEqual([2, 1]) + expect(counts(VIEWS, byVisitor([page("/pricing"), ev("$pageview")]))).toEqual([2, 1]) + }) + + test("a session entry comes before the session's first event, and a visitor who sent nothing is a leaver", () => { + const opts: Opts = { + steps: [{ kind: "session", dimension: "utmSource", value: "twitter" }, page("/")], + keyBy: "visitor", + windowSeconds: 3_600, + } + expect(counts(ENTRIES, opts)).toEqual([2, 1]) + expect(sqlLeavers(db, ENTRIES.orgId, opts)).toEqual([[2, "", 1]]) + }) + test("steps done before the first one do not count", () => { expect(counts(caseDataset("outOfOrder"), byUser([ev("start"), ev("middle"), ev("finish")]))).toEqual([ 2, 1, 1, @@ -675,19 +786,19 @@ function randomDatasets( return Arr.makeBy(count, (index) => { const orgId = `random_${index}` const people = 2 + rng.int(4) - const used = new Set() const events = Arr.flatMap(Arr.range(1, people), (person) => { const userId = rng.chance(0.7) ? `u${person}` : "" const visitorId = userId === "" || rng.chance(0.5) ? `v${person}` : "" const sessionId = visitorId === "" ? "" : `s${person}` - return Arr.makeBy(1 + rng.int(9), (seq) => { - // A distinct millisecond per row: ties are the hand-built cases' job. - let atMs = rng.int(4 * HOUR_MS) - while (used.has(atMs)) atMs += 1 - used.add(atMs) + let previousMs = -1 + return Arr.makeBy(1 + rng.int(9), (index) => { + // One row in five lands in the millisecond of the one before it, and a server row + // has no seq, so some rows tie outright. + const atMs = previousMs >= 0 && rng.chance(0.2) ? previousMs : rng.int(4 * HOUR_MS) + previousMs = atMs return event({ atMs, - seq, + seq: sessionId === "" ? 0 : index, userId, visitorId, sessionId, diff --git a/docs/product-events-funnels.md b/docs/product-events-funnels.md index 94343f4467..e020b0f5a2 100644 --- a/docs/product-events-funnels.md +++ b/docs/product-events-funnels.md @@ -494,30 +494,40 @@ ClickHouse. ## How a funnel is counted (2026-11-14) Every funnel query orders a person's rows by one key, `orderKey` in -`packages/query-engine/src/ch/queries/product-events.ts`: microseconds times 1024, plus -`Seq % 1024`. `Seq` is what tells a page load from the `track()` call it fires in the same -millisecond; server rows carry `Seq` 0 and order by their own clock. Before this key, counts -ordered by millisecond only, so two rows in one millisecond converted in either order. - +`packages/query-engine/src/ch/queries/product-events.ts`: milliseconds times 2^20, plus +`Seq % 2^20`. `Seq` is what tells a page load from the `track()` call it fires in the same +millisecond. Before this key, counts ordered by millisecond only, so two rows in one millisecond +converted in either order. + +- **A tie** is two rows with the same millisecond and `Seq`. Direct-ingested rows always carry + `Seq` 0, so a backend that sends two events in one request ties them. Tied rows are read in step + order, so they convert. A session entry ties with its session's first event the same way and + comes first. - **An attempt** starts at a step-1 row and takes, for each later step, the first later row that - matches and is still inside the window of the start. One row serves one step. + matches and is no more than the window after the start. One row serves one step. +- **The window** is at most 366 days, the year `product_events` keeps plus a day. - **The level** is the person's deepest attempt. That is `windowFunnel` whenever no row can match - two steps. A funnel that repeats a step (`product_viewed`, `product_viewed`, `add_to_cart`) is - read from `walkQuery` instead: plain `windowFunnel` lets one row satisfy both repeats, and its - `strict_once` mode, which forbids that, took 31 s on 72,000 rows where the walk takes 26 ms. + two steps. Two steps can share a row when the funnel repeats a step (`product_viewed`, + `product_viewed`, `add_to_cart`), or when an event step on a reserved name sits beside a page + step (a page view is stored as a `$pageview` event). Those funnels are read from `walkQuery` + instead: plain `windowFunnel` lets one row satisfy both steps, and its `strict_once` mode, which + forbids that, took 31 s on 72,000 rows where the walk takes 26 ms. - **The chain** is the earliest attempt that gets that deep. A first try that ran out of time gives way to the later one that converted. Time between steps and "where the leavers went" are both - read from the chain, so a funnel never reports a time for people it did not count. This replaced - a walk from the person's first step-1 event, which dropped everyone whose first attempt expired - and returned nothing at all when step 1 was a session step. + read from the chain, by position in the person's rows as the walk counts them, so a funnel + never reports a time for people it did not count. This replaced a walk from the person's first + step-1 event, which dropped everyone whose first attempt expired and returned nothing at all + when step 1 was a session step. +- **A leaver** is anyone who stopped short, including a visitor whose session matched step 1 and + who sent no event at all: they count under "nothing after". - **A page step** matches a browser page view or a mobile screen view (`Kind` `navigation` or `screen`); both are named by `PagePath`. Paths and leavers name a screen the same way. `apps/cli/test/funnel-scenarios.test.ts` holds this definition as a plain TypeScript reference funnel and runs the compiled SQL against it on chDB: four example products (a SaaS signup with server-side steps, a shop checkout with restarts, a mobile onboarding, a two-host content site), -hand-built edge cases and random histories. It needs no Docker; run it with +hand-built edge cases and random histories with tied rows. It needs no Docker; run it with `bun test test/funnel-scenarios.test.ts` in `apps/cli`. -On 2.9M rows and 200,000 people (chDB, five steps): counts 30 ms, time between steps 80 ms (118 ms -before), leavers 122 ms (133 ms before), a funnel with a repeated step 45 ms. +On 2.9M rows and 200,000 people (chDB, five steps): counts 30 ms, time between steps 100 ms (118 ms +before), leavers 155 ms (133 ms before), a funnel with a repeated step 41 ms. diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 170740d6e7..2ea5c952d4 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -4655,7 +4655,7 @@ SELECT LIMIT 50 FORMAT JSON --- builder:product-events:productEventsFunnelBreakdownQuery:attribute-session-step [b0b5b3fb] +-- builder:product-events:productEventsFunnelBreakdownQuery:attribute-session-step [df456bc1] SELECT groups.group AS group, arrayJoin([1, 2, 3, 4]) AS step, @@ -4666,12 +4666,12 @@ SELECT countIf(level >= 1) AS entered FROM (SELECT funnel_events.key AS key, - windowFunnel(619315200000000)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level, + windowFunnel(634178765848575)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level, argMinIf(funnel_events.dim, funnel_events.ord, funnel_events.dim != '') AS group FROM ( SELECT s.UserId AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(s.StartTime)), 1024), modulo(0, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(s.StartTime)), 1048576), modulo(0, 1048576)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, @@ -4686,7 +4686,7 @@ SELECT UNION ALL SELECT e.UserId AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, 0 AS s1, toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, toUInt8(e.EventName = 'signup_completed') AS s3, @@ -4706,7 +4706,7 @@ SELECT ORDER BY group ASC, step ASC FORMAT JSON --- builder:product-events:productEventsFunnelBreakdownQuery:session-dimension [99914415] +-- builder:product-events:productEventsFunnelBreakdownQuery:session-dimension [829aeae4] SELECT groups.group AS group, arrayJoin([1, 2, 3]) AS step, @@ -4717,11 +4717,11 @@ SELECT countIf(level >= 1) AS entered FROM (SELECT funnel_events.key AS key, - windowFunnel(619315200000000)(ord, s1 = 1, s2 = 1, s3 = 1) AS level, + windowFunnel(634178765848575)(ord, s1 = 1, s2 = 1, s3 = 1) AS level, argMinIf(funnel_events.dim, funnel_events.ord, funnel_events.dim != '') AS group FROM (SELECT multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3, @@ -4758,7 +4758,7 @@ SELECT ORDER BY group ASC, step ASC FORMAT JSON --- builder:product-events:productEventsFunnelLeaversQuery:visitor-filtered [7dc23b3d] +-- builder:product-events:productEventsFunnelLeaversQuery:visitor-filtered [e3739cab] SELECT hops.step AS step, tupleElement(hop, 1) AS next, @@ -4774,17 +4774,49 @@ SELECT nexts.next AS next, count() AS count FROM (SELECT - e.key AS key, + d.key AS key, d.step AS step, - argMinIf(e.name, e.ord, e.ord > d.lastOrd) AS next + argMinIf(coalesce(e.name, ''), tuple(coalesce(e.ord, 0), coalesce(e.name, '')), coalesce(e.ord, 0) > d.lastOrd) AS next + FROM (SELECT + chain.key AS key, + chain.level + 1 AS step, + arrayElement([o1, o2, o3], level) AS lastOrd + FROM (SELECT + walked.key AS key, + walked.level AS level, + if(level >= 1, arrayFirstIndex(x -> x.2 = 1 AND x.1 + 634178765848575 >= reachedAt, evs), 0) AS i1, + if(i1 > 0, tupleElement(arrayElement(evs, i1), 1), toUInt64(0)) AS o1, + if(level >= 2, arrayFirstIndex((x, i) -> x.3 = 1 AND i > i1, evs, arrayEnumerate(evs)), 0) AS i2, + if(i2 > 0, tupleElement(arrayElement(evs, i2), 1), toUInt64(0)) AS o2, + if(level >= 3, arrayFirstIndex((x, i) -> x.4 = 1 AND i > i2, evs, arrayEnumerate(evs)), 0) AS i3, + if(i3 > 0, tupleElement(arrayElement(evs, i3), 1), toUInt64(0)) AS o3 + FROM (SELECT + person_events.key AS key, + person_events.evs AS evs, + arrayFill(v -> v > 0, arrayMap(x -> if(x.2 = 1, x.1, toUInt64(0)), evs)) AS a1, + arrayMap((x, p) -> if(x.3 = 1 AND p > 0 AND x.1 <= p + 634178765848575, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a1), toUInt64(0))) AS b2, + arrayFill(v -> v > 0, b2) AS a2, + arrayMap((x, p) -> if(x.4 = 1 AND p > 0 AND x.1 <= p + 634178765848575, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a2), toUInt64(0))) AS b3, + arrayFill(v -> v > 0, b3) AS a3, + arrayFirst(v -> v > 0, a1) AS f1, + tupleElement(arrayFirst((x, b) -> b > 0, evs, b2), 1) AS f2, + tupleElement(arrayFirst((x, b) -> b > 0, evs, b3), 1) AS f3, + toUInt8((f1 > 0) + (f2 > 0) + (f3 > 0)) AS level, + greatest(f1, f2, f3) AS reachedAt + FROM (SELECT + funnel_events.key AS key, + arraySort(x -> (x.1, 1 - x.2, 1 - x.3, 1 - x.4), groupArray(tuple(ord, s1, s2, s3))) AS evs FROM (SELECT e.VisitorId AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, - if((e.Kind IN ('navigation', 'screen') AND e.PagePath != ''), e.PagePath, e.EventName) AS name + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, + toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, + toUInt8(e.EventName = 'signup_completed') AS s2, + toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 FROM product_events AS e WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' + AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND e.VisitorId != '' AND e.VisitorId IN (SELECT session_replays.VisitorId AS key @@ -4821,44 +4853,18 @@ SELECT AND product_events.Kind = 'custom' AND product_events.EventName = 'signup_started' GROUP BY sessionId) - GROUP BY key)) AS e - INNER JOIN (SELECT - chain.key AS key, - chain.level + 1 AS step, - arrayElement([o1, o2, o3], level) AS lastOrd - FROM (SELECT - walked.key AS key, - walked.level AS level, - if(level >= 1, tupleElement(arrayFirst(x -> x.2 = 1 AND x.1 + 619315200000000 >= reachedAt, evs), 1), toUInt64(0)) AS o1, - if(level >= 2, tupleElement(arrayFirst(x -> x.3 = 1 AND x.1 > o1, evs), 1), toUInt64(0)) AS o2, - if(level >= 3, tupleElement(arrayFirst(x -> x.4 = 1 AND x.1 > o2, evs), 1), toUInt64(0)) AS o3 - FROM (SELECT - person_events.key AS key, - person_events.evs AS evs, - arrayFill(v -> v > 0, arrayMap(x -> if(x.2 = 1, x.1, toUInt64(0)), evs)) AS a1, - arrayMap((x, p) -> if(x.3 = 1 AND p > 0 AND x.1 <= p + 619315200000000, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a1), toUInt64(0))) AS b2, - arrayFill(v -> v > 0, b2) AS a2, - arrayMap((x, p) -> if(x.4 = 1 AND p > 0 AND x.1 <= p + 619315200000000, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a2), toUInt64(0))) AS b3, - arrayFill(v -> v > 0, b3) AS a3, - arrayFirst(v -> v > 0, a1) AS f1, - tupleElement(arrayFirst((x, b) -> b > 0, evs, b2), 1) AS f2, - tupleElement(arrayFirst((x, b) -> b > 0, evs, b3), 1) AS f3, - (f1 > 0) + (f2 > 0) + (f3 > 0) AS level, - greatest(f1, f2, f3) AS reachedAt - FROM (SELECT - funnel_events.key AS key, - arraySort(groupArray(tuple(ord, s1, s2, s3))) AS evs - FROM (SELECT + GROUP BY key)) AS funnel_events + GROUP BY key) AS person_events) AS walked) AS chain + WHERE chain.level >= 1 + AND chain.level < 3) AS d + LEFT JOIN (SELECT e.VisitorId AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, - toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, - toUInt8(e.EventName = 'signup_completed') AS s2, - toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, + if((e.Kind IN ('navigation', 'screen') AND e.PagePath != ''), e.PagePath, e.EventName) AS name FROM product_events AS e WHERE e.OrgId = 'org_sql_catalog' AND e.Timestamp >= '2026-01-01 10:30:00' AND e.Timestamp <= '2026-01-03 14:15:00' - AND (((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev') OR e.EventName = 'signup_completed' OR (e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AND e.VisitorId != '' AND e.VisitorId IN (SELECT session_replays.VisitorId AS key @@ -4895,17 +4901,14 @@ SELECT AND product_events.Kind = 'custom' AND product_events.EventName = 'signup_started' GROUP BY sessionId) - GROUP BY key)) AS funnel_events - GROUP BY key) AS person_events) AS walked) AS chain - WHERE chain.level >= 1 - AND chain.level < 3) AS d ON e.key = d.key + GROUP BY key)) AS e ON d.key = e.key GROUP BY key, step) AS nexts GROUP BY step, next) AS counted GROUP BY step) AS ranked) AS hops ORDER BY step ASC, count DESC, next ASC FORMAT JSON --- builder:product-events:productEventsFunnelQuery:person [7616752e] +-- builder:product-events:productEventsFunnelQuery:person [a82e226b] SELECT arrayJoin([1, 2, 3]) AS step, arrayElement(totals.counts, step) AS count @@ -4913,10 +4916,10 @@ SELECT [countIf(level >= 1), countIf(level >= 2), countIf(level >= 3)] AS counts FROM (SELECT funnel_events.key AS key, - windowFunnel(619315200000000)(ord, s1 = 1, s2 = 1, s3 = 1) AS level + windowFunnel(634178765848575)(ord, s1 = 1, s2 = 1, s3 = 1) AS level FROM (SELECT multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 @@ -4941,7 +4944,7 @@ SELECT ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelQuery:session-key [f32e490d] +-- builder:product-events:productEventsFunnelQuery:session-key [7c4906ca] SELECT arrayJoin([1, 2, 3]) AS step, arrayElement(totals.counts, step) AS count @@ -4949,10 +4952,10 @@ SELECT [countIf(level >= 1), countIf(level >= 2), countIf(level >= 3)] AS counts FROM (SELECT funnel_events.key AS key, - windowFunnel(1843200000000)(ord, s1 = 1, s2 = 1, s3 = 1) AS level + windowFunnel(1887437848575)(ord, s1 = 1, s2 = 1, s3 = 1) AS level FROM (SELECT e.SessionId AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 @@ -4966,7 +4969,7 @@ SELECT ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelQuery:session-step-filtered [51fcc352] +-- builder:product-events:productEventsFunnelQuery:session-step-filtered [03d83cd4] SELECT arrayJoin([1, 2, 3, 4]) AS step, arrayElement(totals.counts, step) AS count @@ -4974,11 +4977,11 @@ SELECT [countIf(level >= 1), countIf(level >= 2), countIf(level >= 3), countIf(level >= 4)] AS counts FROM (SELECT funnel_events.key AS key, - windowFunnel(619315200000000)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level + windowFunnel(634178765848575)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level FROM ( SELECT multiIf(s.UserId != '', s.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), s.VisitorId) AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(s.StartTime)), 1024), modulo(0, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(s.StartTime)), 1048576), modulo(0, 1048576)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, @@ -5050,7 +5053,7 @@ SELECT UNION ALL SELECT multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, 0 AS s1, toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, toUInt8(e.EventName = 'signup_completed') AS s3, @@ -5124,7 +5127,7 @@ SELECT ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelQuery:visitor-session-step [97d46614] +-- builder:product-events:productEventsFunnelQuery:visitor-session-step [36bdf192] SELECT arrayJoin([1, 2, 3, 4]) AS step, arrayElement(totals.counts, step) AS count @@ -5132,11 +5135,11 @@ SELECT [countIf(level >= 1), countIf(level >= 2), countIf(level >= 3), countIf(level >= 4)] AS counts FROM (SELECT funnel_events.key AS key, - windowFunnel(3686400000000)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level + windowFunnel(3774874648575)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level FROM ( SELECT s.VisitorId AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(s.StartTime)), 1024), modulo(0, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(s.StartTime)), 1048576), modulo(0, 1048576)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, @@ -5150,7 +5153,7 @@ SELECT UNION ALL SELECT e.VisitorId AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, 0 AS s1, toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s2, toUInt8(e.EventName = 'signup_completed') AS s3, @@ -5166,7 +5169,7 @@ SELECT ORDER BY step ASC FORMAT JSON --- builder:product-events:productEventsFunnelTimingQuery:person [61465ef0] +-- builder:product-events:productEventsFunnelTimingQuery:person [fa479286] SELECT arrayJoin([2, 3]) AS step, arrayElement(totals.p50s, step) AS p50Ms, @@ -5174,35 +5177,38 @@ SELECT arrayElement(totals.start50s, step) AS sinceStartP50Ms, arrayElement(totals.start90s, step) AS sinceStartP90Ms FROM (SELECT - [0, ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3), 0)] AS p50s, - [0, ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3), 0)] AS p90s, - [0, ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o3, 1024000) - intDiv(o1, 1024000)), level >= 3), 0)] AS start50s, - [0, ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2), 0), ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o3, 1024000) - intDiv(o1, 1024000)), level >= 3), 0)] AS start90s + [0, ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o2, 1048576) - intDiv(o1, 1048576)), level >= 2), 0), ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o3, 1048576) - intDiv(o2, 1048576)), level >= 3), 0)] AS p50s, + [0, ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o2, 1048576) - intDiv(o1, 1048576)), level >= 2), 0), ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o3, 1048576) - intDiv(o2, 1048576)), level >= 3), 0)] AS p90s, + [0, ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o2, 1048576) - intDiv(o1, 1048576)), level >= 2), 0), ifNotFinite(quantileIf(0.5)(toFloat64(intDiv(o3, 1048576) - intDiv(o1, 1048576)), level >= 3), 0)] AS start50s, + [0, ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o2, 1048576) - intDiv(o1, 1048576)), level >= 2), 0), ifNotFinite(quantileIf(0.9)(toFloat64(intDiv(o3, 1048576) - intDiv(o1, 1048576)), level >= 3), 0)] AS start90s FROM (SELECT walked.key AS key, walked.level AS level, - if(level >= 1, tupleElement(arrayFirst(x -> x.2 = 1 AND x.1 + 619315200000000 >= reachedAt, evs), 1), toUInt64(0)) AS o1, - if(level >= 2, tupleElement(arrayFirst(x -> x.3 = 1 AND x.1 > o1, evs), 1), toUInt64(0)) AS o2, - if(level >= 3, tupleElement(arrayFirst(x -> x.4 = 1 AND x.1 > o2, evs), 1), toUInt64(0)) AS o3 + if(level >= 1, arrayFirstIndex(x -> x.2 = 1 AND x.1 + 634178765848575 >= reachedAt, evs), 0) AS i1, + if(i1 > 0, tupleElement(arrayElement(evs, i1), 1), toUInt64(0)) AS o1, + if(level >= 2, arrayFirstIndex((x, i) -> x.3 = 1 AND i > i1, evs, arrayEnumerate(evs)), 0) AS i2, + if(i2 > 0, tupleElement(arrayElement(evs, i2), 1), toUInt64(0)) AS o2, + if(level >= 3, arrayFirstIndex((x, i) -> x.4 = 1 AND i > i2, evs, arrayEnumerate(evs)), 0) AS i3, + if(i3 > 0, tupleElement(arrayElement(evs, i3), 1), toUInt64(0)) AS o3 FROM (SELECT person_events.key AS key, person_events.evs AS evs, arrayFill(v -> v > 0, arrayMap(x -> if(x.2 = 1, x.1, toUInt64(0)), evs)) AS a1, - arrayMap((x, p) -> if(x.3 = 1 AND p > 0 AND x.1 <= p + 619315200000000, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a1), toUInt64(0))) AS b2, + arrayMap((x, p) -> if(x.3 = 1 AND p > 0 AND x.1 <= p + 634178765848575, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a1), toUInt64(0))) AS b2, arrayFill(v -> v > 0, b2) AS a2, - arrayMap((x, p) -> if(x.4 = 1 AND p > 0 AND x.1 <= p + 619315200000000, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a2), toUInt64(0))) AS b3, + arrayMap((x, p) -> if(x.4 = 1 AND p > 0 AND x.1 <= p + 634178765848575, p, toUInt64(0)), evs, arrayPushFront(arrayPopBack(a2), toUInt64(0))) AS b3, arrayFill(v -> v > 0, b3) AS a3, arrayFirst(v -> v > 0, a1) AS f1, tupleElement(arrayFirst((x, b) -> b > 0, evs, b2), 1) AS f2, tupleElement(arrayFirst((x, b) -> b > 0, evs, b3), 1) AS f3, - (f1 > 0) + (f2 > 0) + (f3 > 0) AS level, + toUInt8((f1 > 0) + (f2 > 0) + (f3 > 0)) AS level, greatest(f1, f2, f3) AS reachedAt FROM (SELECT funnel_events.key AS key, - arraySort(groupArray(tuple(ord, s1, s2, s3))) AS evs + arraySort(x -> (x.1, 1 - x.2, 1 - x.3, 1 - x.4), groupArray(tuple(ord, s1, s2, s3))) AS evs FROM (SELECT multiIf(e.UserId != '', e.UserId, coalesce(link.UserId, '') != '', coalesce(link.UserId, ''), e.VisitorId) AS key, - plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, + plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, toUInt8(((e.Kind IN ('navigation', 'screen') AND e.PagePath = '/pricing') AND e.Host = 'maple.dev')) AS s1, toUInt8(e.EventName = 'signup_completed') AS s2, toUInt8((e.EventName = 'plan_started' AND e.Attributes['plan'] = 'startup')) AS s3 diff --git a/packages/query-engine/src/ch/queries/product-events-paths.ts b/packages/query-engine/src/ch/queries/product-events-paths.ts index 013496e033..3bc85f47c7 100644 --- a/packages/query-engine/src/ch/queries/product-events-paths.ts +++ b/packages/query-engine/src/ch/queries/product-events-paths.ts @@ -29,6 +29,7 @@ import { LINK_ALIAS, ProductEventsFunnelError, eventDisplayName, + PAGE_VIEW_KINDS, eventStepCondition, epochMs, flag, @@ -111,14 +112,13 @@ function validate(opts: ProductEventsPathsOpts): ProductEventsFunnelError | unde /** The include filter over a projected `kind` column; `undefined` for "all". Pages cover mobile screens. */ function includeCondition(kind: CH.Expr, include: PathsInclude): CH.Condition | undefined { - const views = ["navigation", "screen"] switch (include) { case "all": return undefined case "events": - return CH.notInList(kind, views) + return CH.notInList(kind, PAGE_VIEW_KINDS) case "pages": - return CH.inList(kind, views) + return CH.inList(kind, PAGE_VIEW_KINDS) } } diff --git a/packages/query-engine/src/ch/queries/product-events.test.ts b/packages/query-engine/src/ch/queries/product-events.test.ts index 67603e58d8..d30274797e 100644 --- a/packages/query-engine/src/ch/queries/product-events.test.ts +++ b/packages/query-engine/src/ch/queries/product-events.test.ts @@ -62,10 +62,11 @@ describe("productEventsFunnelQuery", () => { ), params, ) - // The window is in the order key's unit: 3600 s as microseconds, times 1024. - expect(sql).toContain("windowFunnel(3686400000000)(ord, s1 = 1, s2 = 1, s3 = 1) AS level") + // The window is in the order key's unit: 3600 s as milliseconds times 2^20, to the end of + // its last millisecond. + expect(sql).toContain("windowFunnel(3774874648575)(ord, s1 = 1, s2 = 1, s3 = 1) AS level") expect(sql).toContain( - "plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord", + "plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord", ) expect(sql).toContain("[countIf(level >= 1), countIf(level >= 2), countIf(level >= 3)] AS counts") expect(sql).toContain("arrayJoin([1, 2, 3]) AS step") @@ -150,14 +151,14 @@ describe("productEventsFunnelQuery", () => { ) // The session branch: s1 = 1, every other step 0, at the session's StartTime. expect(oneLine(sql)).toContain( - "SELECT s.VisitorId AS key, plus(multiply(toUInt64(toUnixTimestamp64Micro(s.StartTime)), 1024), modulo(0, 1024)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, 0 AS s4 FROM session_replays AS s", + "SELECT s.VisitorId AS key, plus(multiply(toUInt64(toUnixTimestamp64Milli(s.StartTime)), 1048576), modulo(0, 1048576)) AS ord, 1 AS s1, 0 AS s2, 0 AS s3, 0 AS s4 FROM session_replays AS s", ) expect(sql).toContain("AND s.ReferrerHost = 'news.ycombinator.com'") // The events branch never satisfies the session step. expect(oneLine(sql)).toContain( - "SELECT e.VisitorId AS key, plus(multiply(toUInt64(toUnixTimestamp64Micro(e.Timestamp)), 1024), modulo(e.Seq, 1024)) AS ord, 0 AS s1,", + "SELECT e.VisitorId AS key, plus(multiply(toUInt64(toUnixTimestamp64Milli(e.Timestamp)), 1048576), modulo(e.Seq, 1048576)) AS ord, 0 AS s1,", ) - expect(sql).toContain("windowFunnel(88473600000000)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level") + expect(sql).toContain("windowFunnel(90596967448575)(ord, s1 = 1, s2 = 1, s3 = 1, s4 = 1) AS level") }) it("has no session_replays branch without a session step", () => { @@ -375,16 +376,19 @@ describe("funnel drop-off details", () => { ), params, ) - expect(sql).toContain("arraySort(groupArray(tuple(ord, s1, s2, s3))) AS evs") + // Rows that tie on the order key sort in step order. + expect(sql).toContain( + "arraySort(x -> (x.1, 1 - x.2, 1 - x.3, 1 - x.4), groupArray(tuple(ord, s1, s2, s3))) AS evs", + ) // The first step-1 row still inside the window when the deepest step was first reached. - expect(sql).toContain("arrayFirst(x -> x.2 = 1 AND x.1 + 3686400000000 >= reachedAt, evs)") - // Each later step is the first matching row after the previous step's. - expect(sql).toContain("arrayFirst(x -> x.3 = 1 AND x.1 > o1, evs)") + expect(sql).toContain("arrayFirstIndex(x -> x.2 = 1 AND x.1 + 3774874648575 >= reachedAt, evs)") + // Each later step is the first matching row after the previous step's, by position. + expect(sql).toContain("arrayFirstIndex((x, i) -> x.3 = 1 AND i > i1, evs, arrayEnumerate(evs))") expect(sql).toContain( - "quantileIf(0.5)(toFloat64(intDiv(o2, 1024000) - intDiv(o1, 1024000)), level >= 2)", + "quantileIf(0.5)(toFloat64(intDiv(o2, 1048576) - intDiv(o1, 1048576)), level >= 2)", ) expect(sql).toContain( - "quantileIf(0.9)(toFloat64(intDiv(o3, 1024000) - intDiv(o2, 1024000)), level >= 3)", + "quantileIf(0.9)(toFloat64(intDiv(o3, 1048576) - intDiv(o2, 1048576)), level >= 3)", ) expect(sql).toContain("arrayJoin([2, 3]) AS step") expect(sql).toContain("arrayElement(totals.p50s, step) AS p50Ms") @@ -402,7 +406,7 @@ describe("funnel drop-off details", () => { expect(sql).toContain("arrayElement([o1, o2, o3], level) AS lastOrd") expect(sql).toContain("WHERE chain.level >= 1") expect(sql).toContain("AND chain.level < 3") - expect(sql).toContain("argMinIf(e.name, e.ord, e.ord > d.lastOrd) AS next") + expect(sql).toContain("coalesce(e.ord, 0) > d.lastOrd) AS next") // No identity join on a visitor key, so the branch's columns go unprefixed. expect(sql).toContain( "if((e.Kind IN ('navigation', 'screen') AND e.PagePath != ''), e.PagePath, e.EventName) AS name", @@ -411,6 +415,16 @@ describe("funnel drop-off details", () => { expect(sql).toContain("ORDER BY step ASC, count DESC, next ASC") }) + it("refuses a window longer than the events are kept", () => { + const steps = STEPS + expect( + rejection(productEventsFunnelQuery({ steps, keyBy: "person", windowSeconds: 366 * 86_400 })), + ).toBeUndefined() + expect( + rejection(productEventsFunnelQuery({ steps, keyBy: "person", windowSeconds: 367 * 86_400 })), + ).toMatchObject({ reason: "InvalidWindow" }) + }) + it("need two steps — there is no 'between' with one", () => { const one = [Arr.getUnsafe(STEPS, 0)] expect( diff --git a/packages/query-engine/src/ch/queries/product-events.ts b/packages/query-engine/src/ch/queries/product-events.ts index 4538afa290..070a70a513 100644 --- a/packages/query-engine/src/ch/queries/product-events.ts +++ b/packages/query-engine/src/ch/queries/product-events.ts @@ -55,22 +55,22 @@ export function epochMs(ts: CH.Expr): CH.Expr { return CH.toUInt64(compileFnCall("toUnixTimestamp64Milli", ts)) } -/** How many {@link orderKey} units one millisecond spans. */ -const ORDER_UNITS_PER_MS = 1_024_000 +/** How many {@link orderKey} units one millisecond spans: room for a session's `Seq`. */ +const ORDER_UNITS_PER_MS = 1_048_576 /** - * A row's place in its person's history, as one unsigned integer: microseconds * 1024 + - * (`Seq` % 1024). `Seq` is the tiebreak inside one timestamp (a page load and the `track()` it - * fires share a millisecond), and server rows, which carry `Seq` 0, order by their own clock. - * `windowFunnel` and the chain walk both read this key, so they cannot disagree about order. + * A row's place in its person's history, as one unsigned integer: milliseconds * 2^20 + + * (`Seq` % 2^20). `Seq` is the tiebreak inside a millisecond (a page load and the `track()` it + * fires share one). Server rows carry `Seq` 0, so two of them in one millisecond tie; a tie + * counts in step order. `windowFunnel` and the walk both read this key, so they agree on order. */ function orderKey(ts: CH.Expr, seq: CH.Expr): CH.Expr { - const micros = CH.toUInt64(compileFnCall("toUnixTimestamp64Micro", ts)) + const millis = CH.toUInt64(compileFnCall("toUnixTimestamp64Milli", ts)) return CH.compileTypedFnCall( "plus", T.uint64.schema, - CH.compileTypedFnCall("multiply", T.uint64.schema, micros, CH.lit(1024)), - CH.compileTypedFnCall("modulo", T.uint64.schema, seq, CH.lit(1024)), + CH.compileTypedFnCall("multiply", T.uint64.schema, millis, CH.lit(ORDER_UNITS_PER_MS)), + CH.compileTypedFnCall("modulo", T.uint64.schema, seq, CH.lit(ORDER_UNITS_PER_MS)), ) } @@ -198,6 +198,8 @@ export class ProductEventsFunnelError extends Schema.TaggedError FUNNEL_MAX_WINDOW_SECONDS + ) { return new ProductEventsFunnelError({ reason: "InvalidWindow", - message: `windowSeconds must be a positive number, got ${String(opts.windowSeconds)}`, + message: `windowSeconds must be a positive number up to ${FUNNEL_MAX_WINDOW_SECONDS} (${FUNNEL_MAX_WINDOW_SECONDS / 86_400} days), got ${String(opts.windowSeconds)}`, }) } return undefined @@ -320,11 +326,13 @@ function sessionDimensionColumn($: ReplaysAccessor, dimension: FunnelSessionDime } } -/** A browser page view or a mobile screen view: both are named by `PagePath`. */ +/** The `Kind`s of a view: a browser page view and a mobile screen view, both named by `PagePath`. */ +export const PAGE_VIEW_KINDS = ["navigation", "screen"] + function isPageView( $: EventsAccessor | OpenJoinAccessor, ): CH.Condition { - return CH.inList($.Kind, ["navigation", "screen"]) + return CH.inList($.Kind, PAGE_VIEW_KINDS) } /** The step's predicate over a `product_events` row. `session` steps never match an event row. */ @@ -566,9 +574,15 @@ function funnelSource(plan: FunnelPlan) { : fromQuery(eventsBranch(plan), "funnel_events") } -/** The conversion window in {@link orderKey} units, exact past 2^53. */ +/** + * The conversion window in {@link orderKey} units, exact past 2^53. The last unit of its final + * millisecond is included, so a row that lands on the window's edge counts whatever its `Seq`. + */ const windowUnits = (opts: ProductEventsFunnelOpts): string => - (BigInt(Math.round(opts.windowSeconds * 1000)) * BigInt(ORDER_UNITS_PER_MS)).toString() + ( + BigInt(Math.round(opts.windowSeconds * 1000)) * BigInt(ORDER_UNITS_PER_MS) + + BigInt(ORDER_UNITS_PER_MS - 1) + ).toString() /** What a step matches on, ignoring its attribute and host narrowing: two steps sharing it can match one row. */ const stepSubject = (step: FunnelStep): string => { @@ -582,9 +596,14 @@ const stepSubject = (step: FunnelStep): string => { } } -/** Two steps could be satisfied by the same row, as in a funnel that repeats a step. */ +/** + * Two steps could be satisfied by the same row: a funnel that repeats a step, or an event step + * on a reserved name beside a page step, since a view is stored as a `$pageview` / `$screen` event. + */ const stepsCanShareRow = (steps: ReadonlyArray): boolean => - new Set(Arr.map(steps, stepSubject)).size < steps.length + new Set(Arr.map(steps, stepSubject)).size < steps.length || + (Arr.some(steps, (step) => step.kind === "page") && + Arr.some(steps, (step) => step.kind === "event" && Str.startsWith("$")(step.eventName))) /** * `SELECT key, [, first dim AS group] FROM GROUP BY key`: one row per @@ -642,12 +661,14 @@ function walkQuery(plan: FunnelPlan) { Arr.map(opts.steps, (_, index) => stepColumn(index)), ", ", ) + const tieOrder = Arr.join(["x.1", ...Arr.map(opts.steps, (_, index) => `1 - x.${index + 2}`)], ", ") const perPerson = funnelSource(plan) .select(($) => { const row = { key: $.key as CH.Expr, - // Tuples sort by their first element, the order key. - evs: CH.untypedExpr(`arraySort(groupArray(tuple(ord, ${flags})))`), + // By order key; rows that tie sort in step order, as `windowFunnel` reads them, so a + // session entry comes before the session's first event. + evs: CH.untypedExpr(`arraySort(x -> (${tieOrder}), groupArray(tuple(ord, ${flags})))`), } if (plan.breakdownBy === undefined) return row const dim = $.dim as CH.Expr @@ -681,11 +702,11 @@ function walkQuery(plan: FunnelPlan) { ]), ), level: CH.rawExpr( - Arr.join( + `toUInt8(${Arr.join( Arr.map(firsts, (column) => `(${column} > 0)`), " + ", - ), - T.uint16, + )})`, + T.uint8, ), // Steps are reached in order, so the deepest one's first time is the largest. reachedAt: CH.rawExpr(n === 1 ? "f1" : `greatest(${Arr.join(firsts, ", ")})`, T.uint64), @@ -993,6 +1014,8 @@ export function eventDisplayName( const stepOrderColumn = (index: number) => `o${index + 1}` +const stepIndexColumn = (index: number) => `i${index + 1}` + /** * `SELECT key, level, o1, …, oN`: per person, the funnel level and the order key of each step's * row in their chain (0 for a step the chain did not reach). @@ -1000,7 +1023,8 @@ const stepOrderColumn = (index: number) => `o${index + 1}` * The chain's start is the first step-1 row still inside the window when the deepest step was * first reached ({@link walkQuery}'s `reachedAt`): an earlier start had expired by then, and no * later one is needed. From there each step is the first matching row after the previous one, - * which costs one scan of the person's rows per step. + * by position in the array as the walk counts, so rows that tie on the order key still chain. + * One scan of the person's rows per step. */ function chainQuery(plan: FunnelPlan) { const { opts } = plan @@ -1009,18 +1033,22 @@ function chainQuery(plan: FunnelPlan) { key: $.key, level: $.level, ...Rec.fromEntries( - Arr.map(opts.steps, (_, index) => { + Arr.flatMap(opts.steps, (_, index) => { const step = index + 1 - const row = + const at = stepIndexColumn(index) + const found = step === 1 - ? `x.2 = 1 AND x.1 + ${window} >= reachedAt` - : `x.${step + 1} = 1 AND x.1 > ${stepOrderColumn(index - 1)}` + ? `arrayFirstIndex(x -> x.2 = 1 AND x.1 + ${window} >= reachedAt, evs)` + : `arrayFirstIndex((x, i) -> x.${step + 1} = 1 AND i > ${stepIndexColumn(index - 1)}, evs, arrayEnumerate(evs))` return [ - stepOrderColumn(index), - CH.rawExpr( - `if(level >= ${step}, tupleElement(arrayFirst(x -> ${row}, evs), 1), toUInt64(0))`, - T.uint64, - ), + [at, CH.rawExpr(`if(level >= ${step}, ${found}, 0)`, T.uint32)], + [ + stepOrderColumn(index), + CH.rawExpr( + `if(${at} > 0, tupleElement(arrayElement(evs, ${at}), 1), toUInt64(0))`, + T.uint64, + ), + ], ] }), ), @@ -1142,15 +1170,16 @@ export function productEventsFunnelLeaversQuery( })) .where(($) => [($.level as CH.Expr).gte(1), ($.level as CH.Expr).lt(n)]) - const nexts = fromQuery(personEventsBranch(plan), "e") - .innerJoinQuery(dropped, "d", (e, d) => e.key.eq(d.key)) + // From the dropped persons outward: one with no event at all (a session entry and nothing else) + // still counts, under `''`. Ties on the order key break by name, so the answer does not vary. + const nexts = fromQuery(dropped, "d") + .leftJoinQuery(personEventsBranch(plan), "e", (d, e) => d.key.eq(e.key)) .select(($) => ({ key: $.key, - step: $.d.step as CH.Expr, - next: argMinIf( - $.name as CH.Expr, - $.ord, - ($.ord as CH.Expr).gt($.d.lastOrd as CH.Expr), + step: $.step, + next: CH.rawExpr( + "argMinIf(coalesce(e.name, ''), tuple(coalesce(e.ord, 0), coalesce(e.name, '')), coalesce(e.ord, 0) > d.lastOrd)", + T.string, ), })) .groupBy("key", "step") From a487a8bf1a90104bf8c9f5bf603faa20a9af472f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 03:37:36 +0100 Subject: [PATCH 8/8] fix(web): funnel tab review findings - A step keeps its own filter through a reorder or a removal: each draft carries the last filter that parsed instead of being paired with the URL by position. - The Time to convert tile says nobody converted, and an unreached step shows no time, instead of reading the query's zeros as a span. - No delta against the previous period while that read refetches for a new definition. - The filter lines stay while the last filter is emptied to retype it. - A breakdown group nobody entered is left out, an empty breakdown says so, and a one-step funnel asks for no breakdown and says why. - A row's Funnel and a suggested funnel start clean. - The word or inside a quoted filter value is part of the value. - The Vs everyone delta's hover text names what it compares with. - Only the step filter line is one row tall: the where editor passes a control class through and other editors keep their height. --- .../dashboards/widget/funnel-filters.test.ts | 7 + .../page/dashboards/widget/funnel-filters.ts | 4 +- apps/web/src/page/kit/funnel-steps.ts | 4 +- .../page/kit/query-builder/where-editor.ts | 5 +- .../src/page/product-analytics/definition.ts | 29 ++-- .../product-analytics.scene.test.ts | 133 +++++++++++++++++- apps/web/src/page/product-analytics/update.ts | 36 +++-- .../page/product-analytics/view-funnels.ts | 82 +++++++---- packages/views/src/ui/delta.ts | 6 +- 9 files changed, 253 insertions(+), 53 deletions(-) diff --git a/apps/web/src/page/dashboards/widget/funnel-filters.test.ts b/apps/web/src/page/dashboards/widget/funnel-filters.test.ts index df0a5423ab..ffc91695c4 100644 --- a/apps/web/src/page/dashboards/widget/funnel-filters.test.ts +++ b/apps/web/src/page/dashboards/widget/funnel-filters.test.ts @@ -23,6 +23,13 @@ describe("parseFunnelStepFilter", () => { expect(parseFunnelStepFilter("attr.plan = 'pro'")).toEqual({ ok: true, value: { plan: "pro" } }) }) + it("reads the word or inside a quoted value as part of the value", () => { + expect(parseFunnelStepFilter("label = \"Sign in or sign up\" AND state = 'OR'")).toEqual({ + ok: true, + value: { label: "Sign in or sign up", state: "OR" }, + }) + }) + it("rejects operators the funnel query cannot run, OR, duplicates and empty values", () => { expect(parseFunnelStepFilter('plan != "pro"')).toMatchObject({ ok: false }) expect(parseFunnelStepFilter("plan contains pro")).toMatchObject({ ok: false }) diff --git a/apps/web/src/page/dashboards/widget/funnel-filters.ts b/apps/web/src/page/dashboards/widget/funnel-filters.ts index 723143b2ab..44f6cc4981 100644 --- a/apps/web/src/page/dashboards/widget/funnel-filters.ts +++ b/apps/web/src/page/dashboards/widget/funnel-filters.ts @@ -41,7 +41,9 @@ const EQUALS_CLAUSE = /^([^\s=!<>~]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))$/ function parseEqualsClauses(text: string): FunnelFilterParse> { const trimmed = Str.trim(text) if (trimmed === "") return { ok: true, value: [] } - if (/\bOR\b/i.test(trimmed)) return { ok: false, error: 'only "AND" joins clauses here' } + // Outside quotes only: `label = "sign in or sign up"` is a value. + if (/\bOR\b/i.test(Str.replace(/"[^"]*"|'[^']*'/g, "")(trimmed))) + return { ok: false, error: 'only "AND" joins clauses here' } const clauses: EqualsClause[] = [] for (const part of splitWhereClause(trimmed)) { const match = EQUALS_CLAUSE.exec(part) diff --git a/apps/web/src/page/kit/funnel-steps.ts b/apps/web/src/page/kit/funnel-steps.ts index 2f7feda30f..49a776aa80 100644 --- a/apps/web/src/page/kit/funnel-steps.ts +++ b/apps/web/src/page/kit/funnel-steps.ts @@ -537,7 +537,9 @@ export const stepFilterLine = (h: HtmlBuilder, options: StepFilterLineOpti placeholder: 'plan = "pro" AND source = "cli"', rows: 1, className: "min-w-0 flex-1", - textareaClassName: "min-h-[28px] resize-none text-xs", + textareaClassName: "min-h-[28px] text-xs", + // One line: the control's own minimum is two rows. + controlClassName: "min-h-0 resize-none max-sm:min-h-0", onInput: options.onInput, } return vstack(h, { gap: "0.5", className: "pl-6" }, [ diff --git a/apps/web/src/page/kit/query-builder/where-editor.ts b/apps/web/src/page/kit/query-builder/where-editor.ts index 7682de1bcc..13a776901a 100644 --- a/apps/web/src/page/kit/query-builder/where-editor.ts +++ b/apps/web/src/page/kit/query-builder/where-editor.ts @@ -35,6 +35,8 @@ export type WhereEditorOptions = Readonly< /** The wrapper's, after `relative`. */ className?: string textareaClassName: string + /** The textarea's own, where `textareaClassName` styles its frame: its minimum height, `resize-none`. */ + controlClassName?: string onInput: (value: string) => M } & ( | { /** Enter is swallowed and reported. */ onEnter: M; autocomplete?: undefined } @@ -105,8 +107,7 @@ export const whereEditor = (h: HtmlBuilder, options: WhereEditorOptions options.textareaClassName, isHighlighting && "text-transparent caret-foreground", ), - // One row means one line: without this the control keeps its two-row minimum. - controlClassName: options.rows === 1 ? "min-h-0 resize-none max-sm:min-h-0" : undefined, + controlClassName: options.controlClassName, defaultValue: options.value, attributes: [ h.AriaLabel(options.ariaLabel), diff --git a/apps/web/src/page/product-analytics/definition.ts b/apps/web/src/page/product-analytics/definition.ts index f002791d7e..5c690ba2b0 100644 --- a/apps/web/src/page/product-analytics/definition.ts +++ b/apps/web/src/page/product-analytics/definition.ts @@ -92,7 +92,8 @@ export const funnelCountsParamsOf = (search: Search): ProductEventsFunnelWidgetP export const funnelBreakdownParamsOf = (search: Search): ProductEventsFunnelWidgetParams | null => { const params = funnelCountsParamsOf(search) const breakdownBy = funnelOf(search).breakdownBy - if (params === null || breakdownBy === undefined) return null + // One step has no conversion to compare, so the split is not asked for. + if (params === null || breakdownBy === undefined || params.steps.length < 2) return null return { ...params, breakdownBy, breakdownLimit: FUNNEL_BREAKDOWN_MAX_LIMIT } } @@ -143,19 +144,25 @@ const trimmed = (step: S): S => { } /** - * A draft step as the URL keeps it: the typed filter compiled to `attributeEquals`. A clause still - * being typed does not parse yet, so the step keeps the filter `running` has for the same event. + * A draft whose typed filter parses, with `attributeEquals` set to what it compiles to. One that + * does not parse yet is left alone, so it keeps the last filter that did, wherever the step moves. */ -export const toFunnelStep = (draft: StepDraft, running?: FunnelStep): FunnelStep => { +export const withParsedFilter = (draft: StepDraft): StepDraft => { if (draft.kind !== "event") return draft const compiled = compileFunnelStep(draft) - if (compiled.ok) return compiled.value - const step: FunnelStep = { kind: "event", eventName: draft.eventName } - return running?.kind === "event" && - running.eventName === draft.eventName && - running.attributeEquals !== undefined - ? { ...step, attributeEquals: running.attributeEquals } - : step + if (!compiled.ok || compiled.value.kind !== "event") return draft + const { attributeEquals: _stale, ...rest } = draft + return compiled.value.attributeEquals === undefined + ? rest + : { ...rest, attributeEquals: compiled.value.attributeEquals } +} + +/** A draft step as the URL keeps it: the event and the last filter that parsed, no typed text. */ +export const toFunnelStep = (draft: StepDraft): FunnelStep => { + const parsed = withParsedFilter(draft) + if (parsed.kind !== "event") return parsed + const { filterClause: _clause, ...step } = parsed + return step } /** Whether any step narrows its event by a property. */ diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index c10724b2f6..4ca795160f 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -1,7 +1,7 @@ import { OrgId } from "@maple/domain" import { Array as Arr, Option, Result, Schema } from "effect" import { fromString } from "foldkit/url" -import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" +import { expect as expectView, given, Mount, scene, selector, text, withViewInputs } from "foldkit/scene" import * as Chart from "@maple/views/charts/chart" import * as Select from "@maple/views/ui/select" import { MeasureChartContainer } from "@maple/views/charts/chart-mounts" @@ -360,7 +360,20 @@ describe("product analytics", () => { { name: "signup_started", value: 1_000 }, { name: "signup_completed", value: 500 }, ]) - scene({ update: pageUpdate, view }, given(compared), measured, expectView(text("11.5 pts")).toExist()) + scene( + { update: pageUpdate, view }, + given(compared), + measured, + expectView(selector('[title="Up 11.5 pts vs the previous period"]')).toExist(), + ) + // While the previous period refetches for a new definition, the old rows are not compared. + const refetching = pageUpdate(compared, Message.ClickedRetry({ slot: "previousFunnel" })).model + scene( + { update: pageUpdate, view }, + given(refetching), + measured, + expectView(selector('[title="Up 11.5 pts vs the previous period"]')).not.toExist(), + ) }) test("a breakdown splits the funnel into up to twenty groups and compares each with everyone", () => { @@ -386,8 +399,62 @@ describe("product analytics", () => { expectView(text("By UTM source")).toExist(), // The newsletter converts 75% against 61.5% overall; google 54.8%. expectView(text("75%")).toExist(), - expectView(text("13.5 pts")).toExist(), - expectView(text("6.7 pts")).toExist(), + expectView(selector('[title="Up 13.5 pts vs everyone in this window"]')).toExist(), + expectView(selector('[title="Down 6.7 pts vs everyone in this window"]')).toExist(), + ) + }) + + test("a group nobody entered is left out, and a breakdown with no groups says so", () => { + const model = settled( + resolved({ tab: "funnels", steps: SIGNUP, breakdown: "utmSource" }).model, + "funnel", + FUNNEL, + ) + const lateOnly = [ + { name: "signup_started", value: 0, group: "partner" }, + { name: "signup_completed", value: 4, group: "partner" }, + ] + scene( + { update: pageUpdate, view }, + given(settled(model, "funnelBreakdown", lateOnly)), + measured, + expectView(text("partner")).not.toExist(), + expectView(text("Nobody who entered has a UTM source in this window.")).toExist(), + ) + }) + + test("a one-step funnel asks for no breakdown and says a second step is needed", () => { + const one: Search["steps"] = [{ kind: "event", eventName: "signup_started" }] + const { commands, model } = resolved({ tab: "funnels", steps: one, breakdown: "country" }) + expect( + Arr.filter(commands ?? [], (command) => command.args?.slot === "funnelBreakdown"), + ).toHaveLength(0) + scene( + { update: pageUpdate, view }, + given(settled(model, "funnel", [{ name: "signup_started", value: 1_206 }])), + measured, + expectView(text("Add a second step to compare how each group converts.")).toExist(), + ) + }) + + test("a funnel nobody finished says so instead of showing a time", () => { + const unfinished: ReadonlyArray = [ + { name: "signup_started", value: 1_206 }, + { + name: "signup_completed", + value: 0, + p50Ms: 0, + p90Ms: 0, + sinceStartP50Ms: 0, + sinceStartP90Ms: 0, + }, + ] + scene( + { update: pageUpdate, view }, + given(settled(resolved({ tab: "funnels", steps: SIGNUP }).model, "funnel", unfinished)), + measured, + expectView(text("nobody converted")).toExist(), + expectView(text("under 1s")).not.toExist(), ) }) @@ -430,6 +497,64 @@ describe("product analytics", () => { ) }) + test("a step keeps its own filter when another step is removed while its clause is half typed", () => { + const twoFilters: Search["steps"] = [ + { kind: "event", eventName: "page_viewed", attributeEquals: { page: "pricing" } }, + { kind: "event", eventName: "page_viewed", attributeEquals: { page: "checkout" } }, + ] + const halfTyped = pageUpdate( + resolved({ tab: "funnels", steps: twoFilters }).model, + Message.ChangedStep({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 1, field: "filterClause", value: "page = " }), + }), + ).model + const removed = pageUpdate( + halfTyped, + Message.ChangedStep({ scope: "funnel", edit: StepEdit.Removed({ index: 0 }) }), + ) + expect(searchOf(removed.outMessage).steps).toEqual([ + { kind: "event", eventName: "page_viewed", attributeEquals: { page: "checkout" } }, + ]) + }) + + test("emptying a filter to retype it keeps the filter lines", () => { + const filtered: Search["steps"] = [ + { kind: "event", eventName: "signup_started" }, + { kind: "event", eventName: "signup_completed", attributeEquals: { method: "google" } }, + ] + const emptied = pageUpdate( + resolved({ tab: "funnels", steps: filtered }).model, + Message.ChangedStep({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 1, field: "filterClause", value: "" }), + }), + ).model + scene( + { update: pageUpdate, view }, + given(emptied), + expectView(selector('[aria-label="Step 2 attribute filter"]')).toExist(), + ) + }) + + test("an Events row's Funnel starts clean, even on the event being edited", () => { + const halfTyped = pageUpdate( + pageUpdate( + resolved({ tab: "funnels", steps: [{ kind: "event", eventName: "signup_started" }] }).model, + Message.ToggledStepFilters(), + ).model, + Message.ChangedStep({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 0, field: "filterClause", value: "plan =" }), + }), + ).model + const opened = pageUpdate( + halfTyped, + Message.ClickedStartFunnel({ step: { kind: "event", eventName: "signup_started" } }), + ) + expect(opened.model.steps).toEqual([{ kind: "event", eventName: "signup_started" }]) + }) + test("a shared funnel with a filtered step opens with its filter line, and the toggle clears it", () => { const filtered: Search["steps"] = [ { kind: "event", eventName: "signup_started" }, diff --git a/apps/web/src/page/product-analytics/update.ts b/apps/web/src/page/product-analytics/update.ts index 5ee674eebd..799c4a924e 100644 --- a/apps/web/src/page/product-analytics/update.ts +++ b/apps/web/src/page/product-analytics/update.ts @@ -24,7 +24,15 @@ import type { PresetValue } from "../kit/time-controls" import * as TimeRangePicker from "../kit/time-range-picker" import { CompleteReloadSpin, ResolveWindow, WaitForTypingPause } from "./command" import { draftFromFunnelStep } from "../dashboards/widget/funnel-filters" -import { anchorNameOf, hasStepFilter, pathsOf, type Search, toAnchor, toFunnelStep } from "./definition" +import { + anchorNameOf, + hasStepFilter, + pathsOf, + type Search, + toAnchor, + toFunnelStep, + withParsedFilter, +} from "./definition" import { FilterKey, Message, type RequestMode, SlotName } from "./message" import { AttributeKeyRow, EventRow, FunnelGroupRow, FunnelRow, type Model, PathsRow } from "./model" import { requestSlots } from "./requests" @@ -78,9 +86,10 @@ const searchHref = (search: Search) => hrefOf(AppRoute.ProductAnalytics({ search * Goes to `search`. A new definition (a row's Funnel) replaces the drafts; the drafts' own (a blank * Page anchor the URL leaves out) keeps them. Either way a pending typing pause is cancelled. */ -const navigate = (model: Model, search: Search, isReplace = false): Return => { +const navigate = (model: Model, search: Search, isReplace = false, isNewDefinition = false): Return => { const committed = committedOf(search) - const drafts = committed === committedOf(withDrafts(model)) ? { committed } : draftsOf(search) + const drafts = + !isNewDefinition && committed === committedOf(withDrafts(model)) ? { committed } : draftsOf(search) return { model: { ...model, @@ -111,7 +120,7 @@ const draftsOf = (search: Search): Pick /** The search with the drafts written in, so a tab or filter change carries what is being typed. */ export const withDrafts = (model: Model): Search => { - const steps = Arr.map(model.steps, (draft, index) => toFunnelStep(draft, model.search.steps?.[index])) + const steps = Arr.map(model.steps, toFunnelStep) const anchor = toAnchor(model.anchor) return { ...model.search, @@ -125,7 +134,10 @@ const commit = (model: Model): Return => navigate(model, withDrafts(model), true const applyEdit = (model: Model, scope: StepScope, edit: StepEdit): Model => scope === "funnel" - ? { ...model, steps: applyStepEdit(model.steps, edit, FUNNEL_MAX_STEPS) } + ? { + ...model, + steps: Arr.map(applyStepEdit(model.steps, edit, FUNNEL_MAX_STEPS), withParsedFilter), + } : { ...model, anchor: applyAnchorEdit(model.anchor, edit) } /** A typed value waits for a pause before it runs; anything picked runs now. */ @@ -183,7 +195,8 @@ export const init = (route: { readonly search: Search }, context: PageContext): attributeKeys: Keyed.idle(), paths: Keyed.idle(), ...draftsOf(route.search), - showsStepFilters: false, + // A funnel that arrives with a filter shows its lines, and keeps them while one is retyped. + showsStepFilters: hasStepFilter(Arr.map(route.search.steps ?? [], draftFromFunnelStep)), typingVersion: 0, navigatedTo: null, stepCombos: {}, @@ -225,7 +238,11 @@ export const routeChanged = ( : isOwn ? {} : { typingVersion } - const next: Model = { ...model, ...drafts, search: route.search } + const withDefinition: Model = { ...model, ...drafts, search: route.search } + const next: Model = { + ...withDefinition, + showsStepFilters: withDefinition.showsStepFilters || hasStepFilter(withDefinition.steps), + } const isAhead = !isNewDefinition && !isOwn && committedOf(withDrafts(next)) !== committedOf(route.search) const step = routeWindowStep(model.search, route.search, { resolve: (): Return => ({ @@ -291,11 +308,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re ChangedEventsQuery: ({ value }) => ({ model: { ...model, eventsQuery: value } }), PickedEventsKind: ({ kind }) => ({ model: { ...model, eventsKind: kind } }), ClickedClearEventsFilters: () => ({ model: { ...model, eventsQuery: "", eventsKind: "all" } }), + // A row's Funnel and a suggested funnel start over: nothing half-typed carries into them. ClickedStartFunnel: ({ step }) => - navigate(model, { ...withDrafts(model), tab: "funnels", steps: [step] }), + navigate(model, { ...withDrafts(model), tab: "funnels", steps: [step] }, false, true), ClickedStartPaths: ({ anchor }) => navigate(model, { ...withDrafts(model), tab: "paths", anchor }), ClickedSuggestedFunnel: ({ steps }) => - navigate(model, { ...withDrafts(model), tab: "funnels", steps }), + navigate(model, { ...withDrafts(model), tab: "funnels", steps }, false, true), ChangedStep: ({ scope, edit }) => edited(model, scope, edit, edit._tag === "ChangedText"), GotStepComboMessage: ({ id, message: inner }) => { const next = stepComboUpdate(model.stepCombos, id, inner, (m) => diff --git a/apps/web/src/page/product-analytics/view-funnels.ts b/apps/web/src/page/product-analytics/view-funnels.ts index 27cd8380d6..2033789d53 100644 --- a/apps/web/src/page/product-analytics/view-funnels.ts +++ b/apps/web/src/page/product-analytics/view-funnels.ts @@ -287,10 +287,15 @@ const points = (magnitude: number): string => `${(magnitude * 100).toFixed(1)} p /** The same funnel over the window before, once it answered with people in it. */ const previousOf = (model: Model): ReadonlyArray | undefined => { + // A refetch still holds the last definition's rows: people must not be compared with sessions. + if (AsyncData.isRefreshing(model.previousFunnel.data)) return undefined const rows = Option.getOrUndefined(AsyncData.getData(model.previousFunnel.data)) return rows === undefined || (rows[0]?.value ?? 0) === 0 ? undefined : rows } +/** A span people actually took; the query answers 0 both for "instant" and for "nobody". */ +const span = (ms: number): string => (ms < 1_000 ? "under 1s" : fmtSpan(ms)) + const stats = (h: H, model: Model, rows: ReadonlyArray, keyBy: FunnelKeyBy): Html => { const entered = rows[0]?.value ?? 0 const last = rows.at(-1) @@ -323,13 +328,12 @@ const stats = (h: H, model: Model, rows: ReadonlyArray, keyBy: Funnel statRailItem(h, { eyebrow: "Time to convert", eyebrowTooltip: "From the first step to the last, for those who reached the last step.", - value: last?.sinceStartP50Ms === undefined ? "–" : fmtSpan(last.sinceStartP50Ms), - subline: - last?.sinceStartP90Ms === undefined - ? hasSteps - ? "nobody converted" - : "one step" - : `median · 90% within ${fmtSpan(last.sinceStartP90Ms)}`, + value: converted === 0 || last?.sinceStartP50Ms === undefined ? "–" : span(last.sinceStartP50Ms), + subline: !hasSteps + ? "one step" + : converted === 0 || last?.sinceStartP90Ms === undefined + ? "nobody converted" + : `median · 90% within ${span(last.sinceStartP90Ms)}`, }), statRailItem(h, { eyebrow: "Biggest drop", @@ -413,17 +417,18 @@ const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy): H numericTableCell(h, [index === 0 ? "–" : formatNumber(dropped)]), numericTableCell( h, - row.p50Ms === undefined + // Nobody reached the step: the query's zeros are not a time. + row.p50Ms === undefined || row.value === 0 ? ["–"] : [ - fmtSpan(row.p50Ms), + span(row.p50Ms), ...(row.p90Ms === undefined ? [] : [ typography( h, { size: "2xs", tone: "muted", className: "ml-1.5" }, - [`p90 ${fmtSpan(row.p90Ms)}`], + [`p90 ${span(row.p90Ms)}`], ), ]), ], @@ -442,14 +447,24 @@ const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy): H ]) } +/** One muted line inside a panel, where a table would have nothing to list. */ +const panelNote = (h: H, text: string): Html => + typography(h, { size: "xs", tone: "muted", as: "p", className: "px-4 py-3" }, [text]) + type Group = Readonly<{ name: string; entered: number; converted: number }> -/** The grouped rows as one line per group: who entered and who reached the last step. */ +/** + * The grouped rows as one line per group: who entered and who reached the last step. A group + * nobody entered (its people did a later step only) has no conversion to show and is left out. + */ const groupsOf = (rows: ReadonlyArray): ReadonlyArray => - Arr.map(Arr.dedupe(Arr.map(rows, (row) => row.group)), (name) => { - const steps = Arr.filter(rows, (row) => row.group === name) - return { name, entered: steps[0]?.value ?? 0, converted: steps.at(-1)?.value ?? 0 } - }) + Arr.filter( + Arr.map(Arr.dedupe(Arr.map(rows, (row) => row.group)), (name) => { + const steps = Arr.filter(rows, (row) => row.group === name) + return { name, entered: steps[0]?.value ?? 0, converted: steps.at(-1)?.value ?? 0 } + }), + (group) => group.entered > 0, + ) const breakdownTable = ( h: H, @@ -495,7 +510,11 @@ const breakdownTable = ( ]), ]), numericTableCell(h, [ - delta(h, { ratio: conversion - inputs.overall, format: points }), + delta(h, { + ratio: conversion - inputs.overall, + format: points, + comparedWith: "everyone in this window", + }), ]), ], undefined, @@ -531,7 +550,12 @@ const breakdown = ( title: "Couldn't split the funnel", onRetry: Message.ClickedRetry({ slot: "funnelBreakdown" }), }), - onData: (rows) => breakdownTable(h, groupsOf(rows), { label, ...inputs }), + onData: (rows) => { + const groups = groupsOf(rows) + return groups.length === 0 + ? panelNote(h, `Nobody who entered has a ${label} in this window.`) + : breakdownTable(h, groups, { label, ...inputs }) + }, }), ]) } @@ -567,14 +591,24 @@ const results = (h: H, model: Model): Html => }), panelChartBody(h, [chart(h, model, rows, isDropoff)]), ]), - ...(breakdownBy === undefined || rows.length < 2 + ...(breakdownBy === undefined ? [] - : [ - breakdown(h, model, breakdownBy, { - keyBy, - overall: conversionOf(rows) ?? 0, - }), - ]), + : rows.length < 2 + ? [ + panel(h, {}, [ + panelHeader(h, { title: `By ${breakdownLabel(breakdownBy)}` }), + panelNote( + h, + "Add a second step to compare how each group converts.", + ), + ]), + ] + : [ + breakdown(h, model, breakdownBy, { + keyBy, + overall: conversionOf(rows) ?? 0, + }), + ]), panel(h, {}, [panelHeader(h, { title: "Steps" }), stepsTable(h, rows, keyBy)]), ]) }, diff --git a/packages/views/src/ui/delta.ts b/packages/views/src/ui/delta.ts index 56d9d6fe5a..a2e9de4037 100644 --- a/packages/views/src/ui/delta.ts +++ b/packages/views/src/ui/delta.ts @@ -28,6 +28,8 @@ export interface DeltaOptions { readonly flatThreshold?: number readonly format?: (magnitude: number) => string readonly muteWorse?: boolean + /** What the change is measured against, for the hover text. Default "the previous period". */ + readonly comparedWith?: string readonly className?: string } @@ -54,7 +56,9 @@ export const delta = (h: HtmlBuilder, options: DeltaOptions): Html => { return h.span( [ h.Class(cn(deltaStyles, tone, options.className)), - h.Title(`${flat ? "Flat" : rose ? "Up" : "Down"} ${magnitude} vs the previous period`), + h.Title( + `${flat ? "Flat" : rose ? "Up" : "Down"} ${magnitude} vs ${options.comparedWith ?? "the previous period"}`, + ), h.DataAttribute("slot", "delta"), ], [