implement sessions metric expanded queries

This commit is contained in:
Francis Cao 2025-08-04 18:27:31 -07:00
parent c8b4ee8ca5
commit c1cad16cb9
10 changed files with 352 additions and 13 deletions

View file

@ -30,7 +30,7 @@ export function App({ children }) {
<Column> <Column>
<SideNav /> <SideNav />
</Column> </Column>
<Column alignItems="center" overflow="auto" position="relative"> <Column alignItems="center" overflow="auto" position="relative" marginLeft="2">
<TopNav /> <TopNav />
{children} {children}
</Column> </Column>

View file

@ -156,6 +156,7 @@ export function WebsiteExpandedView({
itemCount={25} itemCount={25}
allowFilter={true} allowFilter={true}
allowSearch={true} allowSearch={true}
expanded={true}
/> />
</Column> </Column>
</Grid> </Grid>

View file

@ -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();
}

View file

@ -31,6 +31,7 @@ export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteEventsQuery'; export * from './queries/useWebsiteEventsQuery';
export * from './queries/useWebsiteEventsSeriesQuery'; export * from './queries/useWebsiteEventsSeriesQuery';
export * from './queries/useWebsiteMetricsQuery'; export * from './queries/useWebsiteMetricsQuery';
export * from './queries/useWebsiteExpandedMetricsQuery';
export * from './queries/useWebsiteValuesQuery'; export * from './queries/useWebsiteValuesQuery';
export * from './useApi'; export * from './useApi';
export * from './useConfig'; export * from './useConfig';

View file

@ -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<WebsiteExpandedMetricsData>,
) {
const { get, useQuery } = useApi();
const date = useDateParameters(websiteId);
const filters = useFilterParameters();
return useQuery<WebsiteExpandedMetricsData>({
queryKey: [
'websites:metrics:expanded',
{
websiteId,
...date,
...filters,
...params,
},
],
queryFn: async () =>
get(`/websites/${websiteId}/metrics/expanded`, {
...date,
...filters,
...params,
}),
enabled: !!websiteId,
placeholderData: keepPreviousData,
...options,
});
}

View file

@ -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 (
<DataTable data={data}>
<DataColumn id="label" label={title} align="start" width="300px">
{row =>
renderLabel
? renderLabel({ x: row?.label, country: row?.['country'] }, Number(row.id))
: (row.label ?? formatMessage(labels.unknown))
}
</DataColumn>
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
{row => row?.['visitors']?.toLocaleString()}
</DataColumn>
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
{row => row?.['visits']?.toLocaleString()}
</DataColumn>
<DataColumn id="pageviews" label={formatMessage(labels.views)} align="end">
{row => row?.['pageviews']?.toLocaleString()}
</DataColumn>
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
{row => {
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
return Math.round(+n) + '%';
}}
</DataColumn>
<DataColumn id="visitDuration" label={formatMessage(labels.visitDuration)} align="end">
{row => {
const n = (row?.['totaltime'] / row?.['visits']) * 100;
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
}}
</DataColumn>
</DataTable>
);
}

View file

@ -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 { 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 { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
import { percentFilter } from '@/lib/filters'; import { percentFilter } from '@/lib/filters';
import { useNavigation, useWebsiteMetricsQuery, useMessages, useFormat } from '@/components/hooks'; import { Column, Icon, Row, SearchField, Text } from '@umami/react-zen';
import { Arrow } from '@/components/icons'; import { ReactNode, useMemo, useState } from 'react';
import { ListExpandedTable, ListExpandedTableProps } from './ListExpandedTable';
import { ListTable, ListTableProps } from './ListTable'; import { ListTable, ListTableProps } from './ListTable';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { DownloadButton } from '@/components/input/DownloadButton';
export interface MetricsTableProps extends ListTableProps { export interface MetricsTableProps extends ListTableProps {
websiteId: string; websiteId: string;
@ -21,6 +28,7 @@ export interface MetricsTableProps extends ListTableProps {
showMore?: boolean; showMore?: boolean;
params?: { [key: string]: any }; params?: { [key: string]: any };
allowDownload?: boolean; allowDownload?: boolean;
expanded?: boolean;
children?: ReactNode; children?: ReactNode;
} }
@ -35,6 +43,7 @@ export function MetricsTable({
showMore = true, showMore = true,
params, params,
allowDownload = true, allowDownload = true,
expanded = false,
children, children,
...props ...props
}: MetricsTableProps) { }: MetricsTableProps) {
@ -43,7 +52,21 @@ export function MetricsTable({
const { updateParams } = useNavigation(); const { updateParams } = useNavigation();
const { formatMessage, labels } = useMessages(); 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, websiteId,
{ {
type, type,
@ -53,9 +76,12 @@ export function MetricsTable({
}, },
{ {
retryDelay: delay || DEFAULT_ANIMATION_DURATION, retryDelay: delay || DEFAULT_ANIMATION_DURATION,
enabled: !expanded,
}, },
); );
const { data, isLoading, isFetching, error } = expanded ? expandedQuery : query;
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (data) { if (data) {
let items = data as any[]; let items = data as any[];
@ -95,7 +121,12 @@ export function MetricsTable({
{allowDownload && <DownloadButton filename={type} data={filteredData} />} {allowDownload && <DownloadButton filename={type} data={filteredData} />}
</Row> </Row>
</Row> </Row>
{data && <ListTable {...(props as ListTableProps)} data={filteredData} />} {data &&
(expanded ? (
<ListExpandedTable {...(props as ListExpandedTableProps)} data={data} />
) : (
<ListTable {...(props as ListTableProps)} data={filteredData} />
))}
<Row justifyContent="center"> <Row justifyContent="center">
{showMore && data && !error && limit && ( {showMore && data && !error && limit && (
<LinkButton href={updateParams({ view: type })} variant="quiet"> <LinkButton href={updateParams({ view: type })} variant="quiet">

View file

@ -28,6 +28,7 @@ export * from '@/queries/sql/sessions/getSessionData';
export * from '@/queries/sql/sessions/getSessionDataProperties'; export * from '@/queries/sql/sessions/getSessionDataProperties';
export * from '@/queries/sql/sessions/getSessionDataValues'; export * from '@/queries/sql/sessions/getSessionDataValues';
export * from '@/queries/sql/sessions/getSessionMetrics'; export * from '@/queries/sql/sessions/getSessionMetrics';
export * from '@/queries/sql/sessions/getSessionExpandedMetrics';
export * from '@/queries/sql/sessions/getWebsiteSessions'; export * from '@/queries/sql/sessions/getWebsiteSessions';
export * from '@/queries/sql/sessions/getWebsiteSessionStats'; export * from '@/queries/sql/sessions/getWebsiteSessionStats';
export * from '@/queries/sql/sessions/getWebsiteSessionsWeekly'; export * from '@/queries/sql/sessions/getWebsiteSessionsWeekly';

View file

@ -28,9 +28,9 @@ async function relationalQuery(
else string_value else string_value
end as "value", end as "value",
count(distinct session_data.session_id) as "total" count(distinct session_data.session_id) as "total"
from website_event e from website_event
${cohortQuery} ${cohortQuery}
join session_data d join session_data
on session_data.session_id = website_event.session_id on session_data.session_id = website_event.session_id
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}} 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)), data_type = 4, toString(date_trunc('hour', date_value)),
string_value) as "value", string_value) as "value",
uniq(session_data.session_id) as "total" uniq(session_data.session_id) as "total"
from website_event e from website_event
${cohortQuery} ${cohortQuery}
join session_data d final join session_data final
on session_data.session_id = website_event.session_id on session_data.session_id = website_event.session_id
where website_event.website_id = {websiteId:UUID} where website_event.website_id = {websiteId:UUID}
and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}

View file

@ -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<SessionExpandedMetricsData[]> {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
parameters: SessionExpandedMetricsParameters,
filters: QueryFilters,
): Promise<SessionExpandedMetricsData[]> {
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<SessionExpandedMetricsData[]> {
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 },
);
}