fix: autocomplete for lucene column values (#720)

![image](https://github.com/user-attachments/assets/14048d5c-a88b-46ef-b15b-1412791923de)

Added autocomplete for potential search values for lucene where clauses.
Added testing for useAutoCompleteOptions and useGetKeyValues.

Ref: HDX-1509
This commit is contained in:
Aaron Knudtson 2025-04-08 17:36:42 -04:00 committed by GitHub
parent 56e39dc484
commit 092a292b60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 672 additions and 45 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
fix: autocomplete for key-values complete for v2 lucene

View file

@ -278,7 +278,7 @@ export default function NamespaceDetailsSidePanel({
const { data: logServiceNames } = useGetKeyValues(
{
chartConfig: {
chartConfigs: {
from: logSource.from,
where: `${logSource?.resourceAttributesExpression}.k8s.namespace.name:"${namespaceName}"`,
whereLanguage: 'lucene',

View file

@ -297,7 +297,7 @@ export default function NodeDetailsSidePanel({
const { data: logServiceNames } = useGetKeyValues(
{
chartConfig: {
chartConfigs: {
from: logSource.from,
where: `${logSource?.resourceAttributesExpression}.k8s.node.name:"${nodeName}"`,
whereLanguage: 'lucene',

View file

@ -280,7 +280,7 @@ export default function PodDetailsSidePanel({
const { data: logServiceNames } = useGetKeyValues(
{
chartConfig: {
chartConfigs: {
from: logSource.from,
where: `${logSource?.resourceAttributesExpression}.k8s.pod.name:"${podName}"`,
whereLanguage: 'lucene',

View file

@ -1,11 +1,27 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { TableConnection } from '@hyperdx/common-utils/dist/metadata';
import { Field, TableConnection } from '@hyperdx/common-utils/dist/metadata';
import { genEnglishExplanation } from '@hyperdx/common-utils/dist/queryParser';
import AutocompleteInput from '@/AutocompleteInput';
import { useAllFields } from '@/hooks/useMetadata';
import {
ILanguageFormatter,
useAutoCompleteOptions,
} from './hooks/useAutoCompleteOptions';
export class LuceneLanguageFormatter implements ILanguageFormatter {
formatFieldValue(f: Field): string {
return f.path.join('.');
}
formatFieldLabel(f: Field): string {
return `${f.path.join('.')} (${f.jsType})`;
}
formatKeyValPair(key: string, value: string): string {
return `${key}:"${value}"`;
}
}
export default function SearchInputV2({
tableConnections,
@ -34,31 +50,17 @@ export default function SearchInputV2({
} = useController(props);
const ref = useRef<HTMLInputElement>(null);
const { data: fields } = useAllFields(tableConnections ?? [], {
enabled:
!!tableConnections &&
(Array.isArray(tableConnections) ? tableConnections.length > 0 : true),
});
const autoCompleteOptions = useMemo(() => {
const _columns = (fields ?? []).filter(c => c.jsType !== null);
const baseOptions = _columns.map(c => ({
value: c.path.join('.'),
label: `${c.path.join('.')} (${c.jsType})`,
}));
const suggestionOptions =
additionalSuggestions?.map(column => ({
value: column,
label: column,
})) ?? [];
return [...baseOptions, ...suggestionOptions];
}, [fields, additionalSuggestions]);
const [parsedEnglishQuery, setParsedEnglishQuery] = useState<string>('');
const autoCompleteOptions = useAutoCompleteOptions(
new LuceneLanguageFormatter(),
value,
{
tableConnections,
additionalSuggestions,
},
);
useEffect(() => {
genEnglishExplanation(value).then(q => {
setParsedEnglishQuery(q);

View file

@ -380,7 +380,7 @@ export const DBSearchPageFilters = ({
isLoading: isFacetsLoading,
isFetching: isFacetsFetching,
} = useGetKeyValues({
chartConfig: { ...chartConfig, dateRange },
chartConfigs: { ...chartConfig, dateRange },
keys: datum,
});

View file

@ -42,19 +42,19 @@ function useMetricNames(metricSource: TSource) {
}, [metricSource]);
const { data: gaugeMetrics } = useGetKeyValues({
chartConfig: gaugeConfig,
chartConfigs: gaugeConfig,
keys: ['MetricName'],
limit: MAX_METRIC_NAME_OPTIONS,
disableRowLimit: true,
});
// const { data: histogramMetrics } = useGetKeyValues({
// chartConfig: histogramConfig,
// chartConfigs: histogramConfig,
// keys: ['MetricName'],
// limit: MAX_METRIC_NAME_OPTIONS,
// disableRowLimit: true,
// });
const { data: sumMetrics } = useGetKeyValues({
chartConfig: sumConfig,
chartConfigs: sumConfig,
keys: ['MetricName'],
limit: MAX_METRIC_NAME_OPTIONS,
disableRowLimit: true,

View file

@ -0,0 +1,210 @@
import { JSDataType } from '@hyperdx/common-utils/dist/clickhouse';
import { Field } from '@hyperdx/common-utils/dist/metadata';
import { renderHook } from '@testing-library/react';
import { LuceneLanguageFormatter } from '../../SearchInputV2';
import { useAutoCompleteOptions } from '../useAutoCompleteOptions';
import { useAllFields, useGetKeyValues } from '../useMetadata';
if (!globalThis.structuredClone) {
globalThis.structuredClone = (obj: any) => {
return JSON.parse(JSON.stringify(obj));
};
}
// Mock dependencies
jest.mock('../useMetadata', () => ({
...jest.requireActual('../useMetadata.tsx'),
useAllFields: jest.fn(),
useGetKeyValues: jest.fn(),
}));
const luceneFormatter = new LuceneLanguageFormatter();
const mockFields: Field[] = [
{
path: ['ResourceAttributes'],
jsType: JSDataType.Map,
type: 'map',
},
{
path: ['ResourceAttributes', 'service.name'],
jsType: JSDataType.String,
type: 'string',
},
{
path: ['TraceAttributes', 'trace.id'],
jsType: JSDataType.String,
type: 'string',
},
];
const mockTableConnections = [
{
databaseName: 'test_db',
tableName: 'traces',
connectionId: 'conn1',
},
];
describe('useAutoCompleteOptions', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
// Setup default mock implementations
(useAllFields as jest.Mock).mockReturnValue({
data: mockFields,
});
(useGetKeyValues as jest.Mock).mockReturnValue({
data: null,
});
});
it('should return field options with correct lucene formatting', () => {
const { result } = renderHook(() =>
useAutoCompleteOptions(luceneFormatter, 'ResourceAttributes', {
tableConnections: mockTableConnections,
}),
);
expect(result.current).toEqual([
{
value: 'ResourceAttributes',
label: 'ResourceAttributes (map)',
},
{
value: 'ResourceAttributes.service.name',
label: 'ResourceAttributes.service.name (string)',
},
{
value: 'TraceAttributes.trace.id',
label: 'TraceAttributes.trace.id (string)',
},
]);
});
it('should return key value options with correct lucene formatting', () => {
const mockKeyValues = [
{
key: 'ResourceAttributes.service.name',
value: ['frontend', 'backend'],
},
];
(useGetKeyValues as jest.Mock).mockReturnValue({
data: mockKeyValues,
});
const { result } = renderHook(() =>
useAutoCompleteOptions(
luceneFormatter,
'ResourceAttributes.service.name',
{
tableConnections: mockTableConnections,
},
),
);
expect(result.current).toEqual([
{
value: 'ResourceAttributes',
label: 'ResourceAttributes (map)',
},
{
value: 'ResourceAttributes.service.name',
label: 'ResourceAttributes.service.name (string)',
},
{
value: 'TraceAttributes.trace.id',
label: 'TraceAttributes.trace.id (string)',
},
{
value: 'ResourceAttributes.service.name:"frontend"',
label: 'ResourceAttributes.service.name:"frontend"',
},
{
value: 'ResourceAttributes.service.name:"backend"',
label: 'ResourceAttributes.service.name:"backend"',
},
]);
});
// TODO: Does this test case need to be removed after HDX-1548?
it('should handle nested key value options', () => {
const mockKeyValues = [
{
key: 'ResourceAttributes',
value: [
{
'service.name': 'frontend',
'deployment.environment': 'production',
},
],
},
];
(useGetKeyValues as jest.Mock).mockReturnValue({
data: mockKeyValues,
});
const { result } = renderHook(() =>
useAutoCompleteOptions(luceneFormatter, 'ResourceAttributes', {
tableConnections: mockTableConnections,
}),
);
//console.log(result.current);
expect(result.current).toEqual([
{
value: 'ResourceAttributes',
label: 'ResourceAttributes (map)',
},
{
value: 'ResourceAttributes.service.name',
label: 'ResourceAttributes.service.name (string)',
},
{
value: 'TraceAttributes.trace.id',
label: 'TraceAttributes.trace.id (string)',
},
{
value: 'ResourceAttributes.service.name:"frontend"',
label: 'ResourceAttributes.service.name:"frontend"',
},
{
value: 'ResourceAttributes.deployment.environment:"production"',
label: 'ResourceAttributes.deployment.environment:"production"',
},
]);
});
it('should handle additional suggestions', () => {
const { result } = renderHook(() =>
useAutoCompleteOptions(luceneFormatter, 'ResourceAttributes', {
tableConnections: mockTableConnections,
additionalSuggestions: ['custom.field'],
}),
);
expect(result.current).toEqual([
{
value: 'ResourceAttributes',
label: 'ResourceAttributes (map)',
},
{
value: 'ResourceAttributes.service.name',
label: 'ResourceAttributes.service.name (string)',
},
{
value: 'TraceAttributes.trace.id',
label: 'TraceAttributes.trace.id (string)',
},
{
value: 'custom.field',
label: 'custom.field',
},
]);
});
});

View file

@ -1,4 +1,224 @@
import { deduplicate2dArray } from '../useMetadata';
import React from 'react';
import * as metadataModule from '@hyperdx/app/src/metadata';
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse';
import { Metadata, MetadataCache } from '@hyperdx/common-utils/dist/metadata';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { deduplicate2dArray, useGetKeyValues } from '../useMetadata';
// Create a mock ChartConfig based on the Zod schema
const createMockChartConfig = (
overrides: Partial<ChartConfigWithDateRange> = {},
): ChartConfigWithDateRange =>
({
timestampValueExpression: '',
connection: 'foo',
from: {
databaseName: 'telemetry',
tableName: 'traces',
},
...overrides,
}) as ChartConfigWithDateRange;
describe('useGetKeyValues', () => {
let queryClient: QueryClient;
let wrapper: React.ComponentType<{ children: any }>;
let mockMetadata: Metadata;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// initialize metadata object
mockMetadata = new Metadata({} as ClickhouseClient, {} as MetadataCache);
jest.spyOn(metadataModule, 'getMetadata').mockReturnValue(mockMetadata);
// Create a new QueryClient for each test
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Create a wrapper component with QueryClientProvider
wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
});
// Test case: Basic functionality with single chart config
it('should fetch key values for a single chart config', async () => {
// Arrange
const mockChartConfig = createMockChartConfig();
const mockKeys = ["ResourceAttributes['service.name']"];
const mockKeyValues = [
{
key: "ResourceAttributes['service.name']",
value: ['frontend', 'backend', 'database'],
},
];
jest.spyOn(mockMetadata, 'getKeyValues').mockResolvedValue(mockKeyValues);
// Act
const { result } = renderHook(
() =>
useGetKeyValues({
chartConfigs: mockChartConfig,
keys: mockKeys,
}),
{ wrapper },
);
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
//console.log(result.current.data);
expect(result.current.data).toEqual(mockKeyValues);
});
// Test case: Multiple chart configs with different configurations
it('should fetch key values for multiple chart configs', async () => {
// Arrange
const mockChartConfigs = [
createMockChartConfig({
from: { databaseName: 'telemetry', tableName: 'traces' },
groupBy: "ResourceAttributes['service.name']",
}),
createMockChartConfig({
from: { databaseName: 'logs', tableName: 'application_logs' },
orderBy: '"timestamp" DESC',
}),
];
const mockKeys = [
'ResourceAttributes.service.name',
'ResourceAttributes.environment',
];
jest
.spyOn(mockMetadata, 'getKeyValues')
.mockResolvedValueOnce([
{
key: "ResourceAttributes['service.name']",
value: ['frontend', 'backend'],
},
])
.mockResolvedValueOnce([
{
key: "ResourceAttributes['environment']",
value: ['production', 'staging'],
},
]);
// Act
const { result } = renderHook(
() =>
useGetKeyValues({
chartConfigs: mockChartConfigs,
keys: mockKeys,
}),
{ wrapper },
);
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([
{
key: "ResourceAttributes['service.name']",
value: ['frontend', 'backend'],
},
{
key: "ResourceAttributes['environment']",
value: ['production', 'staging'],
},
]);
expect(jest.spyOn(mockMetadata, 'getKeyValues')).toHaveBeenCalledTimes(2);
});
// Test case: Handling empty keys
it('should not fetch when keys array is empty', () => {
// Arrange
const mockChartConfig = createMockChartConfig();
// Act
const { result } = renderHook(
() =>
useGetKeyValues({
chartConfigs: mockChartConfig,
keys: [],
}),
{ wrapper },
);
// Assert
expect(result.current.isFetched).toBe(false);
expect(jest.spyOn(mockMetadata, 'getKeyValues')).not.toHaveBeenCalled();
});
// Test case: Custom limit and disableRowLimit
it('should pass custom limit and disableRowLimit', async () => {
// Arrange
const mockChartConfig = createMockChartConfig();
const mockKeys = ['ResourceAttributes.service.name'];
const mockKeyValues = [
{
key: "ResourceAttributes['service.name']",
value: ['frontend', 'backend'],
},
];
jest.spyOn(mockMetadata, 'getKeyValues').mockResolvedValue(mockKeyValues);
// Act
const { result } = renderHook(
() =>
useGetKeyValues({
chartConfigs: mockChartConfig,
keys: mockKeys,
limit: 50,
disableRowLimit: true,
}),
{ wrapper },
);
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
// Test case: Error handling
it('should handle errors when fetching key values', async () => {
// Arrange
const mockChartConfig = createMockChartConfig();
const mockKeys = ['ResourceAttributes.service.name'];
jest
.spyOn(mockMetadata, 'getKeyValues')
.mockRejectedValue(new Error('Fetch failed'));
// Act
const { result } = renderHook(
() =>
useGetKeyValues({
chartConfigs: mockChartConfig,
keys: mockKeys,
}),
{ wrapper },
);
// Assert
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(expect.any(Error));
expect(result.current.error!.message).toBe('Fetch failed');
});
});
describe('deduplicate2dArray', () => {
// Test basic deduplication

View file

@ -0,0 +1,165 @@
import { useEffect, useMemo, useState } from 'react';
import { Field, TableConnection } from '@hyperdx/common-utils/dist/metadata';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import {
deduplicate2dArray,
useAllFields,
useGetKeyValues,
} from '@/hooks/useMetadata';
import { toArray } from '@/utils';
export interface ILanguageFormatter {
formatFieldValue: (f: Field) => string;
formatFieldLabel: (f: Field) => string;
formatKeyValPair: (key: string, value: string) => string;
}
export function useAutoCompleteOptions(
formatter: ILanguageFormatter,
value: string,
{
tableConnections,
additionalSuggestions,
}: {
tableConnections?: TableConnection | TableConnection[];
additionalSuggestions?: string[];
},
) {
// Fetch and gather all field options
const { data: fields } = useAllFields(tableConnections ?? [], {
enabled:
!!tableConnections &&
(Array.isArray(tableConnections) ? tableConnections.length > 0 : true),
});
const { fieldCompleteOptions, fieldCompleteMap } = useMemo(() => {
const _columns = (fields ?? []).filter(c => c.jsType !== null);
const fieldCompleteMap = new Map<string, Field>();
const baseOptions = _columns.map(c => {
const val = {
value: formatter.formatFieldValue(c),
label: formatter.formatFieldLabel(c),
};
fieldCompleteMap.set(val.value, c);
return val;
});
const suggestionOptions =
additionalSuggestions?.map(column => ({
value: column,
label: column,
})) ?? [];
const fieldCompleteOptions = [...baseOptions, ...suggestionOptions];
return { fieldCompleteOptions, fieldCompleteMap };
}, [formatter, fields, additionalSuggestions]);
// searchField is used for the purpose of checking if a key is valid and key values should be fetched
const [searchField, setSearchField] = useState<Field | null>(null);
// check if any search field matches
useEffect(() => {
const v = fieldCompleteMap.get(value);
if (v) {
setSearchField(v);
}
}, [fieldCompleteMap, value]);
// clear search field if no key matches anymore
useEffect(() => {
if (!searchField) return;
if (
!(value as string).startsWith(formatter.formatFieldValue(searchField))
) {
setSearchField(null);
}
}, [searchField, setSearchField, value]);
const searchKeys = useMemo(
() =>
searchField
? [
searchField.path.length > 1
? `${searchField.path[0]}['${searchField.path[1]}']`
: searchField.path[0],
]
: [],
[searchField],
);
// hooks to get key values
const chartConfigs = toArray(tableConnections).map(
({ databaseName, tableName, connectionId }) =>
({
connection: connectionId,
from: {
databaseName,
tableName,
},
timestampValueExpression: '',
select: '',
where: '',
}) as ChartConfigWithDateRange,
);
const { data: keyVals } = useGetKeyValues({
chartConfigs,
keys: searchKeys,
});
const keyValCompleteOptions = useMemo<
{ value: string; label: string }[]
>(() => {
if (!keyVals || !searchField) return fieldCompleteOptions;
const output = // TODO: Fix this hacky type assertion caused by bug in HDX-1548
(
keyVals as unknown as {
key: string;
value: (string | { [key: string]: string })[];
}[]
).flatMap(kv => {
return kv.value.flatMap(v => {
if (typeof v === 'string') {
const value = formatter.formatKeyValPair(
formatter.formatFieldValue(searchField),
v,
);
return [
{
value,
label: value,
},
];
} else if (typeof v === 'object') {
// TODO: Fix type issues mentioned in HDX-1548
const output: {
value: string;
label: string;
}[] = [];
for (const [key, val] of Object.entries(v)) {
if (typeof key !== 'string' || typeof val !== 'string') {
console.error('unknown type for autocomplete object ', v);
return [];
}
const field = structuredClone(searchField);
field.path.push(key);
const value = formatter.formatKeyValPair(
formatter.formatFieldValue(field),
val,
);
output.push({
value,
label: value,
});
}
return output;
} else {
return [];
}
});
});
return output;
}, [fieldCompleteOptions, keyVals, searchField]);
// combine all autocomplete options
return useMemo(() => {
return deduplicate2dArray([fieldCompleteOptions, keyValCompleteOptions]);
}, [fieldCompleteOptions, keyValCompleteOptions]);
}

View file

@ -13,6 +13,7 @@ import {
} from '@tanstack/react-query';
import { getMetadata } from '@/metadata';
import { toArray } from '@/utils';
export function useColumns(
{
@ -96,12 +97,12 @@ export function useTableMetadata(
export function useGetKeyValues(
{
chartConfig,
chartConfigs,
keys,
limit,
disableRowLimit,
}: {
chartConfig: ChartConfigWithDateRange;
chartConfigs: ChartConfigWithDateRange | ChartConfigWithDateRange[];
keys: string[];
limit?: number;
disableRowLimit?: boolean;
@ -109,16 +110,26 @@ export function useGetKeyValues(
options?: Omit<UseQueryOptions<any, Error>, 'queryKey'>,
) {
const metadata = getMetadata();
const chartConfigsArr = toArray(chartConfigs);
return useQuery<{ key: string; value: string[] }[]>({
queryKey: ['useMetadata.useGetKeyValues', { chartConfig, keys }],
queryFn: async () => {
return metadata.getKeyValues({
chartConfig,
keys: keys.slice(0, 20), // Limit to 20 keys for now, otherwise request fails (max header size)
limit,
disableRowLimit,
});
},
queryKey: [
'useMetadata.useGetKeyValues',
...chartConfigsArr.map(cc => ({ ...cc })),
...keys,
],
queryFn: async () =>
(
await Promise.all(
chartConfigsArr.map(chartConfig =>
metadata.getKeyValues({
chartConfig,
keys: keys.slice(0, 20), // Limit to 20 keys for now, otherwise request fails (max header size)
limit,
disableRowLimit,
}),
),
)
).flatMap(v => v),
staleTime: 1000 * 60 * 5, // Cache every 5 min
enabled: !!keys.length,
placeholderData: keepPreviousData,
@ -126,6 +137,10 @@ export function useGetKeyValues(
});
}
export function deduplicateArray<T extends object>(array: T[]): T[] {
return deduplicate2dArray([array]);
}
export function deduplicate2dArray<T extends object>(array2d: T[][]): T[] {
// deduplicate common fields
const array: T[] = [];

View file

@ -649,3 +649,10 @@ export function getMetricTableName(
metricType.toLowerCase() as keyof typeof source.metricTables
];
}
/**
* Converts (T | T[]) to T[]. If undefined, empty array
*/
export function toArray<T>(obj?: T | T[]): T[] {
return !obj ? [] : Array.isArray(obj) ? obj : [obj];
}

View file

@ -457,6 +457,8 @@ export class Metadata {
})
.then(res => res.json<any>());
// TODO: Fix type issues mentioned in HDX-1548. value is not acually a
// string[], sometimes it's { [key: string]: string; }
return Object.entries(json.data[0]).map(([key, value]) => ({
key: keys[parseInt(key.replace('param', ''))],
value: (value as string[])?.filter(Boolean), // remove nulls