Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -227,14 +227,23 @@ const seed = async (): Promise<void> => {
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<ReadonlyArray<Record<string, unknown>>> => {
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 12 additions & 12 deletions packages/query-engine/src/__sql_baseline__/catalog.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 14 additions & 2 deletions packages/query-engine/src/ch/queries/web-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading