mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
Design at autocomplete/autocomplete.md
Rewrites the query editor's autocomplete across every surface that has
one, and
follows the field-value path far enough to fix the bugs it was hiding.
53
commits, in six phases; each phase was written test-first, reviewed
adversarially, and checked in a browser before the next one started.
## What was wrong
The completion providers were wired inconsistently. Six surfaces bound a
resolver that was never defined, so their value completion could not
resolve at
all — SLO, dashboards and anomaly detection among them. Traces never
bound
`:suggestions`. Alerts never called `updateStreamKeywords`. PromQL ran a
second
editor runtime purely for its grammar, and asked
`/prometheus/api/v1/series`
— the heaviest endpoint available — for data the stream schema already
has.
## Phases
**1 — icons, staleness, quoting.** Correct `CompletionItemKind` glyphs,
stream
keywords refreshed when the stream changes, `spath` quoting.
**2 — the function catalog.** Custom functions get one tab stop per
argument,
org VRL functions stop appearing twice, column types read from
`dataType` as
well as `type`.
**3 — signature help, hover, async completion.** Providers return real
`CompletionList`s with `incomplete: true`, so a list that arrives late
is
re-queried rather than pinned.
**4 — numeric ranking.** Numeric columns sort first inside a numeric
aggregate.
**5 — values for streams nobody has searched.** The value cache only
ever held
what a search had already returned, so a stream you had not queried
offered
nothing. Values are now fetched on demand, bounded (10s timeout, 60s
cooldown,
one in-flight request per key), and shared between the editor and the
builder.
PromQL joins the same path: labels from the stream schema, values from
the
shared cache. `/series` now has zero production callers.
**6 — one way to fetch a value.** Item 22 only, in this PR.
## The dashboard filter 400
Clicking the ✕ beside "Select Field" in a filter's dropdown sets the
column to
`{}`, and the watcher on `condition.column` then asks for that column's
values.
`loadFilterItem` built `fields: [row.field]` out of `undefined`,
JSON.stringify
turned that into `null`, and the server answered
```
400 {"code":400,"message":"SerdeJsonError# invalid type: null, expected a string"}
```
`_values_stream` answers 400, not an empty result, for each way a piece
of the
payload can go missing — and every one is reachable: an unresolved
stream alias
(`.find(…)?.stream`), a stream not yet chosen (`""`), a range not yet
set. The
range fails differently: `meta.dateTime` starts as `{start_time: ""}`
and
`""?.toISOString()` does not short-circuit, so it throws and both
callers
surface "Something went wrong!" for a lookup the user never asked to
fail.
Guarded at the two callers, where the payload is built and where the
throw
happens. Not at `fetchFieldValues` — a guard there is too late to stop
the
throw and would duplicate validation the callers need anyway. The
trigger is
left alone: re-picking a field must still load its values.
## Notable removals
CodeMirror is gone — it was still bundled for the PromQL grammar alone.
Bundle 26.3 MB → 25.43 MB; the PromQL chunks 442.7 KB → 20.6 KB. The
PromQL
term catalog went from 7 entries to 113, generated from Prometheus's own
tables
and vendored with its MIT notice.
## Verification
- 76 files, +10.5k/−1.4k, ~9,100 tests passing across the touched areas.
- `vue-tsc` clean, `npm run build` exit 0.
- Every phase checked in a running browser against a live instance,
including
the 400 above: the ✕ now produces no request and no toast, adding a
filter and
re-picking a field each still fetch once, and the value list still
populates.
## Known, deliberately out of scope
- A filter still fetches twice (once on add, once on open) — that is
item 23,
the cache read, not this PR.
- `useMetricsExplorer.loadFilterItem` has the same unguarded
`toISOString()`
pattern on a different surface.
- `services/metrics.ts` and `services/search.ts` still carry dead
`get_promql_series` wrappers now that nothing calls `/series`.
258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import { ref, type Ref } from "vue";
|
|
import store from "@/stores";
|
|
import { generateTraceContext } from "@/utils/zincutils";
|
|
import useHttpStreaming from "@/composables/useStreamingSearch";
|
|
import { captureFromValuesApi } from "@/composables/fieldValueStore";
|
|
|
|
export interface FieldValueEntry {
|
|
key: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface FieldValuesState {
|
|
isLoading: boolean;
|
|
values: FieldValueEntry[];
|
|
hasMore: boolean;
|
|
errMsg: string;
|
|
}
|
|
|
|
/**
|
|
* Shared composable for streaming field values across logs, traces, and pipeline.
|
|
*
|
|
* Manages per-field state (loading, values, hasMore, errMsg) and per-stream
|
|
* value accumulation so callers get cross-stream aggregation for free.
|
|
*
|
|
* Usage:
|
|
* const { fieldValues, fetchFieldValues, cancelFieldStream, resetFieldValues } =
|
|
* useFieldValuesStream();
|
|
*/
|
|
const useFieldValuesStream = () => {
|
|
const { fetchQueryDataWithHttpStream, cancelStreamQueryBasedOnRequestId } = useHttpStreaming();
|
|
|
|
// Public state: fieldName → { isLoading, values, hasMore, errMsg }
|
|
const fieldValues: Ref<Record<string, FieldValuesState>> = ref({});
|
|
|
|
// Internal per-stream values for cross-stream aggregation:
|
|
// fieldName → streamName → { values, hasMore }
|
|
const streamFieldValues: Ref<
|
|
Record<string, Record<string, { values: FieldValueEntry[]; hasMore: boolean }>>
|
|
> = ref({});
|
|
|
|
// Finalized values from previous "load more" pages (immutable during streaming)
|
|
const fieldValuesFinalizedValues: Ref<Record<string, FieldValueEntry[]>> = ref({});
|
|
|
|
// Cumulative size requested per field (grows on "load more")
|
|
const fieldValuesCurrentSize: Ref<Record<string, number>> = ref({});
|
|
|
|
// Active trace IDs per field (supports multiple concurrent streams per field)
|
|
const traceIdMapper: Ref<Record<string, string[]>> = ref({});
|
|
|
|
// ─── Trace ID helpers ────────────────────────────────────────────────────
|
|
|
|
const addTraceId = (field: string, traceId: string) => {
|
|
if (!traceIdMapper.value[field]) traceIdMapper.value[field] = [];
|
|
traceIdMapper.value[field].push(traceId);
|
|
};
|
|
|
|
const removeTraceId = (field: string, traceId: string) => {
|
|
if (traceIdMapper.value[field]) {
|
|
traceIdMapper.value[field] = traceIdMapper.value[field].filter((id) => id !== traceId);
|
|
}
|
|
};
|
|
|
|
// ─── Public API ──────────────────────────────────────────────────────────
|
|
|
|
/** Cancel all active streams for a field. */
|
|
const cancelFieldStream = (fieldName: string) => {
|
|
const traceIds = traceIdMapper.value[fieldName];
|
|
if (traceIds?.length) {
|
|
traceIds.forEach((traceId) =>
|
|
cancelStreamQueryBasedOnRequestId({
|
|
trace_id: traceId,
|
|
org_id: store.state.selectedOrganization.identifier,
|
|
}),
|
|
);
|
|
traceIdMapper.value[fieldName] = [];
|
|
}
|
|
};
|
|
|
|
/** Reset field state; pass isLoading=true when a new fetch is about to start. */
|
|
const resetFieldValues = (fieldName: string, isLoading = false) => {
|
|
fieldValues.value[fieldName] = {
|
|
values: [],
|
|
isLoading,
|
|
hasMore: false,
|
|
errMsg: "",
|
|
};
|
|
streamFieldValues.value[fieldName] = {};
|
|
delete fieldValuesFinalizedValues.value[fieldName];
|
|
};
|
|
|
|
/** Kick off a streaming values fetch for the given payload. */
|
|
const fetchFieldValues = (payload: any) => {
|
|
const { traceId } = generateTraceContext();
|
|
const wsPayload = {
|
|
queryReq: payload,
|
|
type: "values" as const,
|
|
traceId,
|
|
org_id: store.state.selectedOrganization.identifier,
|
|
meta: payload,
|
|
};
|
|
|
|
fetchQueryDataWithHttpStream(wsPayload, {
|
|
data: handleResponse,
|
|
error: handleError,
|
|
complete: handleClose,
|
|
reset: handleReset,
|
|
});
|
|
|
|
addTraceId(payload.fields[0], traceId);
|
|
};
|
|
|
|
// ─── Stream handlers ─────────────────────────────────────────────────────
|
|
|
|
const handleResponse = (payload: any, response: any) => {
|
|
const fieldName = payload?.queryReq?.fields[0];
|
|
const streamName = payload?.queryReq?.stream_name;
|
|
const pageSize = store.state.zoConfig?.query_values_default_num || 10;
|
|
|
|
if (!fieldName) return;
|
|
|
|
try {
|
|
if (response.type === "cancel_response") {
|
|
removeTraceId(fieldName, response.content.trace_id);
|
|
return;
|
|
}
|
|
if (response.type !== "search_response_hits") return;
|
|
|
|
if (!fieldValues.value[fieldName]) resetFieldValues(fieldName);
|
|
if (!streamFieldValues.value[fieldName]) streamFieldValues.value[fieldName] = {};
|
|
if (!streamFieldValues.value[fieldName][streamName])
|
|
streamFieldValues.value[fieldName][streamName] = {
|
|
values: [],
|
|
hasMore: false,
|
|
};
|
|
|
|
if (response.content?.results?.hits?.length) {
|
|
const chunkValues: FieldValueEntry[] = [];
|
|
response.content.results.hits.forEach((item: any) => {
|
|
item.values?.forEach((subItem: any) => {
|
|
chunkValues.push({
|
|
key: subItem.zo_sql_key ? subItem.zo_sql_key : "null",
|
|
count: parseInt(subItem.zo_sql_num),
|
|
});
|
|
});
|
|
});
|
|
|
|
// Background capture — does not block handleResponse return
|
|
if (chunkValues.length > 0 && fieldName) {
|
|
captureFromValuesApi(
|
|
{
|
|
org: store.state.selectedOrganization.identifier,
|
|
streamType: payload?.queryReq?.stream_type ?? "logs",
|
|
streamName: streamName ?? "",
|
|
},
|
|
fieldName,
|
|
chunkValues,
|
|
);
|
|
}
|
|
|
|
// The backend returns the full cumulative result set (from rank 0 to
|
|
// from+size), so always replace per-stream values rather than appending.
|
|
streamFieldValues.value[fieldName][streamName].values = chunkValues;
|
|
|
|
// Aggregate values across all streams and sort by count descending.
|
|
const aggregated: Record<string, number> = {};
|
|
Object.values(streamFieldValues.value[fieldName]).forEach(({ values }) => {
|
|
values.forEach(({ key, count }) => {
|
|
aggregated[key] = (aggregated[key] ?? 0) + count;
|
|
});
|
|
});
|
|
|
|
const aggregatedArray = Object.entries(aggregated)
|
|
.map(([key, count]) => ({ key, count }))
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
// Merge with finalized values from previous pages.
|
|
const finalized = fieldValuesFinalizedValues.value[fieldName] || [];
|
|
const currentSize = fieldValuesCurrentSize.value[fieldName] || pageSize;
|
|
|
|
if (finalized.length > 0) {
|
|
const finalizedKeys = new Set(finalized.map((v) => v.key));
|
|
const merged = [...finalized];
|
|
for (const item of aggregatedArray) {
|
|
if (!finalizedKeys.has(item.key)) {
|
|
merged.push(item);
|
|
}
|
|
}
|
|
merged.sort((a, b) => b.count - a.count);
|
|
fieldValues.value[fieldName].values = merged;
|
|
} else {
|
|
fieldValues.value[fieldName].values = aggregatedArray;
|
|
}
|
|
|
|
fieldValues.value[fieldName].hasMore = aggregatedArray.length >= currentSize;
|
|
}
|
|
|
|
fieldValues.value[fieldName].isLoading = false;
|
|
} catch {
|
|
if (fieldValues.value[fieldName]) {
|
|
fieldValues.value[fieldName].errMsg = "Failed to fetch field values";
|
|
fieldValues.value[fieldName].isLoading = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleError = (payload: any) => {
|
|
const fieldName = payload?.queryReq?.fields[0];
|
|
if (fieldName && fieldValues.value[fieldName]) {
|
|
fieldValues.value[fieldName].isLoading = false;
|
|
fieldValues.value[fieldName].errMsg = "Failed to fetch field values";
|
|
}
|
|
if (fieldName) removeTraceId(fieldName, payload.traceId);
|
|
};
|
|
|
|
const handleClose = (payload: any) => {
|
|
const fieldName = payload?.queryReq?.fields[0];
|
|
if (fieldName && fieldValues.value[fieldName]) {
|
|
fieldValues.value[fieldName].isLoading = false;
|
|
}
|
|
if (fieldName) removeTraceId(fieldName, payload.traceId);
|
|
};
|
|
|
|
const handleReset = (data: any) => {
|
|
const fieldName = data?.queryReq?.fields[0];
|
|
if (fieldName) {
|
|
resetFieldValues(fieldName, true);
|
|
traceIdMapper.value[fieldName] = [];
|
|
fetchFieldValues(data.queryReq);
|
|
}
|
|
};
|
|
|
|
return {
|
|
fieldValues,
|
|
fieldValuesFinalizedValues,
|
|
fieldValuesCurrentSize,
|
|
fetchFieldValues,
|
|
cancelFieldStream,
|
|
resetFieldValues,
|
|
};
|
|
};
|
|
|
|
export default useFieldValuesStream;
|