diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json
index 035bc83fd..38a7460d3 100644
--- a/public/intl/messages/en-US.json
+++ b/public/intl/messages/en-US.json
@@ -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",
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx
new file mode 100644
index 000000000..a424d595a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx
@@ -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 (
+
+
+ {filters.map((filter, index) => (
+ handleRemove(index)}
+ />
+ ))}
+
+
+
+
+ {t(labels.clearAll)}
+
+
+
+ );
+}
+
+function FilterPill({
+ label,
+ operator,
+ value,
+ onRemove,
+}: {
+ label: string;
+ operator: string;
+ value: string;
+ onRemove: () => void;
+}) {
+ return (
+
+
+
+ {label}
+
+ {operator}
+
+ {value}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx
new file mode 100644
index 000000000..4f39b41e2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx
@@ -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 (
+ } label={t(labels.propertyFilter)} variant="outline">
+ {({ close }) => (
+
+ )}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx
new file mode 100644
index 000000000..82030cd32
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ handleAdd(key.toString())}>
+ {(fields as any[]).map(field => (
+
+ {field.propertyName}
+
+ ))}
+
+
+
+ {filters.map((filter, index) => (
+ handleChange(index, f)}
+ onRemove={() => handleRemove(index)}
+ />
+ ))}
+ {!filters.length && }
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx
new file mode 100644
index 000000000..4ca358029
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx
@@ -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 (
+
+
+
+
+
+ {isNumeric || isFreeText ? (
+ onChange({ ...filter, value: v })}
+ inputMode={isNumeric ? 'numeric' : 'text'}
+ />
+ ) : (
+ onChange({ ...filter, value: vals.join(',') })}
+ searchValue={search}
+ onSearch={setSearch}
+ allowSearch
+ renderValue={vals => (vals.length > 0 ? vals.join(', ') : undefined)}
+ renderEmptyState={() =>
+ isLoading ? (
+
+ ) : (
+
+ )
+ }
+ >
+ {filteredValues.map(v => (
+
+ {v}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx
index 09b559810..17ef799da 100644
--- a/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx
@@ -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(() => {
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx
index a0aad0e39..ac8b74dd2 100644
--- a/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx
@@ -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 [];
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx
index 5a7f8e5d1..431b87a6f 100644
--- a/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx
@@ -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, { 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, { 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 (
@@ -139,9 +152,9 @@ export function EventDataPropertyChart({
diff --git a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
index 68a1cc5ab..5f3b68e5d 100644
--- a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
+++ b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
@@ -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([]);
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 }) {
>
{data && (
-
-
-
-
-
-
-
-
- {properties.map((field: { propertyName: string }) => (
-
- {field.propertyName}
-
- ))}
-
-
-
+
+
+
+
+
+
+
+
+
+ {properties.map((field: { propertyName: string }) => (
+
+ {field.propertyName}
+
+ ))}
+
+
+
+ {eventName && (
+
+ )}
+
+ )}
+ {eventName && (
+
)}
{eventName && propertyName && (
@@ -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}
/>
)}
)}
- {eventName && }
+ {eventName && (
+
+ )}
);
diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts
index 37e1047f4..048fe7c5c 100644
--- a/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts
+++ b/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts
@@ -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);
}
diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts
index 7e923689f..4a60edbef 100644
--- a/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts
+++ b/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts
@@ -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);
}
diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/route.ts
index 37216f6e3..c43aa9f3f 100644
--- a/src/app/api/websites/[websiteId]/event-data-pivot/route.ts
+++ b/src/app/api/websites/[websiteId]/event-data-pivot/route.ts
@@ -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);
}
diff --git a/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts b/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts
index eb8b63638..d02674e89 100644
--- a/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts
+++ b/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts
@@ -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({
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),
diff --git a/src/components/hooks/queries/useEventDataPivotQuery.ts b/src/components/hooks/queries/useEventDataPivotQuery.ts
index e805c2b91..e9f689ac7 100644
--- a/src/components/hooks/queries/useEventDataPivotQuery.ts
+++ b/src/components/hooks/queries/useEventDataPivotQuery.ts
@@ -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),
diff --git a/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts b/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts
index a60f80442..68d963ba8 100644
--- a/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts
+++ b/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts
@@ -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({
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),
diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx
index 90be256be..c5d6e3c10 100644
--- a/src/components/input/FilterBar.tsx
+++ b/src/components/input/FilterBar.tsx
@@ -58,8 +58,9 @@ export function FilterBar({ websiteId }: { websiteId?: string }) {
justifyContent="space-between"
padding="2"
backgroundColor="surface-sunken"
+ wrap="wrap"
>
-
+
{segment && !isLoading && (
{
theme="dark"
>
-
+
{label}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index d5d77c206..79adab814 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -190,6 +190,7 @@ export const labels: Record = {
type: 'label.type',
filter: 'label.filter',
filters: 'label.filters',
+ propertyFilter: 'label.property-filter',
breakdown: 'label.breakdown',
true: 'label.true',
false: 'label.false',
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
index 0a8739b6c..5f3bedd0a 100644
--- a/src/lib/clickhouse.ts
+++ b/src/lib/clickhouse.ts
@@ -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, options?: QueryOptions) {
};
}
+function getEventPropertyFilterQuery(filters: EventPropertyFilter[] = []): {
+ sql: string;
+ params: Record;
+} {
+ if (!filters.length) return { sql: '', params: {} };
+
+ const parts: string[] = [];
+ const params: Record = {};
+
+ 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 = {
+ 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,
@@ -321,6 +394,7 @@ export default {
getDateSQL,
getSearchSQL,
getFilterQuery,
+ getEventPropertyFilterQuery,
getUTCString,
parseFilters,
pagedRawQuery,
diff --git a/src/lib/params.ts b/src/lib/params.ts
index f1452254d..60c563f10 100644
--- a/src/lib/params.ts
+++ b/src/lib/params.ts
@@ -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): 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 {
+ const counts: Record = {};
+ 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}`];
+ }),
+ );
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
index c3b6aaca3..266f11455 100644
--- a/src/lib/prisma.ts
+++ b/src/lib/prisma.ts
@@ -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, options?: QueryOptions) {
};
}
+function getEventPropertyFilterQuery(filters: EventPropertyFilter[] = []): {
+ sql: string;
+ params: Record;
+} {
+ if (!filters.length) return { sql: '', params: {} };
+
+ const parts: string[] = [];
+ const params: Record = {};
+
+ 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 = {
+ 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, name?: string): Promise {
if (process.env.LOG_QUERY) {
log('QUERY:\n', sql);
@@ -426,6 +488,7 @@ export default {
getDateSQL,
getDateWeeklySQL,
getFilterQuery,
+ getEventPropertyFilterQuery,
getSearchParameters,
getTimestampDiffSQL,
getSearchSQL,
diff --git a/src/lib/request.ts b/src/lib/request.ts
index 69f6eb1d7..40ffabe06 100644
--- a/src/lib/request.ts
+++ b/src/lib/request.ts
@@ -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];
}
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 72104352e..7569f2821 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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;
diff --git a/src/queries/sql/events/getEventDataNumericSeries.ts b/src/queries/sql/events/getEventDataNumericSeries.ts
index 5b0ef85d2..64bd9b66e 100644
--- a/src/queries/sql/events/getEventDataNumericSeries.ts
+++ b/src/queries/sql/events/getEventDataNumericSeries.ts
@@ -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,
);
}
diff --git a/src/queries/sql/events/getEventDataPivot.ts b/src/queries/sql/events/getEventDataPivot.ts
index eb69fa410..00044b649 100644
--- a/src/queries/sql/events/getEventDataPivot.ts
+++ b/src/queries/sql/events/getEventDataPivot.ts
@@ -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,
);
diff --git a/src/queries/sql/events/getEventDataPropertySeries.ts b/src/queries/sql/events/getEventDataPropertySeries.ts
index deb1c65b9..c8ef193dc 100644
--- a/src/queries/sql/events/getEventDataPropertySeries.ts
+++ b/src/queries/sql/events/getEventDataPropertySeries.ts
@@ -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,
);
}