Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/pattern-filter-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@hyperdx/app': minor
---

feat: filter for or exclude a pattern from Event Patterns

Each row in Event Patterns now has "Filter for this pattern" and "Exclude this
pattern" buttons on hover. Each adds a filter for every event that fits the
pattern in the time range, not only the sampled rows, so the results table,
histogram and counts all update. Exclusions stack, show as pills next to the
other filters, survive sidebar filter changes, and are kept in the URL and in
saved searches. A new include replaces the previous one, and switching source
removes pattern filters.
40 changes: 40 additions & 0 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
import {
BuilderChartConfigWithDateRange,
ChartConfigWithDateRange,
ConditionFilter,
DisplayType,
Filter,
isPersistableUserId,
Expand Down Expand Up @@ -102,6 +103,13 @@ import ResourceTerraformPopover from '@/components/Iac/ResourceTerraformPopover'
import { InlineNameInput } from '@/components/InlineNameInput/InlineNameInput';
import { InputControlled } from '@/components/InputControlled';
import OnboardingModal from '@/components/OnboardingModal';
import {
isPatternFilter,
parsePatternFilter,
PatternFilter,
PatternFilterAction,
withPatternFilter,
} from '@/components/Patterns/patternFilter';
import {
isCustomSearchGroupBy,
resolveSearchGroupBy,
Expand Down Expand Up @@ -1468,8 +1476,37 @@ function DBSearchPageContent() {
onFilterChange: handleSetFilters,
dateTimeColumns,
knownColumns,
isPreservedFilter: isPatternFilter,
});

const patternFilters = useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Two separate derivations of the pattern-filter set

useSearchPageFilterState already splits pattern filters out of filters through isPreservedFilter (searchFilters.tsx:129). DBSearchPage then parses filters again for the pills and adds its own remove/add handlers inline. Have the hook return the preserved filters and a remover, or move this into a usePatternFilters hook in patternFilter.ts, so the pills and the facet-preservation logic use one source.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

searchFilters.tsx stays pattern-agnostic on purpose: it only knows isPreservedFilter, and the page derives the pills from the same filters the hook reads, so the two always agree.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Pattern-filter state (parse list, add, remove) is hand-written inside DBSearchPage, which also tracks the same filters a second way

useSearchPageFilterState already collects the same set through isPreservedFilter into preservedFiltersRef, so these filters now have two copies that must stay in sync. Move patternFilters, addPatternFilter and removePatternFilter into a usePatternFilters(filters, handleSetFilters) hook in components/Patterns/patternFilter.ts, or expose the preserved filters from useSearchPageFilterState. That keeps pattern-filter handling in one module instead of the multi-thousand-line page.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

() =>
(filters ?? []).flatMap(filter => {
const parsed = parsePatternFilter(filter);
return parsed ? [parsed] : [];
}),
[filters],
);
const removePatternFilter = useCallback(
(removed: PatternFilter) =>
handleSetFilters(
(filters ?? []).filter(
f => f.type !== 'sql' || f.condition !== removed.condition,
),
),
[filters, handleSetFilters],
);
// Showing matches switches to the results view.
const addPatternFilter = useCallback(
(added: ConditionFilter, action: PatternFilterAction) => {
handleSetFilters(withPatternFilter(filters ?? [], added));
if (action === 'include') {
setAnalysisMode('results');
}
},
[filters, handleSetFilters, setAnalysisMode],
);

useEffect(() => {
// If the user changes the source dropdown, reset the select and orderby fields
// to match the new source selected
Expand Down Expand Up @@ -2659,6 +2696,8 @@ function DBSearchPageContent() {
searchFilters={searchFilters}
chartConfig={filtersChartConfig}
dateTimeColumns={dateTimeColumns}
patternFilters={patternFilters}
onRemovePatternFilter={removePatternFilter}
mt={6}
/>
</form>
Expand Down Expand Up @@ -2800,6 +2839,7 @@ function DBSearchPageContent() {
draftPatternColumn={draftPatternColumn}
onDraftPatternColumnChange={setDraftPatternColumn}
onSubmit={onSubmit}
onPatternFilter={addPatternFilter}
totalCountConfig={totalCountConfig}
totalCountQueryKeyPrefix={QUERY_KEY_PREFIX}
/>
Expand Down
77 changes: 77 additions & 0 deletions packages/app/src/__tests__/searchFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,83 @@ describe('searchFilters', () => {
]);
});

it('keeps preserved filters through updates until clear all', () => {
onFilterChange.mockClear();
const pattern = { type: 'sql' as const, condition: "match(Body, '^a$')" };
const other = { type: 'sql' as const, condition: "match(Body, '^b$')" };
const { result } = renderHook(() =>
useSearchPageFilterState({
searchQuery: [pattern, other],
onFilterChange,
knownColumns: new Set(),
isPreservedFilter: f => f === pattern,
}),
);

act(() => {
result.current.setFilterValue('service', 'app');
});
expect(onFilterChange).toHaveBeenLastCalledWith([
{ type: 'sql', condition: "service IN ('app')" },
pattern,
]);

act(() => {
result.current.clearAllFilters();
});
expect(onFilterChange).toHaveBeenLastCalledWith([]);
});

it('without isPreservedFilter, still drops other SQL filters on update', () => {
onFilterChange.mockClear();
const service = { type: 'sql' as const, condition: "service IN ('app')" };
const raw = { type: 'sql' as const, condition: "match(Body, '^a$')" };
const { result } = renderHook(() =>
useSearchPageFilterState({
searchQuery: [service, raw],
onFilterChange,
knownColumns: new Set(),
}),
);

act(() => {
result.current.setFilterValue('level', 'error');
});
expect(onFilterChange).toHaveBeenLastCalledWith([
service,
{ type: 'sql', condition: "level IN ('error')" },
]);

let dropped: string[] = [];
act(() => {
dropped = result.current.retainFiltersByColumns(
new Set(['service', 'level']),
);
});
expect(dropped).toEqual([]);
});

it('drops preserved filters when reconciling to another source', () => {
onFilterChange.mockClear();
const service = { type: 'sql' as const, condition: "service IN ('app')" };
const pattern = { type: 'sql' as const, condition: "match(Body, '^a$')" };
const { result } = renderHook(() =>
useSearchPageFilterState({
searchQuery: [service, pattern],
onFilterChange,
knownColumns: new Set(),
isPreservedFilter: f => f === pattern,
}),
);

let dropped: string[] = [];
act(() => {
dropped = result.current.retainFiltersByColumns(new Set(['service']));
});
expect(dropped).toEqual([pattern.condition]);
expect(onFilterChange).toHaveBeenLastCalledWith([service]);
});

it('setOnlyFilters applies all filters in a single onFilterChange emit', () => {
onFilterChange.mockClear();
const { result } = renderHook(() =>
Expand Down
34 changes: 30 additions & 4 deletions packages/app/src/components/ActiveFilterPills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist
import { Flex, FlexProps, Text } from '@mantine/core';

import { FilterPill, FilterValueEditor } from '@/components/FilterPill';
import type { PatternFilter } from '@/components/Patterns/patternFilter';
import { simplifyPattern } from '@/components/Patterns/simplifyPattern';
import { useGetKeyValues } from '@/hooks/useMetadata';
import type { FilterStateHook } from '@/searchFilters';
import { useFormatTime } from '@/useFormatTime';
Expand All @@ -14,6 +16,7 @@ const VALUE_EDIT_LIMIT = 50;
// Stable identity so a caller that omits the prop doesn't invalidate the
// flattenFilters useMemo on every render.
const EMPTY_DATE_TIME_COLUMNS: ReadonlyMap<string, string> = new Map();
const NO_PATTERN_FILTERS: PatternFilter[] = [];

type FormatTime = ReturnType<typeof useFormatTime>;

Expand Down Expand Up @@ -153,9 +156,14 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({
invalidFieldReason,
chartConfig,
dateTimeColumns = EMPTY_DATE_TIME_COLUMNS,
patternFilters = NO_PATTERN_FILTERS,
onRemovePatternFilter,
...flexProps
}: {
searchFilters: FilterStateHook;
/** Filters added from the Event Patterns view, shown after the others. */
patternFilters?: PatternFilter[];
onRemovePatternFilter?: (filter: PatternFilter) => void;
/**
* Map of DateTime/Date column name → ClickHouse type. Their pill values are
* formatted to the user's locale/timezone for display, matching the results
Expand Down Expand Up @@ -262,12 +270,19 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({
clearTimeout(confirmTimerRef.current);
}, [confirmClear, clearAllFilters]);

if (pills.length === 0) {
const totalCount = pills.length + patternFilters.length;
if (totalCount === 0) {
return null;
}

const visiblePills = expanded ? pills : pills.slice(0, MAX_VISIBLE_PILLS);
const hiddenCount = pills.length - MAX_VISIBLE_PILLS;
// The cap covers facet and pattern pills together, facet pills first.
const visibleCount = expanded ? totalCount : MAX_VISIBLE_PILLS;
const visiblePills = pills.slice(0, visibleCount);
const visiblePatternFilters = patternFilters.slice(
0,
Math.max(visibleCount - pills.length, 0),
);
const hiddenCount = totalCount - MAX_VISIBLE_PILLS;

return (
<Flex gap={4} px="sm" wrap="wrap" align="center" {...flexProps}>
Expand All @@ -288,6 +303,17 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({
/>
);
})}
{visiblePatternFilters.map(filter => (
<FilterPill
key={filter.condition}
field="Pattern"
operator={filter.action === 'exclude' ? '!=' : '='}
value={simplifyPattern(filter.template)}
isExcluded={filter.action === 'exclude'}
onRemove={() => onRemovePatternFilter?.(filter)}
data-testid="active-filter-pill-pattern"
/>
))}
{!expanded && hiddenCount > 0 && (
<Text
size="xxs"
Expand All @@ -310,7 +336,7 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({
Show less
</Text>
)}
{pills.length >= 2 && (
{totalCount >= 2 && (
<Text
size="xxs"
c={confirmClear ? 'red.4' : 'dimmed'}
Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/components/DBRowTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ export const RawLogTable = memo(
selectionResetKey,
onSelectedRowsChange,
getRowWhere,
rowActions,
variant = 'default',
onRemoveColumn,
}: {
Expand Down Expand Up @@ -432,6 +433,8 @@ export const RawLogTable = memo(
sortOrder?: SortingState;
onSortingChange?: (v: SortingState | null) => void;
getRowWhere?: (row: Record<string, any>) => RowWhereResult;
/** Hover buttons for a row, shown instead of the row copy buttons. */
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
variant?: DBRowTableVariant;
onRemoveColumn?: (column: string) => void;
}) => {
Expand Down Expand Up @@ -1336,7 +1339,12 @@ export const RawLogTable = memo(
);
})}
{/* Row-level copy buttons */}
{getRowWhere && (
{rowActions && (
<div className={styles.rowButtons}>
{rowActions(row.original)}
</div>
)}
{getRowWhere && !rowActions && (
<DBRowTableRowButtons
row={row.original}
getRowWhere={getRowWhere}
Expand Down
25 changes: 25 additions & 0 deletions packages/app/src/components/PatternTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import {
BuilderChartConfigWithDateRange,
ConditionFilter,
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
Expand All @@ -17,6 +18,8 @@ import {
buildPatternColumnExpression,
PatternColumnSelector,
} from './Patterns/PatternColumnSelector';
import type { PatternFilterAction } from './Patterns/patternFilter';
import { PatternRowActions } from './Patterns/PatternRowActions';
import PatternSidePanel from './PatternSidePanel';

const emptyMap = new Map();
Expand All @@ -30,6 +33,7 @@ export default function PatternTable({
draftPatternColumn: externalDraftPatternColumn,
onDraftPatternColumnChange: externalOnDraftPatternColumnChange,
onSubmit: externalOnSubmit,
onPatternFilter,
source,
}: {
config: BuilderChartConfigWithDateRange;
Expand All @@ -39,6 +43,10 @@ export default function PatternTable({
draftPatternColumn?: string;
onDraftPatternColumnChange?: (value: string) => void;
onSubmit?: () => void;
onPatternFilter?: (
filter: ConditionFilter,
action: PatternFilterAction,
) => void;
totalCountQueryKeyPrefix: string;
source?: TSource;
}) {
Expand Down Expand Up @@ -94,6 +102,22 @@ export default function PatternTable({
) as Pattern[];
}, [groupedResults]);

// The rows are the patterns. One without a template gets no buttons.
const renderRowActions = useMemo(
() =>
onPatternFilter == null
? undefined
: (row: Record<string, unknown>) =>
typeof row.template === 'string' ? (
<PatternRowActions
template={row.template}
valueExpression={effectiveBodyValueExpression}
onFilter={onPatternFilter}
/>
) : null,
[effectiveBodyValueExpression, onPatternFilter],
);

return (
<>
{hasExternalPatternColumn && (
Expand Down Expand Up @@ -164,6 +188,7 @@ export default function PatternTable({
}}
config={patternQueryConfig}
showExpandButton={false}
rowActions={renderRowActions}
/>
{selectedPattern && source && (
<PatternSidePanel
Expand Down
Loading
Loading