fix(dashboards): keep panel WHERE in Logs Explorer and open log lines from Go To Logs (#16395)

Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
This commit is contained in:
ktx-vaidehi
2026-11-04 15:40:36 +00:00
committed by GitHub
co-authored by Abhay Padamani
parent 0337b781b3
commit f2a7adad65
9 changed files with 534 additions and 24 deletions
@@ -22,6 +22,7 @@ import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import config from "@/aws-exports";
import { b64DecodeUnicode } from "@/utils/formatters";
import { needsConfirmation, normalizePrefill } from "@/utils/alerts/alertPrefill";
import { getAlertSource } from "@/utils/alerts/alertSourceRegistry";
import {
@@ -52,6 +53,8 @@ vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: (...args: any[]) => toastMock(...args),
}));
const ensureDrilldownSchemaMock = vi.fn(async (..._args: unknown[]) => {});
// ── ODialog stub ─────────────────────────────────────────────────────────────
// Stub preserves slot content (so QueryInspector / ShowLegendsPopup mounting
// can be asserted) and re-emits update:open + click:* events that the
@@ -252,11 +255,14 @@ describe("PanelContainer", () => {
// (PanelSchemaRenderer.vue:1448-1480, exposed at :1802). Tests drive
// it through setNoData() so the consumer reads the same contract the
// real renderer publishes, not a shape invented by the stub.
data: () => ({ noData: "" }),
data: () => ({ noData: "", panelBaseWhere: "" }),
methods: {
setNoData(this: any, value: string) {
this.noData = value;
},
ensureDrilldownSchema(...args: unknown[]) {
return ensureDrilldownSchemaMock(...args);
},
},
},
SinglePanelMove: {
@@ -1289,6 +1295,140 @@ describe("PanelContainer", () => {
expect(windowOpenSpy).toHaveBeenCalled();
});
const goToLogs = async ({
sql,
stream = "slo_slices",
streamType = "logs",
baseWhere = "",
vrl = "",
}: {
sql: string;
stream?: string;
streamType?: string;
baseWhere?: string;
vrl?: string;
}) => {
vi.spyOn(window, "open").mockImplementation(() => null);
wrapper = createWrapper({
data: {
...mockPanelData,
queries: [
{
query: sql,
fields: { stream, stream_type: streamType },
vrlFunctionQuery: vrl,
},
],
},
});
await wrapper.vm.metaDataValue({
queries: [{ query: sql, startTime: 1672531200000, endTime: 1672617599000 }],
});
const renderer = wrapper.findComponent({ name: "PanelSchemaRenderer" });
renderer.vm.panelBaseWhere = baseWhere;
await wrapper.vm.onLogPanel();
await flushPromises();
const { default: shortURL } = await import("@/services/short_url");
const url = new URL((shortURL.create as any).mock.calls.at(-1)[1]);
return {
query: b64DecodeUnicode(url.searchParams.get("query") ?? ""),
histogram: url.searchParams.get("show_histogram"),
params: url.searchParams,
};
};
const aggregateSql =
'SELECT slo_id as "x_axis_1", count(*) as "y_axis_1" FROM "slo_slices" WHERE rev = 1793591100 GROUP BY x_axis_1';
it("sends the panel's rows as SELECT * with its WHERE and turns the histogram on", async () => {
const { query, histogram, params } = await goToLogs({
sql: aggregateSql,
baseWhere: "rev = 1793591100",
});
expect(ensureDrilldownSchemaMock).toHaveBeenCalledWith(0, true);
expect(query).toBe(
'SELECT * FROM "slo_slices" WHERE rev = 1793591100 ORDER BY _timestamp DESC',
);
expect(histogram).toBe("true");
expect(params.get("sql_mode")).toBe("true");
expect(params.get("stream")).toBe("slo_slices");
expect(params.get("from")).toBe("1672531200000");
expect(params.get("to")).toBe("1672617599000");
});
it("sends SELECT * without WHERE for a panel without one", async () => {
const { query } = await goToLogs({
sql: 'SELECT count(*) as "y" FROM "slo_slices" GROUP BY slo_id',
});
expect(query).toBe('SELECT * FROM "slo_slices" ORDER BY _timestamp DESC');
});
it.each([
[
"a JOIN query",
"SELECT a.slo_id, count(*) FROM slo_slices a JOIN stream_b b ON a.id = b.id WHERE a.rev = 1 GROUP BY a.slo_id",
],
["an aliased table", "SELECT count(*) FROM slo_slices d WHERE d.rev = 1 GROUP BY d.slo_id"],
[
"a WITH query",
"WITH t AS (SELECT * FROM x) SELECT count(*) FROM slo_slices WHERE rev = 1 GROUP BY slo_id",
],
[
"a UNION query",
"SELECT count(*) FROM slo_slices WHERE rev = 1 UNION SELECT count(*) FROM slo_slices WHERE rev = 2",
],
[
"a FROM subquery",
"SELECT count(*) FROM (SELECT * FROM slo_slices WHERE rev = 1) t GROUP BY slo_id",
],
])("sends %s unchanged with the histogram off", async (_label, sql) => {
const { query, histogram } = await goToLogs({ sql, baseWhere: "rev = 1" });
expect(query).toBe(sql);
expect(histogram).toBe("false");
});
it("falls back to the whole query when the WHERE lookup returns empty", async () => {
const { query, histogram } = await goToLogs({ sql: aggregateSql, baseWhere: "" });
expect(query).toBe(aggregateSql);
expect(histogram).toBe("false");
});
it("falls back without a WHERE lookup for a non-logs stream", async () => {
const sql = "SELECT count(*) FROM traces_stream GROUP BY service_name";
const { query, histogram } = await goToLogs({
sql,
stream: "traces_stream",
streamType: "traces",
});
expect(ensureDrilldownSchemaMock).not.toHaveBeenCalled();
expect(query).toBe(sql);
expect(histogram).toBe("false");
});
it("converts a panel whose string values contain union / with", async () => {
const { query } = await goToLogs({
sql: "SELECT count(*) FROM slo_slices WHERE msg = 'union with x' GROUP BY slo_id",
baseWhere: "msg = 'union with x'",
});
expect(query).toBe(
"SELECT * FROM \"slo_slices\" WHERE msg = 'union with x' ORDER BY _timestamp DESC",
);
});
it("sends the whole query with its VRL function when the panel has one", async () => {
const vrl = ".y_axis_1 = .y_axis_1 * 2";
const { query, histogram, params } = await goToLogs({
sql: aggregateSql,
baseWhere: "rev = 1793591100",
vrl,
});
expect(ensureDrilldownSchemaMock).not.toHaveBeenCalled();
expect(query).toBe(aggregateSql);
expect(histogram).toBe("false");
expect(b64DecodeUnicode(params.get("functionContent") ?? "")).toBe(vrl);
expect(params.get("fn_editor")).toBe("true");
});
it("should handle missing query data gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -732,6 +732,8 @@ import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { isEqual } from "lodash-es";
import shortURL from "@/services/short_url";
import { supportsResultSchema } from "@/utils/crossLinking";
import { buildPanelLogsQuery } from "@/utils/dashboard/panelLogsQuery";
import { isEnrichmentTablePanel } from "@/utils/dashboard/streamLifetime";
import { raw, useI18nTyped } from "@/types/i18n";
import { usePanelTitle } from "@/composables/dashboard/usePanelTitle";
@@ -1169,6 +1171,7 @@ export default defineComponent({
queryDetails: any,
currentUrl: string,
vrlFunctionQueryEncoded: string,
showHistogram: boolean,
) => {
const logsUrl = new URL(currentUrl + "/logs");
logsUrl.searchParams.set("stream_type", queryDetails.queries[0]?.fields?.stream_type);
@@ -1191,10 +1194,26 @@ export default defineComponent({
} else {
logsUrl.searchParams.set("quick_mode", "false");
}
logsUrl.searchParams.set("show_histogram", "false");
logsUrl.searchParams.set("show_histogram", showHistogram ? "true" : "false");
return logsUrl;
};
// null = keep the executed query, since the WHERE could not be read or rebuilt safely.
const buildLogsRowsQuery = async (
queryDetails: any,
query: string,
streamName: string,
): Promise<string | null> => {
const renderer = PanleSchemaRendererRef.value;
// The panel's VRL was written for its result columns, which raw log rows don't have.
if (queryDetails.queries[0]?.vrlFunctionQuery?.trim()) return null;
if (!supportsResultSchema(queryDetails.queries[0]?.fields?.stream_type) || !renderer) {
return null;
}
await renderer.ensureDrilldownSchema(0, true);
return buildPanelLogsQuery(query, streamName, renderer.panelBaseWhere ?? "");
};
const licenseLocks = useLicenseLocks();
const goToLogsFeature = computed(() =>
streamTypeFeature(props.data?.queries?.[0]?.fields?.stream_type),
@@ -1217,11 +1236,10 @@ export default defineComponent({
const { originalQuery, streamName } = getOriginalQueryAndStream(queryDetails, metaData) || {};
if (!originalQuery || !streamName) return;
let modifiedQuery = originalQuery;
const executedQuery = originalQuery.replace(/`/g, '"');
const rowsQuery = await buildLogsRowsQuery(queryDetails, executedQuery, streamName);
modifiedQuery = modifiedQuery.replace(/`/g, '"');
const encodedQuery: any = b64EncodeUnicode(modifiedQuery);
const encodedQuery: any = b64EncodeUnicode((rowsQuery ?? executedQuery).replace(/`/g, '"'));
const vrlFunctionQuery = queryDetails.queries[0]?.vrlFunctionQuery;
const vrlFunctionQueryEncoded: any = b64EncodeUnicode(vrlFunctionQuery);
@@ -1238,6 +1256,7 @@ export default defineComponent({
queryDetails,
currentUrl,
vrlFunctionQueryEncoded,
rowsQuery !== null,
);
// Use short_url service to shorten the URL and redirect
@@ -182,6 +182,7 @@ import {
} from "@/utils/dashboard/convertCustomChartData";
import { FORECAST_MIN_DAYS, forecastAlertFromChart } from "@/utils/alerts/forecastAlert";
import { getPanelDataForPageKey } from "@/composables/dashboard/useDashboardPanel";
import searchService from "@/services/search";
import { DASHBOARD_ANNOTATION_SOURCES_KEY } from "@/composables/dashboard/usePanelAnnotationSources";
import {
alertCreationDialog,
@@ -840,6 +841,13 @@ describe("PanelSchemaRenderer", () => {
// The table's search icon opens the logs drawer; the public page turns drilldowns off.
const exploreFromTable = async (allowDrilldown: boolean) => {
const resultSchema = vi.spyOn(searchService, "result_schema").mockResolvedValue({
data: {
where_clause: "rev = 1793591100",
cross_links: { stream_links: [], org_links: [] },
},
} as any);
onTestFinished(() => resultSchema.mockRestore());
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/", component: { template: "<div />" } }],
@@ -862,7 +870,12 @@ describe("PanelSchemaRenderer", () => {
.findComponent({ name: "TableRenderer" })
.vm.$emit("explore-cell", { columnId: "count", value: 7, row: {} });
await flushPromises();
return { drawerOpen: wrapper.vm.cellDrawer.open, query: router.currentRoute.value.query };
return {
drawerOpen: wrapper.vm.cellDrawer.open,
baseWhere: wrapper.vm.cellDrawer.baseWhere,
query: router.currentRoute.value.query,
resultSchema,
};
};
it("opens the logs drawer from a table cell when drilldowns are on", async () => {
@@ -871,6 +884,34 @@ describe("PanelSchemaRenderer", () => {
expect(query.cell_value).toBe("7");
});
it("filters the cell drawer by the panel WHERE even with cross-linking off", async () => {
expect(store.state.zoConfig.enable_cross_linking).toBeFalsy();
const { baseWhere, resultSchema } = await exploreFromTable(true);
expect(resultSchema).toHaveBeenCalledTimes(1);
expect(resultSchema.mock.calls[0][0].cross_linking).toBe(false);
expect(baseWhere).toBe("rev = 1793591100");
});
// PanelContainer's Go To Logs reads these off the renderer ref.
it("exposes the panel WHERE through ensureDrilldownSchema on the instance", async () => {
const resultSchema = vi.spyOn(searchService, "result_schema").mockResolvedValue({
data: { where_clause: "rev = 1", where_by_stream: {} },
} as any);
onTestFinished(() => resultSchema.mockRestore());
wrapper = createWrapper();
await wrapper.vm.ensureDrilldownSchema(0, true);
expect(resultSchema).toHaveBeenCalledTimes(1);
expect(wrapper.vm.panelBaseWhere).toBe("rev = 1");
});
it("makes no result_schema call on a chart click with cross-linking off", async () => {
const resultSchema = vi.spyOn(searchService, "result_schema");
onTestFinished(() => resultSchema.mockRestore());
wrapper = withDrilldown();
await wrapper.vm.onPanelClick(click);
expect(resultSchema).not.toHaveBeenCalled();
});
it("does not open the logs drawer from a table cell when drilldowns are off", async () => {
const { drawerOpen, query } = await exploreFromTable(false);
expect(drawerOpen).toBe(false);
@@ -2620,7 +2620,7 @@ export default defineComponent({
const executedSql = String(metadata.value?.queries?.[qi]?.query ?? query?.query ?? "");
// Panel WHERE is fetched lazily (not on panel render) — resolve it for THIS query
await ensureDrilldownSchema(qi);
await ensureDrilldownSchema(qi, true);
const cell = getCellDrilldownField(qi, alias);
const stream = cell?.streamName ?? query?.fields?.stream ?? query?.stream ?? "";
@@ -2742,6 +2742,8 @@ export default defineComponent({
cellDrawer,
onCellSendToAiChat,
exploreCellInLogs,
ensureDrilldownSchema,
panelBaseWhere,
onCellDrawerOpenChange,
exemplarsEligible,
exemplarsEnabled,
@@ -993,6 +993,106 @@ describe("usePanelDrilldown", () => {
expect(api.crossLinksData.value).toEqual({ stream_links: [], org_links: [] });
});
describe("panel WHERE lookup vs cross-linking", () => {
const schemaWithLinksAndWhere = () =>
resultSchemaMock.mockResolvedValue({
data: {
where_clause: "rev = 1793591100",
where_by_stream: { slo_slices: "rev = 1793591100" },
cross_links: {
stream_links: [{ name: "L", url: "https://x", fields: [{ name: "f", alias: "f" }] }],
org_links: [],
},
},
});
const chartClick = {
componentType: "series",
event: { offsetX: 1, offsetY: 1 },
dataIndex: 0,
seriesName: "s",
value: ["x", 1],
};
it("fetches the panel WHERE with cross-linking off, without asking for cross-links", async () => {
schemaWithLinksAndWhere();
const deps = makeDeps();
deps.store.state.zoConfig.enable_cross_linking = false;
const api = usePanelDrilldown(deps as any);
await api.ensureDrilldownSchema(0, true);
expect(resultSchemaMock).toHaveBeenCalledTimes(1);
expect(resultSchemaMock.mock.calls[0][0].cross_linking).toBe(false);
expect(api.panelBaseWhere.value).toBe("rev = 1793591100");
expect(api.panelWhereByStream.value).toEqual({ slo_slices: "rev = 1793591100" });
expect(api.crossLinksData.value).toEqual({ stream_links: [], org_links: [] });
});
it("makes no result_schema call on a chart click with cross-linking off", async () => {
const deps = makeDeps();
deps.store.state.zoConfig.enable_cross_linking = false;
const api = usePanelDrilldown(deps as any);
await api.onChartClick(chartClick);
expect(resultSchemaMock).not.toHaveBeenCalled();
});
it("asks for cross-links and gets the WHERE with cross-linking on", async () => {
schemaWithLinksAndWhere();
const deps = makeDeps();
const api = usePanelDrilldown(deps as any);
await api.ensureDrilldownSchema(0, true);
expect(resultSchemaMock.mock.calls[0][0].cross_linking).toBe(true);
expect(api.panelBaseWhere.value).toBe("rev = 1793591100");
expect(api.crossLinksData.value.stream_links).toHaveLength(1);
});
it.each([
[
"a non-logs stream",
(d: any) => (d.panelSchema.value.queries[0].fields.stream_type = "metrics"),
],
["a PromQL panel", (d: any) => (d.panelSchema.value.queryType = "promql")],
[
"an empty query",
(d: any) => {
d.panelSchema.value.queries[0].query = "";
d.metadata.value.queries[0].query = "";
},
],
])("never calls result_schema for %s, even when the WHERE is needed", async (_, setup) => {
const deps = makeDeps();
deps.store.state.zoConfig.enable_cross_linking = false;
setup(deps);
const api = usePanelDrilldown(deps as any);
await api.ensureDrilldownSchema(0, true);
expect(resultSchemaMock).not.toHaveBeenCalled();
expect(api.panelBaseWhere.value).toBe("");
});
it("does not let a WHERE-only cached result hide cross-links once cross-linking is on", async () => {
schemaWithLinksAndWhere();
const deps = makeDeps();
deps.store.state.zoConfig.enable_cross_linking = false;
const api = usePanelDrilldown(deps as any);
await api.ensureDrilldownSchema(0, true);
expect(api.crossLinksData.value.stream_links).toHaveLength(0);
deps.store.state.zoConfig.enable_cross_linking = true;
await api.ensureDrilldownSchema();
expect(resultSchemaMock).toHaveBeenCalledTimes(2);
expect(resultSchemaMock.mock.calls[1][0].cross_linking).toBe(true);
expect(api.crossLinksData.value.stream_links).toHaveLength(1);
});
});
describe("cross-link drilldown", () => {
const mockStreamLink = (url: string) =>
resultSchemaMock.mockResolvedValue({
@@ -28,7 +28,7 @@ import {
replaceVariablePlaceholders,
} from "@/utils/dashboard/variables/variablesUtils";
import searchService from "@/services/search";
import { isCrossLinkingEnabledForStream } from "@/utils/crossLinking";
import { isCrossLinkingEnabledForStream, supportsResultSchema } from "@/utils/crossLinking";
import useStreams from "@/composables/useStreams";
import { getEnrichmentTableRange } from "@/utils/dashboard/streamLifetime";
import { isSafeNavigableUrl } from "@/utils/safeUrl";
@@ -1307,28 +1307,32 @@ export function usePanelDrilldown({
// Fetch cross-links + WHERE for the clicked query, once, on demand, and apply them to the
// shared refs. Concurrent callers of the same query share the in-flight promise; a repeat
// click on a query already fetched hits the cache. `queryIndex` selects the multi-query cell.
const ensureDrilldownSchema = async (queryIndex = 0): Promise<void> => {
// `needWhere` fetches the panel WHERE even with cross-linking off; chart clicks need only links.
const ensureDrilldownSchema = async (queryIndex = 0, needWhere = false): Promise<void> => {
const query =
metadata.value?.queries?.[queryIndex]?.query ||
panelSchema.value?.queries?.[queryIndex]?.query ||
"";
// Cross-linking is only supported for logs streams; skip the result_schema call for panels
// backed by other stream types (metrics, traces, enrichment_tables) to avoid failing requests.
const crossLinkStreamType = panelSchema.value?.queries?.[queryIndex]?.fields?.stream_type;
// result_schema errors for non-logs streams (metrics, traces, enrichment_tables).
const streamType = panelSchema.value?.queries?.[queryIndex]?.fields?.stream_type;
const crossLinking = isCrossLinkingEnabledForStream(store.state.zoConfig, streamType);
if (
!isCrossLinkingEnabledForStream(store.state.zoConfig, crossLinkStreamType) ||
!supportsResultSchema(streamType) ||
!(crossLinking || needWhere) ||
!query ||
panelSchema.value?.queryType === "promql"
) {
applyDrilldownSchema(EMPTY_SCHEMA);
return;
}
const cached = schemaCache.get(query);
// Keyed by the cross-linking flag too, so a WHERE-only result never hides links later.
const cacheKey = `${crossLinking}:${query}`;
const cached = schemaCache.get(cacheKey);
if (cached) {
applyDrilldownSchema(cached);
return;
}
const inFlight = schemaInFlight.get(query);
const inFlight = schemaInFlight.get(cacheKey);
if (inFlight) return inFlight;
const fetchPromise = (async () => {
try {
@@ -1349,24 +1353,27 @@ export function usePanelDrilldown({
},
page_type: "dashboards",
is_streaming: false,
cross_linking: true,
cross_linking: crossLinking,
},
"dashboards",
);
const schema: DrilldownSchema = {
crossLinks: response.data?.cross_links || { stream_links: [], org_links: [] },
crossLinks: (crossLinking && response.data?.cross_links) || {
stream_links: [],
org_links: [],
},
baseWhere: response.data?.where_clause ?? "",
whereByStream: response.data?.where_by_stream ?? {},
};
schemaCache.set(query, schema);
schemaCache.set(cacheKey, schema);
applyDrilldownSchema(schema);
} catch {
applyDrilldownSchema(EMPTY_SCHEMA);
} finally {
schemaInFlight.delete(query);
schemaInFlight.delete(cacheKey);
}
})();
schemaInFlight.set(query, fetchPromise);
schemaInFlight.set(cacheKey, fetchPromise);
return fetchPromise;
};
+5 -3
View File
@@ -19,6 +19,10 @@
// enrichment_tables), so the call must be skipped for them.
export const CROSS_LINKING_SUPPORTED_STREAM_TYPES = ["logs"] as const;
/** True when `result_schema` can be called for this stream type, whatever the cross-linking config. */
export const supportsResultSchema = (streamType: string | undefined): boolean =>
(CROSS_LINKING_SUPPORTED_STREAM_TYPES as readonly string[]).includes(streamType ?? "");
/**
* Returns true when cross-linking should be active for the given stream type.
* Requires the `enable_cross_linking` config flag to be on AND the stream type
@@ -27,6 +31,4 @@ export const CROSS_LINKING_SUPPORTED_STREAM_TYPES = ["logs"] as const;
export const isCrossLinkingEnabledForStream = (
zoConfig: any,
streamType: string | undefined,
): boolean =>
!!zoConfig?.enable_cross_linking &&
CROSS_LINKING_SUPPORTED_STREAM_TYPES.includes(streamType as any);
): boolean => !!zoConfig?.enable_cross_linking && supportsResultSchema(streamType);
@@ -0,0 +1,110 @@
// 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 { describe, expect, it } from "vitest";
import { buildPanelLogsQuery } from "@/utils/dashboard/panelLogsQuery";
describe("buildPanelLogsQuery", () => {
it("turns an aggregate panel into a SELECT * with the panel WHERE", async () => {
const sql =
'SELECT slo_id as "x_axis_1", count(*) as "y_axis_1" FROM "slo_slices" WHERE rev = 1793591100 GROUP BY x_axis_1';
expect(await buildPanelLogsQuery(sql, "slo_slices", "rev = 1793591100")).toBe(
'SELECT * FROM "slo_slices" WHERE rev = 1793591100 ORDER BY _timestamp DESC',
);
});
it("omits WHERE when the panel has none", async () => {
const sql = 'SELECT count(*) as "y" FROM "default" GROUP BY k8s_namespace_name';
expect(await buildPanelLogsQuery(sql, "default", "")).toBe(
'SELECT * FROM "default" ORDER BY _timestamp DESC',
);
});
it.each([
[
"own filters per stream",
"SELECT a.svc, count(*) FROM stream_a a JOIN stream_b b ON a.id = b.id WHERE a.env = 'prod' AND b.tier = 'gold' GROUP BY a.svc",
],
[
"filters only on the other stream",
"SELECT a.svc FROM stream_a a JOIN stream_b b ON a.id = b.id WHERE b.tier = 'gold'",
],
[
"a cross-stream filter",
"SELECT a.svc FROM stream_a a JOIN stream_b b ON a.id = b.id WHERE a.x = b.y",
],
["no WHERE", "SELECT count(*) FROM stream_a JOIN stream_b ON stream_a.id = stream_b.id"],
["a comma join", "SELECT count(*) FROM stream_a, stream_b WHERE level = 'error'"],
])("returns null for a JOIN with %s", async (_label, sql) => {
expect(await buildPanelLogsQuery(sql, "stream_a", "env = 'prod'")).toBeNull();
});
it.each([
["a bare alias", "SELECT count(*) FROM \"default\" d WHERE d.level = 'error'"],
["an AS alias", "SELECT count(*) FROM \"default\" AS d WHERE d.level = 'error'"],
["an alias and no WHERE", 'SELECT count(*) FROM "default" AS d'],
])("returns null for a table with %s", async (_label, sql) => {
expect(await buildPanelLogsQuery(sql, "default", "d.level = 'error'")).toBeNull();
});
it.each([
["WITH", "WITH t AS (SELECT * FROM x) SELECT count(*) FROM s WHERE a = 1"],
["UNION", "SELECT count(*) FROM s WHERE a = 1 UNION SELECT count(*) FROM s WHERE a = 2"],
["INTERSECT", "SELECT k FROM s INTERSECT SELECT k FROM s WHERE a = 2"],
["EXCEPT", "SELECT k FROM s EXCEPT SELECT k FROM s WHERE a = 2"],
["FROM subquery", "SELECT count(*) FROM (SELECT * FROM s WHERE a = 1) t"],
["HAVING", "SELECT k, count(*) AS c FROM s GROUP BY k HAVING count(*) > 5"],
])("returns null for %s", async (_label, sql) => {
expect(await buildPanelLogsQuery(sql, "s", "a = 1")).toBeNull();
});
it("returns null when the SQL has a WHERE but none was read", async () => {
expect(await buildPanelLogsQuery("SELECT count(*) FROM s WHERE a = 1", "s", "")).toBeNull();
});
it("returns null when the stream is not the one the SQL reads", async () => {
expect(await buildPanelLogsQuery("SELECT count(*) FROM other", "s", "")).toBeNull();
});
it("returns null for SQL the parser cannot read", async () => {
expect(await buildPanelLogsQuery("SELEC count( FROM s", "s", "")).toBeNull();
});
it("ignores set-operation and CTE keywords inside string literals", async () => {
const where = "msg = 'union with x' AND m2 = '(select 1)'";
expect(await buildPanelLogsQuery(`SELECT count(*) FROM s WHERE ${where}`, "s", where)).toBe(
`SELECT * FROM "s" WHERE ${where} ORDER BY _timestamp DESC`,
);
});
it("keeps a subquery that sits inside the WHERE", async () => {
const where = "a IN (SELECT a FROM s2 WHERE b = 1)";
expect(await buildPanelLogsQuery(`SELECT count(*) FROM s WHERE ${where}`, "s", where)).toBe(
`SELECT * FROM "s" WHERE ${where} ORDER BY _timestamp DESC`,
);
});
it("quotes a stream name that is not a bare identifier", async () => {
expect(await buildPanelLogsQuery('SELECT count(*) FROM "my-stream"', "my-stream", "")).toBe(
'SELECT * FROM "my-stream" ORDER BY _timestamp DESC',
);
});
it("returns null when the SQL holds more than one statement", async () => {
expect(
await buildPanelLogsQuery("SELECT count(*) FROM s; SELECT count(*) FROM s", "s", ""),
).toBeNull();
});
});
+89
View File
@@ -0,0 +1,89 @@
// 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 useParser from "@/composables/useParser";
import {
maxParenDepth,
SQL_PARSE_MAX_DEPTH,
stripWherePredicate,
} from "@/utils/query/sqlComplexity";
interface FromEntry {
table?: unknown;
as?: string | null;
}
interface SelectAst {
type?: string;
with?: unknown;
_next?: unknown;
set_op?: unknown;
having?: unknown;
where?: unknown;
from?: FromEntry[] | null;
}
interface SqlParser {
astify: (sql: string) => SelectAst | SelectAst[];
}
let parserPromise: Promise<SqlParser> | null = null;
const loadParser = (): Promise<SqlParser> => {
if (!parserPromise) {
parserPromise = useParser().sqlParser();
parserPromise.catch(() => {
parserPromise = null;
});
}
return parserPromise;
};
const parseSingleSelect = async (sql: string): Promise<SelectAst | null> => {
// astify() is exponential in WHERE nesting; the WHERE text itself comes from the backend.
const parseTarget = stripWherePredicate(sql);
if (maxParenDepth(parseTarget) > SQL_PARSE_MAX_DEPTH) return null;
try {
const parsed = (await loadParser()).astify(parseTarget);
const statements = Array.isArray(parsed) ? parsed : [parsed];
return statements.length === 1 ? statements[0] : null;
} catch {
return null;
}
};
/** The panel SQL as `SELECT *` over its stream, or null when that would drop or misread a filter. */
export const buildPanelLogsQuery = async (
sql: string,
stream: string,
baseWhere: string,
): Promise<string | null> => {
if (!sql || !stream) return null;
const ast = await parseSingleSelect(sql);
if (ast?.type !== "select" || ast.with || ast._next || ast.set_op || ast.having) return null;
// One plain table only: a JOIN's filters can't all move onto one stream.
const from = Array.isArray(ast.from) ? ast.from : [];
if (from.length !== 1 || from[0].table !== stream) return null;
// The WHERE would keep the alias prefix, which neither this query nor the Logs histogram has.
if (from[0].as) return null;
const whereText = baseWhere.trim();
if (ast.where && !whereText) return null;
const table = `"${stream.replace(/"/g, '""')}"`;
const filter = whereText ? ` WHERE ${whereText}` : "";
return `SELECT * FROM ${table}${filter} ORDER BY _timestamp DESC`;
};