From 6678b1908beb14161cec76170fc003e2a52f4650 Mon Sep 17 00:00:00 2001 From: Bhargav Date: Tue, 1 Sep 2026 07:13:13 +0000 Subject: [PATCH] fix(logs): scope Search History to the active telemetry type/stream (#14059) Co-authored-by: Hengfei Yang --- src/config/src/meta/search.rs | 36 ++++- web/src/plugins/logs/Index.spec.ts | 20 +++ web/src/plugins/logs/Index.vue | 12 +- web/src/plugins/logs/SearchHistory.spec.js | 180 ++++++++++++++++++++- web/src/plugins/logs/SearchHistory.vue | 50 +++++- web/src/services/search.spec.ts | 39 +++++ web/src/services/search.ts | 15 +- 7 files changed, 340 insertions(+), 12 deletions(-) diff --git a/src/config/src/meta/search.rs b/src/config/src/meta/search.rs index 2a70e15cbe..61b075ccc8 100644 --- a/src/config/src/meta/search.rs +++ b/src/config/src/meta/search.rs @@ -1653,25 +1653,40 @@ mod search_history_utils { let mut query = format!("SELECT * FROM {search_stream_name} WHERE event='Search'"); if let Some(org_id) = self.org_id.filter(|s| !s.is_empty()) { - query.push_str(&format!(" AND org_id = '{org_id}'")); + query.push_str(&format!(" AND org_id = {}", quote_sql_literal(&org_id))); } if let Some(stream_type) = self.stream_type.filter(|s| !s.is_empty()) { - query.push_str(&format!(" AND stream_type = '{stream_type}'")); + query.push_str(&format!( + " AND stream_type = {}", + quote_sql_literal(&stream_type) + )); } if let Some(stream_name) = self.stream_name.filter(|s| !s.is_empty()) { - query.push_str(&format!(" AND stream_name = '{stream_name}'")); + query.push_str(&format!( + " AND stream_name = {}", + quote_sql_literal(&stream_name) + )); } if let Some(user_email) = self.user_email.filter(|s| !s.is_empty()) { - query.push_str(&format!(" AND user_email = '{user_email}'")); + query.push_str(&format!( + " AND user_email = {}", + quote_sql_literal(&user_email) + )); } if let Some(trace_id) = self.trace_id.filter(|s| !s.is_empty()) { - query.push_str(&format!(" AND trace_id = '{trace_id}'")); + query.push_str(&format!(" AND trace_id = {}", quote_sql_literal(&trace_id))); } query } } + // Escapes single quotes so a value cannot break out of the SQL string literal it is + // interpolated into (these fields come from client-controlled request params). + fn quote_sql_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) + } + #[cfg(test)] mod tests { use super::SearchHistoryQueryBuilder; @@ -1771,6 +1786,17 @@ mod search_history_utils { assert_eq!(query, expected_query); } + + #[test] + fn test_escapes_single_quotes_in_values() { + let query = SearchHistoryQueryBuilder::new() + .with_stream_name(&Some("x' OR '1'='1".to_string())) + .build(SEARCH_STREAM_NAME); + assert_eq!( + query, + "SELECT * FROM usage WHERE event='Search' AND stream_name = 'x'' OR ''1''=''1'" + ); + } } } diff --git a/web/src/plugins/logs/Index.spec.ts b/web/src/plugins/logs/Index.spec.ts index 27d95ed24d..b6e02d845f 100644 --- a/web/src/plugins/logs/Index.spec.ts +++ b/web/src/plugins/logs/Index.spec.ts @@ -695,6 +695,26 @@ describe("Logs Index", async () => { ); }); + it("Should forward the active traces stream_type/stream to Search History", async () => { + // Search History must be scoped to whichever telemetry type the user was + // viewing, not always "logs" — see showSearchHistoryfn in Index.vue. + const pushSpy = vi.spyOn(wrapper.vm.router, "push"); + wrapper.vm.searchObj.data.stream.streamType = "traces"; + wrapper.vm.searchObj.data.stream.selectedStream = ["my_trace_stream"]; + + wrapper.vm.showSearchHistoryfn(); + + expect(pushSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: "searchHistory", + query: expect.objectContaining({ + stream_type: "traces", + stream: "my_trace_stream", + }), + }), + ); + }); + it("Should set histogram date using searchBarRef", async () => { const setCustomDate = vi.fn(); wrapper.vm.searchBarRef = { dateTimeRef: { setCustomDate } } as any; diff --git a/web/src/plugins/logs/Index.vue b/web/src/plugins/logs/Index.vue index 2483a41d9e..bd688857eb 100644 --- a/web/src/plugins/logs/Index.vue +++ b/web/src/plugins/logs/Index.vue @@ -1309,9 +1309,19 @@ export default defineComponent({ }; const showSearchHistoryfn = () => { // Search History is now its own route (was an `action=history` overlay). + // Forward the active stream type/name so the history shown there matches + // whichever telemetry type the user was viewing (logs/traces/metrics). + // With more than one stream selected there's no single name to forward + // without silently dropping the others, so leave history unscoped by + // stream in that case. + const selectedStreams = searchObj.data.stream.selectedStream; router.push({ name: "searchHistory", - query: { org_identifier: store.state.selectedOrganization.identifier }, + query: { + org_identifier: store.state.selectedOrganization.identifier, + stream_type: searchObj.data.stream.streamType, + stream: selectedStreams.length === 1 ? selectedStreams[0] : "", + }, }); }; diff --git a/web/src/plugins/logs/SearchHistory.spec.js b/web/src/plugins/logs/SearchHistory.spec.js index d298544f9f..c31aa2de45 100644 --- a/web/src/plugins/logs/SearchHistory.spec.js +++ b/web/src/plugins/logs/SearchHistory.spec.js @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import i18n from "@/locales"; import SearchHistory from "./SearchHistory.vue"; import searchService from "@/services/search"; -import { useRouter } from "vue-router"; +import { useRouter, useRoute } from "vue-router"; // Mock Toast vi.mock("@/lib/feedback/Toast/useToast", () => ({ @@ -118,6 +118,7 @@ describe("SearchHistory Component", () => { stubs: { OButton: true, OIcon: true, + OTag: true, OSwitch: true, OTable: true, OSpinner: true, @@ -271,6 +272,183 @@ describe("SearchHistory Component", () => { }); }); + describe("Stream-type aware history (Traces/Metrics support)", () => { + // Search History must scope both the fetched data and the page's + // subtitle indicator to whichever telemetry type/stream the user was on + // when they opened it (route query set by Index.vue's showSearchHistoryfn), + // not always "logs". + const mountWithRouteQuery = (query) => { + vi.mocked(useRoute).mockReturnValue({ + query, + params: {}, + path: "/search-history", + }); + + return mount(SearchHistory, { + global: { + plugins: [i18n], + provide: { store: mockStore }, + stubs: { + OButton: true, + OIcon: true, + OTag: true, + OSwitch: true, + OTable: true, + OSpinner: true, + DateTime: { + template: '
', + methods: { setAbsoluteTime: vi.fn() }, + }, + AppTabs: true, + QueryEditor: true, + TenstackTable: true, + NoData: true, + }, + mocks: { $router: { push: routerPushMock } }, + }, + props: { isClicked: false }, + }); + }; + + it("requests history for the active traces stream from the route query", async () => { + const tracesWrapper = mountWithRouteQuery({ + org_identifier: "test-org", + stream_type: "traces", + stream: "my-trace-stream", + }); + tracesWrapper.vm.dateTimeToBeSent = { + valueType: "relative", + relativeTimePeriod: "15m", + startTime: 0, + endTime: 0, + }; + + await tracesWrapper.vm.fetchSearchHistory(); + await flushPromises(); + + expect(searchService.get_history).toHaveBeenCalledWith( + "test-org", + expect.any(Number), + expect.any(Number), + "traces", + "my-trace-stream", + ); + + tracesWrapper.unmount(); + }); + + it("requests history for the active metrics stream from the route query", async () => { + const metricsWrapper = mountWithRouteQuery({ + org_identifier: "test-org", + stream_type: "metrics", + stream: "my-metric-stream", + }); + metricsWrapper.vm.dateTimeToBeSent = { + valueType: "relative", + relativeTimePeriod: "15m", + startTime: 0, + endTime: 0, + }; + + await metricsWrapper.vm.fetchSearchHistory(); + await flushPromises(); + + expect(searchService.get_history).toHaveBeenCalledWith( + "test-org", + expect.any(Number), + expect.any(Number), + "metrics", + "my-metric-stream", + ); + + metricsWrapper.unmount(); + }); + + it("falls back to logs when the route query carries no stream_type", async () => { + const logsWrapper = mountWithRouteQuery({ org_identifier: "test-org" }); + logsWrapper.vm.dateTimeToBeSent = { + valueType: "relative", + relativeTimePeriod: "15m", + startTime: 0, + endTime: 0, + }; + + await logsWrapper.vm.fetchSearchHistory(); + await flushPromises(); + + expect(searchService.get_history).toHaveBeenCalledWith( + "test-org", + expect.any(Number), + expect.any(Number), + "logs", + "", + ); + + logsWrapper.unmount(); + }); + + it("exposes the active stream type/name for the page's subtitle indicator", () => { + const tracesWrapper = mountWithRouteQuery({ + org_identifier: "test-org", + stream_type: "traces", + stream: "my-trace-stream", + }); + + expect(tracesWrapper.vm.activeStreamType).toBe("traces"); + expect(tracesWrapper.vm.activeStreamName).toBe("my-trace-stream"); + + tracesWrapper.unmount(); + }); + }); + + describe("Navigation - goToLogs preserves the row's own stream type", () => { + it("routes back to the traces stream when the history row is a traces search", async () => { + const mockTracesRow = { + sql: "SELECT * FROM my-trace-stream", + stream_name: "my-trace-stream", + stream_type: "traces", + org_id: "test-org", + toBeStoredStartTime: 1000, + toBeStoredEndTime: 2000, + duration: "1 second", + }; + + await wrapper.vm.goToLogs(mockTracesRow); + + expect(routerPushMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/logs", + query: expect.objectContaining({ + stream_type: "traces", + stream: "my-trace-stream", + }), + }), + ); + }); + + it("routes back to metrics when the history row is a metrics search", async () => { + const mockMetricsRow = { + sql: "SELECT * FROM my-metric-stream", + stream_name: "my-metric-stream", + stream_type: "metrics", + org_id: "test-org", + toBeStoredStartTime: 1000, + toBeStoredEndTime: 2000, + duration: "1 second", + }; + + await wrapper.vm.goToLogs(mockMetricsRow); + + expect(routerPushMock).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + stream_type: "metrics", + }), + }), + ); + }); + }); + describe("Date Time Handling", () => { it("updates datetime when changed", async () => { const newDateTime = { diff --git a/web/src/plugins/logs/SearchHistory.vue b/web/src/plugins/logs/SearchHistory.vue index 7b38711f17..182891b447 100644 --- a/web/src/plugins/logs/SearchHistory.vue +++ b/web/src/plugins/logs/SearchHistory.vue @@ -6,6 +6,14 @@ :back="{ onClick: closeSearchHistory }" bleed > +