diff --git a/apps/api/src/services/warehouse/web-analytics-parity.clickhouse.e2e.test.ts b/apps/api/src/services/warehouse/web-analytics-parity.clickhouse.e2e.test.ts index f50a2d6ae..698c415fc 100644 --- a/apps/api/src/services/warehouse/web-analytics-parity.clickhouse.e2e.test.ts +++ b/apps/api/src/services/warehouse/web-analytics-parity.clickhouse.e2e.test.ts @@ -227,14 +227,23 @@ const seed = async (): Promise => { database, ) - const sessionRows = SEED_SESSIONS.map( - (row) => - `(${quote(ORG_ID)}, ${quote(row.sessionId)}, ${quote(row.startTime)}, ${row.durationMs}, ${row.pageViews}, ${row.version}, ${quote(row.visitorId)}, ${row.visitorIsNew}, ${quote(row.referrerHost)}, ${quote(row.country)}, ${quote(row.deviceType)}, ${quote(row.browserName)}, ${quote(row.osName)}, ${quote(row.language)}, ${quote(row.utmSource)}, ${quote(row.host)}, ${quote(row.entryPath)}, ${quote(row.exitPath)})`, - ).join(",\n") - await clickhouseExec( - `INSERT INTO session_replays (OrgId, SessionId, StartTime, DurationMs, PageViews, Version, VisitorId, VisitorIsNew, ReferrerHost, Country, DeviceType, BrowserName, OsName, Language, UtmSource, Host, EntryPath, ExitPath) VALUES\n${sessionRows}`, - database, - ) + // One INSERT per version, with merges held off. `optimize_on_insert` is on by + // default, so a single block collapses a ReplacingMergeTree's duplicates + // before they are ever visible — seeding that way leaves this suite asserting + // over pre-deduplicated rows, which is the one state it exists to rule out. + await clickhouseExec(`SYSTEM STOP MERGES session_replays`, database) + for (const version of [...new Set(SEED_SESSIONS.map((row) => row.version))].sort()) { + const sessionRows = SEED_SESSIONS.filter((row) => row.version === version) + .map( + (row) => + `(${quote(ORG_ID)}, ${quote(row.sessionId)}, ${quote(row.startTime)}, ${row.durationMs}, ${row.pageViews}, ${row.version}, ${quote(row.visitorId)}, ${row.visitorIsNew}, ${quote(row.referrerHost)}, ${quote(row.country)}, ${quote(row.deviceType)}, ${quote(row.browserName)}, ${quote(row.osName)}, ${quote(row.language)}, ${quote(row.utmSource)}, ${quote(row.host)}, ${quote(row.entryPath)}, ${quote(row.exitPath)})`, + ) + .join(",\n") + await clickhouseExec( + `INSERT INTO session_replays (OrgId, SessionId, StartTime, DurationMs, PageViews, Version, VisitorId, VisitorIsNew, ReferrerHost, Country, DeviceType, BrowserName, OsName, Language, UtmSource, Host, EntryPath, ExitPath) VALUES\n${sessionRows}`, + database, + ) + } } const runJson = async (sql: string): Promise>> => { @@ -420,6 +429,25 @@ describe.skipIf(!clickhouseE2eEnabled)("web analytics raw-vs-rollup parity", () assert.strictEqual(Number(drift[0]?.n), 0, "write-time URL parsing diverged from read-time") }) + /** + * Parity is an equality between two sources, so it holds just as well when + * both are wrong. `session_replays` is a `ReplacingMergeTree(Version)` and the + * seed's s1/s2 carry an un-merged v1 (`PageViews = 0`) beside a v2 reporting + * two page views: a `uniqIf(SessionId, PageViews <= 1)` matches on the v1 row + * and calls every one of them a bounce. + */ + it("counts a bounce from the session's latest version, not any version", async () => { + const rows = await runJson( + CH.compileUnsafe(CH.webAnalyticsSummaryQuery({ useProductEvents: false }), window).sql, + ) + const row = rows[0] + assert.isDefined(row, "summary returned no rows") + // s1 and s2 ended on two page views; only s3, still on its start row, is a + // bounce. s4 carries no VisitorId and is outside the measured population. + assert.strictEqual(Number(row?.identifiedSessions), 3) + assert.strictEqual(Number(row?.bouncedSessions), 1) + }) + // A `for` loop, not `describe.each`: the latter OOMs tsc in this repo. for (const query of QUERIES) { for (const filterCase of FILTER_CASES) { diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index c07c7bb26..7338e2d27 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -5019,12 +5019,12 @@ SELECT ORDER BY bucket ASC FORMAT JSON --- builder:web-analytics:webAnalyticsSummaryQuery:default [74bf3d64] +-- builder:web-analytics:webAnalyticsSummaryQuery:default [9a0359fc] SELECT uniqIf(VisitorId, VisitorId != '') AS visitors, uniq(SessionId) AS sessions, uniqIf(SessionId, VisitorIsNew = 1) AS newSessions, - uniqIf(SessionId, (PageViews <= 1 AND VisitorId != '')) AS bouncedSessions, + uniqIf(SessionId, VisitorId != '') - uniqIf(SessionId, (PageViews > 1 AND VisitorId != '')) AS bouncedSessions, uniqIf(SessionId, VisitorId != '') AS identifiedSessions, uniqIf(SessionId, multiSearchAnyCaseInsensitive(UserAgent, ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-User', 'PerplexityBot', 'Google-Extended', 'Applebot-Extended', 'meta-externalagent', 'meta-webindexer', 'Bytespider', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'Googlebot', 'GoogleOther', 'AdsBot-Google', 'Google-Read-Aloud', 'bingbot', 'YandexBot', 'Baiduspider', 'DuckDuckBot', 'Applebot', 'Sogou', 'SeznamBot', 'AhrefsSiteAudit', 'AhrefsBot', 'SemrushBot', 'DataForSeoBot', 'DotBot', 'MJ12bot', 'Barkrowler', 'Screaming Frog', 'facebookexternalhit', 'Twitterbot', 'LinkedInBot', 'Slackbot', 'Discordbot', 'TelegramBot', 'Pinterest', 'HubSpot Crawler', 'Stripebot', 'UptimeRobot', 'Pingdom', 'StatusCake', 'Headless', 'bot/', 'bot\x3B', 'bot)', 'crawler', 'spider', '+http'])) AS botSessions, round(ifNotFinite(avgIf(assumeNotNull(DurationMs), DurationMs > 0), 0)) AS avgDurationMs @@ -5034,12 +5034,12 @@ SELECT AND StartTime <= '2026-01-03 14:15:00' FORMAT JSON --- builder:web-analytics:webAnalyticsSummaryQuery:default-rollup [74bf3d64] +-- builder:web-analytics:webAnalyticsSummaryQuery:default-rollup [9a0359fc] SELECT uniqIf(VisitorId, VisitorId != '') AS visitors, uniq(SessionId) AS sessions, uniqIf(SessionId, VisitorIsNew = 1) AS newSessions, - uniqIf(SessionId, (PageViews <= 1 AND VisitorId != '')) AS bouncedSessions, + uniqIf(SessionId, VisitorId != '') - uniqIf(SessionId, (PageViews > 1 AND VisitorId != '')) AS bouncedSessions, uniqIf(SessionId, VisitorId != '') AS identifiedSessions, uniqIf(SessionId, multiSearchAnyCaseInsensitive(UserAgent, ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-User', 'PerplexityBot', 'Google-Extended', 'Applebot-Extended', 'meta-externalagent', 'meta-webindexer', 'Bytespider', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'Googlebot', 'GoogleOther', 'AdsBot-Google', 'Google-Read-Aloud', 'bingbot', 'YandexBot', 'Baiduspider', 'DuckDuckBot', 'Applebot', 'Sogou', 'SeznamBot', 'AhrefsSiteAudit', 'AhrefsBot', 'SemrushBot', 'DataForSeoBot', 'DotBot', 'MJ12bot', 'Barkrowler', 'Screaming Frog', 'facebookexternalhit', 'Twitterbot', 'LinkedInBot', 'Slackbot', 'Discordbot', 'TelegramBot', 'Pinterest', 'HubSpot Crawler', 'Stripebot', 'UptimeRobot', 'Pingdom', 'StatusCake', 'Headless', 'bot/', 'bot\x3B', 'bot)', 'crawler', 'spider', '+http'])) AS botSessions, round(ifNotFinite(avgIf(assumeNotNull(DurationMs), DurationMs > 0), 0)) AS avgDurationMs @@ -5049,12 +5049,12 @@ SELECT AND StartTime <= '2026-01-03 14:15:00' FORMAT JSON --- builder:web-analytics:webAnalyticsSummaryQuery:filtered [816bf9de] +-- builder:web-analytics:webAnalyticsSummaryQuery:filtered [7be4ede6] SELECT uniqIf(VisitorId, VisitorId != '') AS visitors, uniq(SessionId) AS sessions, uniqIf(SessionId, VisitorIsNew = 1) AS newSessions, - uniqIf(SessionId, (PageViews <= 1 AND VisitorId != '')) AS bouncedSessions, + uniqIf(SessionId, VisitorId != '') - uniqIf(SessionId, (PageViews > 1 AND VisitorId != '')) AS bouncedSessions, uniqIf(SessionId, VisitorId != '') AS identifiedSessions, uniqIf(SessionId, multiSearchAnyCaseInsensitive(UserAgent, ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-User', 'PerplexityBot', 'Google-Extended', 'Applebot-Extended', 'meta-externalagent', 'meta-webindexer', 'Bytespider', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'Googlebot', 'GoogleOther', 'AdsBot-Google', 'Google-Read-Aloud', 'bingbot', 'YandexBot', 'Baiduspider', 'DuckDuckBot', 'Applebot', 'Sogou', 'SeznamBot', 'AhrefsSiteAudit', 'AhrefsBot', 'SemrushBot', 'DataForSeoBot', 'DotBot', 'MJ12bot', 'Barkrowler', 'Screaming Frog', 'facebookexternalhit', 'Twitterbot', 'LinkedInBot', 'Slackbot', 'Discordbot', 'TelegramBot', 'Pinterest', 'HubSpot Crawler', 'Stripebot', 'UptimeRobot', 'Pingdom', 'StatusCake', 'Headless', 'bot/', 'bot\x3B', 'bot)', 'crawler', 'spider', '+http'])) AS botSessions, round(ifNotFinite(avgIf(assumeNotNull(DurationMs), DurationMs > 0), 0)) AS avgDurationMs @@ -5093,12 +5093,12 @@ SELECT GROUP BY sessionId) FORMAT JSON --- builder:web-analytics:webAnalyticsSummaryQuery:filtered-rollup [7dc77773] +-- builder:web-analytics:webAnalyticsSummaryQuery:filtered-rollup [81474d1b] SELECT uniqIf(VisitorId, VisitorId != '') AS visitors, uniq(SessionId) AS sessions, uniqIf(SessionId, VisitorIsNew = 1) AS newSessions, - uniqIf(SessionId, (PageViews <= 1 AND VisitorId != '')) AS bouncedSessions, + uniqIf(SessionId, VisitorId != '') - uniqIf(SessionId, (PageViews > 1 AND VisitorId != '')) AS bouncedSessions, uniqIf(SessionId, VisitorId != '') AS identifiedSessions, uniqIf(SessionId, multiSearchAnyCaseInsensitive(UserAgent, ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-User', 'PerplexityBot', 'Google-Extended', 'Applebot-Extended', 'meta-externalagent', 'meta-webindexer', 'Bytespider', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'Googlebot', 'GoogleOther', 'AdsBot-Google', 'Google-Read-Aloud', 'bingbot', 'YandexBot', 'Baiduspider', 'DuckDuckBot', 'Applebot', 'Sogou', 'SeznamBot', 'AhrefsSiteAudit', 'AhrefsBot', 'SemrushBot', 'DataForSeoBot', 'DotBot', 'MJ12bot', 'Barkrowler', 'Screaming Frog', 'facebookexternalhit', 'Twitterbot', 'LinkedInBot', 'Slackbot', 'Discordbot', 'TelegramBot', 'Pinterest', 'HubSpot Crawler', 'Stripebot', 'UptimeRobot', 'Pingdom', 'StatusCake', 'Headless', 'bot/', 'bot\x3B', 'bot)', 'crawler', 'spider', '+http'])) AS botSessions, round(ifNotFinite(avgIf(assumeNotNull(DurationMs), DurationMs > 0), 0)) AS avgDurationMs @@ -5137,13 +5137,13 @@ SELECT GROUP BY sessionId) FORMAT JSON --- builder:web-analytics:webAnalyticsTimeseriesQuery:default [bdbaa144] +-- builder:web-analytics:webAnalyticsTimeseriesQuery:default [7ac5c3d6] SELECT toStartOfInterval(StartTime, INTERVAL 3600 SECOND) AS bucket, uniqIf(VisitorId, VisitorId != '') AS visitors, uniq(SessionId) AS sessions, uniqIf(SessionId, VisitorIsNew = 1) AS newSessions, - uniqIf(SessionId, (PageViews <= 1 AND VisitorId != '')) AS bouncedSessions, + uniqIf(SessionId, VisitorId != '') - uniqIf(SessionId, (PageViews > 1 AND VisitorId != '')) AS bouncedSessions, uniqIf(SessionId, VisitorId != '') AS identifiedSessions, round(ifNotFinite(avgIf(assumeNotNull(DurationMs), DurationMs > 0), 0)) AS avgDurationMs FROM session_replays @@ -5154,13 +5154,13 @@ SELECT ORDER BY bucket ASC FORMAT JSON --- builder:web-analytics:webAnalyticsTimeseriesQuery:default-rollup [bdbaa144] +-- builder:web-analytics:webAnalyticsTimeseriesQuery:default-rollup [7ac5c3d6] SELECT toStartOfInterval(StartTime, INTERVAL 3600 SECOND) AS bucket, uniqIf(VisitorId, VisitorId != '') AS visitors, uniq(SessionId) AS sessions, uniqIf(SessionId, VisitorIsNew = 1) AS newSessions, - uniqIf(SessionId, (PageViews <= 1 AND VisitorId != '')) AS bouncedSessions, + uniqIf(SessionId, VisitorId != '') - uniqIf(SessionId, (PageViews > 1 AND VisitorId != '')) AS bouncedSessions, uniqIf(SessionId, VisitorId != '') AS identifiedSessions, round(ifNotFinite(avgIf(assumeNotNull(DurationMs), DurationMs > 0), 0)) AS avgDurationMs FROM session_replays diff --git a/packages/query-engine/src/ch/queries/web-analytics.ts b/packages/query-engine/src/ch/queries/web-analytics.ts index 033b8d5f3..aa0fa4841 100644 --- a/packages/query-engine/src/ch/queries/web-analytics.ts +++ b/packages/query-engine/src/ch/queries/web-analytics.ts @@ -496,6 +496,14 @@ export interface WebAnalyticsSummaryOutput { * Within that population `PageViews <= 1` rather than `= 1`, so a session whose * only row is still the v1 start row counts as a bounce instead of vanishing * from both sides of the ratio. + * + * It is counted as "identified, minus those with a page view above one" rather + * than directly, because `session_replays` is a `ReplacingMergeTree(Version)` + * whose v1 start row and v2 end row coexist until a merge. A direct + * `uniqIf(SessionId, PageViews <= 1)` matches a session if ANY visible version + * satisfies it, so every multi-page session was a bounce for as long as its + * start row survived — the same class of error the `avgDurationMs` predicate + * already guards against. */ export function webAnalyticsSummaryQuery( filters: WebAnalyticsFilters = {}, @@ -505,7 +513,9 @@ export function webAnalyticsSummaryQuery( visitors: CH.uniqIf($.VisitorId, $.VisitorId.neq("")), sessions: CH.uniq($.SessionId), newSessions: CH.uniqIf($.SessionId, $.VisitorIsNew.eq(1)), - bouncedSessions: CH.uniqIf($.SessionId, $.PageViews.lte(1).and($.VisitorId.neq(""))), + bouncedSessions: CH.uniqIf($.SessionId, $.VisitorId.neq("")).sub( + CH.uniqIf($.SessionId, $.PageViews.gt(1).and($.VisitorId.neq(""))), + ), identifiedSessions: CH.uniqIf($.SessionId, $.VisitorId.neq("")), botSessions: CH.uniqIf($.SessionId, isBotCond($.UserAgent)), // avgIf over the ended rows only: the v1 row's DurationMs is NULL, and @@ -614,7 +624,9 @@ export function webAnalyticsTimeseriesQuery( visitors: CH.uniqIf($.VisitorId, $.VisitorId.neq("")), sessions: CH.uniq($.SessionId), newSessions: CH.uniqIf($.SessionId, $.VisitorIsNew.eq(1)), - bouncedSessions: CH.uniqIf($.SessionId, $.PageViews.lte(1).and($.VisitorId.neq(""))), + bouncedSessions: CH.uniqIf($.SessionId, $.VisitorId.neq("")).sub( + CH.uniqIf($.SessionId, $.PageViews.gt(1).and($.VisitorId.neq(""))), + ), identifiedSessions: CH.uniqIf($.SessionId, $.VisitorId.neq("")), // avgIf over the ended rows only — same as the summary: the v1 row's // DurationMs is NULL and would average in as a NULL.