From e202b740a1406d5a9f051f13d27e16b5ce0c2080 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 9 Sep 2026 18:55:35 -0700 Subject: [PATCH] feat(api): split performance and UTM analytics datasets --- src/app/api/reports/performance/route.ts | 4 +- src/app/api/reports/revenue/route.ts | 6 +- .../[websiteId]/performance/chart/route.ts | 19 ++ .../[websiteId]/performance/metrics/route.ts | 28 +++ .../[websiteId]/performance/stats/route.ts | 19 ++ .../[websiteId]/revenue/chart/route.ts | 2 +- .../[websiteId]/revenue/metrics/route.ts | 4 +- .../[websiteId]/revenue/sessions/route.ts | 2 +- .../[websiteId]/revenue/stats/route.ts | 4 +- .../websites/[websiteId]/utm/metrics/route.ts | 17 ++ src/lib/analytics-schema.ts | 13 ++ src/queries/sql/index.ts | 2 +- .../sql/performance/getPerformance.test.ts | 68 ++++++ src/queries/sql/performance/getPerformance.ts | 34 +++ .../sql/performance/getPerformanceChart.ts | 81 +++++++ .../getPerformanceMetrics.ts | 0 .../sql/performance/getPerformanceStats.ts | 135 ++++++++--- src/queries/sql/reports/getPerformance.ts | 216 ------------------ .../{reports => revenue}/getRevenueChart.ts | 0 .../{reports => revenue}/getRevenueMetrics.ts | 0 .../getRevenueSessions.ts | 0 .../{reports => revenue}/getRevenueStats.ts | 0 .../revenueFilters.test.ts | 0 src/queries/sql/{reports => utm}/getUTM.ts | 0 24 files changed, 397 insertions(+), 257 deletions(-) create mode 100644 src/app/api/websites/[websiteId]/performance/chart/route.ts create mode 100644 src/app/api/websites/[websiteId]/performance/metrics/route.ts create mode 100644 src/app/api/websites/[websiteId]/performance/stats/route.ts create mode 100644 src/app/api/websites/[websiteId]/utm/metrics/route.ts create mode 100644 src/queries/sql/performance/getPerformance.test.ts create mode 100644 src/queries/sql/performance/getPerformance.ts create mode 100644 src/queries/sql/performance/getPerformanceChart.ts rename src/queries/sql/{reports => performance}/getPerformanceMetrics.ts (100%) delete mode 100644 src/queries/sql/reports/getPerformance.ts rename src/queries/sql/{reports => revenue}/getRevenueChart.ts (100%) rename src/queries/sql/{reports => revenue}/getRevenueMetrics.ts (100%) rename src/queries/sql/{reports => revenue}/getRevenueSessions.ts (100%) rename src/queries/sql/{reports => revenue}/getRevenueStats.ts (100%) rename src/queries/sql/{reports => revenue}/revenueFilters.test.ts (100%) rename src/queries/sql/{reports => utm}/getUTM.ts (100%) diff --git a/src/app/api/reports/performance/route.ts b/src/app/api/reports/performance/route.ts index 449eb33cd..ba10908f1 100644 --- a/src/app/api/reports/performance/route.ts +++ b/src/app/api/reports/performance/route.ts @@ -2,8 +2,8 @@ import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { reportResultSchema } from '@/lib/schema'; import { canViewWebsiteSection } from '@/permissions'; -import { getPerformance, type PerformanceParameters } from '@/queries/sql/reports/getPerformance'; -import { getPerformanceMetrics } from '@/queries/sql/reports/getPerformanceMetrics'; +import { getPerformance, type PerformanceParameters } from '@/queries/sql/performance/getPerformance'; +import { getPerformanceMetrics } from '@/queries/sql/performance/getPerformanceMetrics'; export async function POST(request: Request) { const { auth, body, error } = await parseRequest(request, reportResultSchema); diff --git a/src/app/api/reports/revenue/route.ts b/src/app/api/reports/revenue/route.ts index bf8237d55..e301e3131 100644 --- a/src/app/api/reports/revenue/route.ts +++ b/src/app/api/reports/revenue/route.ts @@ -3,12 +3,12 @@ import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { reportResultSchema } from '@/lib/schema'; import { canViewWebsiteSection } from '@/permissions'; -import { getRevenueChart, type RevenuParameters } from '@/queries/sql/reports/getRevenueChart'; +import { getRevenueChart, type RevenuParameters } from '@/queries/sql/revenue/getRevenueChart'; import { getRevenueMetrics, type RevenueMetricsResult, -} from '@/queries/sql/reports/getRevenueMetrics'; -import { getRevenueStats } from '@/queries/sql/reports/getRevenueStats'; +} from '@/queries/sql/revenue/getRevenueMetrics'; +import { getRevenueStats } from '@/queries/sql/revenue/getRevenueStats'; export async function POST(request: Request) { const { auth, body, error } = await parseRequest(request, reportResultSchema); diff --git a/src/app/api/websites/[websiteId]/performance/chart/route.ts b/src/app/api/websites/[websiteId]/performance/chart/route.ts new file mode 100644 index 000000000..a639677f1 --- /dev/null +++ b/src/app/api/websites/[websiteId]/performance/chart/route.ts @@ -0,0 +1,19 @@ +import { performanceChartQuerySchema } from '@/lib/analytics-schema'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsiteSection } from '@/permissions'; +import type { PerformanceParameters } from '@/queries/sql/performance/getPerformance'; +import { getPerformanceChart } from '@/queries/sql/performance/getPerformanceChart'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, query, error } = await parseRequest(request, performanceChartQuerySchema); + if (error) return error(); + const { websiteId } = await params; + if (!(await canViewWebsiteSection(auth, websiteId, 'performance'))) return unauthorized(); + const filters = await getQueryFilters(query, websiteId); + const parameters = { ...query, ...filters } as PerformanceParameters; + return json(await getPerformanceChart(websiteId, parameters, filters)); +} diff --git a/src/app/api/websites/[websiteId]/performance/metrics/route.ts b/src/app/api/websites/[websiteId]/performance/metrics/route.ts new file mode 100644 index 000000000..ceef2d16a --- /dev/null +++ b/src/app/api/websites/[websiteId]/performance/metrics/route.ts @@ -0,0 +1,28 @@ +import { performanceMetricsQuerySchema } from '@/lib/analytics-schema'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsiteSection } from '@/permissions'; +import type { PerformanceParameters } from '@/queries/sql/performance/getPerformance'; +import { getPerformanceMetrics } from '@/queries/sql/performance/getPerformanceMetrics'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, query, error } = await parseRequest(request, performanceMetricsQuerySchema); + if (error) return error(); + const { websiteId } = await params; + if (!(await canViewWebsiteSection(auth, websiteId, 'performance'))) return unauthorized(); + const filters = await getQueryFilters(query, websiteId); + const parameters = { ...query, ...filters } as PerformanceParameters; + const columns = { path: 'url_path', title: 'page_title', device: 'device', browser: 'browser' }; + return json( + await getPerformanceMetrics( + websiteId, + parameters, + filters, + columns[query.type], + query.limit ?? (query.type === 'device' ? undefined : 500), + ), + ); +} diff --git a/src/app/api/websites/[websiteId]/performance/stats/route.ts b/src/app/api/websites/[websiteId]/performance/stats/route.ts new file mode 100644 index 000000000..cc733dc95 --- /dev/null +++ b/src/app/api/websites/[websiteId]/performance/stats/route.ts @@ -0,0 +1,19 @@ +import { performanceStatsQuerySchema } from '@/lib/analytics-schema'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsiteSection } from '@/permissions'; +import type { PerformanceParameters } from '@/queries/sql/performance/getPerformance'; +import { getPerformanceStats } from '@/queries/sql/performance/getPerformanceStats'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, query, error } = await parseRequest(request, performanceStatsQuerySchema); + if (error) return error(); + const { websiteId } = await params; + if (!(await canViewWebsiteSection(auth, websiteId, 'performance'))) return unauthorized(); + const filters = await getQueryFilters(query, websiteId); + const parameters = { ...query, ...filters } as PerformanceParameters; + return json(await getPerformanceStats(websiteId, parameters, filters)); +} diff --git a/src/app/api/websites/[websiteId]/revenue/chart/route.ts b/src/app/api/websites/[websiteId]/revenue/chart/route.ts index 93e171d06..24953c7d6 100644 --- a/src/app/api/websites/[websiteId]/revenue/chart/route.ts +++ b/src/app/api/websites/[websiteId]/revenue/chart/route.ts @@ -3,7 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { filterParams, withDateRange } from '@/lib/schema'; import { canViewWebsiteSection } from '@/permissions'; -import { getRevenueChart, type RevenuParameters } from '@/queries/sql/reports/getRevenueChart'; +import { getRevenueChart, type RevenuParameters } from '@/queries/sql/revenue/getRevenueChart'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/revenue/metrics/route.ts b/src/app/api/websites/[websiteId]/revenue/metrics/route.ts index 75ce6e111..bd36e777d 100644 --- a/src/app/api/websites/[websiteId]/revenue/metrics/route.ts +++ b/src/app/api/websites/[websiteId]/revenue/metrics/route.ts @@ -3,8 +3,8 @@ import { getQueryFilters, parseRequest } from '@/lib/request'; import { badRequest, json, unauthorized } from '@/lib/response'; import { filterParams, withDateRange } from '@/lib/schema'; import { canViewWebsiteSection } from '@/permissions'; -import type { RevenuParameters } from '@/queries/sql/reports/getRevenueChart'; -import { getRevenueMetrics, type RevenueMetricType } from '@/queries/sql/reports/getRevenueMetrics'; +import type { RevenuParameters } from '@/queries/sql/revenue/getRevenueChart'; +import { getRevenueMetrics, type RevenueMetricType } from '@/queries/sql/revenue/getRevenueMetrics'; const revenueMetricType = z.enum(['country', 'region', 'referrer', 'channel']); diff --git a/src/app/api/websites/[websiteId]/revenue/sessions/route.ts b/src/app/api/websites/[websiteId]/revenue/sessions/route.ts index c09488054..b6d60b45d 100644 --- a/src/app/api/websites/[websiteId]/revenue/sessions/route.ts +++ b/src/app/api/websites/[websiteId]/revenue/sessions/route.ts @@ -3,7 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { filterParams, pagingParams, searchParams, withDateRange } from '@/lib/schema'; import { canViewWebsiteSection } from '@/permissions'; -import { getRevenueSessions } from '@/queries/sql/reports/getRevenueSessions'; +import { getRevenueSessions } from '@/queries/sql/revenue/getRevenueSessions'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/revenue/stats/route.ts b/src/app/api/websites/[websiteId]/revenue/stats/route.ts index 68295515f..bfa83bf9c 100644 --- a/src/app/api/websites/[websiteId]/revenue/stats/route.ts +++ b/src/app/api/websites/[websiteId]/revenue/stats/route.ts @@ -4,8 +4,8 @@ import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { filterParams, withDateRange } from '@/lib/schema'; import { canViewWebsiteSection } from '@/permissions'; -import type { RevenuParameters } from '@/queries/sql/reports/getRevenueChart'; -import { getRevenueStats } from '@/queries/sql/reports/getRevenueStats'; +import type { RevenuParameters } from '@/queries/sql/revenue/getRevenueChart'; +import { getRevenueStats } from '@/queries/sql/revenue/getRevenueStats'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/utm/metrics/route.ts b/src/app/api/websites/[websiteId]/utm/metrics/route.ts new file mode 100644 index 000000000..774590ac4 --- /dev/null +++ b/src/app/api/websites/[websiteId]/utm/metrics/route.ts @@ -0,0 +1,17 @@ +import { utmMetricsQuerySchema } from '@/lib/analytics-schema'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsiteSection } from '@/permissions'; +import { getUTM } from '@/queries/sql/utm/getUTM'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, query, error } = await parseRequest(request, utmMetricsQuerySchema); + if (error) return error(); + const { websiteId } = await params; + if (!(await canViewWebsiteSection(auth, websiteId, 'utm'))) return unauthorized(); + const filters = await getQueryFilters(query, websiteId); + return json(await getUTM(websiteId, { ...filters, column: query.type }, filters)); +} diff --git a/src/lib/analytics-schema.ts b/src/lib/analytics-schema.ts index c14c68d8b..bc654a985 100644 --- a/src/lib/analytics-schema.ts +++ b/src/lib/analytics-schema.ts @@ -83,3 +83,16 @@ export const goalDefinitionSchema = z.object({ description: z.string().max(500).optional(), parameters: goalParametersSchema, }); + +export const performanceStatsQuerySchema = analyticsSchema({}); +export const performanceChartQuerySchema = analyticsSchema({ + metric: z.enum(['lcp', 'inp', 'cls', 'fcp', 'ttfb']).optional(), +}); +export const performanceMetricsQuerySchema = analyticsSchema({ + metric: z.enum(['lcp', 'inp', 'cls', 'fcp', 'ttfb']).optional(), + type: z.enum(['path', 'title', 'device', 'browser']), + limit: z.coerce.number().int().min(1).max(500).optional(), +}); +export const utmMetricsQuerySchema = analyticsSchema({ + type: z.enum(['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']), +}); diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts index aab63ca74..6058a7e1e 100644 --- a/src/queries/sql/index.ts +++ b/src/queries/sql/index.ts @@ -41,7 +41,7 @@ export * from './breakdown/getBreakdown'; export * from './funnels/getFunnel'; export * from './journeys/getJourney'; export * from './retention/getRetention'; -export * from './reports/getUTM'; +export * from './utm/getUTM'; export * from './sessions/createSession'; export * from './sessions/getLinkedDistinctIds'; export * from './sessions/getLinkedSessionIds'; diff --git a/src/queries/sql/performance/getPerformance.test.ts b/src/queries/sql/performance/getPerformance.test.ts new file mode 100644 index 000000000..34b3c5ad3 --- /dev/null +++ b/src/queries/sql/performance/getPerformance.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getPerformance } from './getPerformance'; +import { getPerformanceChart } from './getPerformanceChart'; +import { getPerformanceStats } from './getPerformanceStats'; +const mocks = vi.hoisted(() => ({ + backend: 'prisma', + rawQuery: vi.fn(), + parseFilters: vi.fn(() => ({ + queryParams: {}, + filterQuery: 'and filtered', + cohortQuery: '', + joinSessionQuery: '', + })), +})); +vi.mock('@/lib/db', () => ({ + PRISMA: 'prisma', + CLICKHOUSE: 'clickhouse', + runQuery: (queries: any) => queries[mocks.backend](), +})); +vi.mock('@/lib/prisma', () => ({ + default: { + rawQuery: mocks.rawQuery, + parseFilters: mocks.parseFilters, + getDateSQL: () => 'bucket', + }, +})); +vi.mock('@/lib/clickhouse', () => ({ + default: { + rawQuery: mocks.rawQuery, + parseFilters: mocks.parseFilters, + getDateSQL: () => 'bucket', + }, +})); +const parameters = { + startDate: new Date(1000), + endDate: new Date(2000), + unit: 'day', + timezone: 'UTC', + metric: 'inp', +}; + +describe.each(['prisma', 'clickhouse'])('%s performance datasets', backend => { + beforeEach(() => { + mocks.backend = backend; + mocks.rawQuery.mockReset().mockResolvedValue([]); + }); + test('chart executes only the selected metric time series', async () => { + expect(await getPerformanceChart('website', parameters, {})).toEqual({ chart: [] }); + expect(mocks.rawQuery).toHaveBeenCalledTimes(1); + const sql = mocks.rawQuery.mock.calls[0][0]; + expect(sql).toContain('inp'); + expect(sql).toContain('group by t'); + expect(sql).not.toContain('lcp_p50'); + expect(sql).toContain('and filtered'); + }); + test('stats executes only the summary and preserves empty-result defaults', async () => { + const stats = await getPerformanceStats('website', parameters, {}); + expect(stats.count).toBe(0); + expect(stats.lcp).toEqual({ p50: 0, p75: 0, p95: 0 }); + expect(mocks.rawQuery).toHaveBeenCalledTimes(1); + expect(mocks.rawQuery.mock.calls[0][0]).not.toContain('group by t'); + }); + test('legacy composition preserves the chart and summary shape', async () => { + const result = await getPerformance('website', parameters, {}); + expect(mocks.rawQuery).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ chart: [], summary: { count: 0 } }); + }); +}); diff --git a/src/queries/sql/performance/getPerformance.ts b/src/queries/sql/performance/getPerformance.ts new file mode 100644 index 000000000..cf8a525af --- /dev/null +++ b/src/queries/sql/performance/getPerformance.ts @@ -0,0 +1,34 @@ +import type { QueryFilters } from '@/lib/types'; +import { getPerformanceChart } from './getPerformanceChart'; +import { getPerformanceStats } from './getPerformanceStats'; + +export interface PerformanceParameters { + startDate: Date; + endDate: Date; + unit: string; + timezone: string; + metric: string; +} + +export interface PerformanceResult { + chart: { t: string; p50: number; p75: number; p95: number }[]; + summary: { + lcp: { p50: number; p75: number; p95: number }; + inp: { p50: number; p75: number; p95: number }; + cls: { p50: number; p75: number; p95: number }; + fcp: { p50: number; p75: number; p95: number }; + ttfb: { p50: number; p75: number; p95: number }; + count: number; + }; +} + +// Compatibility composition for the legacy report endpoint. +export async function getPerformance( + ...args: [websiteId: string, parameters: PerformanceParameters, filters: QueryFilters] +): Promise { + const [{ chart }, summary] = await Promise.all([ + getPerformanceChart(...args), + getPerformanceStats(...args), + ]); + return { chart, summary }; +} diff --git a/src/queries/sql/performance/getPerformanceChart.ts b/src/queries/sql/performance/getPerformanceChart.ts new file mode 100644 index 000000000..40570586a --- /dev/null +++ b/src/queries/sql/performance/getPerformanceChart.ts @@ -0,0 +1,81 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +import type { PerformanceParameters, PerformanceResult } from './getPerformance'; + +export async function getPerformanceChart( + ...args: [websiteId: string, parameters: PerformanceParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: PerformanceParameters, + filters: QueryFilters, +): Promise> { + const { startDate, endDate, unit = 'day', timezone = 'utc', metric = 'lcp' } = parameters; + const { getDateSQL, rawQuery, parseFilters } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + const chart = await rawQuery( + ` + select + ${getDateSQL('website_event.created_at', unit, timezone)} t, + percentile_cont(0.5) within group (order by ${metric}) as p50, + percentile_cont(0.75) within group (order by ${metric}) as p75, + percentile_cont(0.95) within group (order by ${metric}) as p95 + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.event_type = 5 + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by t + order by t + `, + { ...queryParams, startDate, endDate }, + ); + + return { chart }; +} + +async function clickhouseQuery( + websiteId: string, + parameters: PerformanceParameters, + filters: QueryFilters, +): Promise> { + const { startDate, endDate, unit = 'day', timezone = 'utc', metric = 'lcp' } = parameters; + const { getDateSQL, rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + + const chart = await rawQuery<{ t: string; p50: number; p75: number; p95: number }[]>( + ` + select + ${getDateSQL('created_at', unit, timezone)} t, + quantile(0.5)(${metric}) as p50, + quantile(0.75)(${metric}) as p75, + quantile(0.95)(${metric}) as p95 + from website_event + ${cohortQuery} + where website_event.website_id = {websiteId:UUID} + and website_event.event_type = 5 + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by t + order by t + `, + { ...queryParams, startDate, endDate }, + ); + + return { chart }; +} diff --git a/src/queries/sql/reports/getPerformanceMetrics.ts b/src/queries/sql/performance/getPerformanceMetrics.ts similarity index 100% rename from src/queries/sql/reports/getPerformanceMetrics.ts rename to src/queries/sql/performance/getPerformanceMetrics.ts diff --git a/src/queries/sql/performance/getPerformanceStats.ts b/src/queries/sql/performance/getPerformanceStats.ts index 0c2b49506..0c5b61836 100644 --- a/src/queries/sql/performance/getPerformanceStats.ts +++ b/src/queries/sql/performance/getPerformanceStats.ts @@ -3,16 +3,11 @@ import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; -export interface PerformanceStatsResult { - lcp: number; - inp: number; - cls: number; - fcp: number; - ttfb: number; - count: number; -} +import type { PerformanceParameters, PerformanceResult } from './getPerformance'; -export async function getPerformanceStats(...args: [websiteId: string, filters: QueryFilters]) { +export async function getPerformanceStats( + ...args: [websiteId: string, parameters: PerformanceParameters, filters: QueryFilters] +) { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), @@ -21,22 +16,34 @@ export async function getPerformanceStats(...args: [websiteId: string, filters: async function relationalQuery( websiteId: string, + parameters: PerformanceParameters, filters: QueryFilters, -): Promise { +): Promise { + const { startDate, endDate } = parameters; const { rawQuery, parseFilters } = prisma; const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, }); - const result = await rawQuery( + const summaryResult = await rawQuery( ` select - percentile_cont(0.75) within group (order by lcp) as lcp, - percentile_cont(0.75) within group (order by inp) as inp, - percentile_cont(0.75) within group (order by cls) as cls, - percentile_cont(0.75) within group (order by fcp) as fcp, - percentile_cont(0.75) within group (order by ttfb) as ttfb, + percentile_cont(0.5) within group (order by lcp) as lcp_p50, + percentile_cont(0.75) within group (order by lcp) as lcp_p75, + percentile_cont(0.95) within group (order by lcp) as lcp_p95, + percentile_cont(0.5) within group (order by inp) as inp_p50, + percentile_cont(0.75) within group (order by inp) as inp_p75, + percentile_cont(0.95) within group (order by inp) as inp_p95, + percentile_cont(0.5) within group (order by cls) as cls_p50, + percentile_cont(0.75) within group (order by cls) as cls_p75, + percentile_cont(0.95) within group (order by cls) as cls_p95, + percentile_cont(0.5) within group (order by fcp) as fcp_p50, + percentile_cont(0.75) within group (order by fcp) as fcp_p75, + percentile_cont(0.95) within group (order by fcp) as fcp_p95, + percentile_cont(0.5) within group (order by ttfb) as ttfb_p50, + percentile_cont(0.75) within group (order by ttfb) as ttfb_p75, + percentile_cont(0.95) within group (order by ttfb) as ttfb_p95, count(*) as count from website_event ${cohortQuery} @@ -46,27 +53,68 @@ async function relationalQuery( and website_event.created_at between {{startDate}} and {{endDate}} ${filterQuery} `, - queryParams, - ); + { ...queryParams, startDate, endDate }, + ).then(result => result?.[0]); - return result?.[0] || { lcp: 0, inp: 0, cls: 0, fcp: 0, ttfb: 0, count: 0 }; + const summary = { + lcp: { + p50: Number(summaryResult?.lcp_p50 || 0), + p75: Number(summaryResult?.lcp_p75 || 0), + p95: Number(summaryResult?.lcp_p95 || 0), + }, + inp: { + p50: Number(summaryResult?.inp_p50 || 0), + p75: Number(summaryResult?.inp_p75 || 0), + p95: Number(summaryResult?.inp_p95 || 0), + }, + cls: { + p50: Number(summaryResult?.cls_p50 || 0), + p75: Number(summaryResult?.cls_p75 || 0), + p95: Number(summaryResult?.cls_p95 || 0), + }, + fcp: { + p50: Number(summaryResult?.fcp_p50 || 0), + p75: Number(summaryResult?.fcp_p75 || 0), + p95: Number(summaryResult?.fcp_p95 || 0), + }, + ttfb: { + p50: Number(summaryResult?.ttfb_p50 || 0), + p75: Number(summaryResult?.ttfb_p75 || 0), + p95: Number(summaryResult?.ttfb_p95 || 0), + }, + count: Number(summaryResult?.count || 0), + }; + + return summary; } async function clickhouseQuery( websiteId: string, + parameters: PerformanceParameters, filters: QueryFilters, -): Promise { +): Promise { + const { startDate, endDate } = parameters; const { rawQuery, parseFilters } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); - const result = await rawQuery( + const summaryResult = await rawQuery( ` select - quantile(0.75)(lcp) as lcp, - quantile(0.75)(inp) as inp, - quantile(0.75)(cls) as cls, - quantile(0.75)(fcp) as fcp, - quantile(0.75)(ttfb) as ttfb, + quantile(0.5)(lcp) as lcp_p50, + quantile(0.75)(lcp) as lcp_p75, + quantile(0.95)(lcp) as lcp_p95, + quantile(0.5)(inp) as inp_p50, + quantile(0.75)(inp) as inp_p75, + quantile(0.95)(inp) as inp_p95, + quantile(0.5)(cls) as cls_p50, + quantile(0.75)(cls) as cls_p75, + quantile(0.95)(cls) as cls_p95, + quantile(0.5)(fcp) as fcp_p50, + quantile(0.75)(fcp) as fcp_p75, + quantile(0.95)(fcp) as fcp_p95, + quantile(0.5)(ttfb) as ttfb_p50, + quantile(0.75)(ttfb) as ttfb_p75, + quantile(0.95)(ttfb) as ttfb_p95, count() as count from website_event ${cohortQuery} @@ -75,8 +123,37 @@ async function clickhouseQuery( and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} `, - queryParams, - ); + { ...queryParams, startDate, endDate }, + ).then(result => result?.[0]); - return result?.[0] || { lcp: 0, inp: 0, cls: 0, fcp: 0, ttfb: 0, count: 0 }; + const summary = { + lcp: { + p50: Number(summaryResult?.lcp_p50 || 0), + p75: Number(summaryResult?.lcp_p75 || 0), + p95: Number(summaryResult?.lcp_p95 || 0), + }, + inp: { + p50: Number(summaryResult?.inp_p50 || 0), + p75: Number(summaryResult?.inp_p75 || 0), + p95: Number(summaryResult?.inp_p95 || 0), + }, + cls: { + p50: Number(summaryResult?.cls_p50 || 0), + p75: Number(summaryResult?.cls_p75 || 0), + p95: Number(summaryResult?.cls_p95 || 0), + }, + fcp: { + p50: Number(summaryResult?.fcp_p50 || 0), + p75: Number(summaryResult?.fcp_p75 || 0), + p95: Number(summaryResult?.fcp_p95 || 0), + }, + ttfb: { + p50: Number(summaryResult?.ttfb_p50 || 0), + p75: Number(summaryResult?.ttfb_p75 || 0), + p95: Number(summaryResult?.ttfb_p95 || 0), + }, + count: Number(summaryResult?.count || 0), + }; + + return summary; } diff --git a/src/queries/sql/reports/getPerformance.ts b/src/queries/sql/reports/getPerformance.ts deleted file mode 100644 index 1abe9c54a..000000000 --- a/src/queries/sql/reports/getPerformance.ts +++ /dev/null @@ -1,216 +0,0 @@ -import clickhouse from '@/lib/clickhouse'; -import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; -import prisma from '@/lib/prisma'; -import type { QueryFilters } from '@/lib/types'; - -export interface PerformanceParameters { - startDate: Date; - endDate: Date; - unit: string; - timezone: string; - metric: string; -} - -export interface PerformanceResult { - chart: { t: string; p50: number; p75: number; p95: number }[]; - summary: { - lcp: { p50: number; p75: number; p95: number }; - inp: { p50: number; p75: number; p95: number }; - cls: { p50: number; p75: number; p95: number }; - fcp: { p50: number; p75: number; p95: number }; - ttfb: { p50: number; p75: number; p95: number }; - count: number; - }; -} - -export async function getPerformance( - ...args: [websiteId: string, parameters: PerformanceParameters, filters: QueryFilters] -) { - return runQuery({ - [PRISMA]: () => relationalQuery(...args), - [CLICKHOUSE]: () => clickhouseQuery(...args), - }); -} - -async function relationalQuery( - websiteId: string, - parameters: PerformanceParameters, - filters: QueryFilters, -): Promise { - const { startDate, endDate, unit = 'day', timezone = 'utc', metric = 'lcp' } = parameters; - const { getDateSQL, rawQuery, parseFilters } = prisma; - const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ - ...filters, - websiteId, - }); - - const chart = await rawQuery( - ` - select - ${getDateSQL('website_event.created_at', unit, timezone)} t, - percentile_cont(0.5) within group (order by ${metric}) as p50, - percentile_cont(0.75) within group (order by ${metric}) as p75, - percentile_cont(0.95) within group (order by ${metric}) as p95 - from website_event - ${cohortQuery} - ${joinSessionQuery} - where website_event.website_id = {{websiteId::uuid}} - and website_event.event_type = 5 - and website_event.created_at between {{startDate}} and {{endDate}} - ${filterQuery} - group by t - order by t - `, - { ...queryParams, startDate, endDate }, - ); - - const summaryResult = await rawQuery( - ` - select - percentile_cont(0.5) within group (order by lcp) as lcp_p50, - percentile_cont(0.75) within group (order by lcp) as lcp_p75, - percentile_cont(0.95) within group (order by lcp) as lcp_p95, - percentile_cont(0.5) within group (order by inp) as inp_p50, - percentile_cont(0.75) within group (order by inp) as inp_p75, - percentile_cont(0.95) within group (order by inp) as inp_p95, - percentile_cont(0.5) within group (order by cls) as cls_p50, - percentile_cont(0.75) within group (order by cls) as cls_p75, - percentile_cont(0.95) within group (order by cls) as cls_p95, - percentile_cont(0.5) within group (order by fcp) as fcp_p50, - percentile_cont(0.75) within group (order by fcp) as fcp_p75, - percentile_cont(0.95) within group (order by fcp) as fcp_p95, - percentile_cont(0.5) within group (order by ttfb) as ttfb_p50, - percentile_cont(0.75) within group (order by ttfb) as ttfb_p75, - percentile_cont(0.95) within group (order by ttfb) as ttfb_p95, - count(*) as count - from website_event - ${cohortQuery} - ${joinSessionQuery} - where website_event.website_id = {{websiteId::uuid}} - and website_event.event_type = 5 - and website_event.created_at between {{startDate}} and {{endDate}} - ${filterQuery} - `, - { ...queryParams, startDate, endDate }, - ).then(result => result?.[0]); - - const summary = { - lcp: { - p50: Number(summaryResult?.lcp_p50 || 0), - p75: Number(summaryResult?.lcp_p75 || 0), - p95: Number(summaryResult?.lcp_p95 || 0), - }, - inp: { - p50: Number(summaryResult?.inp_p50 || 0), - p75: Number(summaryResult?.inp_p75 || 0), - p95: Number(summaryResult?.inp_p95 || 0), - }, - cls: { - p50: Number(summaryResult?.cls_p50 || 0), - p75: Number(summaryResult?.cls_p75 || 0), - p95: Number(summaryResult?.cls_p95 || 0), - }, - fcp: { - p50: Number(summaryResult?.fcp_p50 || 0), - p75: Number(summaryResult?.fcp_p75 || 0), - p95: Number(summaryResult?.fcp_p95 || 0), - }, - ttfb: { - p50: Number(summaryResult?.ttfb_p50 || 0), - p75: Number(summaryResult?.ttfb_p75 || 0), - p95: Number(summaryResult?.ttfb_p95 || 0), - }, - count: Number(summaryResult?.count || 0), - }; - - return { chart, summary }; -} - -async function clickhouseQuery( - websiteId: string, - parameters: PerformanceParameters, - filters: QueryFilters, -): Promise { - const { startDate, endDate, unit = 'day', timezone = 'utc', metric = 'lcp' } = parameters; - const { getDateSQL, rawQuery, parseFilters } = clickhouse; - const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); - - const chart = await rawQuery<{ t: string; p50: number; p75: number; p95: number }[]>( - ` - select - ${getDateSQL('created_at', unit, timezone)} t, - quantile(0.5)(${metric}) as p50, - quantile(0.75)(${metric}) as p75, - quantile(0.95)(${metric}) as p95 - from website_event - ${cohortQuery} - where website_event.website_id = {websiteId:UUID} - and website_event.event_type = 5 - and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} - ${filterQuery} - group by t - order by t - `, - { ...queryParams, startDate, endDate }, - ); - - const summaryResult = await rawQuery( - ` - select - quantile(0.5)(lcp) as lcp_p50, - quantile(0.75)(lcp) as lcp_p75, - quantile(0.95)(lcp) as lcp_p95, - quantile(0.5)(inp) as inp_p50, - quantile(0.75)(inp) as inp_p75, - quantile(0.95)(inp) as inp_p95, - quantile(0.5)(cls) as cls_p50, - quantile(0.75)(cls) as cls_p75, - quantile(0.95)(cls) as cls_p95, - quantile(0.5)(fcp) as fcp_p50, - quantile(0.75)(fcp) as fcp_p75, - quantile(0.95)(fcp) as fcp_p95, - quantile(0.5)(ttfb) as ttfb_p50, - quantile(0.75)(ttfb) as ttfb_p75, - quantile(0.95)(ttfb) as ttfb_p95, - count() as count - from website_event - ${cohortQuery} - where website_event.website_id = {websiteId:UUID} - and website_event.event_type = 5 - and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} - ${filterQuery} - `, - { ...queryParams, startDate, endDate }, - ).then(result => result?.[0]); - - const summary = { - lcp: { - p50: Number(summaryResult?.lcp_p50 || 0), - p75: Number(summaryResult?.lcp_p75 || 0), - p95: Number(summaryResult?.lcp_p95 || 0), - }, - inp: { - p50: Number(summaryResult?.inp_p50 || 0), - p75: Number(summaryResult?.inp_p75 || 0), - p95: Number(summaryResult?.inp_p95 || 0), - }, - cls: { - p50: Number(summaryResult?.cls_p50 || 0), - p75: Number(summaryResult?.cls_p75 || 0), - p95: Number(summaryResult?.cls_p95 || 0), - }, - fcp: { - p50: Number(summaryResult?.fcp_p50 || 0), - p75: Number(summaryResult?.fcp_p75 || 0), - p95: Number(summaryResult?.fcp_p95 || 0), - }, - ttfb: { - p50: Number(summaryResult?.ttfb_p50 || 0), - p75: Number(summaryResult?.ttfb_p75 || 0), - p95: Number(summaryResult?.ttfb_p95 || 0), - }, - count: Number(summaryResult?.count || 0), - }; - - return { chart, summary }; -} diff --git a/src/queries/sql/reports/getRevenueChart.ts b/src/queries/sql/revenue/getRevenueChart.ts similarity index 100% rename from src/queries/sql/reports/getRevenueChart.ts rename to src/queries/sql/revenue/getRevenueChart.ts diff --git a/src/queries/sql/reports/getRevenueMetrics.ts b/src/queries/sql/revenue/getRevenueMetrics.ts similarity index 100% rename from src/queries/sql/reports/getRevenueMetrics.ts rename to src/queries/sql/revenue/getRevenueMetrics.ts diff --git a/src/queries/sql/reports/getRevenueSessions.ts b/src/queries/sql/revenue/getRevenueSessions.ts similarity index 100% rename from src/queries/sql/reports/getRevenueSessions.ts rename to src/queries/sql/revenue/getRevenueSessions.ts diff --git a/src/queries/sql/reports/getRevenueStats.ts b/src/queries/sql/revenue/getRevenueStats.ts similarity index 100% rename from src/queries/sql/reports/getRevenueStats.ts rename to src/queries/sql/revenue/getRevenueStats.ts diff --git a/src/queries/sql/reports/revenueFilters.test.ts b/src/queries/sql/revenue/revenueFilters.test.ts similarity index 100% rename from src/queries/sql/reports/revenueFilters.test.ts rename to src/queries/sql/revenue/revenueFilters.test.ts diff --git a/src/queries/sql/reports/getUTM.ts b/src/queries/sql/utm/getUTM.ts similarity index 100% rename from src/queries/sql/reports/getUTM.ts rename to src/queries/sql/utm/getUTM.ts