feat: add exclude event filters
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user