Skip to content

Commit 69d9a41

Browse files
authored
feat: Filter on isRootSpan column if present (#1461)
Closes HDX-3009 # Summary This PR updates the Root Spans Only filter to support a materialized `isRootSpan` column, which is expected to be present on some trace table schemas. If that column is present, then the `Root Spans Only` filter will add a filter like 'isRootSpan IN (TRUE)` to the query, instead of the default `ParentSpanId IN ('')`. The UI has also been updated to support displaying and pinning boolean filter values. **Note:** We still only query string type filter values, so we won't show isRootSpan unless Root Spans Only has been toggled. <details> <summary>I confirmed that if `isRootSpan` is in the ordering key, then this new condition will utilize the key to prune granules:</summary> <img width="1678" height="1065" alt="Screenshot 2025-12-11 at 11 54 35 AM" src="https://github.com/user-attachments/assets/e22ae689-25a9-4d6b-b0f6-cc8f8396c35b" /> </details> ## Demo <details> <summary>For a source with the isRootSpan filter</summary> https://github.com/user-attachments/assets/ccc7a890-b16e-4de6-bbb9-295fb10aa214 </details> <details> <summary>For a source without the isRootSpan filter (no change)</summary> https://github.com/user-attachments/assets/33d4dd0a-136a-4284-812c-ddd12e67246e </details>
1 parent 65bcc1e commit 69d9a41

7 files changed

Lines changed: 185 additions & 76 deletions

File tree

‎.changeset/violet-paws-smash.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
feat: Filter on isRootSpan column if present

‎packages/app/src/DashboardFilters.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,10 @@ const DashboardFilters = ({
102102
filter={filter}
103103
dateRange={dateRange}
104104
onChange={value => onSetFilterValue(filter.expression, value)}
105-
value={
106-
filterValues[filter.expression]?.included.values().next().value
107-
}
105+
value={filterValues[filter.expression]?.included
106+
.values()
107+
.next()
108+
.value?.toString()}
108109
/>
109110
))}
110111
</Group>

‎packages/app/src/__tests__/searchFilters.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@ describe('searchFilters', () => {
6565
{ type: 'sql', condition: "toString(json.key) NOT IN ('other value')" },
6666
]);
6767
});
68+
69+
it('should should handle boolean filter values', () => {
70+
const filters = {
71+
isRootSpan: {
72+
included: new Set<string | boolean>([true]),
73+
excluded: new Set<string | boolean>([]),
74+
},
75+
another_column: {
76+
included: new Set<string | boolean>([]),
77+
excluded: new Set<string | boolean>([true, false]),
78+
},
79+
};
80+
expect(filtersToQuery(filters)).toEqual([
81+
{ type: 'sql', condition: 'isRootSpan IN (true)' },
82+
{ type: 'sql', condition: 'another_column NOT IN (true, false)' },
83+
]);
84+
});
6885
});
6986

7087
describe('parseQuery', () => {
@@ -275,6 +292,29 @@ describe('searchFilters', () => {
275292
},
276293
});
277294
});
295+
296+
it('handles boolean filter values', () => {
297+
const result = parseQuery([
298+
{
299+
type: 'sql',
300+
condition: `isRootSpan IN (true)`,
301+
},
302+
{
303+
type: 'sql',
304+
condition: `another_boolean NOT IN (TRUE, FALSE)`,
305+
},
306+
]);
307+
expect(result.filters).toEqual({
308+
isRootSpan: {
309+
included: new Set([true]),
310+
excluded: new Set(),
311+
},
312+
another_boolean: {
313+
included: new Set(),
314+
excluded: new Set([true, false]),
315+
},
316+
});
317+
});
278318
});
279319

280320
describe('areFiltersEqual', () => {
@@ -323,6 +363,30 @@ describe('searchFilters', () => {
323363
};
324364
expect(areFiltersEqual(a, b)).toBe(true);
325365
});
366+
367+
it('should handle boolean filters', () => {
368+
const a = {
369+
isRootSpan: {
370+
included: new Set<string | boolean>([true]),
371+
excluded: new Set<string | boolean>(),
372+
},
373+
another_column: {
374+
included: new Set<string | boolean>(),
375+
excluded: new Set<string | boolean>([true, false]),
376+
},
377+
};
378+
const b = {
379+
isRootSpan: {
380+
included: new Set<string | boolean>([true]),
381+
excluded: new Set<string | boolean>(),
382+
},
383+
another_column: {
384+
included: new Set<string | boolean>(),
385+
excluded: new Set<string | boolean>([false, true]),
386+
},
387+
};
388+
expect(areFiltersEqual(a, b)).toBe(true);
389+
});
326390
});
327391

328392
describe('useSearchPageFilterState', () => {

‎packages/app/src/components/DBSearchPageFilters.tsx‎

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,19 @@ import { IconSearch } from '@tabler/icons-react';
3333

3434
import {
3535
useAllFields,
36+
useColumns,
3637
useGetKeyValues,
3738
useGetValuesDistribution,
3839
useJsonColumns,
3940
useTableMetadata,
4041
} from '@/hooks/useMetadata';
4142
import { useMetadataWithSettings } from '@/hooks/useMetadata';
4243
import useResizable from '@/hooks/useResizable';
43-
import { FilterStateHook, usePinnedFilters } from '@/searchFilters';
44+
import {
45+
FilterStateHook,
46+
IS_ROOT_SPAN_COLUMN_NAME,
47+
usePinnedFilters,
48+
} from '@/searchFilters';
4449
import { useSource } from '@/source';
4550
import { mergePath } from '@/utils';
4651

@@ -300,19 +305,19 @@ const FilterRangeDisplay = ({
300305

301306
export type FilterGroupProps = {
302307
name: string;
303-
options: { value: string; label: string }[];
308+
options: { value: string | boolean; label: string }[];
304309
optionsLoading?: boolean;
305310
selectedValues?: {
306-
included: Set<string>;
307-
excluded: Set<string>;
311+
included: Set<string | boolean>;
312+
excluded: Set<string | boolean>;
308313
range?: { min: number; max: number };
309314
};
310-
onChange: (value: string) => void;
315+
onChange: (value: string | boolean) => void;
311316
onClearClick: VoidFunction;
312-
onOnlyClick: (value: string) => void;
313-
onExcludeClick: (value: string) => void;
314-
onPinClick: (value: string) => void;
315-
isPinned: (value: string) => boolean;
317+
onOnlyClick: (value: string | boolean) => void;
318+
onExcludeClick: (value: string | boolean) => void;
319+
onPinClick: (value: string | boolean) => void;
320+
isPinned: (value: string | boolean) => boolean;
316321
onFieldPinClick?: VoidFunction;
317322
isFieldPinned?: boolean;
318323
onLoadMore: (key: string) => void;
@@ -355,7 +360,9 @@ export const FilterGroup = ({
355360
// Accordion expanded state
356361
const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false);
357362
// Track recently moved items for highlight animation
358-
const [recentlyMoved, setRecentlyMoved] = useState<Set<string>>(new Set());
363+
const [recentlyMoved, setRecentlyMoved] = useState<Set<string | boolean>>(
364+
new Set(),
365+
);
359366
// Show what percentage of the data has each value
360367
const [showDistributions, setShowDistributions] = useState(false);
361368
// For live searches, don't refresh percentages when date range changes
@@ -438,7 +445,7 @@ export const FilterGroup = ({
438445
return [
439446
...Array.from(selectedSet)
440447
.filter(value => !options.find(option => option.value === value))
441-
.map(value => ({ value, label: value })),
448+
.map(value => ({ value, label: value.toString() })),
442449
...options,
443450
];
444451
}, [options, selectedValues]);
@@ -455,11 +462,11 @@ export const FilterGroup = ({
455462
.filter(option => {
456463
return (
457464
option.value &&
458-
option.value.toLowerCase().includes(search.toLowerCase())
465+
option.label.toLowerCase().includes(search.toLowerCase())
459466
);
460467
})
461468
.toSorted((a, b) =>
462-
a.value.localeCompare(b.value, undefined, { numeric: true }),
469+
a.label.localeCompare(b.label, undefined, { numeric: true }),
463470
);
464471
}
465472

@@ -485,14 +492,14 @@ export const FilterGroup = ({
485492
if (!aExcluded && bExcluded) return 1;
486493

487494
// Then sort by estimated percentage of rows with this value, if available
488-
const aPercentage = distributionData?.get(a.value) ?? 0;
489-
const bPercentage = distributionData?.get(b.value) ?? 0;
495+
const aPercentage = distributionData?.get(a.value.toString()) ?? 0;
496+
const bPercentage = distributionData?.get(b.value.toString()) ?? 0;
490497
if (aPercentage !== bPercentage) {
491498
return bPercentage - aPercentage;
492499
}
493500

494501
// Finally sort alphabetically/numerically
495-
return a.value.localeCompare(b.value, undefined, { numeric: true });
502+
return a.label.localeCompare(b.label, undefined, { numeric: true });
496503
});
497504
}, [
498505
search,
@@ -512,7 +519,7 @@ export const FilterGroup = ({
512519

513520
// Simple highlight animation when checkbox is checked
514521
const handleChange = useCallback(
515-
(value: string) => {
522+
(value: string | boolean) => {
516523
const wasIncluded = selectedValues.included.has(value);
517524

518525
// If checking (not unchecking), trigger highlight animation
@@ -662,7 +669,7 @@ export const FilterGroup = ({
662669
)}
663670
{displayedOptions.map(option => (
664671
<FilterCheckbox
665-
key={option.value}
672+
key={option.value.toString()}
666673
label={option.label}
667674
pinned={isPinned(option.value)}
668675
className={
@@ -684,7 +691,7 @@ export const FilterGroup = ({
684691
isPercentageLoading={isFetchingDistribution}
685692
percentage={
686693
showDistributions && distributionData
687-
? (distributionData.get(option.value) ?? 0)
694+
? (distributionData.get(option.value.toString()) ?? 0)
688695
: undefined
689696
}
690697
/>
@@ -794,7 +801,7 @@ const DBSearchPageFiltersComponent = ({
794801
const setFilterValue = useCallback(
795802
(
796803
property: string,
797-
value: string,
804+
value: string | boolean,
798805
action?: 'only' | 'exclude' | 'include' | undefined,
799806
) => {
800807
return _setFilterValue(property, value, action);
@@ -821,6 +828,11 @@ const DBSearchPageFiltersComponent = ({
821828
tableName: chartConfig.from.tableName,
822829
connectionId: chartConfig.connection,
823830
});
831+
const { data: columns } = useColumns({
832+
databaseName: chartConfig.from.databaseName,
833+
tableName: chartConfig.from.tableName,
834+
connectionId: chartConfig.connection,
835+
});
824836

825837
const { data: source } = useSource({ id: sourceId });
826838
const { data: tableMetadata } = useTableMetadata(tcFromSource(source));
@@ -913,7 +925,7 @@ const DBSearchPageFiltersComponent = ({
913925
return Array.from(mergedKeys).map(key => {
914926
const queriedValues = facetsMap.get(key);
915927
const pinnedValues = pinnedFilters[key];
916-
const mergedValues = new Set<string>([
928+
const mergedValues = new Set<string | boolean>([
917929
...(queriedValues ?? []),
918930
...(pinnedValues ?? []),
919931
]);
@@ -961,7 +973,7 @@ const DBSearchPageFiltersComponent = ({
961973
);
962974

963975
const shownFacets = useMemo(() => {
964-
const _facets: { key: string; value: string[] }[] = [];
976+
const _facets: { key: string; value: (string | boolean)[] }[] = [];
965977
for (const _facet of facetsWithPinnedValues ?? []) {
966978
const facet = structuredClone(_facet);
967979
if (jsonColumns?.some(col => facet.key.startsWith(col))) {
@@ -996,7 +1008,10 @@ const DBSearchPageFiltersComponent = ({
9961008
key => !_facets.some(facet => facet.key === key),
9971009
);
9981010
for (const key of remainingFilterState) {
999-
_facets.push({ key, value: Array.from(filterState[key].included) });
1011+
_facets.push({
1012+
key,
1013+
value: Array.from(filterState[key].included).map(v => v.toString()),
1014+
});
10001015
}
10011016

10021017
// prioritize facets that are primary keys
@@ -1052,22 +1067,30 @@ const DBSearchPageFiltersComponent = ({
10521067
if (!source?.parentSpanIdExpression) return;
10531068

10541069
if (rootSpansOnly) {
1055-
setFilterValue(source.parentSpanIdExpression, '', 'only');
1070+
if (columns?.some(col => col.name === IS_ROOT_SPAN_COLUMN_NAME)) {
1071+
setFilterValue(IS_ROOT_SPAN_COLUMN_NAME, true, 'only');
1072+
} else {
1073+
setFilterValue(source.parentSpanIdExpression, '', 'only');
1074+
}
10561075
} else {
10571076
clearFilter(source.parentSpanIdExpression);
1077+
clearFilter(IS_ROOT_SPAN_COLUMN_NAME);
10581078
}
10591079
},
1060-
[setFilterValue, clearFilter, source],
1080+
[setFilterValue, clearFilter, source, columns],
10611081
);
10621082

10631083
const isRootSpansOnly = useMemo(() => {
10641084
if (!source?.parentSpanIdExpression || source.kind !== SourceKind.Trace)
10651085
return false;
10661086

10671087
const parentSpanIdFilter = filterState?.[source?.parentSpanIdExpression];
1088+
const isRootSpanFilter = filterState?.[IS_ROOT_SPAN_COLUMN_NAME];
10681089
return (
1069-
parentSpanIdFilter?.included.size === 1 &&
1070-
parentSpanIdFilter?.included.has('')
1090+
(parentSpanIdFilter?.included.size === 1 &&
1091+
parentSpanIdFilter?.included.has('')) ||
1092+
(isRootSpanFilter?.included.size === 1 &&
1093+
isRootSpanFilter?.included.has(true))
10711094
);
10721095
}, [filterState, source]);
10731096

@@ -1214,7 +1237,10 @@ const DBSearchPageFiltersComponent = ({
12141237
},
12151238
{} as Record<
12161239
string,
1217-
{ included: Set<string>; excluded: Set<string> }
1240+
{
1241+
included: Set<string | boolean>;
1242+
excluded: Set<string | boolean>;
1243+
}
12181244
>,
12191245
)}
12201246
onChange={(key, value) => {
@@ -1269,7 +1295,7 @@ const DBSearchPageFiltersComponent = ({
12691295
name={cleanedFacetName(facet.key)}
12701296
options={facet.value.map(value => ({
12711297
value,
1272-
label: value,
1298+
label: value.toString(),
12731299
}))}
12741300
optionsLoading={isFacetsLoading}
12751301
selectedValues={

‎packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,26 @@ import {
88
UnstyledButton,
99
} from '@mantine/core';
1010

11+
import { FilterState } from '@/searchFilters';
12+
1113
import { FilterGroup } from '../DBSearchPageFilters';
1214

1315
import classes from '../../../styles/SearchPage.module.scss';
1416

1517
export type NestedFilterGroupProps = {
1618
name: string;
17-
childFilters: { key: string; value: string[]; propertyPath: string }[];
18-
selectedValues?: Record<
19-
string,
20-
{ included: Set<string>; excluded: Set<string> }
21-
>;
22-
onChange: (key: string, value: string) => void;
19+
childFilters: {
20+
key: string;
21+
value: (string | boolean)[];
22+
propertyPath: string;
23+
}[];
24+
selectedValues?: FilterState;
25+
onChange: (key: string, value: string | boolean) => void;
2326
onClearClick: (key: string) => void;
24-
onOnlyClick: (key: string, value: string) => void;
25-
onExcludeClick: (key: string, value: string) => void;
26-
onPinClick: (key: string, value: string) => void;
27-
isPinned: (key: string, value: string) => boolean;
27+
onOnlyClick: (key: string, value: string | boolean) => void;
28+
onExcludeClick: (key: string, value: string | boolean) => void;
29+
onPinClick: (key: string, value: string | boolean) => void;
30+
isPinned: (key: string, value: string | boolean) => boolean;
2831
onFieldPinClick?: (key: string) => void;
2932
isFieldPinned?: (key: string) => boolean;
3033
onLoadMore: (key: string) => void;
@@ -137,8 +140,8 @@ export const NestedFilterGroup = ({
137140
name={child.propertyPath}
138141
distributionKey={child.key}
139142
options={child.value.map(value => ({
140-
value,
141-
label: value,
143+
value: value,
144+
label: value.toString(),
142145
}))}
143146
optionsLoading={false}
144147
selectedValues={childSelectedValues}

0 commit comments

Comments
 (0)