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>
This commit is contained in:
Drew Davis 2025-12-12 09:11:42 -05:00 committed by GitHub
parent 65bcc1e72e
commit 69d9a4186d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 185 additions and 76 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Filter on isRootSpan column if present

View file

@ -102,9 +102,10 @@ const DashboardFilters = ({
filter={filter}
dateRange={dateRange}
onChange={value => onSetFilterValue(filter.expression, value)}
value={
filterValues[filter.expression]?.included.values().next().value
}
value={filterValues[filter.expression]?.included
.values()
.next()
.value?.toString()}
/>
))}
</Group>

View file

@ -65,6 +65,23 @@ describe('searchFilters', () => {
{ type: 'sql', condition: "toString(json.key) NOT IN ('other value')" },
]);
});
it('should should handle boolean filter values', () => {
const filters = {
isRootSpan: {
included: new Set<string | boolean>([true]),
excluded: new Set<string | boolean>([]),
},
another_column: {
included: new Set<string | boolean>([]),
excluded: new Set<string | boolean>([true, false]),
},
};
expect(filtersToQuery(filters)).toEqual([
{ type: 'sql', condition: 'isRootSpan IN (true)' },
{ type: 'sql', condition: 'another_column NOT IN (true, false)' },
]);
});
});
describe('parseQuery', () => {
@ -275,6 +292,29 @@ describe('searchFilters', () => {
},
});
});
it('handles boolean filter values', () => {
const result = parseQuery([
{
type: 'sql',
condition: `isRootSpan IN (true)`,
},
{
type: 'sql',
condition: `another_boolean NOT IN (TRUE, FALSE)`,
},
]);
expect(result.filters).toEqual({
isRootSpan: {
included: new Set([true]),
excluded: new Set(),
},
another_boolean: {
included: new Set(),
excluded: new Set([true, false]),
},
});
});
});
describe('areFiltersEqual', () => {
@ -323,6 +363,30 @@ describe('searchFilters', () => {
};
expect(areFiltersEqual(a, b)).toBe(true);
});
it('should handle boolean filters', () => {
const a = {
isRootSpan: {
included: new Set<string | boolean>([true]),
excluded: new Set<string | boolean>(),
},
another_column: {
included: new Set<string | boolean>(),
excluded: new Set<string | boolean>([true, false]),
},
};
const b = {
isRootSpan: {
included: new Set<string | boolean>([true]),
excluded: new Set<string | boolean>(),
},
another_column: {
included: new Set<string | boolean>(),
excluded: new Set<string | boolean>([false, true]),
},
};
expect(areFiltersEqual(a, b)).toBe(true);
});
});
describe('useSearchPageFilterState', () => {

View file

@ -33,6 +33,7 @@ import { IconSearch } from '@tabler/icons-react';
import {
useAllFields,
useColumns,
useGetKeyValues,
useGetValuesDistribution,
useJsonColumns,
@ -40,7 +41,11 @@ import {
} from '@/hooks/useMetadata';
import { useMetadataWithSettings } from '@/hooks/useMetadata';
import useResizable from '@/hooks/useResizable';
import { FilterStateHook, usePinnedFilters } from '@/searchFilters';
import {
FilterStateHook,
IS_ROOT_SPAN_COLUMN_NAME,
usePinnedFilters,
} from '@/searchFilters';
import { useSource } from '@/source';
import { mergePath } from '@/utils';
@ -300,19 +305,19 @@ const FilterRangeDisplay = ({
export type FilterGroupProps = {
name: string;
options: { value: string; label: string }[];
options: { value: string | boolean; label: string }[];
optionsLoading?: boolean;
selectedValues?: {
included: Set<string>;
excluded: Set<string>;
included: Set<string | boolean>;
excluded: Set<string | boolean>;
range?: { min: number; max: number };
};
onChange: (value: string) => void;
onChange: (value: string | boolean) => void;
onClearClick: VoidFunction;
onOnlyClick: (value: string) => void;
onExcludeClick: (value: string) => void;
onPinClick: (value: string) => void;
isPinned: (value: string) => boolean;
onOnlyClick: (value: string | boolean) => void;
onExcludeClick: (value: string | boolean) => void;
onPinClick: (value: string | boolean) => void;
isPinned: (value: string | boolean) => boolean;
onFieldPinClick?: VoidFunction;
isFieldPinned?: boolean;
onLoadMore: (key: string) => void;
@ -355,7 +360,9 @@ export const FilterGroup = ({
// Accordion expanded state
const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false);
// Track recently moved items for highlight animation
const [recentlyMoved, setRecentlyMoved] = useState<Set<string>>(new Set());
const [recentlyMoved, setRecentlyMoved] = useState<Set<string | boolean>>(
new Set(),
);
// Show what percentage of the data has each value
const [showDistributions, setShowDistributions] = useState(false);
// For live searches, don't refresh percentages when date range changes
@ -438,7 +445,7 @@ export const FilterGroup = ({
return [
...Array.from(selectedSet)
.filter(value => !options.find(option => option.value === value))
.map(value => ({ value, label: value })),
.map(value => ({ value, label: value.toString() })),
...options,
];
}, [options, selectedValues]);
@ -455,11 +462,11 @@ export const FilterGroup = ({
.filter(option => {
return (
option.value &&
option.value.toLowerCase().includes(search.toLowerCase())
option.label.toLowerCase().includes(search.toLowerCase())
);
})
.toSorted((a, b) =>
a.value.localeCompare(b.value, undefined, { numeric: true }),
a.label.localeCompare(b.label, undefined, { numeric: true }),
);
}
@ -485,14 +492,14 @@ export const FilterGroup = ({
if (!aExcluded && bExcluded) return 1;
// Then sort by estimated percentage of rows with this value, if available
const aPercentage = distributionData?.get(a.value) ?? 0;
const bPercentage = distributionData?.get(b.value) ?? 0;
const aPercentage = distributionData?.get(a.value.toString()) ?? 0;
const bPercentage = distributionData?.get(b.value.toString()) ?? 0;
if (aPercentage !== bPercentage) {
return bPercentage - aPercentage;
}
// Finally sort alphabetically/numerically
return a.value.localeCompare(b.value, undefined, { numeric: true });
return a.label.localeCompare(b.label, undefined, { numeric: true });
});
}, [
search,
@ -512,7 +519,7 @@ export const FilterGroup = ({
// Simple highlight animation when checkbox is checked
const handleChange = useCallback(
(value: string) => {
(value: string | boolean) => {
const wasIncluded = selectedValues.included.has(value);
// If checking (not unchecking), trigger highlight animation
@ -662,7 +669,7 @@ export const FilterGroup = ({
)}
{displayedOptions.map(option => (
<FilterCheckbox
key={option.value}
key={option.value.toString()}
label={option.label}
pinned={isPinned(option.value)}
className={
@ -684,7 +691,7 @@ export const FilterGroup = ({
isPercentageLoading={isFetchingDistribution}
percentage={
showDistributions && distributionData
? (distributionData.get(option.value) ?? 0)
? (distributionData.get(option.value.toString()) ?? 0)
: undefined
}
/>
@ -794,7 +801,7 @@ const DBSearchPageFiltersComponent = ({
const setFilterValue = useCallback(
(
property: string,
value: string,
value: string | boolean,
action?: 'only' | 'exclude' | 'include' | undefined,
) => {
return _setFilterValue(property, value, action);
@ -821,6 +828,11 @@ const DBSearchPageFiltersComponent = ({
tableName: chartConfig.from.tableName,
connectionId: chartConfig.connection,
});
const { data: columns } = useColumns({
databaseName: chartConfig.from.databaseName,
tableName: chartConfig.from.tableName,
connectionId: chartConfig.connection,
});
const { data: source } = useSource({ id: sourceId });
const { data: tableMetadata } = useTableMetadata(tcFromSource(source));
@ -913,7 +925,7 @@ const DBSearchPageFiltersComponent = ({
return Array.from(mergedKeys).map(key => {
const queriedValues = facetsMap.get(key);
const pinnedValues = pinnedFilters[key];
const mergedValues = new Set<string>([
const mergedValues = new Set<string | boolean>([
...(queriedValues ?? []),
...(pinnedValues ?? []),
]);
@ -961,7 +973,7 @@ const DBSearchPageFiltersComponent = ({
);
const shownFacets = useMemo(() => {
const _facets: { key: string; value: string[] }[] = [];
const _facets: { key: string; value: (string | boolean)[] }[] = [];
for (const _facet of facetsWithPinnedValues ?? []) {
const facet = structuredClone(_facet);
if (jsonColumns?.some(col => facet.key.startsWith(col))) {
@ -996,7 +1008,10 @@ const DBSearchPageFiltersComponent = ({
key => !_facets.some(facet => facet.key === key),
);
for (const key of remainingFilterState) {
_facets.push({ key, value: Array.from(filterState[key].included) });
_facets.push({
key,
value: Array.from(filterState[key].included).map(v => v.toString()),
});
}
// prioritize facets that are primary keys
@ -1052,12 +1067,17 @@ const DBSearchPageFiltersComponent = ({
if (!source?.parentSpanIdExpression) return;
if (rootSpansOnly) {
setFilterValue(source.parentSpanIdExpression, '', 'only');
if (columns?.some(col => col.name === IS_ROOT_SPAN_COLUMN_NAME)) {
setFilterValue(IS_ROOT_SPAN_COLUMN_NAME, true, 'only');
} else {
setFilterValue(source.parentSpanIdExpression, '', 'only');
}
} else {
clearFilter(source.parentSpanIdExpression);
clearFilter(IS_ROOT_SPAN_COLUMN_NAME);
}
},
[setFilterValue, clearFilter, source],
[setFilterValue, clearFilter, source, columns],
);
const isRootSpansOnly = useMemo(() => {
@ -1065,9 +1085,12 @@ const DBSearchPageFiltersComponent = ({
return false;
const parentSpanIdFilter = filterState?.[source?.parentSpanIdExpression];
const isRootSpanFilter = filterState?.[IS_ROOT_SPAN_COLUMN_NAME];
return (
parentSpanIdFilter?.included.size === 1 &&
parentSpanIdFilter?.included.has('')
(parentSpanIdFilter?.included.size === 1 &&
parentSpanIdFilter?.included.has('')) ||
(isRootSpanFilter?.included.size === 1 &&
isRootSpanFilter?.included.has(true))
);
}, [filterState, source]);
@ -1214,7 +1237,10 @@ const DBSearchPageFiltersComponent = ({
},
{} as Record<
string,
{ included: Set<string>; excluded: Set<string> }
{
included: Set<string | boolean>;
excluded: Set<string | boolean>;
}
>,
)}
onChange={(key, value) => {
@ -1269,7 +1295,7 @@ const DBSearchPageFiltersComponent = ({
name={cleanedFacetName(facet.key)}
options={facet.value.map(value => ({
value,
label: value,
label: value.toString(),
}))}
optionsLoading={isFacetsLoading}
selectedValues={

View file

@ -8,23 +8,26 @@ import {
UnstyledButton,
} from '@mantine/core';
import { FilterState } from '@/searchFilters';
import { FilterGroup } from '../DBSearchPageFilters';
import classes from '../../../styles/SearchPage.module.scss';
export type NestedFilterGroupProps = {
name: string;
childFilters: { key: string; value: string[]; propertyPath: string }[];
selectedValues?: Record<
string,
{ included: Set<string>; excluded: Set<string> }
>;
onChange: (key: string, value: string) => void;
childFilters: {
key: string;
value: (string | boolean)[];
propertyPath: string;
}[];
selectedValues?: FilterState;
onChange: (key: string, value: string | boolean) => void;
onClearClick: (key: string) => void;
onOnlyClick: (key: string, value: string) => void;
onExcludeClick: (key: string, value: string) => void;
onPinClick: (key: string, value: string) => void;
isPinned: (key: string, value: string) => boolean;
onOnlyClick: (key: string, value: string | boolean) => void;
onExcludeClick: (key: string, value: string | boolean) => void;
onPinClick: (key: string, value: string | boolean) => void;
isPinned: (key: string, value: string | boolean) => boolean;
onFieldPinClick?: (key: string) => void;
isFieldPinned?: (key: string) => boolean;
onLoadMore: (key: string) => void;
@ -137,8 +140,8 @@ export const NestedFilterGroup = ({
name={child.propertyPath}
distributionKey={child.key}
options={child.value.map(value => ({
value,
label: value,
value: value,
label: value.toString(),
}))}
optionsLoading={false}
selectedValues={childSelectedValues}

View file

@ -46,17 +46,21 @@ export function parseMapFieldName(
// Group facets by their base names for map-like fields
export function groupFacetsByBaseName(
facets: { key: string; value: string[] }[],
facets: { key: string; value: (string | boolean)[] }[],
) {
const grouped: Map<
string,
{
key: string;
value: string[];
children: { key: string; value: string[]; propertyPath: string }[];
value: (string | boolean)[];
children: {
key: string;
value: (string | boolean)[];
propertyPath: string;
}[];
}
> = new Map();
const nonGrouped: { key: string; value: string[] }[] = [];
const nonGrouped: { key: string; value: (string | boolean)[] }[] = [];
for (const facet of facets) {
const parsed = parseMapFieldName(facet.key);

View file

@ -4,10 +4,12 @@ import type { Filter } from '@hyperdx/common-utils/dist/types';
import { useLocalStorage } from './utils';
export const IS_ROOT_SPAN_COLUMN_NAME = 'isRootSpan';
export type FilterState = {
[key: string]: {
included: Set<string>;
excluded: Set<string>;
included: Set<string | boolean>;
excluded: Set<string | boolean>;
range?: { min: number; max: number }; // For BETWEEN conditions
};
};
@ -31,7 +33,7 @@ export const filtersToQuery = (
conditions.push({
type: 'sql' as const,
condition: `${actualKey} IN (${Array.from(values.included)
.map(v => `'${v}'`)
.map(v => (typeof v === 'string' ? `'${v}'` : v))
.join(', ')})`,
});
}
@ -39,7 +41,7 @@ export const filtersToQuery = (
conditions.push({
type: 'sql' as const,
condition: `${actualKey} NOT IN (${Array.from(values.excluded)
.map(v => `'${v}'`)
.map(v => (typeof v === 'string' ? `'${v}'` : v))
.join(', ')})`,
});
}
@ -84,9 +86,24 @@ export const areFiltersEqual = (a: FilterState, b: FilterState) => {
return true;
};
// Helper function to split on commas while respecting quoted strings
function splitValuesOnComma(valuesStr: string): string[] {
const values: string[] = [];
// Helper function to parse a string value as boolean if possible, or otherwise
// return as string with surrounding quotes removed.
const getBooleanOrUnquotedString = (value: string): string | boolean => {
const trimmed = value.trim();
if (['true', 'false'].includes(trimmed.toLowerCase())) {
return trimmed.toLowerCase() === 'true';
}
// Remove surrounding quotes if present
return trimmed.startsWith("'") && trimmed.endsWith("'")
? trimmed.slice(1, -1)
: trimmed;
};
// Helper function to split on commas while respecting quoted strings and booleans
function splitValuesOnComma(valuesStr: string): (string | boolean)[] {
const values: (string | boolean)[] = [];
let currentValue = '';
let inString = false;
@ -101,13 +118,7 @@ function splitValuesOnComma(valuesStr: string): string[] {
if (!inString && char === ',') {
if (currentValue.trim()) {
// Remove surrounding quotes if present
const trimmed = currentValue.trim();
const unquoted =
trimmed.startsWith("'") && trimmed.endsWith("'")
? trimmed.slice(1, -1)
: trimmed;
values.push(unquoted);
values.push(getBooleanOrUnquotedString(currentValue));
}
currentValue = '';
continue;
@ -118,12 +129,7 @@ function splitValuesOnComma(valuesStr: string): string[] {
// Add the last value
if (currentValue.trim()) {
const trimmed = currentValue.trim();
const unquoted =
trimmed.startsWith("'") && trimmed.endsWith("'")
? trimmed.slice(1, -1)
: trimmed;
values.push(unquoted);
values.push(getBooleanOrUnquotedString(currentValue));
}
return values;
@ -133,12 +139,12 @@ function splitValuesOnComma(valuesStr: string): string[] {
// This handles both simple conditions and compound conditions with AND
function extractInClauses(condition: string): Array<{
key: string;
values: string[];
values: (string | boolean)[];
isExclude: boolean;
}> {
const results: Array<{
key: string;
values: string[];
values: (string | boolean)[];
isExclude: boolean;
}> = [];
@ -221,8 +227,8 @@ export const parseQuery = (
const state = new Map<
string,
{
included: Set<string>;
excluded: Set<string>;
included: Set<string | boolean>;
excluded: Set<string | boolean>;
range?: { min: number; max: number };
}
>();
@ -311,7 +317,7 @@ export const useSearchPageFilterState = ({
const setFilterValue = React.useCallback(
(
property: string,
value: string,
value: string | boolean,
action?: 'only' | 'exclude' | 'include',
) => {
setFilters(prevFilters => {
@ -400,7 +406,7 @@ export const useSearchPageFilterState = ({
};
type PinnedFilters = {
[key: string]: string[];
[key: string]: (string | boolean)[];
};
export type FilterStateHook = ReturnType<typeof useSearchPageFilterState>;
@ -466,7 +472,7 @@ export function usePinnedFilters(sourceId: string | null) {
usePinnedFilterBySource(sourceId);
const toggleFilterPin = React.useCallback(
(property: string, value: string) => {
(property: string, value: string | boolean) => {
setPinnedFilters(prevFilters =>
produce(prevFilters, draft => {
if (!draft[property]) {
@ -508,7 +514,7 @@ export function usePinnedFilters(sourceId: string | null) {
);
const isFilterPinned = React.useCallback(
(property: string, value: string): boolean => {
(property: string, value: string | boolean): boolean => {
return (
pinnedFilters[property] &&
pinnedFilters[property].some(v => v === value)