diff --git a/web/src/components/dashboards/PanelContainer.spec.ts b/web/src/components/dashboards/PanelContainer.spec.ts index 26e690a6d7..62461f79c9 100644 --- a/web/src/components/dashboards/PanelContainer.spec.ts +++ b/web/src/components/dashboards/PanelContainer.spec.ts @@ -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(() => {}); diff --git a/web/src/components/dashboards/PanelContainer.vue b/web/src/components/dashboards/PanelContainer.vue index e3d39abf26..528124bec1 100644 --- a/web/src/components/dashboards/PanelContainer.vue +++ b/web/src/components/dashboards/PanelContainer.vue @@ -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 => { + 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 diff --git a/web/src/components/dashboards/PanelSchemaRenderer.spec.ts b/web/src/components/dashboards/PanelSchemaRenderer.spec.ts index 502583980d..5714245570 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.spec.ts +++ b/web/src/components/dashboards/PanelSchemaRenderer.spec.ts @@ -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: "
" } }], @@ -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); diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index c7353822c6..84ab2a754e 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -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, diff --git a/web/src/composables/dashboard/usePanelDrilldown.spec.ts b/web/src/composables/dashboard/usePanelDrilldown.spec.ts index d45f9468a7..3498d93094 100644 --- a/web/src/composables/dashboard/usePanelDrilldown.spec.ts +++ b/web/src/composables/dashboard/usePanelDrilldown.spec.ts @@ -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({ diff --git a/web/src/composables/dashboard/usePanelDrilldown.ts b/web/src/composables/dashboard/usePanelDrilldown.ts index 79a04b65e8..d656c4369a 100644 --- a/web/src/composables/dashboard/usePanelDrilldown.ts +++ b/web/src/composables/dashboard/usePanelDrilldown.ts @@ -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 => { + // `needWhere` fetches the panel WHERE even with cross-linking off; chart clicks need only links. + const ensureDrilldownSchema = async (queryIndex = 0, needWhere = false): Promise => { 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; }; diff --git a/web/src/utils/crossLinking.ts b/web/src/utils/crossLinking.ts index b778492aed..83c1cfccfa 100644 --- a/web/src/utils/crossLinking.ts +++ b/web/src/utils/crossLinking.ts @@ -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); diff --git a/web/src/utils/dashboard/panelLogsQuery.spec.ts b/web/src/utils/dashboard/panelLogsQuery.spec.ts new file mode 100644 index 0000000000..e380210e84 --- /dev/null +++ b/web/src/utils/dashboard/panelLogsQuery.spec.ts @@ -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 . + +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(); + }); +}); diff --git a/web/src/utils/dashboard/panelLogsQuery.ts b/web/src/utils/dashboard/panelLogsQuery.ts new file mode 100644 index 0000000000..b9bef42444 --- /dev/null +++ b/web/src/utils/dashboard/panelLogsQuery.ts @@ -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 . + +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 | null = null; + +const loadParser = (): Promise => { + if (!parserPromise) { + parserPromise = useParser().sqlParser(); + parserPromise.catch(() => { + parserPromise = null; + }); + } + return parserPromise; +}; + +const parseSingleSelect = async (sql: string): Promise => { + // 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 => { + 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`; +};