mirror of
https://github.com/umami-software/umami.git
synced 2025-12-08 05:12:36 +01:00
implement sessions metric expanded queries
This commit is contained in:
parent
c8b4ee8ca5
commit
c1cad16cb9
10 changed files with 352 additions and 13 deletions
|
|
@ -30,7 +30,7 @@ export function App({ children }) {
|
|||
<Column>
|
||||
<SideNav />
|
||||
</Column>
|
||||
<Column alignItems="center" overflow="auto" position="relative">
|
||||
<Column alignItems="center" overflow="auto" position="relative" marginLeft="2">
|
||||
<TopNav />
|
||||
{children}
|
||||
</Column>
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ export function WebsiteExpandedView({
|
|||
itemCount={25}
|
||||
allowFilter={true}
|
||||
allowSearch={true}
|
||||
expanded={true}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
|
|
|
|||
87
src/app/api/websites/[websiteId]/metrics/expanded/route.ts
Normal file
87
src/app/api/websites/[websiteId]/metrics/expanded/route.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
47
src/components/metrics/ListExpandedTable.tsx
Normal file
47
src/components/metrics/ListExpandedTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && <DownloadButton filename={type} data={filteredData} />}
|
||||
</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">
|
||||
{showMore && data && !error && limit && (
|
||||
<LinkButton href={updateParams({ view: type })} variant="quiet">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
126
src/queries/sql/sessions/getSessionExpandedMetrics.ts
Normal file
126
src/queries/sql/sessions/getSessionExpandedMetrics.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue