fix: filter values for json casted to string (#1184)

Fixes HDX-2425

Was able to make improvements to the filters appearance, but cast to string for the actual query. Fixes the query and improves the UI!
<img width="420" height="204" alt="image" src="https://github.com/user-attachments/assets/0c30cc9a-ccea-499e-8c5f-51c8c38871e5" />
This commit is contained in:
Aaron Knudtson 2025-09-18 16:20:06 -04:00 committed by GitHub
parent 825452fe86
commit 21f1aa7567
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 225 additions and 6 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: filter values for json casted to string

View file

@ -42,6 +42,21 @@ import { mergePath } from '@/utils';
import resizeStyles from '../../styles/ResizablePanel.module.scss';
import classes from '../../styles/SearchPage.module.scss';
// This function will clean json string attributes specifically. It will turn a string like
// 'toString(ResourceAttributes.`hdx`.`sdk`.`version`)' into 'ResourceAttributes.hdx.sdk.verion'.
export function cleanedFacetName(key: string): string {
if (key.startsWith('toString')) {
return key
.slice('toString('.length, key.length - 1)
.split('.')
.map(str =>
str.startsWith('`') && str.endsWith('`') ? str.slice(1, -1) : str,
)
.join('.');
}
return key;
}
type FilterCheckboxProps = {
label: string;
value?: 'included' | 'excluded' | false;
@ -455,7 +470,7 @@ const DBSearchPageFiltersComponent = ({
filters: filterState,
clearAllFilters,
clearFilter,
setFilterValue,
setFilterValue: _setFilterValue,
isLive,
chartConfig,
analysisMode,
@ -474,6 +489,13 @@ const DBSearchPageFiltersComponent = ({
denoiseResults: boolean;
setDenoiseResults: (denoiseResults: boolean) => void;
} & FilterStateHook) => {
const setFilterValue: typeof _setFilterValue = (
property: string,
value: string,
action?: 'only' | 'exclude' | 'include' | undefined,
) => {
return _setFilterValue(property, value, action);
};
const {
toggleFilterPin,
toggleFieldPin,
@ -612,7 +634,12 @@ const DBSearchPageFiltersComponent = ({
const shownFacets = useMemo(() => {
const _facets: { key: string; value: string[] }[] = [];
for (const facet of facets ?? []) {
for (const _facet of facets ?? []) {
const facet = structuredClone(_facet);
if (jsonColumns?.some(col => facet.key.startsWith(col))) {
facet.key = `toString(${facet.key})`;
}
// don't include empty facets, unless they are already selected
const filter = filterState[facet.key];
const hasSelectedValues =
@ -665,7 +692,14 @@ const DBSearchPageFiltersComponent = ({
});
return _facets;
}, [facets, filterState, tableMetadata, extraFacets, isFieldPinned]);
}, [
facets,
filterState,
tableMetadata,
extraFacets,
isFieldPinned,
jsonColumns,
]);
const showClearAllButton = useMemo(
() =>
@ -778,7 +812,7 @@ const DBSearchPageFiltersComponent = ({
{shownFacets.map(facet => (
<FilterGroup
key={facet.key}
name={facet.key}
name={cleanedFacetName(facet.key)}
options={facet.value.map(value => ({
value,
label: value,

View file

@ -1,7 +1,187 @@
import { render, screen, within } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilterGroup, type FilterGroupProps } from '../DBSearchPageFilters';
import {
cleanedFacetName,
FilterGroup,
type FilterGroupProps,
} from '../DBSearchPageFilters';
describe('cleanedFacetName', () => {
describe('basic functionality', () => {
it('should return non-toString strings unchanged', () => {
expect(cleanedFacetName('simple.field')).toBe('simple.field');
expect(cleanedFacetName('column_name')).toBe('column_name');
expect(cleanedFacetName('ResourceAttributes.service.name')).toBe(
'ResourceAttributes.service.name',
);
});
it('should handle empty strings', () => {
expect(cleanedFacetName('')).toBe('');
});
it('should handle strings that do not start with toString', () => {
expect(cleanedFacetName('notToString(field)')).toBe('notToString(field)');
expect(cleanedFacetName("JSONExtractString(data, 'field')")).toBe(
"JSONExtractString(data, 'field')",
);
});
});
describe('JSON column cleaning', () => {
it('should clean basic ResourceAttributes paths', () => {
expect(
cleanedFacetName('toString(ResourceAttributes.`service`.`name`)'),
).toBe('ResourceAttributes.service.name');
expect(
cleanedFacetName('toString(ResourceAttributes.`hdx`.`sdk`.`version`)'),
).toBe('ResourceAttributes.hdx.sdk.version');
});
it('should handle mixed quoted and unquoted ResourceAttributes', () => {
expect(
cleanedFacetName('toString(ResourceAttributes.`service`.name)'),
).toBe('ResourceAttributes.service.name');
expect(
cleanedFacetName('toString(ResourceAttributes.service.`name`)'),
).toBe('ResourceAttributes.service.name');
});
it('should handle deeply nested ResourceAttributes', () => {
expect(
cleanedFacetName(
'toString(ResourceAttributes.`telemetry`.`sdk`.`language`)',
),
).toBe('ResourceAttributes.telemetry.sdk.language');
expect(
cleanedFacetName(
'toString(ResourceAttributes.`cloud`.`provider`.`account`.`id`)',
),
).toBe('ResourceAttributes.cloud.provider.account.id');
});
it('should handle ResourceAttributes with special characters', () => {
expect(
cleanedFacetName('toString(ResourceAttributes.`service-name`)'),
).toBe('ResourceAttributes.service-name');
expect(
cleanedFacetName('toString(ResourceAttributes.`service`.`version`)'),
).toBe('ResourceAttributes.service.version');
expect(
cleanedFacetName('toString(ResourceAttributes.`k8s`.`pod`.`name`)'),
).toBe('ResourceAttributes.k8s.pod.name');
});
it('should clean basic LogAttributes paths', () => {
expect(
cleanedFacetName('toString(LogAttributes.`severity`.`text`)'),
).toBe('LogAttributes.severity.text');
expect(cleanedFacetName('toString(LogAttributes.`level`)')).toBe(
'LogAttributes.level',
);
});
it('should handle deeply nested LogAttributes', () => {
expect(
cleanedFacetName('toString(LogAttributes.`context`.`user`.`id`)'),
).toBe('LogAttributes.context.user.id');
expect(
cleanedFacetName('toString(LogAttributes.`http`.`request`.`method`)'),
).toBe('LogAttributes.http.request.method');
});
it('should handle Map access in ResourceAttributes', () => {
expect(
cleanedFacetName(
"ResourceAttributes['http.request.headers.user-agent']",
),
).toBe("ResourceAttributes['http.request.headers.user-agent']");
});
});
describe('edge cases with Attributes', () => {
it('should handle attributes with spaces', () => {
expect(
cleanedFacetName('toString(ResourceAttributes.`service name`)'),
).toBe('ResourceAttributes.service name');
expect(cleanedFacetName('toString(LogAttributes.`error message`)')).toBe(
'LogAttributes.error message',
);
});
it('should handle attributes with numbers', () => {
expect(
cleanedFacetName('toString(ResourceAttributes.`service`.`v2`)'),
).toBe('ResourceAttributes.service.v2');
expect(
cleanedFacetName('toString(LogAttributes.`error`.`code`.`404`)'),
).toBe('LogAttributes.error.code.404');
});
});
describe('real-world OpenTelemetry patterns', () => {
// Common OTEL semantic conventions
it('should handle service attributes', () => {
expect(
cleanedFacetName('toString(ResourceAttributes.`service`.`name`)'),
).toBe('ResourceAttributes.service.name');
expect(
cleanedFacetName('toString(ResourceAttributes.`service`.`version`)'),
).toBe('ResourceAttributes.service.version');
expect(
cleanedFacetName(
'toString(ResourceAttributes.`service`.`instance`.`id`)',
),
).toBe('ResourceAttributes.service.instance.id');
});
it('should handle telemetry SDK attributes', () => {
expect(
cleanedFacetName(
'toString(ResourceAttributes.`telemetry`.`sdk`.`name`)',
),
).toBe('ResourceAttributes.telemetry.sdk.name');
expect(
cleanedFacetName(
'toString(ResourceAttributes.`telemetry`.`sdk`.`language`)',
),
).toBe('ResourceAttributes.telemetry.sdk.language');
expect(
cleanedFacetName(
'toString(ResourceAttributes.`telemetry`.`sdk`.`version`)',
),
).toBe('ResourceAttributes.telemetry.sdk.version');
});
it('should handle HTTP attributes', () => {
expect(cleanedFacetName('toString(LogAttributes.`http`.`method`)')).toBe(
'LogAttributes.http.method',
);
expect(
cleanedFacetName('toString(LogAttributes.`http`.`status_code`)'),
).toBe('LogAttributes.http.status_code');
expect(cleanedFacetName('toString(LogAttributes.`http`.`url`)')).toBe(
'LogAttributes.http.url',
);
});
});
});
describe('FilterGroup', () => {
const defaultProps: FilterGroupProps = {