diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx index fbe51e0e3..bec5289cd 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx @@ -48,6 +48,7 @@ export function EventsTable(props: DataTableProps) { : } label={t(row.eventName ? labels.triggeredEvent : labels.viewedPage)} + labelProps={{ wrap: 'nowrap' }} /> ({ + CLICKHOUSE: 'clickhouse', + PRISMA: 'prisma', + runQuery: vi.fn((queries: Record unknown>) => queries[state.mode]()), + })); + + vi.doMock('@/lib/prisma', () => ({ + default: { + rawQuery: prismaRawQuery, + parseFilters: prismaParseFilters, + }, + })); + + vi.doMock('@/lib/clickhouse', () => ({ + default: { + rawQuery: clickhouseRawQuery, + parseFilters: clickhouseParseFilters, + }, + })); + + const mod = await import('./getWebsiteEventStats'); + + return { + getWebsiteEventStats: mod.getWebsiteEventStats, + prismaRawQuery, + clickhouseRawQuery, + }; +} + +afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('getWebsiteEventStats', () => { + test('postgres aggregates directly from filtered custom events', async () => { + const { getWebsiteEventStats, prismaRawQuery } = await loadModule({ mode: 'prisma' }); + + await getWebsiteEventStats('website-1', { path: '/pricing' } as any); + + const [query] = prismaRawQuery.mock.calls[0]; + + expect(query).toContain('cast(count(*) as bigint) as "events"'); + expect(query).toContain('count(distinct website_event.session_id) as "visitors"'); + expect(query).toContain('count(distinct website_event.visit_id) as "visits"'); + expect(query).toContain('count(distinct website_event.event_name) as "uniqueEvents"'); + expect(query).toContain('from website_event'); + expect(query).toContain('join session on session.session_id = website_event.session_id'); + expect(query).not.toContain('group by 1, 2, 3'); + expect(query).not.toContain('sum(t.c)'); + }); + + test('clickhouse aggregates directly from filtered custom events', async () => { + const { getWebsiteEventStats, clickhouseRawQuery } = await loadModule({ + mode: 'clickhouse', + }); + + await getWebsiteEventStats('website-1', { path: '/pricing' } as any); + + const [query] = clickhouseRawQuery.mock.calls[0]; + + expect(query).toContain('count(*) as "events"'); + expect(query).toContain('uniq(session_id) as "visitors"'); + expect(query).toContain('uniq(visit_id) as "visits"'); + expect(query).toContain('uniq(event_name) as "uniqueEvents"'); + expect(query).toContain('from website_event'); + expect(query).not.toContain('group by session_id, visit_id, event_name'); + expect(query).not.toContain('sum(t.c)'); + }); +}); diff --git a/src/queries/sql/events/getWebsiteEventStats.ts b/src/queries/sql/events/getWebsiteEventStats.ts index 27179d10a..e73856020 100644 --- a/src/queries/sql/events/getWebsiteEventStats.ts +++ b/src/queries/sql/events/getWebsiteEventStats.ts @@ -34,25 +34,17 @@ async function relationalQuery( return rawQuery( ` select - cast(coalesce(sum(t.c), 0) as bigint) as "events", - count(distinct t.session_id) as "visitors", - count(distinct t.visit_id) as "visits", - count(distinct t.event_name) as "uniqueEvents" - from ( - select - website_event.session_id, - website_event.visit_id, - website_event.event_name, - count(*) as "c" - from website_event - ${cohortQuery} - ${joinSessionQuery} - where website_event.website_id = {{websiteId::uuid}} - and website_event.created_at between {{startDate}} and {{endDate}} - and website_event.event_type = 2 - ${filterQuery} - group by 1, 2, 3 - ) as t + cast(count(*) as bigint) as "events", + count(distinct website_event.session_id) as "visitors", + count(distinct website_event.visit_id) as "visits", + count(distinct website_event.event_name) as "uniqueEvents" + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type = 2 + ${filterQuery} `, queryParams, FUNCTION_NAME, @@ -72,24 +64,16 @@ async function clickhouseQuery( return rawQuery( ` select - sum(t.c) as "events", - uniq(t.session_id) as "visitors", - uniq(t.visit_id) as "visits", - count(distinct t.event_name) as "uniqueEvents" - from ( - select - session_id, - visit_id, - event_name, - count(*) c - from website_event - ${cohortQuery} - where website_id = {websiteId:UUID} - and created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_type = 2 - ${filterQuery} - group by session_id, visit_id, event_name - ) as t; + count(*) as "events", + uniq(session_id) as "visitors", + uniq(visit_id) as "visits", + uniq(event_name) as "uniqueEvents" + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = 2 + ${filterQuery}; `, queryParams, FUNCTION_NAME, diff --git a/src/queries/sql/events/getWebsiteEvents.test.ts b/src/queries/sql/events/getWebsiteEvents.test.ts new file mode 100644 index 000000000..56db24015 --- /dev/null +++ b/src/queries/sql/events/getWebsiteEvents.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const prismaParseFiltersResult = { + queryParams: { websiteId: 'website-1' }, + filterQuery: 'and website_event.url_path = {{path}}', + dateQuery: 'and website_event.created_at between {{startDate}} and {{endDate}}', + cohortQuery: 'join cohort on cohort.session_id = website_event.session_id', + joinSessionQuery: 'inner join session on website_event.session_id = session.session_id and website_event.website_id = session.website_id', +}; + +const clickhouseParseFiltersResult = { + queryParams: { websiteId: 'website-1' }, + filterQuery: 'and url_path = {path:String}', + dateQuery: 'and created_at between {startDate:DateTime64} and {endDate:DateTime64}', + cohortQuery: 'join cohort on cohort.session_id = website_event.session_id', + joinSessionQuery: '', +}; + +async function loadModule({ + mode, +}: { + mode: 'prisma' | 'clickhouse'; +}) { + vi.resetModules(); + + const state = { mode }; + const prismaRawQuery = vi + .fn() + .mockResolvedValueOnce([{ num: '25' }]) + .mockResolvedValueOnce([{ id: 'event-1' }]); + const prismaParseFilters = vi.fn().mockReturnValue(prismaParseFiltersResult); + const clickhouseRawQuery = vi + .fn() + .mockResolvedValueOnce([{ num: '25' }]) + .mockResolvedValueOnce([{ id: 'event-1' }]); + const clickhouseParseFilters = vi.fn().mockReturnValue(clickhouseParseFiltersResult); + + vi.doMock('@/lib/db', () => ({ + CLICKHOUSE: 'clickhouse', + PRISMA: 'prisma', + runQuery: vi.fn((queries: Record unknown>) => queries[state.mode]()), + })); + + vi.doMock('@/lib/prisma', () => ({ + default: { + rawQuery: prismaRawQuery, + parseFilters: prismaParseFilters, + }, + })); + + vi.doMock('@/lib/clickhouse', () => ({ + default: { + rawQuery: clickhouseRawQuery, + parseFilters: clickhouseParseFilters, + }, + })); + + const mod = await import('./getWebsiteEvents'); + + return { + getWebsiteEvents: mod.getWebsiteEvents, + prismaRawQuery, + clickhouseRawQuery, + }; +} + +afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('getWebsiteEvents', () => { + test('postgres counts from the lightweight filtered event query and enriches only the paged rows', async () => { + const { getWebsiteEvents, prismaRawQuery } = await loadModule({ mode: 'prisma' }); + + const result = await getWebsiteEvents('website-1', { + page: 1, + pageSize: 20, + maxResults: 10000, + orderBy: 'createdAt', + search: 'signup', + } as any); + + const [countQuery] = prismaRawQuery.mock.calls[0]; + const [dataQuery] = prismaRawQuery.mock.calls[1]; + + expect(countQuery).toContain('select count(*) as num from (select 1 from ('); + expect(countQuery).not.toContain('exists('); + expect(dataQuery).toContain('with paged_events as ('); + expect(dataQuery).toContain('join website_event on website_event.event_id = paged_events.event_id'); + expect(dataQuery).toContain('exists('); + expect(dataQuery).toContain('where event_data.website_event_id = website_event.event_id'); + expect(dataQuery).toContain('and event_data.website_id = {{websiteId::uuid}}'); + expect(dataQuery).toContain('and event_data.created_at between {{startDate}} and {{endDate}}'); + expect(dataQuery).toContain('order by paged_events.created_at desc'); + expect(dataQuery).not.toContain('created_at between {{startDate}} and {{endDate}}) AS "hasData"'); + expect(result).toEqual({ + data: [{ id: 'event-1' }], + count: 25, + page: 1, + pageSize: 20, + orderBy: 'createdAt', + isCapped: false, + }); + }); + + test('clickhouse counts from the lightweight filtered event query and resolves hasData from paged ids', async () => { + const { getWebsiteEvents, clickhouseRawQuery } = await loadModule({ mode: 'clickhouse' }); + + const result = await getWebsiteEvents('website-1', { + page: 1, + pageSize: 20, + maxResults: 10000, + orderBy: 'createdAt', + search: 'signup', + } as any); + + const [countQuery] = clickhouseRawQuery.mock.calls[0]; + const [dataQuery] = clickhouseRawQuery.mock.calls[1]; + + expect(countQuery).toContain('select count(*) as num from (select 1 from ('); + expect(countQuery).not.toContain('event_data'); + expect(dataQuery).toContain('with paged_events as ('); + expect(dataQuery).toContain('inner join website_event on website_event.event_id = paged_events.event_id'); + expect(dataQuery).toContain('where website_id = {websiteId:UUID}'); + expect(dataQuery).toContain('from event_data'); + expect(dataQuery).toContain('and created_at between {startDate:DateTime64} and {endDate:DateTime64}'); + expect(dataQuery).toContain('and event_id in (select event_id from paged_events)'); + expect(dataQuery).toContain('order by paged_events.created_at desc'); + expect(dataQuery).not.toContain('event_id IN (select event_id'); + expect(result).toEqual({ + data: [{ id: 'event-1' }], + count: '25', + page: 1, + pageSize: 20, + orderBy: 'createdAt', + search: 'signup', + isCapped: false, + }); + }); + + test('clickhouse reuses the parsed date filter for hasData so timezone-aware ranges stay aligned', async () => { + vi.resetModules(); + + const clickhouseRawQuery = vi + .fn() + .mockResolvedValueOnce([{ num: '25' }]) + .mockResolvedValueOnce([{ id: 'event-1' }]); + + vi.doMock('@/lib/db', () => ({ + CLICKHOUSE: 'clickhouse', + PRISMA: 'prisma', + runQuery: vi.fn((queries: Record unknown>) => queries.clickhouse()), + })); + + vi.doMock('@/lib/prisma', () => ({ + default: {}, + })); + + vi.doMock('@/lib/clickhouse', () => ({ + default: { + rawQuery: clickhouseRawQuery, + parseFilters: vi.fn().mockReturnValue({ + ...clickhouseParseFiltersResult, + dateQuery: + 'and created_at between toTimezone({startDate:DateTime64},{timezone:String}) and toTimezone({endDate:DateTime64},{timezone:String})', + }), + }, + })); + + const { getWebsiteEvents } = await import('./getWebsiteEvents'); + + await getWebsiteEvents('website-1', { + page: 1, + pageSize: 20, + timezone: 'America/Los_Angeles', + } as any); + + const [dataQuery] = clickhouseRawQuery.mock.calls[1]; + + expect(dataQuery).toContain( + 'and created_at between toTimezone({startDate:DateTime64},{timezone:String}) and toTimezone({endDate:DateTime64},{timezone:String})', + ); + expect(dataQuery).not.toContain( + 'and created_at between {startDate:DateTime64} and {endDate:DateTime64}\r\n and event_id in (select event_id from paged_events)', + ); + }); +}); diff --git a/src/queries/sql/events/getWebsiteEvents.ts b/src/queries/sql/events/getWebsiteEvents.ts index d5f3376a5..a57d767ef 100644 --- a/src/queries/sql/events/getWebsiteEvents.ts +++ b/src/queries/sql/events/getWebsiteEvents.ts @@ -1,4 +1,5 @@ import clickhouse from '@/lib/clickhouse'; +import { DEFAULT_PAGE_SIZE } from '@/lib/constants'; import { EVENT_TYPE } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; @@ -14,20 +15,48 @@ export function getWebsiteEvents(...args: [websiteId: string, filters: QueryFilt } async function relationalQuery(websiteId: string, filters: QueryFilters) { - const { pagedRawQuery, parseFilters } = prisma; - const { search } = filters; - const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ + const { rawQuery, parseFilters } = prisma; + const { search, page = 1, pageSize, maxResults, orderBy } = filters; + const size = +pageSize || DEFAULT_PAGE_SIZE; + const offset = +size * (+page - 1); + const { filterQuery, dateQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, }); + const hasDataDateQuery = dateQuery.replaceAll('website_event.', 'event_data.'); const searchQuery = search ? `and ((event_name ilike {{search}} and event_type = ${EVENT_TYPE.customEvent}) or (url_path ilike {{search}} and event_type = ${EVENT_TYPE.pageView}))` : ''; - return pagedRawQuery( + const eventQuery = ` + select + website_event.event_id, + website_event.created_at + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.event_type != ${EVENT_TYPE.performance} + ${dateQuery} + ${filterQuery} + ${searchQuery} + `; + + const countQuery = maxResults + ? `select count(*) as num from (select 1 from (${eventQuery}) t limit ${+maxResults}) t2` + : `select count(*) as num from (${eventQuery}) t`; + + const count = await rawQuery(countQuery, queryParams).then(res => Number(res[0].num)); + + const data = await rawQuery( ` + with paged_events as ( + ${eventQuery} + order by created_at desc + limit ${size} offset ${offset} + ) select website_event.event_id as "id", website_event.website_id as "websiteId", @@ -40,37 +69,45 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { website_event.referrer_query as "referrerQuery", website_event.referrer_domain as "referrerDomain", session.country as country, - city as city, - device as device, - os as os, - browser as browser, - page_title as "pageTitle", + session.city as city, + session.device as device, + session.os as os, + session.browser as browser, + website_event.page_title as "pageTitle", website_event.event_type as "eventType", website_event.event_name as "eventName", - event_id IN (select website_event_id - from event_data - where website_id = {{websiteId::uuid}} - and created_at between {{startDate}} and {{endDate}}) AS "hasData" - from website_event - ${cohortQuery} - join session on session.session_id = website_event.session_id + exists( + select 1 + from event_data + where event_data.website_event_id = website_event.event_id + and event_data.website_id = {{websiteId::uuid}} + ${hasDataDateQuery} + ) as "hasData" + from paged_events + join website_event on website_event.event_id = paged_events.event_id + join session on session.session_id = website_event.session_id and session.website_id = website_event.website_id - where website_event.website_id = {{websiteId::uuid}} - and website_event.event_type != ${EVENT_TYPE.performance} - ${dateQuery} - ${filterQuery} - ${searchQuery} - order by website_event.created_at desc + order by paged_events.created_at desc `, queryParams, - filters, FUNCTION_NAME, ); + + return { + data, + count, + page: +page, + pageSize: size, + orderBy, + isCapped: !!maxResults && +count >= +maxResults, + }; } async function clickhouseQuery(websiteId: string, filters: QueryFilters) { - const { pagedRawQuery, parseFilters } = clickhouse; - const { search } = filters; + const { rawQuery, parseFilters } = clickhouse; + const { search, page = 1, pageSize, maxResults, orderBy } = filters; + const size = +pageSize || DEFAULT_PAGE_SIZE; + const offset = +size * (+page - 1); const { queryParams, dateQuery, cohortQuery, filterQuery } = parseFilters({ ...filters, websiteId, @@ -81,31 +118,10 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { or (positionCaseInsensitive(url_path, {search:String}) > 0 and event_type = ${EVENT_TYPE.pageView}))` : ''; - return pagedRawQuery( - ` + const eventQuery = ` select - event_id as id, - website_id as websiteId, - session_id as sessionId, - created_at as createdAt, - hostname, - url_path as urlPath, - url_query as urlQuery, - referrer_path as referrerPath, - referrer_query as referrerQuery, - referrer_domain as referrerDomain, - country as country, - city as city, - device as device, - os as os, - browser as browser, - page_title as pageTitle, - event_type as eventType, - event_name as eventName, - event_id IN (select event_id - from event_data - where website_id = {websiteId:UUID} - ${dateQuery}) as hasData + event_id, + created_at from website_event ${cohortQuery} where website_id = {websiteId:UUID} @@ -113,10 +129,62 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${dateQuery} ${filterQuery} ${searchQuery} - order by created_at desc + `; + + const countQuery = maxResults + ? `select count(*) as num from (select 1 from (${eventQuery}) t limit ${+maxResults}) t2` + : `select count(*) as num from (${eventQuery}) t`; + + const count = await rawQuery(countQuery, queryParams).then(res => res[0].num); + + const data = await rawQuery( + ` + with paged_events as ( + ${eventQuery} + order by created_at desc + limit ${size} offset ${offset} + ) + select + website_event.event_id as id, + website_event.website_id as websiteId, + website_event.session_id as sessionId, + website_event.created_at as createdAt, + website_event.hostname, + website_event.url_path as urlPath, + website_event.url_query as urlQuery, + website_event.referrer_path as referrerPath, + website_event.referrer_query as referrerQuery, + website_event.referrer_domain as referrerDomain, + website_event.country as country, + website_event.city as city, + website_event.device as device, + website_event.os as os, + website_event.browser as browser, + website_event.page_title as pageTitle, + website_event.event_type as eventType, + website_event.event_name as eventName, + website_event.event_id in ( + select event_id + from event_data + where website_id = {websiteId:UUID} + ${dateQuery} + and event_id in (select event_id from paged_events) + ) as hasData + from paged_events + inner join website_event on website_event.event_id = paged_events.event_id + order by paged_events.created_at desc `, queryParams, - filters, FUNCTION_NAME, ); + + return { + data, + count, + page: +page, + pageSize: size, + orderBy, + search, + isCapped: !!maxResults && +count >= +maxResults, + }; }