add event-data property filtering

This commit is contained in:
Francis Cao
2026-04-24 11:39:40 -07:00
parent cf2ab575ba
commit 77dda7268e
25 changed files with 748 additions and 122 deletions
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combined",
"filter-raw": "Raw",
"filters": "Filters",
"property-filter": "Property Filter",
"first-click": "First click",
"first-seen": "First seen",
"funnel": "Funnel",
@@ -0,0 +1,96 @@
'use client';
import { Button, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { X } from '@/components/icons';
import type { EventPropertyFilter } from '@/lib/types';
export function EventDataFilterBar({
filters,
onChange,
}: {
filters: EventPropertyFilter[];
onChange: (filters: EventPropertyFilter[]) => void;
}) {
const { t, labels } = useMessages();
if (!filters.length) return null;
const operatorLabel = (op: string) => {
switch (op) {
case 'eq': return t(labels.is);
case 'neq': return t(labels.isNot);
case 'c': return t(labels.contains);
case 'dnc': return t(labels.doesNotContain);
case 'regex': return t(labels.regexMatch);
case 'notRegex': return t(labels.regexNotMatch);
case 'gt': return t(labels.greaterThan);
case 'lt': return t(labels.lessThan);
case 'gte': return t(labels.greaterThanEquals);
case 'lte': return t(labels.lessThanEquals);
default: return op;
}
};
const handleRemove = (index: number) => {
onChange(filters.filter((_, i) => i !== index));
};
return (
<Row gap alignItems="center" justifyContent="space-between" padding="2" backgroundColor="surface-sunken" wrap="wrap">
<Row alignItems="center" gap="2" wrap="wrap" width={{ base: '100%', md: 'auto' }}>
{filters.map((filter, index) => (
<FilterPill
key={`${filter.propertyName}-${index}`}
label={filter.propertyName}
operator={operatorLabel(filter.operator)}
value={filter.value}
onRemove={() => handleRemove(index)}
/>
))}
</Row>
<TooltipTrigger delay={0}>
<Button variant="zero" onPress={() => onChange([])}>
<Icon>
<X />
</Icon>
</Button>
<Tooltip>
<Text>{t(labels.clearAll)}</Text>
</Tooltip>
</TooltipTrigger>
</Row>
);
}
function FilterPill({
label,
operator,
value,
onRemove,
}: {
label: string;
operator: string;
value: string;
onRemove: () => void;
}) {
return (
<Row border padding="2" color backgroundColor borderRadius alignItems="center" gap="4" theme="dark">
<Row alignItems="center" gap="2" style={{ maxWidth: 'min(500px, calc(100vw - 10rem))', minWidth: 0, overflow: 'hidden' }}>
<Text color="primary" weight="bold">
{label}
</Text>
<Text color="muted">{operator}</Text>
<Text
color="primary"
weight="bold"
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{value}
</Text>
</Row>
<Icon onClick={onRemove} size="xs" style={{ cursor: 'pointer' }}>
<X />
</Icon>
</Row>
);
}
@@ -0,0 +1,34 @@
'use client';
import { useMessages } from '@/components/hooks';
import { ListFilter } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import type { EventPropertyFilter } from '@/lib/types';
import { EventDataFilterEditForm } from './EventDataFilterEditForm';
export function EventDataFilterButton({
websiteId,
eventName,
eventFilters,
onApply,
}: {
websiteId: string;
eventName: string;
eventFilters: EventPropertyFilter[];
onApply: (filters: EventPropertyFilter[]) => void;
}) {
const { t, labels } = useMessages();
return (
<DialogButton icon={<ListFilter />} label={t(labels.propertyFilter)} variant="outline">
{({ close }) => (
<EventDataFilterEditForm
websiteId={websiteId}
eventName={eventName}
value={eventFilters}
onApply={onApply}
onClose={close}
/>
)}
</DialogButton>
);
}
@@ -0,0 +1,108 @@
'use client';
import { Button, Column, Grid, Icon, List, ListItem, Menu, MenuItem, MenuTrigger, Popover, Row } from '@umami/react-zen';
import { useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useEventDataFieldsQuery, useMessages, useMobile } from '@/components/hooks';
import { Plus } from '@/components/icons';
import type { EventPropertyFilter } from '@/lib/types';
import { EventDataFilterRecord } from './EventDataFilterRecord';
export function EventDataFilterEditForm({
websiteId,
eventName,
value,
onApply,
onClose,
}: {
websiteId: string;
eventName: string;
value: EventPropertyFilter[];
onApply: (filters: EventPropertyFilter[]) => void;
onClose: () => void;
}) {
const { t, labels, messages } = useMessages();
const { isMobile } = useMobile();
const [filters, setFilters] = useState<EventPropertyFilter[]>(value);
const { data: fields = [] } = useEventDataFieldsQuery(websiteId, eventName);
const handleAdd = (propertyName: string) => {
const field = (fields as any[]).find(f => f.propertyName === propertyName);
const dataType: number = field?.dataType ?? 1;
setFilters(prev => [...prev, { propertyName, dataType, operator: 'eq', value: '' }]);
};
const handleChange = (index: number, filter: EventPropertyFilter) => {
setFilters(prev => prev.map((f, i) => (i === index ? filter : f)));
};
const handleRemove = (index: number) => {
setFilters(prev => prev.filter((_, i) => i !== index));
};
return (
<Column width={isMobile ? 'auto' : '700px'} gap="6">
<Column minHeight="400px">
<Grid columns={{ base: '1fr', md: '180px 1fr' }} overflow="hidden" gapY="6">
<Row display={{ base: 'flex', md: 'none' }}>
<MenuTrigger>
<Button>
<Icon>
<Plus />
</Icon>
</Button>
<Popover placement="bottom start" shouldFlip>
<Menu
onAction={key => handleAdd(key.toString())}
style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
>
{(fields as any[]).map(field => (
<MenuItem key={field.propertyName} id={field.propertyName}>
{field.propertyName}
</MenuItem>
))}
</Menu>
</Popover>
</MenuTrigger>
</Row>
<Column
display={{ base: 'none', md: 'flex' }}
border="right"
paddingRight="3"
marginRight="6"
>
<List onAction={key => handleAdd(key.toString())}>
{(fields as any[]).map(field => (
<ListItem key={field.propertyName} id={field.propertyName}>
{field.propertyName}
</ListItem>
))}
</List>
</Column>
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
{filters.map((filter, index) => (
<EventDataFilterRecord
key={`${filter.propertyName}-${index}`}
websiteId={websiteId}
eventName={eventName}
filter={filter}
onChange={f => handleChange(index, f)}
onRemove={() => handleRemove(index)}
/>
))}
{!filters.length && <Empty message={t(messages.nothingSelected)} />}
</Column>
</Grid>
</Column>
<Row alignItems="center" justifyContent="space-between" gap>
<Button onPress={() => setFilters([])}>{t(labels.reset)}</Button>
<Row gap>
<Button onPress={onClose}>{t(labels.cancel)}</Button>
<Button variant="primary" onPress={() => { onApply(filters); onClose(); }}>
{t(labels.apply)}
</Button>
</Row>
</Row>
</Column>
);
}
@@ -0,0 +1,121 @@
'use client';
import { Button, Column, Grid, Icon, Label, ListItem, Loading, Select, TextField } from '@umami/react-zen';
import { useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { MultiSelect } from '@/components/common/MultiSelect';
import { useEventDataValuesQuery, useMessages } from '@/components/hooks';
import { X } from '@/components/icons';
import type { EventPropertyFilter } from '@/lib/types';
const STRING_OPERATORS = ['eq', 'neq', 'c', 'dnc', 'regex', 'notRegex'] as const;
const NUMERIC_OPERATORS = ['eq', 'neq', 'gt', 'lt', 'gte', 'lte'] as const;
export function EventDataFilterRecord({
websiteId,
eventName,
filter,
onChange,
onRemove,
}: {
websiteId: string;
eventName: string;
filter: EventPropertyFilter;
onChange: (filter: EventPropertyFilter) => void;
onRemove: () => void;
}) {
const { t, labels, messages } = useMessages();
const [search, setSearch] = useState('');
const isNumeric = filter.dataType === 2;
const operators = isNumeric ? NUMERIC_OPERATORS : STRING_OPERATORS;
const isFreeText = filter.operator === 'c' || filter.operator === 'dnc' || filter.operator === 'regex' || filter.operator === 'notRegex';
const { data, isLoading } = useEventDataValuesQuery(
websiteId,
eventName,
filter.propertyName,
{ enabled: !isNumeric && !isFreeText },
);
const values = (data as Array<{ value: string }> | undefined)?.map(d => d.value) ?? [];
const filteredValues = search
? values.filter(v => v.toLowerCase().includes(search.toLowerCase()))
: values;
const selected = filter.value ? filter.value.split(',').filter(Boolean) : [];
const operatorLabel = (op: string) => {
switch (op) {
case 'eq': return t(labels.is);
case 'neq': return t(labels.isNot);
case 'c': return t(labels.contains);
case 'dnc': return t(labels.doesNotContain);
case 'regex': return t(labels.regexMatch);
case 'notRegex': return t(labels.regexNotMatch);
case 'gt': return t(labels.greaterThan);
case 'lt': return t(labels.lessThan);
case 'gte': return t(labels.greaterThanEquals);
case 'lte': return t(labels.lessThanEquals);
default: return op;
}
};
const handleOperatorChange = (op: string) => {
// clear value when switching between multi-select and free-text modes
const wasMulti = filter.operator === 'eq' || filter.operator === 'neq';
const isMulti = op === 'eq' || op === 'neq';
onChange({ ...filter, operator: op, value: wasMulti === isMulti ? filter.value : '' });
};
return (
<Column>
<Label>{filter.propertyName}</Label>
<Grid columns="1fr auto" gap>
<Grid columns={{ base: '1fr', md: '200px 1fr' }} gap>
<Select value={filter.operator} onChange={handleOperatorChange}>
{operators.map((op: string) => (
<ListItem key={op} id={op}>
{operatorLabel(op)}
</ListItem>
))}
</Select>
{isNumeric || isFreeText ? (
<TextField
value={filter.value}
onChange={v => onChange({ ...filter, value: v })}
inputMode={isNumeric ? 'numeric' : 'text'}
/>
) : (
<MultiSelect
value={selected}
onChange={vals => onChange({ ...filter, value: vals.join(',') })}
searchValue={search}
onSearch={setSearch}
allowSearch
renderValue={vals => (vals.length > 0 ? vals.join(', ') : undefined)}
renderEmptyState={() =>
isLoading ? (
<Loading placement="center" icon="dots" />
) : (
<Empty message={t(messages.noResultsFound)} />
)
}
>
{filteredValues.map(v => (
<ListItem key={v} id={v}>
{v}
</ListItem>
))}
</MultiSelect>
)}
</Grid>
<Column justifyContent="flex-start">
<Button onPress={onRemove}>
<Icon>
<X />
</Icon>
</Button>
</Column>
</Grid>
</Column>
);
}
@@ -14,15 +14,18 @@ import {
import { renderDateLabels } from '@/lib/charts';
import { getThemeColors } from '@/lib/colors';
import { generateTimeSeries } from '@/lib/date';
import type { EventPropertyFilter } from '@/lib/types';
export function EventDataNumericChart({
websiteId,
eventName,
propertyName,
eventFilters = [],
}: {
websiteId: string;
eventName: string;
propertyName: string;
eventFilters?: EventPropertyFilter[];
}) {
const { t, labels } = useMessages();
const { theme } = useTheme();
@@ -36,12 +39,14 @@ export function EventDataNumericChart({
eventName,
propertyName,
'sum',
eventFilters,
);
const avgQuery = useEventDataNumericSeriesQuery(
websiteId,
eventName,
propertyName,
'avg',
eventFilters,
);
const chartData: any = useMemo(() => {
@@ -8,20 +8,23 @@ import Link from '@/components/common/Link';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Pager } from '@/components/common/Pager';
import { useEventDataPivotQuery, useEventDataPropertiesQuery, useMessages, useMobile, useNavigation } from '@/components/hooks';
import type { EventPropertyFilter } from '@/lib/types';
export function EventDataPivotTable({
websiteId,
eventName,
eventFilters = [],
}: {
websiteId: string;
eventName: string;
eventFilters?: EventPropertyFilter[];
}) {
const { t, labels } = useMessages();
const { router, updateParams } = useNavigation();
const { isMobile } = useMobile();
const propertiesQuery = useEventDataPropertiesQuery(websiteId);
const pivotQuery = useEventDataPivotQuery(websiteId, eventName);
const pivotQuery = useEventDataPivotQuery(websiteId, eventName, eventFilters);
const propertyKeys = useMemo(() => {
if (!propertiesQuery.data || !eventName) return [];
@@ -8,7 +8,6 @@ import { LoadingPanel } from '@/components/common/LoadingPanel';
import {
useDateRange,
useEventDataPropertySeriesQuery,
useEventDataValuesQuery,
useLocale,
useMessages,
useTimezone,
@@ -17,26 +16,44 @@ import { ListTable } from '@/components/metrics/ListTable';
import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
import type { EventPropertyFilter } from '@/lib/types';
export function EventDataPropertyChart({
websiteId,
eventName,
propertyName,
eventFilters = [],
}: {
websiteId: string;
eventName: string;
propertyName: string;
eventFilters?: EventPropertyFilter[];
}) {
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 { data, isLoading, isFetching, error } = useEventDataPropertySeriesQuery(
websiteId,
eventName,
propertyName,
eventFilters,
);
// Aggregate totals per value from the already-filtered time series
const aggregated = useMemo(() => {
if (!data) return [];
const totals = (data as any[]).reduce((obj: Record<string, number>, { x, y }) => {
obj[x] = (obj[x] ?? 0) + y;
return obj;
}, {});
return Object.entries(totals)
.map(([value, total]) => ({ value, total: total as number }))
.sort((a, b) => b.total - a.total);
}, [data]);
const valueLabels = useMemo(() => aggregated.map(({ value }) => value), [aggregated]);
const colorMap = useMemo(() => {
return valueLabels.reduce(
(obj, label, index) => {
@@ -50,13 +67,9 @@ export function EventDataPropertyChart({
const chartData: any = useMemo(() => {
if (!data) return;
const map = (data as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
obj[x] = [];
}
const map = (data as any[]).reduce((obj: Record<string, { x: string; y: number }[]>, { x, t, y }) => {
if (!obj[x]) obj[x] = [];
obj[x].push({ x: t, y });
return obj;
}, {});
@@ -70,57 +83,57 @@ export function EventDataPropertyChart({
},
],
};
} 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,
};
}),
};
}
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 propertySum = useMemo(
() => aggregated.reduce((sum, { total }) => sum + total, 0),
[aggregated],
);
const tableData = useMemo(() => {
if (!valuesQuery.data || propertySum === 0) return [];
return valuesQuery.data.map(({ value, total }) => ({
if (!aggregated.length || propertySum === 0) return [];
return aggregated.map(({ value, total }) => ({
label: value,
count: total,
percent: 100 * (total / propertySum),
}));
}, [valuesQuery.data, propertySum]);
}, [aggregated, propertySum]);
const pieChartData: any = useMemo(() => {
if (!valuesQuery.data?.length) return null;
if (!aggregated.length) return null;
return {
labels: valueLabels,
datasets: [
{
data: valuesQuery.data.map(({ total }) => total),
data: aggregated.map(({ total }) => total),
backgroundColor: valueLabels.map(label => colorMap[label]),
borderWidth: 0,
},
],
};
}, [valuesQuery.data, valueLabels, colorMap]);
}, [aggregated, valueLabels, colorMap]);
return (
<Column gap="6">
@@ -139,9 +152,9 @@ export function EventDataPropertyChart({
</LoadingPanel>
<LoadingPanel
data={tableData}
isLoading={valuesQuery.isLoading}
isFetching={valuesQuery.isFetching}
error={valuesQuery.error}
isLoading={isLoading}
isFetching={isFetching}
error={error}
minHeight="300px"
>
<Grid columns={{ base: '1fr', md: '1fr 1fr' }} gap padding="2" alignItems="start">
@@ -1,8 +1,11 @@
'use client';
import { Column, ComboBox, Grid, Label, ListItem, Select } from '@umami/react-zen';
import { Column, ComboBox, Grid, Label, ListItem, Row, Select } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useEventDataPropertiesQuery, useMessages } from '@/components/hooks';
import type { EventPropertyFilter } from '@/lib/types';
import { EventDataFilterBar } from '../event-data/EventDataFilterBar';
import { EventDataFilterButton } from '../event-data/EventDataFilterButton';
import { EventDataNumericChart } from '../event-data/EventDataNumericChart';
import { EventDataPivotTable } from '../event-data/EventDataPivotTable';
import { EventDataPropertyChart } from '../event-data/EventDataPropertyChart';
@@ -10,6 +13,7 @@ import { EventDataPropertyChart } from '../event-data/EventDataPropertyChart';
export function EventProperties({ websiteId }: { websiteId: string }) {
const [eventName, setEventName] = useState('');
const [propertyName, setPropertyName] = useState('');
const [eventFilters, setEventFilters] = useState<EventPropertyFilter[]>([]);
const { t, labels } = useMessages();
const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId);
@@ -45,6 +49,7 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
const handleEventChange = (value: string) => {
setEventName(value);
setPropertyName('');
setEventFilters([]);
};
return (
@@ -57,39 +62,52 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
>
<Column gap="6" minWidth="0">
{data && (
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" marginBottom="3" gap>
<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>
<Row gap alignItems="end" marginBottom="3" wrap="wrap">
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap style={{ flex: 1 }}>
<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 && (
<EventDataFilterButton
websiteId={websiteId}
eventName={eventName}
eventFilters={eventFilters}
onApply={setEventFilters}
/>
)}
</Row>
)}
{eventName && (
<EventDataFilterBar filters={eventFilters} onChange={setEventFilters} />
)}
{eventName && propertyName && (
<Column border="bottom" paddingBottom="6">
@@ -98,6 +116,7 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
websiteId={websiteId}
eventName={eventName}
propertyName={propertyName}
eventFilters={eventFilters}
/>
)}
{selectedProperty?.dataType === 2 && (
@@ -105,11 +124,18 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
websiteId={websiteId}
eventName={eventName}
propertyName={propertyName}
eventFilters={eventFilters}
/>
)}
</Column>
)}
{eventName && <EventDataPivotTable websiteId={websiteId} eventName={eventName} />}
{eventName && (
<EventDataPivotTable
websiteId={websiteId}
eventName={eventName}
eventFilters={eventFilters}
/>
)}
</Column>
</LoadingPanel>
);
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { parseEventPropertyFilters } from '@/lib/params';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams } from '@/lib/schema';
@@ -32,7 +33,8 @@ export async function GET(
const { eventName, propertyName, metric, ...rest } = query;
const filters = await getQueryFilters(rest, websiteId);
const data = await getEventDataNumericSeries(websiteId, eventName, propertyName, metric, filters);
const eventFilters = parseEventPropertyFilters(query);
const data = await getEventDataNumericSeries(websiteId, eventName, propertyName, metric, filters, eventFilters);
return json(data);
}
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { parseEventPropertyFilters } from '@/lib/params';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams } from '@/lib/schema';
@@ -31,7 +32,8 @@ export async function GET(
const { eventName, propertyName, ...rest } = query;
const filters = await getQueryFilters(rest, websiteId);
const data = await getEventDataPropertySeries(websiteId, eventName, propertyName, filters);
const eventFilters = parseEventPropertyFilters(query);
const data = await getEventDataPropertySeries(websiteId, eventName, propertyName, filters, eventFilters);
return json(data);
}
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { parseEventPropertyFilters } from '@/lib/params';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams, pagingParams } from '@/lib/schema';
@@ -31,7 +32,8 @@ export async function GET(
const { eventName, ...rest } = query;
const filters = await getQueryFilters(rest, websiteId);
const result = await getEventDataPivot(websiteId, eventName, filters);
const eventFilters = parseEventPropertyFilters(query);
const result = await getEventDataPivot(websiteId, eventName, filters, eventFilters);
return json(result);
}
@@ -1,4 +1,5 @@
import type { ReactQueryOptions } from '@/lib/types';
import { serializeEventPropertyFilters } from '@/lib/params';
import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters';
import { useFilterParameters } from '../useFilterParameters';
@@ -8,6 +9,7 @@ export function useEventDataNumericSeriesQuery(
eventName: string,
propertyName: string,
metric: 'sum' | 'avg',
eventFilters: EventPropertyFilter[] = [],
options?: ReactQueryOptions,
) {
const { get, useQuery } = useApi();
@@ -17,7 +19,7 @@ export function useEventDataNumericSeriesQuery(
return useQuery<any>({
queryKey: [
'websites:event-data-pivot:numeric-series',
{ websiteId, eventName, propertyName, metric, startAt, endAt, unit, timezone, ...params },
{ websiteId, eventName, propertyName, metric, eventFilters, startAt, endAt, unit, timezone, ...params },
],
queryFn: () =>
get(`/websites/${websiteId}/event-data-pivot/numeric-series`, {
@@ -28,6 +30,7 @@ export function useEventDataNumericSeriesQuery(
endAt,
unit,
timezone,
...serializeEventPropertyFilters(eventFilters),
...params,
}),
enabled: !!(websiteId && eventName && propertyName),
@@ -1,4 +1,5 @@
import type { ReactQueryOptions } from '@/lib/types';
import { serializeEventPropertyFilters } from '@/lib/params';
import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters';
import { useFilterParameters } from '../useFilterParameters';
@@ -6,6 +7,7 @@ import { useFilterParameters } from '../useFilterParameters';
export function useEventDataPivotQuery(
websiteId: string,
eventName: string,
eventFilters: EventPropertyFilter[] = [],
options?: ReactQueryOptions,
) {
const { get, useQuery } = useApi();
@@ -15,7 +17,7 @@ export function useEventDataPivotQuery(
return useQuery({
queryKey: [
'websites:event-data-pivot',
{ websiteId, eventName, startAt, endAt, unit, timezone, ...params },
{ websiteId, eventName, eventFilters, startAt, endAt, unit, timezone, ...params },
],
queryFn: () =>
get(`/websites/${websiteId}/event-data-pivot`, {
@@ -24,6 +26,7 @@ export function useEventDataPivotQuery(
endAt,
unit,
timezone,
...serializeEventPropertyFilters(eventFilters),
...params,
}),
enabled: !!(websiteId && eventName),
@@ -1,4 +1,5 @@
import type { ReactQueryOptions } from '@/lib/types';
import { serializeEventPropertyFilters } from '@/lib/params';
import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters';
import { useFilterParameters } from '../useFilterParameters';
@@ -7,6 +8,7 @@ export function useEventDataPropertySeriesQuery(
websiteId: string,
eventName: string,
propertyName: string,
eventFilters: EventPropertyFilter[] = [],
options?: ReactQueryOptions,
) {
const { get, useQuery } = useApi();
@@ -16,7 +18,7 @@ export function useEventDataPropertySeriesQuery(
return useQuery<any>({
queryKey: [
'websites:event-data-pivot:property-series',
{ websiteId, eventName, propertyName, startAt, endAt, unit, timezone, ...params },
{ websiteId, eventName, propertyName, eventFilters, startAt, endAt, unit, timezone, ...params },
],
queryFn: () =>
get(`/websites/${websiteId}/event-data-pivot/property-series`, {
@@ -26,6 +28,7 @@ export function useEventDataPropertySeriesQuery(
endAt,
unit,
timezone,
...serializeEventPropertyFilters(eventFilters),
...params,
}),
enabled: !!(websiteId && eventName && propertyName),
+3 -2
View File
@@ -58,8 +58,9 @@ export function FilterBar({ websiteId }: { websiteId?: string }) {
justifyContent="space-between"
padding="2"
backgroundColor="surface-sunken"
wrap="wrap"
>
<Row alignItems="center" gap="2" wrap="wrap">
<Row alignItems="center" gap="2" wrap="wrap" width={{ base: '100%', md: 'auto' }}>
{segment && !isLoading && (
<FilterItem
name="segment"
@@ -149,7 +150,7 @@ const FilterItem = ({ name, label, operator, value, onRemove }) => {
theme="dark"
>
<Row alignItems="center" gap="4">
<Row alignItems="center" gap="2" maxWidth={'500px'}>
<Row alignItems="center" gap="2" style={{ maxWidth: 'min(500px, calc(100vw - 10rem))', minWidth: 0, overflow: 'hidden' }}>
<Text color="primary" weight="bold">
{label}
</Text>
+1
View File
@@ -190,6 +190,7 @@ export const labels: Record<string, string> = {
type: 'label.type',
filter: 'label.filter',
filters: 'label.filters',
propertyFilter: 'label.property-filter',
breakdown: 'label.breakdown',
true: 'label.true',
false: 'label.false',
+76 -2
View File
@@ -1,10 +1,10 @@
import { CLICKHOUSE } from '@/lib/db';
import { type ClickHouseClient, createClient } from '@clickhouse/client';
import { formatInTimeZone } from 'date-fns-tz';
import debug from 'debug';
import { CLICKHOUSE } from '@/lib/db';
import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants';
import { filtersObjectToArray } from './params';
import type { QueryFilters, QueryOptions } from './types';
import type { EventPropertyFilter, QueryFilters, QueryOptions } from './types';
export const CLICKHOUSE_DATE_FORMATS = {
utc: '%Y-%m-%dT%H:%i:%SZ',
@@ -235,6 +235,79 @@ function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
};
}
function getEventPropertyFilterQuery(filters: EventPropertyFilter[] = []): {
sql: string;
params: Record<string, any>;
} {
if (!filters.length) return { sql: '', params: {} };
const parts: string[] = [];
const params: Record<string, any> = {};
filters.forEach(({ propertyName, dataType, operator, value }, i) => {
const keyParam = `epf_key_${i}`;
const valParam = `epf_val_${i}`;
params[keyParam] = propertyName;
const isNumeric = dataType === 2;
const col = isNumeric ? 'number_value' : 'string_value';
let condition: string;
if (isNumeric) {
params[valParam] = parseFloat(value) || 0;
const opMap: Record<string, string> = {
eq: `${col} = {${valParam}:Float64}`,
neq: `${col} != {${valParam}:Float64}`,
gt: `${col} > {${valParam}:Float64}`,
lt: `${col} < {${valParam}:Float64}`,
gte: `${col} >= {${valParam}:Float64}`,
lte: `${col} <= {${valParam}:Float64}`,
};
condition = opMap[operator] ?? `${col} = {${valParam}:Float64}`;
} else if (operator === 'eq' || operator === 'neq') {
const vals = value.split(',').filter(Boolean);
if (!vals.length) return;
params[valParam] = vals;
condition = mapFilter(
col,
operator === 'eq' ? OPERATORS.equals : OPERATORS.notEquals,
valParam,
'String',
);
} else if (operator === 'regex' || operator === 'notRegex') {
if (!value) return;
params[valParam] = value;
condition = mapFilter(
col,
operator === 'regex' ? OPERATORS.regex : OPERATORS.notRegex,
valParam,
'String',
);
} else {
if (!value) return;
params[valParam] = value;
condition = mapFilter(
col,
operator === 'c' ? OPERATORS.contains : OPERATORS.doesNotContain,
valParam,
'String',
);
}
parts.push(`and event_id in (
select event_id
from event_data
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and data_key = {${keyParam}:String}
and data_type = ${dataType}
and ${condition}
)`);
});
return { sql: parts.join('\n'), params };
}
async function pagedRawQuery(
query: string,
queryParams: Record<string, any>,
@@ -321,6 +394,7 @@ export default {
getDateSQL,
getSearchSQL,
getFilterQuery,
getEventPropertyFilterQuery,
getUTCString,
parseFilters,
pagedRawQuery,
+29 -1
View File
@@ -1,5 +1,5 @@
import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
import type { Filter, QueryFilters, QueryOptions } from '@/lib/types';
import type { EventPropertyFilter, Filter, QueryFilters, QueryOptions } from '@/lib/types';
export function parseFilterValue(param: any) {
if (typeof param === 'string') {
@@ -88,3 +88,31 @@ export function filtersArrayToObject(filters: Filter[]) {
return obj;
}, {});
}
export function parseEventPropertyFilters(query: Record<string, any>): EventPropertyFilter[] {
return Object.entries(query)
.filter(([key]) => /^epf_/.test(key))
.flatMap(([key, val]) => {
const dotIndex = (val as string).indexOf('.');
if (dotIndex < 1) return [];
const withoutPrefix = key.slice(4); // strip "epf_"
const propertyName = withoutPrefix.replace(/\d+$/, ''); // strip trailing index digits
const operator = (val as string).slice(0, dotIndex);
const value = (val as string).slice(dotIndex + 1);
const isNumeric =
['gt', 'lt', 'gte', 'lte'].includes(operator) ||
(['eq', 'neq'].includes(operator) && value !== '' && !Number.isNaN(Number(value)));
return [{ propertyName, dataType: isNumeric ? 2 : 1, operator, value }];
});
}
export function serializeEventPropertyFilters(filters: EventPropertyFilter[]): Record<string, string> {
const counts: Record<string, number> = {};
return Object.fromEntries(
filters.map(f => {
const n = counts[f.propertyName] ?? 0;
counts[f.propertyName] = n + 1;
return [`epf_${f.propertyName}${n > 0 ? n : ''}`, `${f.operator}.${f.value}`];
}),
);
}
+65 -2
View File
@@ -1,10 +1,10 @@
import { PrismaClient } from '@/generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { readReplicas } from '@prisma/extension-read-replicas';
import debug from 'debug';
import { PrismaClient } from '@/generated/prisma/client';
import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants';
import { filtersObjectToArray } from './params';
import type { Operator, QueryFilters, QueryOptions } from './types';
import type { EventPropertyFilter, Operator, QueryFilters, QueryOptions } from './types';
const log = debug('umami:prisma');
@@ -252,6 +252,68 @@ function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
};
}
function getEventPropertyFilterQuery(filters: EventPropertyFilter[] = []): {
sql: string;
params: Record<string, any>;
} {
if (!filters.length) return { sql: '', params: {} };
const parts: string[] = [];
const params: Record<string, any> = {};
filters.forEach(({ propertyName, dataType, operator, value }, i) => {
const keyParam = `epf_key_${i}`;
const valParam = `epf_val_${i}`;
params[keyParam] = propertyName;
const isNumeric = dataType === 2;
const col = isNumeric ? 'cast(number_value as decimal)' : 'string_value';
let condition: string;
if (isNumeric) {
params[valParam] = parseFloat(value) || 0;
const opMap: Record<string, string> = {
eq: `${col} = {{${valParam}}}`,
neq: `${col} != {{${valParam}}}`,
gt: `${col} > {{${valParam}}}`,
lt: `${col} < {{${valParam}}}`,
gte: `${col} >= {{${valParam}}}`,
lte: `${col} <= {{${valParam}}}`,
};
condition = opMap[operator] ?? `${col} = {{${valParam}}}`;
} else if (operator === 'eq' || operator === 'neq') {
const vals = value.split(',').filter(Boolean);
if (!vals.length) return;
params[valParam] = vals;
condition =
operator === 'eq'
? `${col} = ANY({{${valParam}::text[]}})`
: `${col} != ALL({{${valParam}::text[]}})`;
} else if (operator === 'regex' || operator === 'notRegex') {
if (!value) return;
params[valParam] = value;
condition = operator === 'regex' ? `${col} ~* {{${valParam}}}` : `${col} !~* {{${valParam}}}`;
} else {
if (!value) return;
params[valParam] = `%${value}%`;
condition =
operator === 'c' ? `${col} ilike {{${valParam}}}` : `${col} not ilike {{${valParam}}}`;
}
parts.push(`and website_event.event_id in (
select website_event_id
from event_data
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and data_key = {{${keyParam}}}
and data_type = ${dataType}
and ${condition}
)`);
});
return { sql: parts.join('\n'), params };
}
async function rawQuery(sql: string, data: Record<string, any>, name?: string): Promise<any> {
if (process.env.LOG_QUERY) {
log('QUERY:\n', sql);
@@ -426,6 +488,7 @@ export default {
getDateSQL,
getDateWeeklySQL,
getFilterQuery,
getEventPropertyFilterQuery,
getSearchParameters,
getTimestampDiffSQL,
getSearchSQL,
+4 -4
View File
@@ -1,5 +1,3 @@
import { startOfMonth, subMonths } from 'date-fns';
import { z } from 'zod';
import { checkAuth } from '@/lib/auth';
import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date';
@@ -8,6 +6,8 @@ import { filtersArrayToObject } from '@/lib/params';
import { badRequest, unauthorized } from '@/lib/response';
import type { QueryFilters } from '@/lib/types';
import { getWebsiteSegment } from '@/queries/prisma';
import { startOfMonth, subMonths } from 'date-fns';
import { z } from 'zod';
export async function parseRequest(
request: Request,
@@ -30,9 +30,9 @@ export async function parseRequest(
} else if (isGet) {
query = result.data;
// Re-add suffixed filter params (e.g., browser1, os2) stripped by Zod schema
// Re-add dynamic params stripped by Zod schema: suffixed filter params (browser1, os2)
for (const key of Object.keys(rawQuery)) {
if (/\d+$/.test(key) && !(key in query)) {
if ((/\d+$/.test(key) || /^epf_/.test(key)) && !(key in query)) {
query[key] = rawQuery[key];
}
}
+7
View File
@@ -30,6 +30,13 @@ export interface Auth {
};
}
export interface EventPropertyFilter {
propertyName: string;
dataType: number;
operator: string;
value: string;
}
export interface Filter {
name: string;
operator: Operator;
@@ -1,7 +1,7 @@
import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
import type { EventPropertyFilter, QueryFilters } from '@/lib/types';
const FUNCTION_NAME = 'getEventDataNumericSeries';
@@ -12,6 +12,7 @@ export async function getEventDataNumericSeries(
propertyName: string,
metric: 'sum' | 'avg',
filters: QueryFilters,
eventFilters?: EventPropertyFilter[],
]
) {
return runQuery({
@@ -26,13 +27,15 @@ async function relationalQuery(
propertyName: string,
metric: 'sum' | 'avg',
filters: QueryFilters,
eventFilters: EventPropertyFilter[] = [],
) {
const { timezone = 'utc', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = prisma;
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma;
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
...filters,
websiteId,
});
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
const aggFn = metric === 'avg' ? 'avg' : 'sum';
return rawQuery(
@@ -53,10 +56,11 @@ async function relationalQuery(
and event_data.data_key = {{propertyName}}
and event_data.data_type = 2
${filterQuery}
${epfSQL}
group by 1
order by 1
`,
{ ...queryParams, eventName, propertyName },
{ ...queryParams, eventName, propertyName, ...epfParams },
FUNCTION_NAME,
);
}
@@ -67,10 +71,12 @@ async function clickhouseQuery(
propertyName: string,
metric: 'sum' | 'avg',
filters: QueryFilters,
eventFilters: EventPropertyFilter[] = [],
): Promise<{ t: string; y: number }[]> {
const { timezone = 'UTC', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
const aggFn = metric === 'avg' ? 'avg' : 'sum';
return rawQuery(
@@ -95,10 +101,11 @@ async function clickhouseQuery(
and event_data.data_key = {propertyName:String}
and event_data.data_type = 2
${filterQuery}
${epfSQL}
group by t
order by t
`,
{ ...queryParams, eventName, propertyName },
{ ...queryParams, eventName, propertyName, ...epfParams },
FUNCTION_NAME,
);
}
+21 -10
View File
@@ -2,12 +2,17 @@ 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';
import type { EventPropertyFilter, QueryFilters } from '@/lib/types';
const FUNCTION_NAME = 'getEventDataPivot';
export async function getEventDataPivot(
...args: [websiteId: string, eventName: string, filters: QueryFilters]
...args: [
websiteId: string,
eventName: string,
filters: QueryFilters,
eventFilters?: EventPropertyFilter[],
]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
@@ -15,8 +20,8 @@ export async function getEventDataPivot(
});
}
async function relationalQuery(websiteId: string, eventName: string, filters: QueryFilters) {
const { rawQuery, parseFilters } = prisma;
async function relationalQuery(websiteId: string, eventName: string, filters: QueryFilters, eventFilters: EventPropertyFilter[] = []) {
const { rawQuery, parseFilters, getEventPropertyFilterQuery } = prisma;
const { page = 1, pageSize } = filters;
const size = +pageSize || DEFAULT_PAGE_SIZE;
const offset = +size * (+page - 1);
@@ -25,6 +30,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
...filters,
websiteId,
});
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
const countResult = await rawQuery(
`
@@ -39,8 +45,9 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
and website_event.created_at between {{startDate}} and {{endDate}}
and website_event.event_name = {{eventName}}
${filterQuery}
${epfSQL}
`,
{ ...queryParams, eventName },
{ ...queryParams, eventName, ...epfParams },
);
const count = countResult[0].num;
@@ -59,6 +66,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
and website_event.created_at between {{startDate}} and {{endDate}}
and website_event.event_name = {{eventName}}
${filterQuery}
${epfSQL}
group by website_event.event_id
order by max(website_event.created_at) desc
limit ${size} offset ${offset}
@@ -85,7 +93,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
and event_data.created_at between {{startDate}} and {{endDate}}
order by website_event.created_at desc
`,
{ ...queryParams, eventName },
{ ...queryParams, eventName, ...epfParams },
FUNCTION_NAME,
);
@@ -106,13 +114,14 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu
return { data: [...eventMap.values()], count, page: +page, pageSize: size };
}
async function clickhouseQuery(websiteId: string, eventName: string, filters: QueryFilters) {
const { rawQuery, parseFilters } = clickhouse;
async function clickhouseQuery(websiteId: string, eventName: string, filters: QueryFilters, eventFilters: EventPropertyFilter[] = []) {
const { rawQuery, parseFilters, getEventPropertyFilterQuery } = 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 { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
const count = await rawQuery(
`
@@ -132,8 +141,9 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu
where event_data_pivot.website_id = {websiteId:UUID}
and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterQuery}
${epfSQL}
`,
{ ...queryParams, eventName },
{ ...queryParams, eventName, ...epfParams },
).then((res: any) => res[0].num);
const data = await rawQuery(
@@ -161,11 +171,12 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu
where event_data_pivot.website_id = {websiteId:UUID}
and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterQuery}
${epfSQL}
group by event_id, session_id, event_name, url_path, created_at
order by created_at desc
limit ${size} offset ${offset}
`,
{ ...queryParams, eventName },
{ ...queryParams, eventName, ...epfParams },
FUNCTION_NAME,
);
@@ -1,12 +1,18 @@
import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
import type { EventPropertyFilter, QueryFilters } from '@/lib/types';
const FUNCTION_NAME = 'getEventDataPropertySeries';
export async function getEventDataPropertySeries(
...args: [websiteId: string, eventName: string, propertyName: string, filters: QueryFilters]
...args: [
websiteId: string,
eventName: string,
propertyName: string,
filters: QueryFilters,
eventFilters?: EventPropertyFilter[],
]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
@@ -19,13 +25,15 @@ async function relationalQuery(
eventName: string,
propertyName: string,
filters: QueryFilters,
eventFilters: EventPropertyFilter[] = [],
) {
const { timezone = 'utc', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = prisma;
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma;
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
...filters,
websiteId,
});
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
return rawQuery(
`
@@ -46,10 +54,11 @@ async function relationalQuery(
and event_data.data_key = {{propertyName}}
and event_data.data_type = 1
${filterQuery}
${epfSQL}
group by 1, 2
order by 2
`,
{ ...queryParams, eventName, propertyName },
{ ...queryParams, eventName, propertyName, ...epfParams },
FUNCTION_NAME,
);
}
@@ -59,10 +68,12 @@ async function clickhouseQuery(
eventName: string,
propertyName: string,
filters: QueryFilters,
eventFilters: EventPropertyFilter[] = [],
): Promise<{ x: string; t: string; y: number }[]> {
const { timezone = 'UTC', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
return rawQuery(
`
@@ -87,10 +98,11 @@ async function clickhouseQuery(
and event_data.data_key = {propertyName:String}
and event_data.data_type = 1
${filterQuery}
${epfSQL}
group by x, t
order by t
`,
{ ...queryParams, eventName, propertyName },
{ ...queryParams, eventName, propertyName, ...epfParams },
FUNCTION_NAME,
);
}