diff --git a/.changeset/fix-surrounding-context-filters.md b/.changeset/fix-surrounding-context-filters.md new file mode 100644 index 0000000000..f7e9019156 --- /dev/null +++ b/.changeset/fix-surrounding-context-filters.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Fix Surrounding Context filters for non-OTEL schemas by using the source's serviceNameExpression for the "Service" filter instead of hardcoded ResourceAttributes lookup. Also adds quick event attribute filters that let users toggle attributes from the current event to narrow surrounding context results. diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index b023cb8c6d..e88972ec47 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -13,6 +13,7 @@ import { COLORS, evaluateColorCondition, formatAttributeClause, + formatColumnEquals, formatDurationMs, formatDurationMsCompact, formatNumber, @@ -42,6 +43,10 @@ describe('formatAttributeClause', () => { expect(formatAttributeClause('data', 'user-id', 'abc-123', true)).toBe( "data['user-id']='abc-123'", ); + + expect(formatAttributeClause('data', 'user-id', "O'Brien", true)).toBe( + "data['user-id']='O''Brien'", + ); }); it('should format lucene attribute clause correctly', () => { @@ -56,6 +61,30 @@ describe('formatAttributeClause', () => { expect(formatAttributeClause('data', 'user-id', 'abc-123', false)).toBe( 'data.user-id:"abc-123"', ); + + expect(formatAttributeClause('data', 'user-id', 'say "hello"', false)).toBe( + 'data.user-id:"say \\"hello\\""', + ); + }); +}); + +describe('formatColumnEquals', () => { + it('formats SQL column equality with quote escaping', () => { + expect(formatColumnEquals('ServiceName', 'my-svc', true)).toBe( + "ServiceName = 'my-svc'", + ); + expect(formatColumnEquals('Name', "O'Brien", true)).toBe( + "Name = 'O''Brien'", + ); + }); + + it('formats Lucene column equality with quote escaping', () => { + expect(formatColumnEquals('ServiceName', 'my-svc', false)).toBe( + 'ServiceName:"my-svc"', + ); + expect(formatColumnEquals('Name', 'say "hello"', false)).toBe( + 'Name:"say \\"hello\\""', + ); }); }); diff --git a/packages/app/src/components/ContextFilterPills.tsx b/packages/app/src/components/ContextFilterPills.tsx new file mode 100644 index 0000000000..976549ab32 --- /dev/null +++ b/packages/app/src/components/ContextFilterPills.tsx @@ -0,0 +1,350 @@ +import { + isLogSource, + isTraceSource, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { Flex, Group, Text, Tooltip } from '@mantine/core'; +import { IconCheck, IconPlus } from '@tabler/icons-react'; + +import { formatAttributeClause, formatColumnEquals } from '@/utils'; + +import { ROW_DATA_ALIASES } from './DBRowDataPanel'; + +export interface QuickFilterItem { + id: string; + label: string; + value: string; + generateWhere: (isSql: boolean) => string; +} + +export interface BuildContextWhereClauseOptions { + selectedFilterIds: string[]; + availableFilters: QuickFilterItem[]; + isSql: boolean; + customWhere?: string; +} + +const MAX_FILTER_VALUE_LENGTH = 200; + +const PROMOTED_RESOURCE_ATTR_KEYS = [ + 'host.name', + 'k8s.pod.name', + 'k8s.namespace.name', + 'k8s.node.name', +]; + +function isSafeLuceneFieldExpression(expression: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(expression); +} + +function isSafeAttributeKey(key: string): boolean { + return /^[A-Za-z0-9_.-]+$/.test(key); +} + +export function extractQuickFilters( + rowData: Record, + source: TSource, +): QuickFilterItem[] { + const filters: QuickFilterItem[] = []; + const skipAliases = new Set(Object.values(ROW_DATA_ALIASES)); + + const serviceNameExpr = + isLogSource(source) || isTraceSource(source) + ? source.serviceNameExpression + : undefined; + const resourceAttrExpr = + 'resourceAttributesExpression' in source + ? source.resourceAttributesExpression + : undefined; + const eventAttrExpr = + isLogSource(source) || isTraceSource(source) + ? source.eventAttributesExpression + : undefined; + + const resourceAttrs = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES] as + | Record + | undefined; + + const serviceNameValue = rowData[ROW_DATA_ALIASES.SERVICE_NAME]; + if (serviceNameExpr && typeof serviceNameValue === 'string') { + const resourceServiceName = + typeof resourceAttrs?.['service.name'] === 'string' + ? resourceAttrs['service.name'] + : undefined; + filters.push({ + id: 'svc', + label: serviceNameExpr, + value: serviceNameValue, + generateWhere: isSql => { + if (isSql || isSafeLuceneFieldExpression(serviceNameExpr)) { + return formatColumnEquals(serviceNameExpr, serviceNameValue, isSql); + } + if (resourceAttrExpr && resourceServiceName) { + return formatAttributeClause( + resourceAttrExpr, + 'service.name', + resourceServiceName, + isSql, + ); + } + return ''; + }, + }); + } else if ( + resourceAttrs?.['service.name'] && + typeof resourceAttrs['service.name'] === 'string' && + resourceAttrExpr + ) { + filters.push({ + id: 'ra:service.name', + label: 'service.name', + value: String(resourceAttrs['service.name']), + generateWhere: isSql => + formatAttributeClause( + resourceAttrExpr, + 'service.name', + String(resourceAttrs['service.name']), + isSql, + ), + }); + } + + if (resourceAttrs && resourceAttrExpr) { + for (const key of PROMOTED_RESOURCE_ATTR_KEYS) { + const val = resourceAttrs[key]; + if (typeof val !== 'string' || !val) continue; + filters.push({ + id: `ra:${key}`, + label: key, + value: val, + generateWhere: isSql => + formatAttributeClause(resourceAttrExpr, key, val, isSql), + }); + } + } + + const addedIds = new Set(filters.map(f => f.id)); + if (filters.some(f => f.id === 'svc')) { + addedIds.add('ra:service.name'); + } + if (resourceAttrs && resourceAttrExpr) { + for (const [key, val] of Object.entries(resourceAttrs)) { + if (addedIds.has(`ra:${key}`)) continue; + if (!isSafeAttributeKey(key)) continue; + if ( + typeof val !== 'string' || + !val || + val.length > MAX_FILTER_VALUE_LENGTH + ) + continue; + filters.push({ + id: `ra:${key}`, + label: key, + value: val, + generateWhere: isSql => + formatAttributeClause(resourceAttrExpr, key, val, isSql), + }); + } + } + + const eventAttrs = rowData[ROW_DATA_ALIASES.EVENT_ATTRIBUTES]; + if (eventAttrs && typeof eventAttrs === 'object' && eventAttrExpr) { + for (const [key, val] of Object.entries( + eventAttrs as Record, + )) { + if (!isSafeAttributeKey(key)) continue; + if ( + typeof val !== 'string' || + !val || + val.length > MAX_FILTER_VALUE_LENGTH + ) + continue; + filters.push({ + id: `ea:${key}`, + label: key, + value: val, + generateWhere: isSql => + formatAttributeClause(eventAttrExpr, key, val, isSql), + }); + } + } + + for (const [key, val] of Object.entries(rowData)) { + if (skipAliases.has(key) || key.startsWith('__hdx_')) continue; + if (!isSafeLuceneFieldExpression(key)) continue; + if (typeof val !== 'string' || !val || val.length > MAX_FILTER_VALUE_LENGTH) + continue; + if (/timestamp|ttl/i.test(key)) continue; + if (serviceNameExpr && key === serviceNameExpr) continue; + + filters.push({ + id: `col:${key}`, + label: key, + value: String(val), + generateWhere: isSql => formatColumnEquals(key, String(val), isSql), + }); + } + + return filters; +} + +const MATCH_PRESET_IDS: Record = { + service: ['svc', 'ra:service.name'], + host: ['svc', 'ra:service.name', 'ra:host.name'], + pod: ['svc', 'ra:service.name', 'ra:k8s.pod.name', 'ra:k8s.namespace.name'], + node: ['svc', 'ra:service.name', 'ra:k8s.node.name'], +}; + +export function getPresetFilterIds( + preset: string, + available: QuickFilterItem[], +): string[] { + const wantedIds = MATCH_PRESET_IDS[preset] ?? []; + const availableIds = new Set(available.map(f => f.id)); + return wantedIds.filter(id => availableIds.has(id)); +} + +export function getAvailablePresets( + available: QuickFilterItem[], +): { label: string; value: string }[] { + const ids = new Set(available.map(f => f.id)); + const hasService = ids.has('svc') || ids.has('ra:service.name'); + const hasHost = ids.has('ra:host.name'); + const hasPod = ids.has('ra:k8s.pod.name'); + const hasNode = ids.has('ra:k8s.node.name'); + + return [ + { label: 'Anything', value: 'all' }, + ...(hasService ? [{ label: 'Service', value: 'service' }] : []), + ...(hasHost ? [{ label: 'Host', value: 'host' }] : []), + ...(hasPod ? [{ label: 'Pod', value: 'pod' }] : []), + ...(hasNode ? [{ label: 'Node', value: 'node' }] : []), + { label: 'Custom', value: 'custom' }, + ]; +} + +export function buildContextWhereClause({ + selectedFilterIds, + availableFilters, + isSql, + customWhere, +}: BuildContextWhereClauseOptions): string { + const clauses: string[] = []; + + for (const filterId of selectedFilterIds) { + const filter = availableFilters.find(f => f.id === filterId); + const clause = filter?.generateWhere(isSql).trim(); + if (clause) { + clauses.push(clause); + } + } + + const trimmedCustomWhere = customWhere?.trim(); + if (trimmedCustomWhere) { + clauses.push(trimmedCustomWhere); + } + + if (clauses.length === 0) return ''; + if (clauses.length === 1) return clauses[0]; + return clauses.map(c => `(${c})`).join(' AND '); +} + +const filterPillStyle = { + display: 'inline-flex', + alignItems: 'center' as const, + gap: 5, + padding: '3px 10px', + borderRadius: 4, + fontSize: 12, + lineHeight: '20px', + cursor: 'pointer', + whiteSpace: 'nowrap' as const, + maxWidth: 360, + overflow: 'hidden', +}; + +export function FilterPill({ + filter, + isSelected, + onToggle, +}: { + filter: QuickFilterItem; + isSelected: boolean; + onToggle: () => void; +}) { + return ( + + + {isSelected ? ( + + ) : ( + + )} + + {filter.label} + + + = + + + {filter.value} + + + + ); +} + +export function FilterLegend() { + return ( + + + + + matching + + + + + + available — tap to add + + + + ); +} diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index a50a326515..45d5174906 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -1,5 +1,6 @@ -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import ms from 'ms'; +import { ErrorBoundary } from 'react-error-boundary'; import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { @@ -9,28 +10,26 @@ import { SourceKind, TSource, } from '@hyperdx/common-utils/dist/types'; -import { Badge, Flex, Group, SegmentedControl } from '@mantine/core'; +import { Flex, Group, ScrollArea, SegmentedControl, Text } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import SearchWhereInput, { getStoredLanguage, } from '@/components/SearchInput/SearchWhereInput'; import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; -import { formatAttributeClause } from '@/utils'; +import { + buildContextWhereClause, + extractQuickFilters, + FilterLegend, + FilterPill, + getAvailablePresets, + getPresetFilterIds, +} from './ContextFilterPills'; import { ROW_DATA_ALIASES } from './DBRowDataPanel'; import { RowSidePanelContext } from './DBRowSidePanel'; import { DBSqlRowTable } from './DBRowTable'; -enum ContextBy { - All = 'all', - Custom = 'custom', - Host = 'host', - Node = 'k8s.node.name', - Pod = 'k8s.pod.name', - Service = 'service', -} - interface ContextSubpanelProps { source: TSource; dbSqlRowTableConfig: BuilderChartConfigWithDateRange | undefined; @@ -58,8 +57,8 @@ export default function ContextSubpanel({ const { whereLanguage: originalLanguage = 'lucene' } = dbSqlRowTableConfig ?? {}; const [range, setRange] = useState(ms('30s')); - const [contextBy, setContextBy] = useState(ContextBy.All); - const { control } = useForm({ + const [activePreset, setActivePreset] = useState('all'); + const { control, reset } = useForm({ defaultValues: { where: '', whereLanguage: @@ -70,7 +69,9 @@ export default function ContextSubpanel({ }); const formWhere = useWatch({ control, name: 'where' }); + const formWhereLanguage = useWatch({ control, name: 'whereLanguage' }); const [debouncedWhere] = useDebouncedValue(formWhere, 1000); + const effectiveWhereLanguage = formWhereLanguage || originalLanguage; const { setChildModalOpen } = useContext(RowSidePanelContext); @@ -90,7 +91,6 @@ export default function ContextSubpanel({ ); const date = useMemo(() => new Date(origTimestamp), [origTimestamp]); - const newDateRange = useMemo( (): [Date, Date] => [ new Date(date.getTime() - range / 2), @@ -99,88 +99,66 @@ export default function ContextSubpanel({ [date, range], ); - /* Functions to help generate WHERE clause based on - which Context the user chooses (All, Host, Node, etc...). - Since we support lucene and sql, we need to format the condition - based on the language - */ - const { - 'k8s.node.name': k8sNodeName, - 'k8s.pod.name': k8sPodName, - 'host.name': host, - 'service.name': service, - } = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES] ?? {}; + // Filter state — showCustomSearch is derived, not stored + const [selectedFilterIds, setSelectedFilterIds] = useState([]); + const showCustomSearch = activePreset === 'custom'; - const CONTEXT_MAPPING = useMemo( - () => - ({ - [ContextBy.All]: { - field: '', - value: '', - }, - [ContextBy.Custom]: { - field: '', - value: debouncedWhere || '', - }, - [ContextBy.Service]: { - field: 'service.name', - value: service, - }, - [ContextBy.Host]: { - field: 'host.name', - value: host, - }, - [ContextBy.Pod]: { - field: 'k8s.pod.name', - value: k8sPodName, - }, - [ContextBy.Node]: { - field: 'k8s.node.name', - value: k8sNodeName, - }, - }) as const, - [k8sNodeName, k8sPodName, host, service, debouncedWhere], - ); + useEffect(() => { + setSelectedFilterIds([]); + setActivePreset('all'); + reset({ + where: '', + whereLanguage: originalLanguage, + }); + }, [originalLanguage, reset, rowId]); - // Main function to generate WHERE clause based on context - const getWhereClause = useCallback( - (contextBy: ContextBy): string => { - const isSql = originalLanguage === 'sql'; - const mapping = CONTEXT_MAPPING[contextBy]; + const availableFilters = useMemo( + () => extractQuickFilters(rowData, source), + [rowData, source], + ); - if (contextBy === ContextBy.All) { - return mapping.value; - } + const presetOptions = useMemo( + () => getAvailablePresets(availableFilters), + [availableFilters], + ); - if (contextBy === ContextBy.Custom) { - return mapping.value.trim(); + const handlePresetChange = useCallback( + (preset: string) => { + setActivePreset(preset); + if (preset === 'custom' || preset === 'all') { + setSelectedFilterIds([]); + return; } - - const attributeClause = formatAttributeClause( - 'ResourceAttributes', - mapping.field, - mapping.value, - isSql, - ); - return attributeClause; + const ids = getPresetFilterIds(preset, availableFilters); + setSelectedFilterIds(ids); }, - [CONTEXT_MAPPING, originalLanguage], + [availableFilters], ); - function generateSegmentedControlData() { - return [ - { label: 'All', value: ContextBy.All }, - ...(service ? [{ label: 'Service', value: ContextBy.Service }] : []), - ...(host ? [{ label: 'Host', value: ContextBy.Host }] : []), - ...(k8sPodName ? [{ label: 'Pod', value: ContextBy.Pod }] : []), - ...(k8sNodeName ? [{ label: 'Node', value: ContextBy.Node }] : []), - { label: 'Custom', value: ContextBy.Custom }, - ]; - } + const toggleFilter = useCallback((id: string) => { + setSelectedFilterIds(prev => + prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id], + ); + setActivePreset('custom'); + }, []); + + const getWhereClause = useCallback((): string => { + return buildContextWhereClause({ + selectedFilterIds, + availableFilters, + isSql: effectiveWhereLanguage === 'sql', + customWhere: showCustomSearch ? debouncedWhere : '', + }); + }, [ + effectiveWhereLanguage, + selectedFilterIds, + availableFilters, + showCustomSearch, + debouncedWhere, + ]); const config = useMemo(() => { - const whereClause = getWhereClause(contextBy); - // missing query info, build config from source with default value + const whereClause = getWhereClause(); if (!dbSqlRowTableConfig) return { connection: source.connection, @@ -193,26 +171,30 @@ export default function ContextSubpanel({ limit: { limit: 200 }, orderBy: `${source.timestampValueExpression} DESC`, where: whereClause, - whereLanguage: originalLanguage, + whereLanguage: effectiveWhereLanguage, dateRange: newDateRange, }; return { ...dbSqlRowTableConfig, where: whereClause, - whereLanguage: originalLanguage, + whereLanguage: effectiveWhereLanguage, dateRange: newDateRange, filters: [], }; }, [ dbSqlRowTableConfig, + effectiveWhereLanguage, getWhereClause, - originalLanguage, newDateRange, - contextBy, source, ]); + const displayedPreset = + selectedFilterIds.length === 0 && activePreset !== 'custom' + ? 'all' + : activePreset; + return ( <> {config && ( @@ -222,22 +204,10 @@ export default function ContextSubpanel({ style={{ flexGrow: 1 }} data-testid={dataTestId} > - - setContextBy(v as ContextBy)} - /> - {contextBy === ContextBy.Custom && ( - - )} + + + ±{ms(range / 2)} + setRange(Number(value))} /> - -
- {contextBy !== ContextBy.All && ( - - {contextBy}:{CONTEXT_MAPPING[contextBy].value} - + + + Match on + + + + {showCustomSearch && ( + + + + )} + {availableFilters.length > 0 && ( + ( + + Unable to load event filters. + )} - - Time range: ±{ms(range / 2)} - -
-
+ > + + + + Matching on{' '} + + {selectedFilterIds.length} + {' '} + attributes + + {selectedFilterIds.length > 0 && ( + { + setSelectedFilterIds([]); + setActivePreset('all'); + }} + > + Clear all + + )} + + + + {availableFilters.map(filter => ( + toggleFilter(filter.id)} + /> + ))} + + + + + + )}
+ ({ + id: 'src-1', + kind: 'log', + name: 'Test Logs', + from: { databaseName: 'default', tableName: 'otel_logs' }, + connection: 'conn-1', + timestampValueExpression: 'Timestamp', + serviceNameExpression: 'ServiceName', + resourceAttributesExpression: 'ResourceAttributes', + eventAttributesExpression: 'LogAttributes', + defaultTableSelectExpression: '*', + ...overrides, + }) as unknown as TSource; + +describe('extractQuickFilters', () => { + it('creates a service pill from serviceNameExpression', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-service', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: {}, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'svc'); + expect(svcFilter).toBeDefined(); + expect(svcFilter!.label).toBe('ServiceName'); + expect(svcFilter!.value).toBe('my-service'); + }); + + it('falls back to resource attribute service.name when no serviceNameExpression', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'service.name': 'api-gateway', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'ra:service.name'); + expect(svcFilter).toBeDefined(); + expect(svcFilter!.value).toBe('api-gateway'); + }); + + it('promotes host.name, k8s.pod.name, k8s.namespace.name, k8s.node.name', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'svc', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'host.name': 'host-1', + 'k8s.pod.name': 'pod-abc', + 'k8s.namespace.name': 'default', + 'k8s.node.name': 'node-1', + 'other.attr': 'value', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + const ids = filters.map(f => f.id); + + expect(ids.indexOf('ra:host.name')).toBeLessThan( + ids.indexOf('ra:other.attr'), + ); + expect(ids.indexOf('ra:k8s.pod.name')).toBeLessThan( + ids.indexOf('ra:other.attr'), + ); + }); + + it('includes event attributes', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: {}, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: { + 'http.method': 'GET', + 'http.url': '/api/health', + }, + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.id === 'ea:http.method')).toBeDefined(); + expect(filters.find(f => f.id === 'ea:http.url')).toBeDefined(); + }); + + it('includes top-level columns as col: filters', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: {}, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + SeverityText: 'ERROR', + ScopeName: 'my-scope', + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.id === 'col:SeverityText')).toBeDefined(); + expect(filters.find(f => f.id === 'col:ScopeName')).toBeDefined(); + }); + + it('skips timestamp-like columns and __hdx_ aliases', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.BODY]: 'test body', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: {}, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + TimestampTime: '2024-01-01', + EventTimeTTL: '2024-01-01', + __hdx_custom: 'hidden', + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.label === 'TimestampTime')).toBeUndefined(); + expect(filters.find(f => f.label === 'EventTimeTTL')).toBeUndefined(); + expect(filters.find(f => f.label === '__hdx_custom')).toBeUndefined(); + expect(filters.find(f => f.label === '__hdx_body')).toBeUndefined(); + }); + + it('skips values longer than 200 characters', () => { + const longValue = 'a'.repeat(201); + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { longkey: longValue }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.id === 'ra:longkey')).toBeUndefined(); + }); + + it('generates correct SQL WHERE clauses', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: "O'Brien", + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { 'host.name': 'host-1' }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'svc')!; + expect(svcFilter.generateWhere(true)).toBe("ServiceName = 'O''Brien'"); + + const hostFilter = filters.find(f => f.id === 'ra:host.name')!; + expect(hostFilter.generateWhere(true)).toBe( + "ResourceAttributes['host.name']='host-1'", + ); + }); + + it('escapes quotes in attribute WHERE clauses', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'host.name': "host-'1", + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: { + message: 'say "hello"', + }, + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + const hostFilter = filters.find(f => f.id === 'ra:host.name')!; + expect(hostFilter.generateWhere(true)).toBe( + "ResourceAttributes['host.name']='host-''1'", + ); + + const messageFilter = filters.find(f => f.id === 'ea:message')!; + expect(messageFilter.generateWhere(false)).toBe( + 'LogAttributes.message:"say \\"hello\\""', + ); + }); + + it('generates correct Lucene WHERE clauses', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-svc', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { 'host.name': 'host-1' }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'svc')!; + expect(svcFilter.generateWhere(false)).toBe('ServiceName:"my-svc"'); + + const hostFilter = filters.find(f => f.id === 'ra:host.name')!; + expect(hostFilter.generateWhere(false)).toBe( + 'ResourceAttributes.host.name:"host-1"', + ); + }); + + it('does not duplicate service.name when serviceNameExpression is selected', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-svc', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'service.name': 'my-svc', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + + expect(filters.filter(f => f.id === 'ra:service.name')).toHaveLength(0); + expect(filters.filter(f => f.id === 'svc')).toHaveLength(1); + }); + + it('falls back to resource service.name for unsafe Lucene service expressions', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-svc', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'service.name': 'my-svc', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource({ + serviceNameExpression: "ResourceAttributes['service.name']", + }); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'svc')!; + expect(svcFilter.generateWhere(false)).toBe( + 'ResourceAttributes.service.name:"my-svc"', + ); + }); + + it('skips unsafe keys that cannot be represented in Lucene', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'bad key': 'value', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: { + 'bad/event': 'value', + }, + 'bad column': 'value', + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.label === 'bad key')).toBeUndefined(); + expect(filters.find(f => f.label === 'bad/event')).toBeUndefined(); + expect(filters.find(f => f.label === 'bad column')).toBeUndefined(); + }); +}); + +describe('getPresetFilterIds', () => { + const available: QuickFilterItem[] = [ + { id: 'svc', label: 'ServiceName', value: 'api', generateWhere: () => '' }, + { + id: 'ra:host.name', + label: 'host.name', + value: 'h1', + generateWhere: () => '', + }, + { + id: 'ra:k8s.pod.name', + label: 'k8s.pod.name', + value: 'pod-1', + generateWhere: () => '', + }, + { + id: 'ra:k8s.namespace.name', + label: 'k8s.namespace.name', + value: 'ns', + generateWhere: () => '', + }, + { + id: 'ra:k8s.node.name', + label: 'k8s.node.name', + value: 'node-1', + generateWhere: () => '', + }, + ]; + + it('returns service IDs for "service" preset', () => { + expect(getPresetFilterIds('service', available)).toEqual(['svc']); + }); + + it('returns service + host IDs for "host" preset', () => { + expect(getPresetFilterIds('host', available)).toEqual([ + 'svc', + 'ra:host.name', + ]); + }); + + it('returns service + pod + namespace IDs for "pod" preset', () => { + expect(getPresetFilterIds('pod', available)).toEqual([ + 'svc', + 'ra:k8s.pod.name', + 'ra:k8s.namespace.name', + ]); + }); + + it('returns service + node IDs for "node" preset', () => { + expect(getPresetFilterIds('node', available)).toEqual([ + 'svc', + 'ra:k8s.node.name', + ]); + }); + + it('returns empty for unknown preset', () => { + expect(getPresetFilterIds('unknown', available)).toEqual([]); + }); + + it('only returns IDs that exist in available filters', () => { + const limited: QuickFilterItem[] = [ + { + id: 'ra:host.name', + label: 'host.name', + value: 'h1', + generateWhere: () => '', + }, + ]; + expect(getPresetFilterIds('host', limited)).toEqual(['ra:host.name']); + }); +}); + +describe('getAvailablePresets', () => { + it('always includes Anything and Custom', () => { + const presets = getAvailablePresets([]); + expect(presets.map(p => p.value)).toContain('all'); + expect(presets.map(p => p.value)).toContain('custom'); + expect(presets.find(p => p.value === 'all')!.label).toBe('Anything'); + }); + + it('includes Service when svc filter exists', () => { + const available: QuickFilterItem[] = [ + { + id: 'svc', + label: 'ServiceName', + value: 'api', + generateWhere: () => '', + }, + ]; + const presets = getAvailablePresets(available); + expect(presets.map(p => p.value)).toContain('service'); + }); + + it('includes Pod when k8s.pod.name filter exists', () => { + const available: QuickFilterItem[] = [ + { + id: 'ra:k8s.pod.name', + label: 'k8s.pod.name', + value: 'pod-1', + generateWhere: () => '', + }, + ]; + const presets = getAvailablePresets(available); + expect(presets.map(p => p.value)).toContain('pod'); + }); + + it('does not include Host when host.name is absent', () => { + const available: QuickFilterItem[] = [ + { + id: 'svc', + label: 'ServiceName', + value: 'api', + generateWhere: () => '', + }, + ]; + const presets = getAvailablePresets(available); + expect(presets.map(p => p.value)).not.toContain('host'); + }); +}); + +describe('buildContextWhereClause', () => { + const filters: QuickFilterItem[] = [ + { + id: 'svc', + label: 'ServiceName', + value: 'api', + generateWhere: isSql => + isSql ? "ServiceName = 'api'" : 'ServiceName:"api"', + }, + { + id: 'ra:host.name', + label: 'host.name', + value: 'h1', + generateWhere: isSql => + isSql + ? "ResourceAttributes['host.name']='h1'" + : 'ResourceAttributes.host.name:"h1"', + }, + { + id: 'empty', + label: 'empty', + value: 'empty', + generateWhere: () => '', + }, + ]; + + it('returns empty string with no filters or custom query', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: [], + availableFilters: filters, + isSql: true, + }), + ).toBe(''); + }); + + it('returns a single clause without wrapping', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: ['svc'], + availableFilters: filters, + isSql: true, + }), + ).toBe("ServiceName = 'api'"); + }); + + it('ANDs multiple selected filter clauses', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: ['svc', 'ra:host.name'], + availableFilters: filters, + isSql: true, + }), + ).toBe("(ServiceName = 'api') AND (ResourceAttributes['host.name']='h1')"); + }); + + it('appends custom search clauses', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: ['svc'], + availableFilters: filters, + isSql: false, + customWhere: 'SeverityText:"ERROR"', + }), + ).toBe('(ServiceName:"api") AND (SeverityText:"ERROR")'); + }); + + it('trims and ignores empty generated/custom clauses', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: ['empty'], + availableFilters: filters, + isSql: true, + customWhere: ' ', + }), + ).toBe(''); + }); +}); diff --git a/packages/app/src/components/__tests__/ContextSidePanel.test.tsx b/packages/app/src/components/__tests__/ContextSidePanel.test.tsx new file mode 100644 index 0000000000..73ad763ce5 --- /dev/null +++ b/packages/app/src/components/__tests__/ContextSidePanel.test.tsx @@ -0,0 +1,135 @@ +import type { ComponentProps } from 'react'; +import { SourceKind, TLogSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import ContextSubpanel from '@/components/ContextSidePanel'; +import { ROW_DATA_ALIASES } from '@/components/DBRowDataPanel'; + +jest.mock('nuqs', () => ({ + createParser: jest.fn(config => config), + useQueryState: jest.fn(() => [null, jest.fn()]), +})); + +jest.mock('@/source', () => ({ + useSource: jest.fn(() => ({ data: null })), +})); + +jest.mock('@/components/DBRowSidePanel', () => { + const React = jest.requireActual('react'); + return { + __esModule: true, + default: () => null, + RowSidePanelContext: React.createContext({ + setChildModalOpen: jest.fn(), + }), + }; +}); + +jest.mock('@/components/DBRowTable', () => ({ + DBSqlRowTable: ({ config }: { config: { where?: string } }) => ( +
+ ), +})); + +jest.mock('@/components/SearchInput/SearchWhereInput', () => { + const React = jest.requireActual('react'); + const { useController } = jest.requireActual('react-hook-form'); + + function MockSearchWhereInput({ + control, + name, + }: { + control: any; + name: string; + }) { + const { field } = useController({ control, name }); + return ; + } + + return { + __esModule: true, + default: MockSearchWhereInput, + getStoredLanguage: jest.fn(() => 'lucene'), + }; +}); + +const source: TLogSource = { + id: 'source-id', + kind: SourceKind.Log, + name: 'logs', + connection: 'conn-id', + from: { databaseName: 'default', tableName: 'logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, Body', + serviceNameExpression: 'ServiceName', + resourceAttributesExpression: 'ResourceAttributes', + eventAttributesExpression: 'LogAttributes', +}; + +const makeRowData = (serviceName: string) => ({ + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.SERVICE_NAME]: serviceName, + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'service.name': serviceName, + 'host.name': `${serviceName}-host`, + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: { + 'http.method': 'GET', + }, + Body: `body ${serviceName}`, +}); + +function renderContextSubpanel( + props: Partial> = {}, +) { + const allProps = { + source, + dbSqlRowTableConfig: undefined, + rowData: makeRowData('api'), + rowId: 'row-1', + ...props, + }; + + return render( + + + , + ); +} + +describe('ContextSubpanel', () => { + it('shows custom search input when manually selecting a pill', () => { + renderContextSubpanel(); + + expect(screen.queryByLabelText('custom-search')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('context-filter-ra:host.name')); + + expect(screen.getByLabelText('custom-search')).toBeInTheDocument(); + }); + + it('clears stale custom search text when row changes', () => { + const { rerender } = renderContextSubpanel(); + + fireEvent.click(screen.getByText('Custom')); + fireEvent.change(screen.getByLabelText('custom-search'), { + target: { value: 'SeverityText:"ERROR"' }, + }); + + rerender( + + + , + ); + + fireEvent.click(screen.getByText('Custom')); + + expect(screen.getByLabelText('custom-search')).toHaveValue(''); + }); +}); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 49a02f959a..b7101aa777 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1210,6 +1210,14 @@ export const optionsToSelectData = (options: Record) => Object.entries(options).map(([value, label]) => ({ value, label })); // Helper function to format attribute clause +function escapeSqlValueSingleQuoted(value: string): string { + return value.replace(/'/g, "''"); +} + +function escapeLuceneDoubleQuoted(value: string): string { + return value.replace(/"/g, '\\"'); +} + export function formatAttributeClause( column: string, field: string, @@ -1217,8 +1225,19 @@ export function formatAttributeClause( isSql: boolean, ): string { return isSql - ? `${column}['${field}']='${value}'` - : `${column}.${field}:"${value}"`; + ? `${column}['${field}']='${escapeSqlValueSingleQuoted(value)}'` + : `${column}.${field}:"${escapeLuceneDoubleQuoted(value)}"`; +} + +export function formatColumnEquals( + column: string, + value: string, + isSql: boolean, +): string { + if (isSql) { + return `${column} = '${escapeSqlValueSingleQuoted(value)}'`; + } + return `${column}:"${escapeLuceneDoubleQuoted(value)}"`; } /**