Files
openobserve/web/src/composables/useFieldValuesStream.ts
T
Prabhat Sharma 624425a08a feat(editor): rebuild query autocomplete, and fix the value path behind it (#13586)
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`.
2026-08-03 22:29:04 +00:00

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;