Skip to content

Commit fcc18e3

Browse files
committed
feat: per-keystroke prefix search for autocomplete via clickhouse
- fix autocomplete returning field names instead of values when a field is active (drop combined deduplicate2dArray, return keyValCompleteOptions only) - add debounced ILIKE prefix filter to chartConfigs so each debounced keystroke fires a targeted clickhouse query instead of filtering a fixed top-N client-side - reduce autocomplete date range from 12h to 1h default to limit clickhouse scan cost; configurable via NEXT_PUBLIC_AUTOCOMPLETE_DATE_RANGE_MS - NEXT_PUBLIC_AUTOCOMPLETE_MIN_CHARS controls when prefix queries start
1 parent aed6b0a commit fcc18e3

2 files changed

Lines changed: 56 additions & 22 deletions

File tree

packages/app/src/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ export const LIVE_TAIL_REFRESH_INTERVAL_MS =
105105
? _rawLiveTailRefreshInterval
106106
: 900000;
107107

108+
const _rawAutocompleteRange = parseInt(
109+
env('NEXT_PUBLIC_AUTOCOMPLETE_DATE_RANGE_MS') ?? '',
110+
10,
111+
);
112+
export const AUTOCOMPLETE_DATE_RANGE_MS =
113+
Number.isFinite(_rawAutocompleteRange) && _rawAutocompleteRange > 0
114+
? _rawAutocompleteRange
115+
: 3600000; // 1 hour default
116+
108117
// Features in development
109118
export const IS_K8S_DASHBOARD_ENABLED = true;
110119
export const IS_METRICS_ENABLED = true;

packages/app/src/hooks/useAutoCompleteOptions.tsx

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import {
55
} from '@hyperdx/common-utils/dist/core/metadata';
66
import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
77

8-
import { NOW } from '@/config';
8+
import { AUTOCOMPLETE_DATE_RANGE_MS, AUTOCOMPLETE_MIN_CHARS, NOW } from '@/config';
99
import {
10-
deduplicate2dArray,
1110
useJsonColumns,
1211
useMultipleAllFields,
1312
useMultipleGetKeyValues,
1413
} from '@/hooks/useMetadata';
15-
import { getLastToken, mergePath, stripNegation, toArray } from '@/utils';
14+
import {
15+
getLastToken,
16+
mergePath,
17+
stripNegation,
18+
toArray,
19+
useDebounce,
20+
} from '@/utils';
1621

1722
export interface ILanguageFormatter {
1823
formatFieldValue: (f: Field) => string;
@@ -107,22 +112,41 @@ export function useAutoCompleteOptions(
107112
[searchField, jsonColumns],
108113
);
109114

115+
// Extract the raw value prefix the user is typing after the colon, e.g.
116+
// `ServiceName:"user-ent` → `user-ent`. Empty when no field is active.
117+
const valuePrefix = useMemo(() => {
118+
if (!searchField) return '';
119+
const lastToken = stripNegation(getLastToken(value));
120+
const colon = lastToken.indexOf(':');
121+
if (colon < 0) return '';
122+
let raw = lastToken.slice(colon + 1);
123+
if (raw.startsWith('"')) raw = raw.slice(1);
124+
if (raw.endsWith('"')) raw = raw.slice(0, -1);
125+
return raw.replace(/\*/g, '');
126+
}, [searchField, value]);
127+
128+
// Debounce so we don't fire a new ClickHouse query on every keystroke
129+
const debouncedValuePrefix = useDebounce(valuePrefix, 300);
130+
110131
// hooks to get key values
111-
const chartConfigs: BuilderChartConfigWithDateRange[] = toArray(
112-
tableConnection,
113-
).map(({ databaseName, tableName, connectionId }) => ({
114-
connection: connectionId,
115-
from: {
116-
databaseName,
117-
tableName,
118-
},
119-
timestampValueExpression: '',
120-
select: '',
121-
where: '',
122-
// TODO: Pull in date for query as arg
123-
// just assuming 1/2 day is okay to query over right now
124-
dateRange: [new Date(NOW - (86400 * 1000) / 2), new Date(NOW)],
125-
}));
132+
const chartConfigs: BuilderChartConfigWithDateRange[] = useMemo(() => {
133+
const fieldPath =
134+
searchField && debouncedValuePrefix.length >= AUTOCOMPLETE_MIN_CHARS
135+
? formatter.formatFieldValue(searchField)
136+
: null;
137+
// Escape single quotes to prevent SQL injection from the typed prefix
138+
const safePrefix = debouncedValuePrefix.replace(/'/g, "''");
139+
return toArray(tableConnection).map(({ databaseName, tableName, connectionId }) => ({
140+
connection: connectionId,
141+
from: { databaseName, tableName },
142+
timestampValueExpression: '',
143+
select: '',
144+
// Push prefix filter into ClickHouse so we aren't limited to the
145+
// top-N values fetched without any value-level filtering
146+
where: fieldPath ? `${fieldPath} ILIKE '${safePrefix}%'` : '',
147+
dateRange: [new Date(NOW - AUTOCOMPLETE_DATE_RANGE_MS), new Date(NOW)],
148+
}));
149+
}, [tableConnection, searchField, debouncedValuePrefix, formatter]);
126150

127151
const { data: keyVals } = useMultipleGetKeyValues({
128152
chartConfigs,
@@ -183,8 +207,9 @@ export function useAutoCompleteOptions(
183207
return output;
184208
}, [fieldCompleteOptions, keyVals, searchField, formatter]);
185209

186-
// combine all autocomplete options
187-
return useMemo(() => {
188-
return deduplicate2dArray([fieldCompleteOptions, keyValCompleteOptions]);
189-
}, [fieldCompleteOptions, keyValCompleteOptions]);
210+
// When a field is detected and values are loaded, keyValCompleteOptions contains
211+
// only the fetched values. When no field is detected, it falls back to
212+
// fieldCompleteOptions. Returning it directly prevents field names from leaking
213+
// into the dropdown while the user is completing a value (e.g. ServiceName:"user").
214+
return keyValCompleteOptions;
190215
}

0 commit comments

Comments
 (0)