From c1cad16cb9453d3e2de95a2c9c29494e8e6c1dbd Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 4 Aug 2025 18:27:31 -0700 Subject: [PATCH] implement sessions metric expanded queries --- src/app/(main)/App.tsx | 2 +- .../[websiteId]/WebsiteExpandedView.tsx | 1 + .../[websiteId]/metrics/expanded/route.ts | 87 ++++++++++++ src/components/hooks/index.ts | 1 + .../queries/useWebsiteExpandedMetricsQuery.ts | 45 +++++++ src/components/metrics/ListExpandedTable.tsx | 47 +++++++ src/components/metrics/MetricsTable.tsx | 47 +++++-- src/queries/index.ts | 1 + .../sql/sessions/getSessionDataValues.ts | 8 +- .../sql/sessions/getSessionExpandedMetrics.ts | 126 ++++++++++++++++++ 10 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 src/app/api/websites/[websiteId]/metrics/expanded/route.ts create mode 100644 src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts create mode 100644 src/components/metrics/ListExpandedTable.tsx create mode 100644 src/queries/sql/sessions/getSessionExpandedMetrics.ts diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx index e3602d5a..ee453941 100644 --- a/src/app/(main)/App.tsx +++ b/src/app/(main)/App.tsx @@ -30,7 +30,7 @@ export function App({ children }) { - + {children} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx index acd72ab9..e6788ae9 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx @@ -156,6 +156,7 @@ export function WebsiteExpandedView({ itemCount={25} allowFilter={true} allowSearch={true} + expanded={true} /> diff --git a/src/app/api/websites/[websiteId]/metrics/expanded/route.ts b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts new file mode 100644 index 00000000..395971b6 --- /dev/null +++ b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts @@ -0,0 +1,87 @@ +import { canViewWebsite } from '@/lib/auth'; +import { EVENT_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams, searchParams } from '@/lib/schema'; +import { + getChannelMetrics, + getEventMetrics, + getPageviewMetrics, + getSessionExpandedMetrics, +} from '@/queries'; +import { z } from 'zod'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: z.string(), + limit: z.coerce.number().optional(), + offset: z.coerce.number().optional(), + ...dateRangeParams, + ...searchParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { type, limit, offset, search } = query; + const filters = await getQueryFilters(query, websiteId); + + if (search) { + filters[type] = `c.${search}`; + } + + if (SESSION_COLUMNS.includes(type)) { + const data = await getSessionExpandedMetrics(websiteId, { type, limit, offset }, filters); + + // if (type === 'language') { + // const combined = {}; + + // for (const { x, y } of data) { + // const key = String(x).toLowerCase().split('-')[0]; + + // if (combined[key] === undefined) { + // combined[key] = { x: key, y }; + // } else { + // combined[key].y += y; + // } + // } + + // return json(Object.values(combined)); + // } + + return json(data); + } + + if (EVENT_COLUMNS.includes(type)) { + let data; + + if (type === 'event') { + data = await getEventMetrics(websiteId, { type, limit, offset }, filters); + } else { + data = await getPageviewMetrics(websiteId, { type, limit, offset }, filters); + } + + return json(data); + } + + if (type === 'channel') { + const data = await getChannelMetrics(websiteId, filters); + + return json(data); + } + + return badRequest(); +} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 5c0d8d6b..e62479fb 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -31,6 +31,7 @@ export * from './queries/useWebsitesQuery'; export * from './queries/useWebsiteEventsQuery'; export * from './queries/useWebsiteEventsSeriesQuery'; export * from './queries/useWebsiteMetricsQuery'; +export * from './queries/useWebsiteExpandedMetricsQuery'; export * from './queries/useWebsiteValuesQuery'; export * from './useApi'; export * from './useConfig'; diff --git a/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts new file mode 100644 index 00000000..0443fd5f --- /dev/null +++ b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts @@ -0,0 +1,45 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import { useApi } from '../useApi'; +import { useFilterParameters } from '../useFilterParameters'; +import { useDateParameters } from '../useDateParameters'; +import { ReactQueryOptions } from '@/lib/types'; + +export type WebsiteExpandedMetricsData = { + label: string; + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +}[]; + +export function useWebsiteExpandedMetricsQuery( + websiteId: string, + params: { type: string; limit?: number; search?: string }, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const date = useDateParameters(websiteId); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:metrics:expanded', + { + websiteId, + ...date, + ...filters, + ...params, + }, + ], + queryFn: async () => + get(`/websites/${websiteId}/metrics/expanded`, { + ...date, + ...filters, + ...params, + }), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/metrics/ListExpandedTable.tsx b/src/components/metrics/ListExpandedTable.tsx new file mode 100644 index 00000000..fb3efdb5 --- /dev/null +++ b/src/components/metrics/ListExpandedTable.tsx @@ -0,0 +1,47 @@ +import { useMessages } from '@/components/hooks'; +import { formatShortTime } from '@/lib/format'; +import { DataColumn, DataTable } from '@umami/react-zen'; +import { ReactNode } from 'react'; + +export interface ListExpandedTableProps { + data?: any[]; + title?: string; + renderLabel?: (row: any, index: number) => ReactNode; +} + +export function ListExpandedTable({ data = [], title, renderLabel }: ListExpandedTableProps) { + const { formatMessage, labels } = useMessages(); + + return ( + + + {row => + renderLabel + ? renderLabel({ x: row?.label, country: row?.['country'] }, Number(row.id)) + : (row.label ?? formatMessage(labels.unknown)) + } + + + {row => row?.['visitors']?.toLocaleString()} + + + {row => row?.['visits']?.toLocaleString()} + + + {row => row?.['pageviews']?.toLocaleString()} + + + {row => { + const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100; + return Math.round(+n) + '%'; + }} + + + {row => { + const n = (row?.['totaltime'] / row?.['visits']) * 100; + return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`; + }} + + + ); +} diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx index c229b00c..760a0da6 100644 --- a/src/components/metrics/MetricsTable.tsx +++ b/src/components/metrics/MetricsTable.tsx @@ -1,13 +1,20 @@ -import { ReactNode, useMemo, useState } from 'react'; -import { Icon, Text, SearchField, Row, Column } from '@umami/react-zen'; import { LinkButton } from '@/components/common/LinkButton'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { + useFormat, + useMessages, + useNavigation, + useWebsiteExpandedMetricsQuery, + useWebsiteMetricsQuery, +} from '@/components/hooks'; +import { Arrow } from '@/components/icons'; +import { DownloadButton } from '@/components/input/DownloadButton'; import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants'; import { percentFilter } from '@/lib/filters'; -import { useNavigation, useWebsiteMetricsQuery, useMessages, useFormat } from '@/components/hooks'; -import { Arrow } from '@/components/icons'; +import { Column, Icon, Row, SearchField, Text } from '@umami/react-zen'; +import { ReactNode, useMemo, useState } from 'react'; +import { ListExpandedTable, ListExpandedTableProps } from './ListExpandedTable'; import { ListTable, ListTableProps } from './ListTable'; -import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { DownloadButton } from '@/components/input/DownloadButton'; export interface MetricsTableProps extends ListTableProps { websiteId: string; @@ -21,6 +28,7 @@ export interface MetricsTableProps extends ListTableProps { showMore?: boolean; params?: { [key: string]: any }; allowDownload?: boolean; + expanded?: boolean; children?: ReactNode; } @@ -35,6 +43,7 @@ export function MetricsTable({ showMore = true, params, allowDownload = true, + expanded = false, children, ...props }: MetricsTableProps) { @@ -43,7 +52,21 @@ export function MetricsTable({ const { updateParams } = useNavigation(); const { formatMessage, labels } = useMessages(); - const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery( + const expandedQuery = useWebsiteExpandedMetricsQuery( + websiteId, + { + type, + limit: 30, + search: searchFormattedValues ? undefined : search, + ...params, + }, + { + retryDelay: delay || DEFAULT_ANIMATION_DURATION, + enabled: expanded, + }, + ); + + const query = useWebsiteMetricsQuery( websiteId, { type, @@ -53,9 +76,12 @@ export function MetricsTable({ }, { retryDelay: delay || DEFAULT_ANIMATION_DURATION, + enabled: !expanded, }, ); + const { data, isLoading, isFetching, error } = expanded ? expandedQuery : query; + const filteredData = useMemo(() => { if (data) { let items = data as any[]; @@ -95,7 +121,12 @@ export function MetricsTable({ {allowDownload && } - {data && } + {data && + (expanded ? ( + + ) : ( + + ))} {showMore && data && !error && limit && ( diff --git a/src/queries/index.ts b/src/queries/index.ts index fba7e548..52ca6513 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -28,6 +28,7 @@ export * from '@/queries/sql/sessions/getSessionData'; export * from '@/queries/sql/sessions/getSessionDataProperties'; export * from '@/queries/sql/sessions/getSessionDataValues'; export * from '@/queries/sql/sessions/getSessionMetrics'; +export * from '@/queries/sql/sessions/getSessionExpandedMetrics'; export * from '@/queries/sql/sessions/getWebsiteSessions'; export * from '@/queries/sql/sessions/getWebsiteSessionStats'; export * from '@/queries/sql/sessions/getWebsiteSessionsWeekly'; diff --git a/src/queries/sql/sessions/getSessionDataValues.ts b/src/queries/sql/sessions/getSessionDataValues.ts index 682bfe4f..e4d04f1a 100644 --- a/src/queries/sql/sessions/getSessionDataValues.ts +++ b/src/queries/sql/sessions/getSessionDataValues.ts @@ -28,9 +28,9 @@ async function relationalQuery( else string_value end as "value", count(distinct session_data.session_id) as "total" - from website_event e + from website_event ${cohortQuery} - join session_data d + join session_data on session_data.session_id = website_event.session_id where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} @@ -58,9 +58,9 @@ async function clickhouseQuery( data_type = 4, toString(date_trunc('hour', date_value)), string_value) as "value", uniq(session_data.session_id) as "total" - from website_event e + from website_event ${cohortQuery} - join session_data d final + join session_data final on session_data.session_id = website_event.session_id where website_event.website_id = {websiteId:UUID} and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} diff --git a/src/queries/sql/sessions/getSessionExpandedMetrics.ts b/src/queries/sql/sessions/getSessionExpandedMetrics.ts new file mode 100644 index 00000000..ba0841dc --- /dev/null +++ b/src/queries/sql/sessions/getSessionExpandedMetrics.ts @@ -0,0 +1,126 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import { QueryFilters } from '@/lib/types'; + +export interface SessionExpandedMetricsParameters { + type: string; + limit?: number | string; + offset?: number | string; +} + +export interface SessionExpandedMetricsData { + label: string; + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +} + +export async function getSessionExpandedMetrics( + ...args: [websiteId: string, parameters: SessionExpandedMetricsParameters, filters: QueryFilters] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: SessionExpandedMetricsParameters, + filters: QueryFilters, +): Promise { + const { type, limit = 500, offset = 0 } = parameters; + const column = FILTER_COLUMNS[type] || type; + const { parseFilters, rawQuery } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + eventType: EVENT_TYPE.pageView, + }, + { + joinSession: SESSION_COLUMNS.includes(type), + }, + ); + const includeCountry = column === 'city' || column === 'region'; + + return rawQuery( + ` + select + ${column} x, + count(distinct website_event.session_id) y + ${includeCountry ? ', country' : ''} + 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 = {{eventType}} + ${filterQuery} + group by 1 + ${includeCountry ? ', 3' : ''} + order by 2 desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: SessionExpandedMetricsParameters, + filters: QueryFilters, +): Promise { + const { type, limit = 500, offset = 0 } = parameters; + const column = FILTER_COLUMNS[type] || type; + const { parseFilters, rawQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + eventType: EVENT_TYPE.pageView, + }); + const includeCountry = column === 'city' || column === 'region'; + + return rawQuery( + ` + select + label, + ${includeCountry ? 'country,' : ''} + sum(t.c) as "pageviews", + uniq(t.session_id) as "visitors", + uniq(t.visit_id) as "visits", + sum(if(t.c = 1, 1, 0)) as "bounces", + sum(max_time-min_time) as "totaltime" + from ( + select + ${column} label, + ${includeCountry ? 'country,' : ''} + session_id, + visit_id, + count(*) c, + min(created_at) min_time, + max(created_at) max_time + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = {eventType:UInt32} + and label != '' + ${filterQuery} + group by label, session_id, visit_id + ${includeCountry ? ', country' : ''} + ) as t + group by label + ${includeCountry ? ', country' : ''} + order by visitors desc, visits desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + ); +}