feat: add exclude event filters

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-18 11:47:35 +01:00
parent 03c18b37ec
commit 7e2d93db45
6 changed files with 263 additions and 7 deletions

View File

@@ -35,6 +35,7 @@ interface PureFilterProps {
filter: IChartEventFilter,
) => void;
className?: string;
immediateInput?: boolean;
}
export function FilterItem({ filter, event }: FilterProps) {
@@ -113,6 +114,7 @@ export function PureFilterItem({
onChangeValue,
onChangeOperator,
className,
immediateInput,
}: PureFilterProps) {
const { projectId } = useAppParams();
@@ -170,6 +172,7 @@ export function PureFilterItem({
<InputEnter
value={filter.value[0] ? String(filter.value[0]) : ''}
onChangeValue={(value) => changeFilterValue([value])}
immediate={immediateInput}
/>
)}
</div>

View File

@@ -1,16 +1,26 @@
import { WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { PropertiesCombobox } from '@/components/report/sidebar/PropertiesCombobox';
import { Button } from '@/components/ui/button';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useEventNames } from '@/hooks/use-event-names';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { shortId } from '@openpanel/common';
import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
IProjectFilterEvent,
IProjectFilterIp,
IProjectFilterProfileId,
} from '@openpanel/validation';
import { zProjectFilterEvent } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { SaveIcon } from 'lucide-react';
import { PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
@@ -22,9 +32,125 @@ type Props = {
const validator = z.object({
ips: z.array(z.string()),
profileIds: z.array(z.string()),
eventRules: z.array(zProjectFilterEvent.omit({ type: true })),
});
type IForm = z.infer<typeof validator>;
type IEventRule = IForm['eventRules'][number];
interface EventRuleItemProps {
projectId: string;
rule: IEventRule;
onChange: (rule: IEventRule) => void;
onRemove: () => void;
}
function EventRuleItem({
projectId,
rule,
onChange,
onRemove,
}: EventRuleItemProps) {
const eventNames = useEventNames({ projectId, anyEvents: false });
const addFilter = (action: {
value: string;
label: string;
description: string;
}) => {
onChange({
...rule,
filters: [
...rule.filters,
{ id: shortId(), name: action.value, operator: 'is', value: [] },
],
});
};
const removeFilter = (filter: IChartEventFilter) => {
onChange({
...rule,
filters: rule.filters.filter((f) => f.id !== filter.id),
});
};
const changeFilterValue = (
value: IChartEventFilterValue[],
filter: IChartEventFilter,
) => {
onChange({
...rule,
filters: rule.filters.map((f) =>
f.id === filter.id ? { ...f, value } : f,
),
});
};
const changeFilterOperator = (
operator: IChartEventFilterOperator,
filter: IChartEventFilter,
) => {
onChange({
...rule,
filters: rule.filters.map((f) =>
f.id === filter.id
? { ...f, operator, value: f.value.filter(Boolean).slice(0, 1) }
: f,
),
});
};
return (
<div className="rounded-lg border bg-def-100">
<div className="flex items-center gap-2 p-4">
<div className="flex-1">
<ComboboxEvents
placeholder="Select event name..."
items={eventNames}
value={rule.name}
onChange={(name) => onChange({ ...rule, name })}
className="w-full"
searchable
/>
</div>
<Button variant="ghost" size="icon" onClick={onRemove}>
<Trash2Icon size={16} />
</Button>
</div>
{rule.filters.length > 0 && (
<>
{rule.filters.map((filter) => (
<PureFilterItem
key={filter.id}
filter={filter}
eventName={rule.name}
onRemove={removeFilter}
onChangeValue={changeFilterValue}
onChangeOperator={changeFilterOperator}
immediateInput
className="border-t p-2 px-4 border-l-2 border-l-emerald-500"
/>
))}
</>
)}
<div className="p-4 border-t">
<PropertiesCombobox onSelect={addFilter} mode="events">
{(setOpen) => (
<Button
variant="outline"
size="sm"
onClick={() => setOpen(true)}
icon={PlusIcon}
>
Add property filter
</Button>
)}
</PropertiesCombobox>
</div>
</div>
);
}
export default function EditProjectFilters({ project }: Props) {
const form = useForm<IForm>({
@@ -38,6 +164,15 @@ export default function EditProjectFilters({ project }: Props) {
(item): item is IProjectFilterProfileId => item.type === 'profile_id',
)
.map((item) => item.profileId),
eventRules: project.filters
.filter((item): item is IProjectFilterEvent => item.type === 'event')
.map(({ name, filters, segment, property, displayName }) => ({
name,
filters,
segment,
property,
displayName,
})),
},
});
@@ -60,10 +195,38 @@ export default function EditProjectFilters({ project }: Props) {
type: 'profile_id' as const,
profileId,
})),
...values.eventRules
.filter((rule) => rule.name)
.map((rule) => ({
type: 'event' as const,
...rule,
})),
],
});
};
const eventRules = form.watch('eventRules');
const addEventRule = () => {
form.setValue('eventRules', [
...eventRules,
{ name: '', filters: [], segment: 'event' },
]);
};
const updateEventRule = (index: number, rule: IEventRule) => {
const updated = [...eventRules];
updated[index] = rule;
form.setValue('eventRules', updated);
};
const removeEventRule = (index: number) => {
form.setValue(
'eventRules',
eventRules.filter((_, i) => i !== index),
);
};
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead className="space-y-2">
@@ -73,7 +236,15 @@ export default function EditProjectFilters({ project }: Props) {
</p>
</WidgetHead>
<WidgetBody>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form
onSubmit={form.handleSubmit(onSubmit)}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) {
e.preventDefault();
}
}}
className="space-y-4"
>
<Controller
name="ips"
control={form.control}
@@ -108,6 +279,30 @@ export default function EditProjectFilters({ project }: Props) {
)}
/>
<WithLabel label="Event rules">
<div className="space-y-3">
{eventRules.map((rule, index) => (
<EventRuleItem
// biome-ignore lint/suspicious/noArrayIndexKey: order is stable
key={index}
projectId={project.id}
rule={rule}
onChange={(updated) => updateEventRule(index, updated)}
onRemove={() => removeEventRule(index)}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addEventRule}
icon={PlusIcon}
>
Add event rule
</Button>
</div>
</WithLabel>
<Button
loading={mutation.isPending}
type="submit"

View File

@@ -9,10 +9,12 @@ import { Input, type InputProps } from './input';
export function InputEnter({
value,
onChangeValue,
immediate,
...props
}: {
value: string | undefined;
onChangeValue: (value: string) => void;
immediate?: boolean;
} & InputProps) {
const [internalValue, setInternalValue] = useState(value ?? '');
@@ -27,7 +29,12 @@ export function InputEnter({
<Input
{...props}
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
onChange={(e) => {
setInternalValue(e.target.value);
if (immediate) {
onChangeValue(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onChangeValue(internalValue);
@@ -36,7 +43,7 @@ export function InputEnter({
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence>
{internalValue !== value && (
{!immediate && internalValue !== value && (
<motion.button
key="refresh"
initial={{ opacity: 0, scale: 0.8 }}

View File

@@ -10,6 +10,8 @@ import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
import {
checkNotificationRulesForEvent,
createEvent,
getProjectByIdCached,
matchEvent,
sessionBuffer,
} from '@openpanel/db';
import type { ILogger } from '@openpanel/logger';
@@ -28,7 +30,26 @@ const merge = <A, B>(a: Partial<A>, b: Partial<B>): A & B =>
async function createEventAndNotify(
payload: IServiceCreateEventPayload,
logger: ILogger,
projectId: string,
) {
// Check project-level event exclude filters
const project = await getProjectByIdCached(projectId);
const eventExcludeFilters = (project?.filters ?? []).filter(
(f) => f.type === 'event',
);
if (eventExcludeFilters.length > 0) {
const isExcluded = eventExcludeFilters.some((filter) =>
matchEvent(payload, filter),
);
if (isExcluded) {
logger.info('Event excluded by project filter', {
event: payload.name,
projectId,
});
return null
}
}
logger.info('Creating event', { event: payload });
const [event] = await Promise.all([
createEvent(payload),
@@ -160,7 +181,7 @@ export async function incomingEvent(
latitude: session?.latitude ?? baseEvent.latitude,
};
return createEventAndNotify(payload as IServiceEvent, logger);
return createEventAndNotify(payload as IServiceEvent, logger, projectId);
}
const sessionEnd = await getSessionEnd({
@@ -196,13 +217,19 @@ export async function incomingEvent(
createdAt: new Date(getTime(payload.createdAt) - 100),
},
logger,
projectId
).catch((error) => {
logger.error('Error creating session start event', { event: payload });
throw error;
});
}
const event = await createEventAndNotify(payload, logger);
const event = await createEventAndNotify(payload, logger, projectId);
if (!event) {
// Skip creating session end when event was excluded
return null
}
if (!sessionEnd) {
logger.info('Creating session end job', { event: payload });

View File

@@ -186,8 +186,26 @@ function matchEventFilters(
case 'regex': {
return value
.map((val) => stripLeadingAndTrailingSlashes(String(val)))
.some((val) => new RegExp(val).test(propertyValue));
.some((val) => {
try {
return new RegExp(val).test(propertyValue);
} catch {
return false;
}
});
}
case 'isNull':
return propertyValue === '';
case 'isNotNull':
return propertyValue !== '';
case 'gt':
return value.some((val) => Number(propertyValue) > Number(val));
case 'lt':
return value.some((val) => Number(propertyValue) < Number(val));
case 'gte':
return value.some((val) => Number(propertyValue) >= Number(val));
case 'lte':
return value.some((val) => Number(propertyValue) <= Number(val));
default:
return false;
}

View File

@@ -465,9 +465,15 @@ export const zProjectFilterProfileId = z.object({
});
export type IProjectFilterProfileId = z.infer<typeof zProjectFilterProfileId>;
export const zProjectFilterEvent = zChartEvent.extend({
type: z.literal('event'),
});
export type IProjectFilterEvent = z.infer<typeof zProjectFilterEvent>;
export const zProjectFilters = z.discriminatedUnion('type', [
zProjectFilterIp,
zProjectFilterProfileId,
zProjectFilterEvent,
]);
export type IProjectFilters = z.infer<typeof zProjectFilters>;