Compare commits

...

4 Commits

Author SHA1 Message Date
Tushar Vats
892443fbd5 fix: new logic 2026-04-21 00:40:59 +05:30
Tushar Vats
4b95f84bfb fix: improve filter key suggestions 2026-04-20 23:56:32 +05:30
Tushar Vats
9d7bce0f2e Merge branch 'main' into tvats-improve-key-suggestions 2026-04-20 15:35:52 +05:30
Tushar Vats
2973598246 fix: improved key suggestions 2026-04-16 05:37:54 +05:30
3 changed files with 128 additions and 35 deletions

View File

@@ -1,5 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { CheckCircleFilled } from '@ant-design/icons';
import {
autocompletion,
@@ -111,6 +112,7 @@ function QuerySearch({
const [isEditorReady, setIsEditorReady] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const queryClient = useQueryClient();
const handleQueryValidation = useCallback((newExpression: string): void => {
try {
@@ -189,6 +191,11 @@ function QuerySearch({
const [keySuggestions, setKeySuggestions] = useState<
QueryKeyDataSuggestionsProps[] | null
>(null);
// Mirror of keySuggestions for synchronous access from inside the
// CodeMirror autocompletion source. The `extensions` array is
// reconfigured on each render, but `startCompletion` may fire before
// React's commit lands the new closure — so we read from this ref.
const keySuggestionsRef = useRef<QueryKeyDataSuggestionsProps[] | null>(null);
const [showExamples] = useState(false);
@@ -216,9 +223,10 @@ function QuerySearch({
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
items.map(({ name, fieldDataType }) => ({
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
fieldContext,
info: '',
details: '',
})),
@@ -255,42 +263,78 @@ function QuerySearch({
!queryData.aggregateAttribute?.key &&
!showFilterSuggestionsWithoutMetric
) {
keySuggestionsRef.current = [];
setKeySuggestions([]);
return;
}
if (hardcodedAttributeKeys) {
keySuggestionsRef.current = hardcodedAttributeKeys;
setKeySuggestions(hardcodedAttributeKeys);
return;
}
lastFetchedKeyRef.current = searchText || '';
lastFetchedKeyRef.current = searchText ?? '';
const response = await getKeySuggestions({
signal: dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
});
try {
// Route through React Query so identical concurrent calls share an
// in-flight promise and results are cached across components and
// remounts (staleTime keeps the entry hot for 5 min).
const response = await queryClient.fetchQuery({
queryKey: [
'fields/keys',
dataSource,
signalSource ?? '',
debouncedMetricName ?? '',
searchText ?? '',
],
queryFn: () =>
getKeySuggestions({
signal: dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
}),
staleTime: 5 * 60 * 1000,
});
if (response.data.data) {
const { keys } = response.data.data;
const options = generateOptions(keys);
// Use a Map to deduplicate by label and preserve order: new options take precedence
const merged = new Map<string, QueryKeyDataSuggestionsProps>();
options.forEach((opt) => merged.set(opt.label, opt));
if (searchText && lastKeyRef.current !== searchText) {
(keySuggestions || []).forEach((opt) => {
if (!merged.has(opt.label)) {
merged.set(opt.label, opt);
}
});
if (response.data.data) {
const { keys } = response.data.data;
const options = generateOptions(keys);
// Deduplicate by `label + fieldContext` so that the same key name
// in different contexts (e.g. attribute.field1 vs resource.field1)
// is preserved — display-time dedup happens in autoSuggestions.
const dedupKey = (opt: QueryKeyDataSuggestionsProps): string =>
`${opt.label}::${opt.fieldContext ?? ''}`;
const merged = new Map<string, QueryKeyDataSuggestionsProps>();
options.forEach((opt) => merged.set(dedupKey(opt), opt));
if (searchText && lastKeyRef.current !== searchText) {
(keySuggestions || []).forEach((opt) => {
const k = dedupKey(opt);
if (!merged.has(k)) {
merged.set(k, opt);
}
});
}
const next = Array.from(merged.values());
keySuggestionsRef.current = next;
setKeySuggestions(next);
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
toggleSuggestions(10);
}
}
setKeySuggestions(Array.from(merged.values()));
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
toggleSuggestions(10);
} catch (error) {
// Suggestions are non-critical — a transient API failure must not
// crash the editor. Callers invoke this fire-and-forget (via the
// debouncer), so an unhandled rejection would bubble up to the
// process and, in test environments, fail the worker. Surface it
// to the console so a real bug (thrown from generateOptions, etc.)
// isn't silently lost.
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.warn('[QuerySearch] fetchKeySuggestions failed:', error);
}
}
},
@@ -303,6 +347,7 @@ function QuerySearch({
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
queryClient,
],
);
@@ -312,6 +357,7 @@ function QuerySearch({
);
useEffect(() => {
keySuggestionsRef.current = [];
setKeySuggestions([]);
debouncedFetchKeySuggestions();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -886,15 +932,58 @@ function QuerySearch({
};
}
if (queryContext.isInKey) {
const searchText = word?.text.toLowerCase().trim() ?? '';
// The ref is the source of truth for the latest cache; the `keySuggestions`
// state is kept in sync only so dependent React children re-render. We
// read the ref first because CodeMirror may re-run this source
// synchronously (via startCompletion) before React commits the new state.
const liveKeySuggestions = keySuggestionsRef.current ?? keySuggestions;
options = (keySuggestions || []).filter((option) =>
option.label.toLowerCase().includes(searchText),
);
// Detect a context-scoped input (`<head>.<rest>`) by asking the cache
// directly: scoped if any cached key's `fieldContext` matches the head.
// No stored enum, no hardcoded list — the backend's response is the
// source of truth for what counts as a context.
const rawSearchText = word?.text.toLowerCase().trim() ?? '';
const dotIdx = rawSearchText.indexOf('.');
const head = dotIdx > 0 ? rawSearchText.slice(0, dotIdx) : '';
const isContextScoped =
!!head &&
(liveKeySuggestions || []).some((opt) => opt.fieldContext === head);
const contextName = isContextScoped
? rawSearchText.slice(dotIdx + 1)
: rawSearchText;
if (options.length === 0 && lastFetchedKeyRef.current !== searchText) {
debouncedFetchKeySuggestions(searchText);
if (queryContext.isInKey || isContextScoped) {
// Debounced fetch with the user's raw input. The backend parses
// "<context>.<rest>" out of `searchText` itself.
if (lastFetchedKeyRef.current !== rawSearchText) {
debouncedFetchKeySuggestions(rawSearchText);
}
if (isContextScoped) {
// "<head>." or "<head>.<rest>" → only keys with matching
// `fieldContext`, optionally substring-filtered by `rest`.
options = (liveKeySuggestions || [])
.filter((opt) => opt.fieldContext === head)
.filter((opt) =>
contextName ? opt.label.toLowerCase().includes(contextName) : true,
)
.map((opt) => ({
...opt,
label: `${head}.${opt.label}`,
}));
} else {
// Bare input: match against field names only. Dedup by label so
// the same name across contexts collapses to one entry.
const seen = new Set<string>();
options = (liveKeySuggestions || [])
.filter((opt) => opt.label.toLowerCase().includes(rawSearchText))
.filter((opt) => {
if (seen.has(opt.label)) {
return false;
}
seen.add(opt.label);
return true;
});
}
// If we have previous pairs, we can prioritize keys that haven't been used yet
@@ -913,7 +1002,7 @@ function QuerySearch({
...option,
boost:
(option.boost || 0) +
(option.label.toLowerCase() === searchText ? 100 : 0),
(option.label.toLowerCase() === rawSearchText ? 100 : 0),
}));
// Add space after selection for keys

View File

@@ -6,7 +6,7 @@ export interface QueryKeyDataSuggestionsProps {
info?: string;
apply?: string;
detail?: string;
fieldContext?: 'resource' | 'scope' | 'attribute' | 'span';
fieldContext?: 'resource' | 'scope' | 'attribute' | 'span' | 'log' | 'body';
fieldDataType?: QUERY_BUILDER_KEY_TYPES;
name: string;
signal: 'traces' | 'logs' | 'metrics';
@@ -25,7 +25,7 @@ export interface QueryKeySuggestionsResponseProps {
export interface QueryKeyRequestProps {
signal: 'traces' | 'logs' | 'metrics';
searchText: string;
fieldContext?: 'resource' | 'scope' | 'attribute' | 'span';
fieldContext?: 'resource' | 'scope' | 'attribute' | 'span' | 'log' | 'body';
fieldDataType?: QUERY_BUILDER_KEY_TYPES;
metricName?: string;
signalSource?: 'meter' | '';

View File

@@ -37,6 +37,9 @@ export type FieldType =
| 'instrumentation_library'
| 'span';
// Broader than the user-typeable context prefixes the filter autocomplete
// surfaces — carries backend-only contexts that appear on query-range
// responses: `metric`, `trace`, `event`, and the empty string.
export type FieldContext =
| 'metric'
| 'log'
@@ -46,6 +49,7 @@ export type FieldContext =
| 'scope'
| 'attribute'
| 'event'
| 'body'
| '';
export type FieldDataType =