Event data properties refactor and design
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
CREATE TABLE IF NOT EXISTS umami.event_data_pivot
|
||||
(
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
event_id UUID,
|
||||
event_name LowCardinality(String),
|
||||
url_path String,
|
||||
created_at DateTime('UTC'),
|
||||
property_keys AggregateFunction(groupArray, String),
|
||||
property_values AggregateFunction(groupArray, String),
|
||||
property_types AggregateFunction(groupArray, UInt32)
|
||||
)
|
||||
ENGINE = AggregatingMergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, event_name, created_at, event_id)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS umami.event_data_pivot_mv
|
||||
TO umami.event_data_pivot
|
||||
AS SELECT
|
||||
website_id,
|
||||
session_id,
|
||||
event_id,
|
||||
event_name,
|
||||
url_path,
|
||||
created_at,
|
||||
groupArrayState(data_key) AS property_keys,
|
||||
groupArrayState(multiIf(
|
||||
data_type = 1, ifNull(string_value, ''),
|
||||
data_type = 2, toString(ifNull(number_value, 0)),
|
||||
data_type = 4, toString(ifNull(date_value, toDateTime(0))),
|
||||
''
|
||||
)) AS property_values,
|
||||
groupArrayState(data_type) AS property_types
|
||||
FROM umami.event_data
|
||||
GROUP BY website_id, session_id, event_id, event_name, url_path, created_at;
|
||||
|
||||
-- Backfill existing data
|
||||
INSERT INTO umami.event_data_pivot
|
||||
SELECT
|
||||
website_id,
|
||||
session_id,
|
||||
event_id,
|
||||
event_name,
|
||||
url_path,
|
||||
created_at,
|
||||
groupArrayState(data_key),
|
||||
groupArrayState(multiIf(
|
||||
data_type = 1, ifNull(string_value, ''),
|
||||
data_type = 2, toString(ifNull(number_value, 0)),
|
||||
data_type = 4, toString(ifNull(date_value, toDateTime(0))),
|
||||
''
|
||||
)),
|
||||
groupArrayState(data_type)
|
||||
FROM umami.event_data
|
||||
GROUP BY website_id, session_id, event_id, event_name, url_path, created_at;
|
||||
@@ -305,4 +305,42 @@ CREATE TABLE umami.session_replay
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (replay_id, website_id, session_id, visit_id, chunk_index)
|
||||
SETTINGS index_granularity = 8192;
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Create event_data_pivot
|
||||
CREATE TABLE IF NOT EXISTS umami.event_data_pivot
|
||||
(
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
event_id UUID,
|
||||
event_name LowCardinality(String),
|
||||
url_path String,
|
||||
created_at DateTime('UTC'),
|
||||
property_keys AggregateFunction(groupArray, String),
|
||||
property_values AggregateFunction(groupArray, String),
|
||||
property_types AggregateFunction(groupArray, UInt32)
|
||||
)
|
||||
ENGINE = AggregatingMergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, event_name, created_at, event_id)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS umami.event_data_pivot_mv
|
||||
TO umami.event_data_pivot
|
||||
AS SELECT
|
||||
website_id,
|
||||
session_id,
|
||||
event_id,
|
||||
event_name,
|
||||
url_path,
|
||||
created_at,
|
||||
groupArrayState(data_key) AS property_keys,
|
||||
groupArrayState(multiIf(
|
||||
data_type = 1, ifNull(string_value, ''),
|
||||
data_type = 2, toString(ifNull(number_value, 0)),
|
||||
data_type = 4, toString(ifNull(date_value, toDateTime(0))),
|
||||
''
|
||||
)) AS property_values,
|
||||
groupArrayState(data_type) AS property_types
|
||||
FROM umami.event_data
|
||||
GROUP BY website_id, session_id, event_id, event_name, url_path, created_at;
|
||||
@@ -287,6 +287,7 @@
|
||||
"select-filter": "Select filter",
|
||||
"select-role": "Select role",
|
||||
"select-component": "Select component",
|
||||
"select-event": "Select an event",
|
||||
"select-website": "Select website",
|
||||
"session": "Session",
|
||||
"session-data": "Session data",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { useTheme } from '@umami/react-zen';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { BarChart } from '@/components/charts/BarChart';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import {
|
||||
useDateRange,
|
||||
useEventDataNumericSeriesQuery,
|
||||
useLocale,
|
||||
useMessages,
|
||||
useTimezone,
|
||||
} from '@/components/hooks';
|
||||
import { renderDateLabels } from '@/lib/charts';
|
||||
import { getThemeColors } from '@/lib/colors';
|
||||
import { generateTimeSeries } from '@/lib/date';
|
||||
|
||||
export function EventDataNumericChart({
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
propertyName: string;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { theme } = useTheme();
|
||||
const { timezone } = useTimezone();
|
||||
const { dateRange: { startDate, endDate, unit } } = useDateRange({ timezone });
|
||||
const { locale, dateLocale } = useLocale();
|
||||
const { colors } = useMemo(() => getThemeColors(theme), [theme]);
|
||||
|
||||
const sumQuery = useEventDataNumericSeriesQuery(
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
'sum',
|
||||
);
|
||||
const avgQuery = useEventDataNumericSeriesQuery(
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
'avg',
|
||||
);
|
||||
|
||||
const chartData: any = useMemo(() => {
|
||||
if (!sumQuery.data && !avgQuery.data) return;
|
||||
|
||||
const sumRows = (sumQuery.data as { t: string; y: number }[] | undefined) ?? [];
|
||||
const avgRows = (avgQuery.data as { t: string; y: number }[] | undefined) ?? [];
|
||||
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
type: 'bar',
|
||||
label: t(labels.sum),
|
||||
data: generateTimeSeries(
|
||||
sumRows.map(({ t, y }) => ({ x: t, y })),
|
||||
startDate,
|
||||
endDate,
|
||||
unit,
|
||||
dateLocale,
|
||||
),
|
||||
barPercentage: 0.9,
|
||||
categoryPercentage: 0.9,
|
||||
...colors.chart.visitors,
|
||||
borderWidth: 1,
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
label: t(labels.average),
|
||||
data: generateTimeSeries(
|
||||
avgRows.map(({ t, y }) => ({ x: t, y })),
|
||||
startDate,
|
||||
endDate,
|
||||
unit,
|
||||
dateLocale,
|
||||
),
|
||||
barPercentage: 0.9,
|
||||
categoryPercentage: 0.9,
|
||||
...colors.chart.views,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [sumQuery.data, avgQuery.data, t, labels, startDate, endDate, unit, dateLocale, colors]);
|
||||
|
||||
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
|
||||
|
||||
return (
|
||||
<Column gap="4">
|
||||
<LoadingPanel
|
||||
isLoading={sumQuery.isLoading || avgQuery.isLoading}
|
||||
error={sumQuery.error || avgQuery.error}
|
||||
minHeight="400px"
|
||||
>
|
||||
{chartData && (
|
||||
<BarChart
|
||||
chartData={chartData}
|
||||
minDate={startDate}
|
||||
maxDate={endDate}
|
||||
unit={unit}
|
||||
stacked={false}
|
||||
renderXLabel={renderXLabel}
|
||||
height="400px"
|
||||
/>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
import { Column, DataColumn, DataTable, Row, Text } from '@umami/react-zen';
|
||||
import { useMemo } from 'react';
|
||||
import { Avatar } from '@/components/common/Avatar';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import Link from '@/components/common/Link';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Pager } from '@/components/common/Pager';
|
||||
import { useEventDataPivotQuery, useEventDataPropertiesQuery, useMessages, useNavigation } from '@/components/hooks';
|
||||
|
||||
export function EventDataPivotTable({
|
||||
websiteId,
|
||||
eventName,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { router, updateParams } = useNavigation();
|
||||
|
||||
const propertiesQuery = useEventDataPropertiesQuery(websiteId);
|
||||
const pivotQuery = useEventDataPivotQuery(websiteId, eventName);
|
||||
|
||||
const propertyKeys = useMemo(() => {
|
||||
if (!propertiesQuery.data || !eventName) return [];
|
||||
const keys = propertiesQuery.data
|
||||
.filter((e: { eventName: string }) => e.eventName === eventName)
|
||||
.map((e: { propertyName: string }) => e.propertyName);
|
||||
return [...new Set<string>(keys)];
|
||||
}, [propertiesQuery.data, eventName]);
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!pivotQuery.data?.data) return [];
|
||||
return pivotQuery.data.data.map(
|
||||
(row: { eventId: string; sessionId: string; eventName: string; urlPath: string; createdAt: string; propertyKeys: string[]; propertyValues: string[] }) => {
|
||||
const flat: Record<string, any> = {
|
||||
eventId: row.eventId,
|
||||
sessionId: row.sessionId,
|
||||
urlPath: row.urlPath,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
row.propertyKeys.forEach((key, i) => {
|
||||
flat[key] = row.propertyValues[i] ?? '';
|
||||
});
|
||||
return flat;
|
||||
},
|
||||
);
|
||||
}, [pivotQuery.data]);
|
||||
|
||||
const tableQuery = useMemo(
|
||||
() =>
|
||||
pivotQuery.data
|
||||
? { ...pivotQuery.data, data: tableData }
|
||||
: undefined,
|
||||
[pivotQuery.data, tableData],
|
||||
);
|
||||
|
||||
const showPager = tableQuery && tableQuery.count > tableQuery.pageSize;
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
router.push(updateParams({ page }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Column gap="4" minWidth="0" width="100%" maxWidth="100%">
|
||||
<LoadingPanel
|
||||
data={tableQuery?.data}
|
||||
isLoading={pivotQuery.isLoading}
|
||||
isFetching={pivotQuery.isFetching}
|
||||
error={pivotQuery.error}
|
||||
renderEmpty={() => <Empty />}
|
||||
>
|
||||
<Column gap="4" minWidth="0" width="100%" maxWidth="100%">
|
||||
<Column minWidth="0" width="100%" maxWidth="100%" overflow="hidden">
|
||||
<DataTable data={tableQuery?.data} style={{ width: '100%' }}>
|
||||
<DataColumn id="session" label={t(labels.session)} width="0.75fr">
|
||||
{(row: any) => (
|
||||
<Link href={updateParams({ session: row.sessionId })}>
|
||||
<Avatar seed={row.sessionId} size={32} />
|
||||
</Link>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="urlPath" label={t(labels.path)} width="1.4fr">
|
||||
{(row: any) => (
|
||||
<Text truncate title={row.urlPath}>
|
||||
{row.urlPath}
|
||||
</Text>
|
||||
)}
|
||||
</DataColumn>
|
||||
{propertyKeys.map(key => (
|
||||
<DataColumn key={key} id={key} label={key} width="1fr">
|
||||
{(row: any) => (
|
||||
<Text truncate title={row[key]}>
|
||||
{row[key] ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</DataColumn>
|
||||
))}
|
||||
<DataColumn id="createdAt" label={t(labels.created)} width="1.1fr">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
</Column>
|
||||
{showPager && tableQuery && (
|
||||
<Row marginTop="6">
|
||||
<Pager
|
||||
page={tableQuery.page}
|
||||
pageSize={tableQuery.pageSize}
|
||||
count={tableQuery.count}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Column>
|
||||
</LoadingPanel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
import { Column, Grid } from '@umami/react-zen';
|
||||
import { colord } from 'colord';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { BarChart } from '@/components/charts/BarChart';
|
||||
import { PieChart } from '@/components/charts/PieChart';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import {
|
||||
useDateRange,
|
||||
useEventDataPropertySeriesQuery,
|
||||
useEventDataValuesQuery,
|
||||
useLocale,
|
||||
useMessages,
|
||||
useTimezone,
|
||||
} from '@/components/hooks';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { renderDateLabels } from '@/lib/charts';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
import { generateTimeSeries } from '@/lib/date';
|
||||
|
||||
export function EventDataPropertyChart({
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
propertyName: string;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { timezone } = useTimezone();
|
||||
const { dateRange: { startDate, endDate, unit } } = useDateRange({ timezone });
|
||||
const { locale, dateLocale } = useLocale();
|
||||
const { data, isLoading, error } = useEventDataPropertySeriesQuery(websiteId, eventName, propertyName);
|
||||
const valuesQuery = useEventDataValuesQuery(websiteId, eventName, propertyName);
|
||||
const valueLabels = useMemo(
|
||||
() => valuesQuery.data?.map(({ value }) => value) ?? [],
|
||||
[valuesQuery.data],
|
||||
);
|
||||
const colorMap = useMemo(() => {
|
||||
return valueLabels.reduce(
|
||||
(obj, label, index) => {
|
||||
obj[label] = CHART_COLORS[index % CHART_COLORS.length];
|
||||
return obj;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
}, [valueLabels]);
|
||||
|
||||
const chartData: any = useMemo(() => {
|
||||
if (!data) return;
|
||||
|
||||
const map = (data as any[]).reduce((obj, { x, t, y }) => {
|
||||
if (!obj[x]) {
|
||||
obj[x] = [];
|
||||
}
|
||||
|
||||
obj[x].push({ x: t, y });
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
if (!map || Object.keys(map).length === 0) {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
|
||||
lineTension: 0,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const keys = [
|
||||
...valueLabels.filter(label => map[label]),
|
||||
...Object.keys(map).filter(key => !valueLabels.includes(key)),
|
||||
];
|
||||
|
||||
return {
|
||||
datasets: keys.map((key, index) => {
|
||||
const color = colord(colorMap[key] || CHART_COLORS[index % CHART_COLORS.length]);
|
||||
return {
|
||||
label: key,
|
||||
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
|
||||
lineTension: 0,
|
||||
backgroundColor: color.alpha(0.6).toRgbString(),
|
||||
borderColor: color.alpha(0.7).toRgbString(),
|
||||
borderWidth: 1,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}, [data, startDate, endDate, unit, dateLocale, valueLabels, colorMap]);
|
||||
|
||||
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
|
||||
const propertySum = useMemo(() => {
|
||||
return valuesQuery.data?.reduce((sum, { total }) => sum + total, 0) ?? 0;
|
||||
}, [valuesQuery.data]);
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!valuesQuery.data || propertySum === 0) return [];
|
||||
|
||||
return valuesQuery.data.map(({ value, total }) => ({
|
||||
label: value,
|
||||
count: total,
|
||||
percent: 100 * (total / propertySum),
|
||||
}));
|
||||
}, [valuesQuery.data, propertySum]);
|
||||
|
||||
const pieChartData: any = useMemo(() => {
|
||||
if (!valuesQuery.data?.length) return null;
|
||||
|
||||
return {
|
||||
labels: valueLabels,
|
||||
datasets: [
|
||||
{
|
||||
data: valuesQuery.data.map(({ total }) => total),
|
||||
backgroundColor: valueLabels.map(label => colorMap[label]),
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [valuesQuery.data, valueLabels, colorMap]);
|
||||
|
||||
return (
|
||||
<Column gap="6">
|
||||
<LoadingPanel isLoading={isLoading} error={error} minHeight="400px">
|
||||
{chartData && (
|
||||
<BarChart
|
||||
chartData={chartData}
|
||||
minDate={startDate}
|
||||
maxDate={endDate}
|
||||
unit={unit}
|
||||
stacked={true}
|
||||
renderXLabel={renderXLabel}
|
||||
height="400px"
|
||||
/>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
<LoadingPanel
|
||||
data={tableData}
|
||||
isLoading={valuesQuery.isLoading}
|
||||
isFetching={valuesQuery.isFetching}
|
||||
error={valuesQuery.error}
|
||||
minHeight="300px"
|
||||
>
|
||||
<Grid columns="1fr 1fr" gap padding="2" alignItems="start">
|
||||
<ListTable title={propertyName} metric={t(labels.count)} data={tableData} />
|
||||
{pieChartData && <PieChart type="doughnut" chartData={pieChartData} />}
|
||||
</Grid>
|
||||
</LoadingPanel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,51 @@
|
||||
import { Column, Grid, ListItem, Select } from '@umami/react-zen';
|
||||
'use client';
|
||||
import { Column, ComboBox, Grid, Label, ListItem, Select } from '@umami/react-zen';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { PieChart } from '@/components/charts/PieChart';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import {
|
||||
useEventDataPropertiesQuery,
|
||||
useEventDataValuesQuery,
|
||||
useMessages,
|
||||
} from '@/components/hooks';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
import { useEventDataPropertiesQuery, useMessages } from '@/components/hooks';
|
||||
import { EventDataNumericChart } from '../event-data/EventDataNumericChart';
|
||||
import { EventDataPivotTable } from '../event-data/EventDataPivotTable';
|
||||
import { EventDataPropertyChart } from '../event-data/EventDataPropertyChart';
|
||||
|
||||
export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
const [propertyName, setPropertyName] = useState('');
|
||||
const [eventName, setEventName] = useState('');
|
||||
|
||||
const [propertyName, setPropertyName] = useState('');
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId);
|
||||
|
||||
const events: string[] = data
|
||||
? data.reduce((arr: string | any[], e: { eventName: any }) => {
|
||||
return !arr.includes(e.eventName) ? arr.concat(e.eventName) : arr;
|
||||
}, [])
|
||||
: [];
|
||||
const properties: string[] = eventName
|
||||
? data?.filter(e => e.eventName === eventName).map(e => e.propertyName)
|
||||
: [];
|
||||
const eventNames = useMemo<string[]>(() => {
|
||||
if (!data) return [];
|
||||
return [...new Set<string>(data.map((e: { eventName: string }) => e.eventName))];
|
||||
}, [data]);
|
||||
|
||||
const properties = useMemo(() => {
|
||||
if (!data || !eventName) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
return data
|
||||
.filter((field: { eventName: string }) => field.eventName === eventName)
|
||||
.filter((field: { propertyName: string; dataType: number }) => {
|
||||
const key = `${field.propertyName}:${field.dataType}`;
|
||||
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [data, eventName]);
|
||||
|
||||
const selectedProperty = useMemo(() => {
|
||||
return properties.find(
|
||||
(field: { propertyName: string; dataType: number }) => field.propertyName === propertyName,
|
||||
);
|
||||
}, [properties, propertyName]);
|
||||
|
||||
const handleEventChange = (value: string) => {
|
||||
setEventName(value);
|
||||
setPropertyName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<LoadingPanel
|
||||
@@ -34,96 +55,62 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
error={error}
|
||||
minHeight="300px"
|
||||
>
|
||||
<Column gap="6">
|
||||
<Column gap="6" minWidth="0">
|
||||
{data && (
|
||||
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" marginBottom="3" gap>
|
||||
<Select
|
||||
label={t(labels.event)}
|
||||
value={eventName}
|
||||
onChange={setEventName}
|
||||
placeholder=""
|
||||
maxHeight={480}
|
||||
>
|
||||
{events?.map(p => (
|
||||
<ListItem key={p} id={p}>
|
||||
{p}
|
||||
</ListItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
label={t(labels.property)}
|
||||
value={propertyName}
|
||||
onChange={setPropertyName}
|
||||
isDisabled={!eventName}
|
||||
placeholder=""
|
||||
maxHeight={480}
|
||||
>
|
||||
{properties?.map(p => (
|
||||
<ListItem key={p} id={p}>
|
||||
{p}
|
||||
</ListItem>
|
||||
))}
|
||||
</Select>
|
||||
<Column gap="1" style={{ minWidth: 0 }}>
|
||||
<Label>{t(labels.event)}</Label>
|
||||
<Select
|
||||
value={eventName}
|
||||
onChange={handleEventChange}
|
||||
placeholder={t(labels.selectEvent)}
|
||||
maxHeight={480}
|
||||
>
|
||||
{eventNames.map(name => (
|
||||
<ListItem key={name} id={name}>
|
||||
{name}
|
||||
</ListItem>
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column gap="1" style={{ minWidth: 0 }}>
|
||||
<Label>{t(labels.property)}</Label>
|
||||
<ComboBox
|
||||
inputValue={propertyName}
|
||||
onInputChange={setPropertyName}
|
||||
isDisabled={!eventName}
|
||||
allowsCustomValue
|
||||
allowsEmptyCollection
|
||||
>
|
||||
{properties.map((field: { propertyName: string }) => (
|
||||
<ListItem key={field.propertyName} id={field.propertyName}>
|
||||
{field.propertyName}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
</Column>
|
||||
</Grid>
|
||||
)}
|
||||
{eventName && propertyName && (
|
||||
<EventValues websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
|
||||
<Column border="bottom" paddingBottom="6">
|
||||
{selectedProperty?.dataType === 1 && (
|
||||
<EventDataPropertyChart
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
propertyName={propertyName}
|
||||
/>
|
||||
)}
|
||||
{selectedProperty?.dataType === 2 && (
|
||||
<EventDataNumericChart
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
propertyName={propertyName}
|
||||
/>
|
||||
)}
|
||||
</Column>
|
||||
)}
|
||||
{eventName && <EventDataPivotTable websiteId={websiteId} eventName={eventName} />}
|
||||
</Column>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const EventValues = ({ websiteId, eventName, propertyName }) => {
|
||||
const {
|
||||
data: values,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
} = useEventDataValuesQuery(websiteId, eventName, propertyName);
|
||||
|
||||
const propertySum = useMemo(() => {
|
||||
return values?.reduce((sum, { total }) => sum + total, 0) ?? 0;
|
||||
}, [values]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!propertyName || !values) return null;
|
||||
return {
|
||||
labels: values.map(({ value }) => value),
|
||||
datasets: [
|
||||
{
|
||||
data: values.map(({ total }) => total),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [propertyName, values]);
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!propertyName || !values || propertySum === 0) return [];
|
||||
return values.map(({ value, total }) => ({
|
||||
label: value,
|
||||
count: total,
|
||||
percent: 100 * (total / propertySum),
|
||||
}));
|
||||
}, [propertyName, values, propertySum]);
|
||||
|
||||
return (
|
||||
<LoadingPanel
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
data={values}
|
||||
error={error}
|
||||
minHeight="300px"
|
||||
gap="6"
|
||||
>
|
||||
{values && (
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
<ListTable title={propertyName} data={tableData} />
|
||||
<PieChart type="doughnut" chartData={chartData} />
|
||||
</Grid>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getEventDataNumericSeries } from '@/queries/sql/events/getEventDataNumericSeries';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
startAt: z.coerce.number().int(),
|
||||
endAt: z.coerce.number().int(),
|
||||
eventName: z.string(),
|
||||
propertyName: z.string(),
|
||||
metric: z.enum(['sum', 'avg']).default('sum'),
|
||||
...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 { eventName, propertyName, metric, ...rest } = query;
|
||||
const filters = await getQueryFilters(rest, websiteId);
|
||||
const data = await getEventDataNumericSeries(websiteId, eventName, propertyName, metric, filters);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getEventDataPropertySeries } from '@/queries/sql/events/getEventDataPropertySeries';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
startAt: z.coerce.number().int(),
|
||||
endAt: z.coerce.number().int(),
|
||||
eventName: z.string(),
|
||||
propertyName: z.string(),
|
||||
...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 { eventName, propertyName, ...rest } = query;
|
||||
const filters = await getQueryFilters(rest, websiteId);
|
||||
const data = await getEventDataPropertySeries(websiteId, eventName, propertyName, filters);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { filterParams, pagingParams } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getEventDataPivot } from '@/queries/sql/events/getEventDataPivot';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
startAt: z.coerce.number().int(),
|
||||
endAt: z.coerce.number().int(),
|
||||
eventName: z.string(),
|
||||
...filterParams,
|
||||
...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 { eventName, ...rest } = query;
|
||||
const filters = await getQueryFilters(rest, websiteId);
|
||||
const result = await getEventDataPivot(websiteId, eventName, filters);
|
||||
|
||||
return json(result);
|
||||
}
|
||||
@@ -18,7 +18,11 @@ export * from './queries/useDashboardQuery';
|
||||
export * from './queries/useDateRangeQuery';
|
||||
export * from './queries/useDeleteQuery';
|
||||
export * from './queries/useEventDataEventsQuery';
|
||||
export * from './queries/useEventDataFieldsQuery';
|
||||
export * from './queries/useEventDataNumericSeriesQuery';
|
||||
export * from './queries/useEventDataPivotQuery';
|
||||
export * from './queries/useEventDataPropertiesQuery';
|
||||
export * from './queries/useEventDataPropertySeriesQuery';
|
||||
export * from './queries/useEventDataQuery';
|
||||
export * from './queries/useEventDataValuesQuery';
|
||||
export * from './queries/useLinkQuery';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
|
||||
export function useEventDataFieldsQuery(
|
||||
websiteId: string,
|
||||
eventName?: string,
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||
const params = useFilterParameters();
|
||||
|
||||
return useQuery<any>({
|
||||
queryKey: [
|
||||
'websites:event-data:fields',
|
||||
{ websiteId, eventName, startAt, endAt, unit, timezone, ...params },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data/fields`, {
|
||||
event: eventName,
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...params,
|
||||
}),
|
||||
enabled: !!websiteId,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
|
||||
export function useEventDataNumericSeriesQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
metric: 'sum' | 'avg',
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||
const params = useFilterParameters();
|
||||
|
||||
return useQuery<any>({
|
||||
queryKey: [
|
||||
'websites:event-data-pivot:numeric-series',
|
||||
{ websiteId, eventName, propertyName, metric, startAt, endAt, unit, timezone, ...params },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data-pivot/numeric-series`, {
|
||||
eventName,
|
||||
propertyName,
|
||||
metric,
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...params,
|
||||
}),
|
||||
enabled: !!(websiteId && eventName && propertyName),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
|
||||
export function useEventDataPivotQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||
const params = useFilterParameters();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'websites:event-data-pivot',
|
||||
{ websiteId, eventName, startAt, endAt, unit, timezone, ...params },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data-pivot`, {
|
||||
eventName,
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...params,
|
||||
}),
|
||||
enabled: !!(websiteId && eventName),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
|
||||
export function useEventDataPropertySeriesQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||
const params = useFilterParameters();
|
||||
|
||||
return useQuery<any>({
|
||||
queryKey: [
|
||||
'websites:event-data-pivot:property-series',
|
||||
{ websiteId, eventName, propertyName, startAt, endAt, unit, timezone, ...params },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data-pivot/property-series`, {
|
||||
eventName,
|
||||
propertyName,
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...params,
|
||||
}),
|
||||
enabled: !!(websiteId && eventName && propertyName),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -134,6 +134,7 @@ export const labels: Record<string, string> = {
|
||||
thisYear: 'label.this-year',
|
||||
allTime: 'label.all-time',
|
||||
customRange: 'label.custom-range',
|
||||
selectEvent: 'label.select-event',
|
||||
selectWebsite: 'label.select-website',
|
||||
selectLink: 'label.select-link',
|
||||
selectPixel: 'label.select-pixel',
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function getEventDataFields(...args: [websiteId: string, filters: Q
|
||||
}
|
||||
|
||||
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||
const { rawQuery, parseFilters, getDateSQL } = prisma;
|
||||
const { rawQuery, parseFilters } = prisma;
|
||||
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
@@ -24,11 +24,6 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||
select
|
||||
data_key as "propertyName",
|
||||
data_type as "dataType",
|
||||
case
|
||||
when data_type = 2 then replace(string_value, '.0000', '')
|
||||
when data_type = 4 then ${getDateSQL('date_value', 'hour')}
|
||||
else string_value
|
||||
end as "value",
|
||||
count(*) as "total"
|
||||
from event_data
|
||||
join website_event on website_event.event_id = event_data.website_event_id
|
||||
@@ -39,9 +34,8 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||
where event_data.website_id = {{websiteId::uuid}}
|
||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||
${filterQuery}
|
||||
group by data_key, data_type, value
|
||||
order by 2 desc
|
||||
limit 100
|
||||
group by data_key, data_type
|
||||
order by "total" desc, "propertyName" asc
|
||||
`,
|
||||
queryParams,
|
||||
FUNCTION_NAME,
|
||||
@@ -51,7 +45,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
filters: QueryFilters,
|
||||
): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> {
|
||||
): Promise<{ propertyName: string; dataType: number; total: number }[]> {
|
||||
const { rawQuery, parseFilters } = clickhouse;
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
|
||||
|
||||
@@ -60,13 +54,10 @@ async function clickhouseQuery(
|
||||
select
|
||||
data_key as propertyName,
|
||||
data_type as dataType,
|
||||
multiIf(data_type = 2, replaceAll(string_value, '.0000', ''),
|
||||
data_type = 4, toString(date_trunc('hour', date_value)),
|
||||
string_value) as "value",
|
||||
count(*) as "total"
|
||||
from event_data
|
||||
any left join (
|
||||
select *
|
||||
select *
|
||||
from website_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
@@ -78,9 +69,8 @@ async function clickhouseQuery(
|
||||
where event_data.website_id = {websiteId:UUID}
|
||||
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${filterQuery}
|
||||
group by data_key, data_type, value
|
||||
order by 2 desc
|
||||
limit 100
|
||||
group by data_key, data_type
|
||||
order by total desc, propertyName asc
|
||||
`,
|
||||
queryParams,
|
||||
FUNCTION_NAME,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
const FUNCTION_NAME = 'getEventDataNumericSeries';
|
||||
|
||||
export async function getEventDataNumericSeries(
|
||||
...args: [
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
metric: 'sum' | 'avg',
|
||||
filters: QueryFilters,
|
||||
]
|
||||
) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(...args),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
metric: 'sum' | 'avg',
|
||||
filters: QueryFilters,
|
||||
) {
|
||||
const { timezone = 'utc', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = prisma;
|
||||
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
});
|
||||
const aggFn = metric === 'avg' ? 'avg' : 'sum';
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
select
|
||||
${getDateSQL('event_data.created_at', unit, timezone)} t,
|
||||
${aggFn}(cast(event_data.number_value as decimal)) y
|
||||
from event_data
|
||||
join website_event on website_event.event_id = event_data.website_event_id
|
||||
and website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type = 2
|
||||
and website_event.event_name = {{eventName}}
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
where event_data.website_id = {{websiteId::uuid}}
|
||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||
and event_data.data_key = {{propertyName}}
|
||||
and event_data.data_type = 2
|
||||
${filterQuery}
|
||||
group by 1
|
||||
order by 1
|
||||
`,
|
||||
{ ...queryParams, eventName, propertyName },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
metric: 'sum' | 'avg',
|
||||
filters: QueryFilters,
|
||||
): Promise<{ t: string; y: number }[]> {
|
||||
const { timezone = 'UTC', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
|
||||
const aggFn = metric === 'avg' ? 'avg' : 'sum';
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
select
|
||||
${getDateSQL('event_data.created_at', unit, timezone)} as t,
|
||||
${aggFn}(event_data.number_value) as y
|
||||
from event_data
|
||||
any left join (
|
||||
select *
|
||||
from website_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type = 2
|
||||
and event_name = {eventName:String}) website_event
|
||||
on website_event.event_id = event_data.event_id
|
||||
and website_event.session_id = event_data.session_id
|
||||
and website_event.website_id = event_data.website_id
|
||||
${cohortQuery}
|
||||
where event_data.website_id = {websiteId:UUID}
|
||||
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_data.data_key = {propertyName:String}
|
||||
and event_data.data_type = 2
|
||||
${filterQuery}
|
||||
group by t
|
||||
order by t
|
||||
`,
|
||||
{ ...queryParams, eventName, propertyName },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
const FUNCTION_NAME = 'getEventDataPivot';
|
||||
|
||||
export async function getEventDataPivot(
|
||||
...args: [websiteId: string, eventName: string, filters: QueryFilters]
|
||||
) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(...args),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(websiteId: string, eventName: string, filters: QueryFilters) {
|
||||
const { rawQuery, parseFilters } = prisma;
|
||||
const { page = 1, pageSize } = filters;
|
||||
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
||||
const offset = +size * (+page - 1);
|
||||
|
||||
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
});
|
||||
|
||||
const countResult = await rawQuery(
|
||||
`
|
||||
select count(distinct website_event.event_id) as num
|
||||
from website_event
|
||||
join event_data on event_data.website_event_id = website_event.event_id
|
||||
and event_data.website_id = {{websiteId::uuid}}
|
||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_name = {{eventName}}
|
||||
${filterQuery}
|
||||
`,
|
||||
{ ...queryParams, eventName },
|
||||
);
|
||||
|
||||
const count = countResult[0].num;
|
||||
|
||||
const rows = await rawQuery(
|
||||
`
|
||||
with paged_events as (
|
||||
select website_event.event_id
|
||||
from website_event
|
||||
join event_data on event_data.website_event_id = website_event.event_id
|
||||
and event_data.website_id = {{websiteId::uuid}}
|
||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_name = {{eventName}}
|
||||
${filterQuery}
|
||||
group by website_event.event_id
|
||||
order by max(website_event.created_at) desc
|
||||
limit ${size} offset ${offset}
|
||||
)
|
||||
select
|
||||
website_event.event_id as "eventId",
|
||||
website_event.session_id as "sessionId",
|
||||
website_event.event_name as "eventName",
|
||||
website_event.url_path as "urlPath",
|
||||
website_event.created_at as "createdAt",
|
||||
event_data.data_key as "dataKey",
|
||||
coalesce(
|
||||
case when event_data.data_type = 1 then event_data.string_value end,
|
||||
case when event_data.data_type = 2 then cast(event_data.number_value as varchar) end,
|
||||
case when event_data.data_type = 4 then cast(event_data.date_value as varchar) end,
|
||||
''
|
||||
) as "value",
|
||||
event_data.data_type as "dataType"
|
||||
from event_data
|
||||
join website_event on website_event.event_id = event_data.website_event_id
|
||||
and website_event.website_id = {{websiteId::uuid}}
|
||||
join paged_events on paged_events.event_id = event_data.website_event_id
|
||||
where event_data.website_id = {{websiteId::uuid}}
|
||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||
order by website_event.created_at desc
|
||||
`,
|
||||
{ ...queryParams, eventName },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
// Pivot flat rows into one record per event
|
||||
const eventMap = new Map<
|
||||
string,
|
||||
{ eventId: string; sessionId: string; eventName: string; urlPath: string; createdAt: Date; propertyKeys: string[]; propertyValues: string[] }
|
||||
>();
|
||||
for (const { eventId, sessionId, eventName: name, urlPath, createdAt, dataKey, value } of rows) {
|
||||
if (!eventMap.has(eventId)) {
|
||||
eventMap.set(eventId, { eventId, sessionId, eventName: name, urlPath, createdAt, propertyKeys: [], propertyValues: [] });
|
||||
}
|
||||
const entry = eventMap.get(eventId);
|
||||
entry.propertyKeys.push(dataKey);
|
||||
entry.propertyValues.push(value ?? '');
|
||||
}
|
||||
|
||||
return { data: [...eventMap.values()], count, page: +page, pageSize: size };
|
||||
}
|
||||
|
||||
async function clickhouseQuery(websiteId: string, eventName: string, filters: QueryFilters) {
|
||||
const { rawQuery, parseFilters } = clickhouse;
|
||||
const { page = 1, pageSize } = filters;
|
||||
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
||||
const offset = +size * (+page - 1);
|
||||
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
|
||||
|
||||
const count = await rawQuery(
|
||||
`
|
||||
select count() as num
|
||||
from umami.event_data_pivot
|
||||
any left join (
|
||||
select *
|
||||
from website_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type = 2
|
||||
and event_name = {eventName:String}) website_event
|
||||
on website_event.event_id = event_data_pivot.event_id
|
||||
and website_event.session_id = event_data_pivot.session_id
|
||||
and website_event.website_id = event_data_pivot.website_id
|
||||
${cohortQuery}
|
||||
where event_data_pivot.website_id = {websiteId:UUID}
|
||||
and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${filterQuery}
|
||||
`,
|
||||
{ ...queryParams, eventName },
|
||||
).then((res: any) => res[0].num);
|
||||
|
||||
const data = await rawQuery(
|
||||
`
|
||||
select
|
||||
event_id as eventId,
|
||||
session_id as sessionId,
|
||||
event_name as eventName,
|
||||
url_path as urlPath,
|
||||
created_at as createdAt,
|
||||
groupArrayMerge(property_keys) as propertyKeys,
|
||||
groupArrayMerge(property_values) as propertyValues
|
||||
from umami.event_data_pivot
|
||||
any left join (
|
||||
select *
|
||||
from website_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type = 2
|
||||
and event_name = {eventName:String}) website_event
|
||||
on website_event.event_id = event_data_pivot.event_id
|
||||
and website_event.session_id = event_data_pivot.session_id
|
||||
and website_event.website_id = event_data_pivot.website_id
|
||||
${cohortQuery}
|
||||
where event_data_pivot.website_id = {websiteId:UUID}
|
||||
and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${filterQuery}
|
||||
group by event_id, session_id, event_name, url_path, created_at
|
||||
order by created_at desc
|
||||
limit ${size} offset ${offset}
|
||||
`,
|
||||
{ ...queryParams, eventName },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
return { data, count, page: +page, pageSize: size };
|
||||
}
|
||||
@@ -31,6 +31,7 @@ async function relationalQuery(
|
||||
select
|
||||
website_event.event_name as "eventName",
|
||||
event_data.data_key as "propertyName",
|
||||
event_data.data_type as "dataType",
|
||||
count(*) as "total"
|
||||
from event_data
|
||||
join website_event on website_event.event_id = event_data.website_event_id
|
||||
@@ -41,8 +42,8 @@ async function relationalQuery(
|
||||
where event_data.website_id = {{websiteId::uuid}}
|
||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||
${filterQuery}
|
||||
group by website_event.event_name, event_data.data_key
|
||||
order by 3 desc
|
||||
group by website_event.event_name, event_data.data_key, event_data.data_type
|
||||
order by 4 desc
|
||||
limit 500
|
||||
`,
|
||||
queryParams,
|
||||
@@ -53,7 +54,7 @@ async function relationalQuery(
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
filters: QueryFilters & { propertyName?: string },
|
||||
): Promise<{ eventName: string; propertyName: string; total: number }[]> {
|
||||
): Promise<{ eventName: string; propertyName: string; dataType: number; total: number }[]> {
|
||||
const { rawQuery, parseFilters } = clickhouse;
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters(
|
||||
{ ...filters, websiteId },
|
||||
@@ -67,6 +68,7 @@ async function clickhouseQuery(
|
||||
select
|
||||
event_name as eventName,
|
||||
data_key as propertyName,
|
||||
data_type as dataType,
|
||||
count(*) as total
|
||||
from event_data
|
||||
any left join (
|
||||
@@ -82,8 +84,8 @@ async function clickhouseQuery(
|
||||
where event_data.website_id = {websiteId:UUID}
|
||||
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${filterQuery}
|
||||
group by event_name, data_key
|
||||
order by 1, 3 desc
|
||||
group by event_name, data_key, data_type
|
||||
order by 1, 4 desc
|
||||
limit 500
|
||||
`,
|
||||
queryParams,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
const FUNCTION_NAME = 'getEventDataPropertySeries';
|
||||
|
||||
export async function getEventDataPropertySeries(
|
||||
...args: [websiteId: string, eventName: string, propertyName: string, filters: QueryFilters]
|
||||
) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(...args),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: string,
|
||||
filters: QueryFilters,
|
||||
) {
|
||||
const { timezone = 'utc', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = prisma;
|
||||
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
select
|
||||
event_data.string_value as x,
|
||||
${getDateSQL('event_data.created_at', unit, timezone)} t,
|
||||
count(*) y
|
||||
from event_data
|
||||
join website_event on website_event.event_id = event_data.website_event_id
|
||||
and website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type = 2
|
||||
and website_event.event_name = {{eventName}}
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
where event_data.website_id = {{websiteId::uuid}}
|
||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||
and event_data.data_key = {{propertyName}}
|
||||
and event_data.data_type = 1
|
||||
${filterQuery}
|
||||
group by 1, 2
|
||||
order by 2
|
||||
`,
|
||||
{ ...queryParams, eventName, propertyName },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
eventName: string,
|
||||
propertyName: 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 });
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
select
|
||||
event_data.string_value as x,
|
||||
${getDateSQL('event_data.created_at', unit, timezone)} as t,
|
||||
count() as y
|
||||
from event_data
|
||||
any left join (
|
||||
select *
|
||||
from website_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type = 2
|
||||
and event_name = {eventName:String}) website_event
|
||||
on website_event.event_id = event_data.event_id
|
||||
and website_event.session_id = event_data.session_id
|
||||
and website_event.website_id = event_data.website_id
|
||||
${cohortQuery}
|
||||
where event_data.website_id = {websiteId:UUID}
|
||||
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_data.data_key = {propertyName:String}
|
||||
and event_data.data_type = 1
|
||||
${filterQuery}
|
||||
group by x, t
|
||||
order by t
|
||||
`,
|
||||
{ ...queryParams, eventName, propertyName },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
export * from './events/getEventDataById';
|
||||
export * from './events/getEventDataEvents';
|
||||
export * from './events/getEventDataFields';
|
||||
export * from './events/getEventDataNumericSeries';
|
||||
export * from './events/getEventDataPivot';
|
||||
export * from './events/getEventDataProperties';
|
||||
export * from './events/getEventDataPropertySeries';
|
||||
export * from './events/getEventDataStats';
|
||||
export * from './events/getEventDataUsage';
|
||||
export * from './events/getEventDataValues';
|
||||
|
||||
Reference in New Issue
Block a user