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

View File

@@ -1,16 +1,26 @@
import { WithLabel } from '@/components/forms/input-with-label'; import { WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input'; 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 { Button } from '@/components/ui/button';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useEventNames } from '@/hooks/use-event-names';
import { handleError, useTRPC } from '@/integrations/trpc/react'; import { handleError, useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { shortId } from '@openpanel/common';
import type { import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
IProjectFilterEvent,
IProjectFilterIp, IProjectFilterIp,
IProjectFilterProfileId, IProjectFilterProfileId,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { zProjectFilterEvent } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query'; 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 { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
@@ -22,9 +32,125 @@ type Props = {
const validator = z.object({ const validator = z.object({
ips: z.array(z.string()), ips: z.array(z.string()),
profileIds: z.array(z.string()), profileIds: z.array(z.string()),
eventRules: z.array(zProjectFilterEvent.omit({ type: true })),
}); });
type IForm = z.infer<typeof validator>; 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) { export default function EditProjectFilters({ project }: Props) {
const form = useForm<IForm>({ const form = useForm<IForm>({
@@ -38,6 +164,15 @@ export default function EditProjectFilters({ project }: Props) {
(item): item is IProjectFilterProfileId => item.type === 'profile_id', (item): item is IProjectFilterProfileId => item.type === 'profile_id',
) )
.map((item) => item.profileId), .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, type: 'profile_id' as const,
profileId, 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 ( return (
<Widget className="max-w-screen-md w-full"> <Widget className="max-w-screen-md w-full">
<WidgetHead className="space-y-2"> <WidgetHead className="space-y-2">
@@ -73,7 +236,15 @@ export default function EditProjectFilters({ project }: Props) {
</p> </p>
</WidgetHead> </WidgetHead>
<WidgetBody> <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 <Controller
name="ips" name="ips"
control={form.control} 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 <Button
loading={mutation.isPending} loading={mutation.isPending}
type="submit" type="submit"

View File

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

View File

@@ -10,6 +10,8 @@ import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
import { import {
checkNotificationRulesForEvent, checkNotificationRulesForEvent,
createEvent, createEvent,
getProjectByIdCached,
matchEvent,
sessionBuffer, sessionBuffer,
} from '@openpanel/db'; } from '@openpanel/db';
import type { ILogger } from '@openpanel/logger'; 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( async function createEventAndNotify(
payload: IServiceCreateEventPayload, payload: IServiceCreateEventPayload,
logger: ILogger, 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 }); logger.info('Creating event', { event: payload });
const [event] = await Promise.all([ const [event] = await Promise.all([
createEvent(payload), createEvent(payload),
@@ -160,7 +181,7 @@ export async function incomingEvent(
latitude: session?.latitude ?? baseEvent.latitude, latitude: session?.latitude ?? baseEvent.latitude,
}; };
return createEventAndNotify(payload as IServiceEvent, logger); return createEventAndNotify(payload as IServiceEvent, logger, projectId);
} }
const sessionEnd = await getSessionEnd({ const sessionEnd = await getSessionEnd({
@@ -196,13 +217,19 @@ export async function incomingEvent(
createdAt: new Date(getTime(payload.createdAt) - 100), createdAt: new Date(getTime(payload.createdAt) - 100),
}, },
logger, logger,
projectId
).catch((error) => { ).catch((error) => {
logger.error('Error creating session start event', { event: payload }); logger.error('Error creating session start event', { event: payload });
throw error; 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) { if (!sessionEnd) {
logger.info('Creating session end job', { event: payload }); logger.info('Creating session end job', { event: payload });

View File

@@ -186,8 +186,26 @@ function matchEventFilters(
case 'regex': { case 'regex': {
return value return value
.map((val) => stripLeadingAndTrailingSlashes(String(val))) .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: default:
return false; return false;
} }

View File

@@ -465,9 +465,15 @@ export const zProjectFilterProfileId = z.object({
}); });
export type IProjectFilterProfileId = z.infer<typeof zProjectFilterProfileId>; 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', [ export const zProjectFilters = z.discriminatedUnion('type', [
zProjectFilterIp, zProjectFilterIp,
zProjectFilterProfileId, zProjectFilterProfileId,
zProjectFilterEvent,
]); ]);
export type IProjectFilters = z.infer<typeof zProjectFilters>; export type IProjectFilters = z.infer<typeof zProjectFilters>;