Merge branch 'dev' into jajaja

# Conflicts:
#	db/mysql/schema.prisma
#	package.json
#	pnpm-lock.yaml
#	src/app/(main)/reports/[reportId]/ReportBody.tsx
#	src/app/(main)/reports/[reportId]/ReportPage.tsx
#	src/app/(main)/reports/utm/UTMView.tsx
#	src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
#	src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx
#	src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
#	src/app/api/reports/[reportId]/route.ts
#	src/app/api/websites/[websiteId]/metrics/route.ts
#	src/components/hooks/queries/useReport.ts
#	src/components/icons.ts
#	src/components/messages.ts
#	src/components/metrics/MetricsTable.module.css
#	src/components/metrics/MetricsTable.tsx
#	src/queries/sql/events/getEventMetrics.ts
#	src/queries/sql/reports/getUTM.ts
This commit is contained in:
Mike Cao 2025-07-23 23:39:28 -07:00
commit 45c9ea9c22
28 changed files with 571 additions and 139 deletions

View file

@ -1,28 +1,36 @@
'use client';
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
import { EventsTable } from '@/components/metrics/EventsTable';
import { useState } from 'react';
import { useState, Key } from 'react';
import { EventsDataTable } from './EventsDataTable';
import { Panel } from '@/components/common/Panel';
import { EventsChart } from '@/components/metrics/EventsChart';
import { useMessages } from '@/components/hooks';
import { EventProperties } from './EventProperties';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { getItem, setItem } from '@/lib/storage';
const KEY_NAME = 'umami.events.tab';
export function EventsPage({ websiteId }) {
const [label, setLabel] = useState(null);
const [tab, setTab] = useState('activity');
const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity');
const { formatMessage, labels } = useMessages();
const handleLabelClick = (value: string) => {
setLabel(value !== label ? value : '');
};
const handleSelect = (value: Key) => {
setItem(KEY_NAME, value);
setTab(value);
};
return (
<Column gap="3">
<WebsiteControls websiteId={websiteId} />
<Panel>
<Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}>
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
<TabList>
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
<Tab id="chart">{formatMessage(labels.chart)}</Tab>

View file

@ -51,8 +51,8 @@ export async function POST(
type,
name,
description,
parameters,
});
parameters: parameters,
} as any);
return json(result);
}

View file

@ -3,7 +3,7 @@ import { parseRequest, getQueryFilters } from '@/lib/request';
import { unauthorized, json } from '@/lib/response';
import { canViewWebsite } from '@/lib/auth';
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
import { getEventMetrics } from '@/queries';
import { getEventStats } from '@/queries';
export async function GET(
request: Request,
@ -31,7 +31,7 @@ export async function GET(
const filters = await getQueryFilters(query, websiteId);
const data = await getEventMetrics(websiteId, filters);
const data = await getEventStats(websiteId, filters);
return json(data);
}

View file

@ -0,0 +1,64 @@
import { z } from 'zod';
import JSZip from 'jszip';
import Papa from 'papaparse';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { unauthorized, json } from '@/lib/response';
import { canViewWebsite } from '@/lib/auth';
import { pagingParams, dateRangeParams } from '@/lib/schema';
import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...dateRangeParams,
...pagingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
getEventMetrics(websiteId, { type: 'event' }, filters),
getPageviewMetrics(websiteId, { type: 'path' }, filters),
getPageviewMetrics(websiteId, { type: 'referrer' }, filters),
getSessionMetrics(websiteId, { type: 'browser' }, filters),
getSessionMetrics(websiteId, { type: 'os' }, filters),
getSessionMetrics(websiteId, { type: 'device' }, filters),
getSessionMetrics(websiteId, { type: 'country' }, filters),
]);
const zip = new JSZip();
const parse = (data: any) => {
return Papa.unparse(data, {
header: true,
skipEmptyLines: true,
});
};
zip.file('events.csv', parse(events));
zip.file('pages.csv', parse(pages));
zip.file('referrers.csv', parse(referrers));
zip.file('browsers.csv', parse(browsers));
zip.file('os.csv', parse(os));
zip.file('devices.csv', parse(devices));
zip.file('countries.csv', parse(countries));
const content = await zip.generateAsync({ type: 'nodebuffer' });
const base64 = content.toString('base64');
return json({ zip: base64 });
}

View file

@ -13,7 +13,12 @@ import {
} from '@/lib/constants';
import { parseRequest, getQueryFilters } from '@/lib/request';
import { json, unauthorized, badRequest } from '@/lib/response';
import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries';
import {
getEventMetrics,
getPageviewMetrics,
getSessionMetrics,
getChannelMetrics,
} from '@/queries';
import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
export async function GET(
@ -71,7 +76,13 @@ export async function GET(
}
if (EVENT_COLUMNS.includes(type)) {
const data = await getPageviewMetrics(websiteId, { type, limit, offset }, filters);
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);
}

1
src/assets/download.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m97.4999924 82.6562576.0000076-11.298912c0-1.957756-1.5870743-3.544838-3.544838-3.544838h-4.785324c-1.9577637 0-3.544838 1.5870743-3.544838 3.5448303l-.0000076 11.2989121c0 1.639595-1.329155 2.96875-2.96875 2.96875l-65.3124924-.0000229c-1.639596 0-2.96875-1.329155-2.968749-2.96875l.0000038-11.298912c0-1.957756-1.5870762-3.544838-3.544836-3.544838h-4.7853256c-1.9577594 0-3.5448372 1.5870743-3.544838 3.544838l-.0000036 11.298912c-.0000026 8.1979752 6.6457672 14.84375 14.8437443 14.84375l65.3124965.0000229c8.1979751 0 14.84375-6.6457672 14.84375-14.8437424z"/><path d="m29.6809349 44.1050034-3.3884087 3.3884048c-1.3843441 1.384346-1.384346 3.6288109-.0000019 5.0131569l19.5066929 19.5067101c2.3174515 2.3200302 6.0768623 2.3221207 8.3968925.0046768.0015564-.0015564.0031128-.0031204.0046692-.0046768l19.5067177-19.5066948c1.384346-1.3843422 1.384346-3.6288109 0-5.0131569l-3.3884125-3.3884048c-1.3843384-1.384346-3.6288071-1.384346-5.0131531-.0000038l-9.3684235 9.3684196.0000153-47.4285965c0-1.9577589-1.5870781-3.544837-3.5448341-3.5448377l-4.7853279-.0000014c-1.9577599-.0000007-3.544838 1.5870759-3.544838 3.5448353l-.0000153 47.4285965-9.3684158-9.3684235c-1.3843459-1.384346-3.6288127-1.384346-5.0131568-.0000038z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
src/assets/export.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><switch><g><path d="m8.7 7.7 2.3-2.3v9.6c0 .6.4 1 1 1s1-.4 1-1v-9.6l2.3 2.3c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0zm12.3 6.3c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1h-14c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1z"/></g></switch></svg>

After

Width:  |  Height:  |  Size: 493 B

View file

@ -1,5 +0,0 @@
declare module '*.css';
declare module '*.svg';
declare module '*.json';
declare module 'react-simple-maps';
declare module 'uuid';

View file

@ -0,0 +1,42 @@
import Papa from 'papaparse';
import { Button, Icon, TooltipTrigger, Tooltip } from '@umami/react-zen';
import { Download } from '@/components/icons';
import { useMessages } from '@/components/hooks';
export function DownloadButton({
filename = 'data',
data,
}: {
filename?: string;
data?: any;
onClick?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const handleClick = async () => {
downloadCsv(`${filename}.csv`, Papa.unparse(data));
};
return (
<TooltipTrigger delay={0}>
<Button variant="quiet" onClick={handleClick} isDisabled={!data}>
<Icon>
<Download />
</Icon>
</Button>
<Tooltip>{formatMessage(labels.download)}</Tooltip>
</TooltipTrigger>
);
}
function downloadCsv(filename: string, data: any) {
const blob = new Blob([data], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,64 @@
import { useState } from 'react';
import { Icon, Tooltip, TooltipTrigger, LoadingButton } from '@umami/react-zen';
import { Download } from '@/components/icons';
import { useMessages, useApi } from '@/components/hooks';
import { useSearchParams } from 'next/navigation';
import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useFilterParameters } from '@/components/hooks/useFilterParameters';
export function ExportButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const [isLoading, setIsLoading] = useState(false);
const date = useDateParameters(websiteId);
const filters = useFilterParameters();
const searchParams = useSearchParams();
const { get } = useApi();
const handleClick = async () => {
setIsLoading(true);
const { zip } = await get(`/websites/${websiteId}/export`, {
...date,
...filters,
...searchParams,
format: 'json',
});
await loadZip(zip);
setIsLoading(false);
};
return (
<TooltipTrigger delay={0}>
<LoadingButton
variant="quiet"
showText={!isLoading}
isLoading={isLoading}
onClick={handleClick}
>
<Icon>
<Download />
</Icon>
</LoadingButton>
<Tooltip>{formatMessage(labels.download)}</Tooltip>
</TooltipTrigger>
);
}
async function loadZip(zip: string) {
const binary = atob(zip);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'download.zip';
a.click();
URL.revokeObjectURL(url);
}

View file

@ -12,6 +12,7 @@ import { isAfter } from 'date-fns';
import { Chevron, Close, Compare } from '@/components/icons';
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
import { DateFilter } from './DateFilter';
import { ExportButton } from '@/components/input/ExportButton';
export function WebsiteDateFilter({
websiteId,
@ -99,6 +100,7 @@ export function WebsiteDateFilter({
<Icon fillColor>{compare ? <Close /> : <Compare />}</Icon>
</Button>
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
<ExportButton websiteId={websiteId} />
</TooltipTrigger>
)}
</Row>

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Select, SelectProps, ListItem } from '@umami/react-zen';
import { Select, SelectProps, ListItem, Text } from '@umami/react-zen';
import { useUserWebsitesQuery, useWebsiteQuery, useNavigation } from '@/components/hooks';
import { ButtonProps } from 'react-basics';
@ -38,7 +38,11 @@ export function WebsiteSelect({
searchValue={search}
onSearch={handleSearch}
onChange={handleSelect}
renderValue={() => website?.name}
renderValue={() => (
<Text truncate style={{ maxWidth: 160 }}>
{website?.name}
</Text>
)}
>
{({ id, name }: any) => <ListItem key={id}>{name}</ListItem>}
</Select>

View file

@ -338,6 +338,7 @@ export const labels = defineMessages({
location: { id: 'label.location', defaultMessage: 'Location' },
chart: { id: 'label.chart', defaultMessage: 'Chart' },
table: { id: 'label.table', defaultMessage: 'Table' },
download: { id: 'label.download', defaultMessage: 'Download' },
traffic: { id: 'label.traffic', defaultMessage: 'Traffic' },
behavior: { id: 'label.behavior', defaultMessage: 'Behavior' },
growth: { id: 'label.growth', defaultMessage: 'Growth' },

View file

@ -32,6 +32,7 @@ export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
metric={formatMessage(labels.actions)}
onDataLoad={handleDataLoad}
renderLabel={renderLabel}
allowDownload={false}
/>
);
}

View file

@ -1,4 +1,4 @@
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { ReactNode, useMemo, useState } from 'react';
import { Icon, Text, SearchField, Row, Column } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton';
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
@ -7,6 +7,7 @@ import { useNavigation, useWebsiteMetricsQuery, useMessages, useFormat } from '@
import { Arrow } from '@/components/icons';
import { ListTable, ListTableProps } from './ListTable';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { DownloadButton } from '@/components/input/DownloadButton';
export interface MetricsTableProps extends ListTableProps {
websiteId: string;
@ -18,9 +19,8 @@ export interface MetricsTableProps extends ListTableProps {
allowSearch?: boolean;
searchFormattedValues?: boolean;
showMore?: boolean;
params?: Record<string, any>;
onDataLoad?: (data: any) => any;
className?: string;
params?: { [key: string]: any };
allowDownload?: boolean;
children?: ReactNode;
}
@ -34,8 +34,7 @@ export function MetricsTable({
searchFormattedValues = false,
showMore = true,
params,
onDataLoad,
className,
allowDownload = true,
children,
...props
}: MetricsTableProps) {
@ -86,22 +85,17 @@ export function MetricsTable({
return [];
}, [data, dataFilter, search, limit, formatValue, type]);
useEffect(() => {
if (data) {
onDataLoad?.(data);
}
}, [data]);
return (
<Column gap="3" justifyContent="space-between">
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error} gap>
<Row alignItems="center" justifyContent="space-between">
{allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
{children}
<Row>
{children}
{allowDownload && <DownloadButton filename={type} data={filteredData} />}
</Row>
</Row>
{data && (
<ListTable {...(props as ListTableProps)} data={filteredData} className={className} />
)}
{data && <ListTable {...(props as ListTableProps)} data={filteredData} />}
<Row justifyContent="center">
{showMore && data && !error && limit && (
<LinkButton href={updateParams({ view: type })} variant="quiet">

View file

@ -1,3 +1,6 @@
declare module '*.css';
declare module '*.svg';
declare module '*.json';
declare module 'bcryptjs';
declare module 'chartjs-adapter-date-fns';
declare module 'cors';
@ -6,5 +9,8 @@ declare module 'debug';
declare module 'fs-extra';
declare module 'jsonwebtoken';
declare module 'md5';
declare module 'papaparse';
declare module 'prettier';
declare module 'react-simple-maps';
declare module 'semver';
declare module 'uuid';

View file

@ -227,8 +227,7 @@ async function rawQuery<T = unknown>(
params: Record<string, unknown> = {},
): Promise<T> {
if (process.env.LOG_QUERY) {
log('QUERY:\n', query);
log('PARAMETERS:\n', params);
log({ query, params });
}
await connect();

View file

@ -11,6 +11,7 @@ export * from '@/queries/sql/events/getEventDataValues';
export * from '@/queries/sql/events/getEventDataStats';
export * from '@/queries/sql/events/getEventDataUsage';
export * from '@/queries/sql/events/getEventMetrics';
export * from '@/queries/sql/events/getEventStats';
export * from '@/queries/sql/events/getWebsiteEvents';
export * from '@/queries/sql/events/getEventUsage';
export * from '@/queries/sql/events/saveEvent';

View file

@ -1,9 +1,15 @@
import clickhouse from '@/lib/clickhouse';
import { EVENT_TYPE } from '@/lib/constants';
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 WebsiteEventMetricParameters {
type: string;
limit?: string;
offset?: string;
}
export interface WebsiteEventMetricData {
x: string;
t: string;
@ -11,7 +17,7 @@ export interface WebsiteEventMetricData {
}
export async function getEventMetrics(
...args: [websiteId: string, filters: QueryFilters]
...args: [websiteId: string, parameters: WebsiteEventMetricParameters, filters: QueryFilters]
): Promise<WebsiteEventMetricData[]> {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
@ -19,29 +25,38 @@ export async function getEventMetrics(
});
}
async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { timezone = 'utc', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = prisma;
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
...filters,
eventType: EVENT_TYPE.customEvent,
});
async function relationalQuery(
websiteId: string,
parameters: WebsiteEventMetricParameters,
filters: QueryFilters,
) {
const { type, limit = 500, offset = 0 } = parameters;
const column = FILTER_COLUMNS[type] || type;
const { rawQuery, parseFilters } = prisma;
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters(
{
...filters,
websiteId,
eventType: EVENT_TYPE.customEvent,
},
{ joinSession: SESSION_COLUMNS.includes(type) },
);
return rawQuery(
`
select
event_name x,
${getDateSQL('website_event.created_at', unit, timezone)} t,
count(*) y
select ${column} x,
count(*) as y
from website_event
${joinSessionQuery}
${cohortQuery}
${joinSessionQuery}
where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
and event_type = {{eventType}}
${filterQuery}
group by 1, 2
order by 2
group by 1
order by 2 desc
limit ${limit}
offset ${offset}
`,
queryParams,
);
@ -49,51 +64,32 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
async function clickhouseQuery(
websiteId: string,
parameters: WebsiteEventMetricParameters,
filters: QueryFilters,
): Promise<WebsiteEventMetricData[]> {
const { timezone = 'UTC', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
const { type, limit = 500, offset = 0 } = parameters;
const column = FILTER_COLUMNS[type] || type;
const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({
...filters,
websiteId,
eventType: EVENT_TYPE.customEvent,
});
let sql = '';
if (filterQuery || cohortQuery) {
sql = `
select
event_name x,
${getDateSQL('created_at', unit, timezone)} t,
count(*) y
from website_event
${cohortQuery}
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
${filterQuery}
group by x, t
order by t
`;
} else {
sql = `
select
event_name x,
${getDateSQL('created_at', unit, timezone)} t,
count(*) y
from (
select arrayJoin(event_name) as event_name,
created_at
from website_event_stats_hourly as website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
) as g
group by x, t
order by t
`;
}
return rawQuery(sql, queryParams);
return rawQuery(
`select ${column} x,
count(*) as y
from website_event
${cohortQuery}
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
${filterQuery}
group by x
order by y desc
limit ${limit}
offset ${offset}
`,
queryParams,
);
}

View file

@ -0,0 +1,100 @@
import clickhouse from '@/lib/clickhouse';
import { EVENT_TYPE } from '@/lib/constants';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import { QueryFilters } from '@/lib/types';
interface WebsiteEventMetric {
x: string;
t: string;
y: number;
}
export async function getEventStats(
...args: [websiteId: string, filters: QueryFilters]
): Promise<WebsiteEventMetric[]> {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { timezone = 'utc', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = prisma;
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
...filters,
websiteId,
eventType: EVENT_TYPE.customEvent,
});
return rawQuery(
`
select
event_name x,
${getDateSQL('website_event.created_at', unit, timezone)} t,
count(*) y
from website_event
${cohortQuery}
${joinSessionQuery}
where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
and event_type = {{eventType}}
${filterQuery}
group by 1, 2
order by 2
`,
queryParams,
);
}
async function clickhouseQuery(
websiteId: string,
filters: QueryFilters,
): Promise<{ x: string; t: string; y: number }[]> {
const { timezone = 'UTC', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({
...filters,
websiteId,
eventType: EVENT_TYPE.customEvent,
});
let sql = '';
if (filterQuery || cohortQuery) {
sql = `
select
event_name x,
${getDateSQL('created_at', unit, timezone)} t,
count(*) y
from website_event
${cohortQuery}
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
${filterQuery}
group by x, t
order by t
`;
} else {
sql = `
select
event_name x,
${getDateSQL('created_at', unit, timezone)} t,
count(*) y
from (
select arrayJoin(event_name) as event_name,
created_at
from website_event_stats_hourly website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
) as g
group by x, t
order by t
`;
}
return rawQuery(sql, queryParams);
}

View file

@ -1,15 +1,16 @@
import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import { QueryFilters } from '@/lib/types';
export interface UTMParameters {
startDate: Date;
endDate: Date;
}
export async function getUTM(
...args: [websiteId: string, parameters: UTMParameters, filters: QueryFilters]
...args: [
websiteId: string,
filters: {
startDate: Date;
endDate: Date;
timezone?: string;
},
]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
@ -19,12 +20,14 @@ export async function getUTM(
async function relationalQuery(
websiteId: string,
parameters: UTMParameters,
filters: QueryFilters,
filters: {
startDate: Date;
endDate: Date;
timezone?: string;
},
) {
const { startDate, endDate } = parameters;
const { rawQuery, parseFilters } = prisma;
const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId, startDate, endDate });
const { startDate, endDate } = filters;
const { rawQuery } = prisma;
return rawQuery(
`
@ -34,21 +37,26 @@ async function relationalQuery(
and created_at between {{startDate}} and {{endDate}}
and coalesce(url_query, '') != ''
and event_type = 1
${filterQuery}
group by 1
`,
queryParams,
).then(result => parseParameters(result as any[]));
{
websiteId,
startDate,
endDate,
},
);
}
async function clickhouseQuery(
websiteId: string,
parameters: UTMParameters,
filters: QueryFilters,
filters: {
startDate: Date;
endDate: Date;
timezone?: string;
},
) {
const { startDate, endDate } = parameters;
const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId, startDate, endDate });
const { startDate, endDate } = filters;
const { rawQuery } = clickhouse;
return rawQuery(
`
@ -58,34 +66,12 @@ async function clickhouseQuery(
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and url_query != ''
and event_type = 1
${filterQuery}
group by 1
`,
queryParams,
).then(result => parseParameters(result as any[]));
}
function parseParameters(data: any[]) {
return data.reduce((obj, { url_query, num }) => {
try {
const searchParams = new URLSearchParams(url_query);
for (const [key, value] of searchParams) {
if (key.match(/^utm_(\w+)$/)) {
const name = value;
if (!obj[key]) {
obj[key] = { [name]: Number(num) };
} else if (!obj[key][name]) {
obj[key][name] = Number(num);
} else {
obj[key][name] += Number(num);
}
}
}
} catch {
// Ignore
}
return obj;
}, {});
{
websiteId,
startDate,
endDate,
},
);
}

View file

@ -6,8 +6,8 @@ import { QueryFilters } from '@/lib/types';
export interface SessionMetricsParameters {
type: string;
limit: number | string;
offset: number | string;
limit?: number | string;
offset?: number | string;
}
export async function getSessionMetrics(