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