diff --git a/.changeset/hungry-onions-buy.md b/.changeset/hungry-onions-buy.md new file mode 100644 index 00000000..ea0b7170 --- /dev/null +++ b/.changeset/hungry-onions-buy.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +fix: filter values for json casted to string diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 8cf571ae..f6f045d5 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -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 => ( ({ value, label: value, diff --git a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx index 516b5437..0b87770b 100644 --- a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx +++ b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx @@ -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 = {