mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
co-authored by
Abhay Padamani
parent
0337b781b3
commit
f2a7adad65
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
};
|
||||
Reference in New Issue
Block a user