fix(logs): scope Search History to the active telemetry type/stream (#14059)

Co-authored-by: Hengfei Yang <hengfei.yang@gmail.com>
This commit is contained in:
Bhargav
2026-09-01 07:13:13 +00:00
committed by GitHub
co-authored by Hengfei Yang
parent 060e117d41
commit 6678b1908b
7 changed files with 340 additions and 12 deletions
+31 -5
View File
@@ -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'"
);
}
}
}
+20
View File
@@ -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;
+11 -1
View File
@@ -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] : "",
},
});
};
+179 -1
View File
@@ -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: '<div class="mock-datetime"></div>',
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 = {
+47 -3
View File
@@ -6,6 +6,14 @@
:back="{ onClick: closeSearchHistory }"
bleed
>
<template #subtitle>
<div class="flex min-w-0 items-center gap-2">
<OTag type="streamType" :value="activeStreamType" />
<span v-if="activeStreamName" class="min-w-0 truncate leading-normal">{{
activeStreamName
}}</span>
</div>
</template>
<template #actions>
<OButton
data-test="search-history-wrap-content-btn"
@@ -141,7 +149,7 @@
@click.stop="goToLogs(row)"
>
<template #icon-left><OIcon name="search" size="xs" /></template>
{{ t("logs.searchHistory.logs") }}
{{ goToLogsLabel(row) }}
</OButton>
<OButton
v-if="
@@ -298,6 +306,8 @@ import AppTabs from "@/components/common/AppTabs.vue";
import config from "@/aws-exports";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import { resolveBadgeLabel } from "@/lib/core/Badge/badgeGroups";
import OTable from "@/lib/core/Table/OTable.vue";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
@@ -322,6 +332,7 @@ export default defineComponent({
QueryEditor,
OButton,
OIcon,
OTag,
OTooltip,
OTable,
OTimeCell,
@@ -361,6 +372,25 @@ export default defineComponent({
const { extractTimestamps } = logsUtils();
const ALLOWED_HISTORY_STREAM_TYPES = ["logs", "metrics", "traces"];
// The route query (set by the page the user navigated from, e.g. Data
// Sources or the Logs page itself) is the source of truth for which
// telemetry type/stream this history view is scoped to; searchObj is the
// fallback for a same-session deep link that didn't carry the query.
// The query param is attacker-controlled (URL), so it's whitelisted here
// as defense in depth on top of the backend validation.
const activeStreamType = computed(() => {
const fromRoute = route.query.stream_type as string;
if (ALLOWED_HISTORY_STREAM_TYPES.includes(fromRoute)) return fromRoute;
return searchObj.data.stream.streamType || "logs";
});
const activeStreamName = computed(() =>
route.query.stream === undefined
? searchObj.data.stream.selectedStream[0] || ""
: (route.query.stream as string),
);
const activeTab = ref("query");
const tabs = ref([
{
@@ -447,7 +477,13 @@ export default defineComponent({
return;
}
const response = await searchService.get_history(org_identifier, startTime, endTime);
const response = await searchService.get_history(
org_identifier,
startTime,
endTime,
activeStreamType.value,
activeStreamName.value,
);
const limitedHits = response.data.hits;
const filteredHits = limitedHits.filter((hit) => hit.event === "Search");
if (filteredHits.length > 0) {
@@ -612,6 +648,11 @@ export default defineComponent({
colorizeRow(row);
}
};
// goToLogs re-opens the row's own stream_type (logs/metrics/traces), so the
// button label must match that destination rather than always saying "Logs".
const goToLogsLabel = (row: { stream_type?: string }) =>
resolveBadgeLabel("streamType", row.stream_type || "logs");
const goToLogs = (row) => {
// emit('closeSearchHistory');
const stream: string = row.stream_name;
@@ -620,7 +661,7 @@ export default defineComponent({
const query = b64EncodeUnicode(row.sql);
const queryObject = {
stream_type: "logs",
stream_type: row.stream_type || "logs",
stream,
period: "15m",
refresh,
@@ -698,6 +739,8 @@ export default defineComponent({
});
return {
searchObj,
activeStreamType,
activeStreamName,
store,
generateColumns,
fetchSearchHistory,
@@ -710,6 +753,7 @@ export default defineComponent({
searchDateTimeRef,
expandedIds,
goToLogs,
goToLogsLabel,
goToInspector,
onExpandedIdsChange,
colorizedSql,
+39
View File
@@ -718,6 +718,45 @@ describe("Search Service", () => {
start_time: 1609459200,
});
});
it("should get search history scoped to a traces stream", async () => {
await search.get_history("test-org", 1609459200, 1609545600, "traces", "my-trace-stream");
expect(mockHttp.post).toHaveBeenCalledWith("/api/test-org/_search_history", {
stream_type: "traces",
org_identifier: "test-org",
user_email: null,
start_time: 1609459200,
end_time: 1609545600,
stream_name: "my-trace-stream",
});
});
it("should get search history scoped to a metrics stream", async () => {
await search.get_history("test-org", 1609459200, 1609545600, "metrics", "my-metric-stream");
expect(mockHttp.post).toHaveBeenCalledWith("/api/test-org/_search_history", {
stream_type: "metrics",
org_identifier: "test-org",
user_email: null,
start_time: 1609459200,
end_time: 1609545600,
stream_name: "my-metric-stream",
});
});
it("should default to logs and omit stream_name when only stream_type is missing", async () => {
await search.get_history("test-org", 1609459200, 1609545600, null, "unscoped-stream");
expect(mockHttp.post).toHaveBeenCalledWith("/api/test-org/_search_history", {
stream_type: "logs",
org_identifier: "test-org",
user_email: null,
start_time: 1609459200,
end_time: 1609545600,
stream_name: "unscoped-stream",
});
});
});
describe("schedule_search", () => {
+13 -2
View File
@@ -383,9 +383,16 @@ const search = {
const url = `/api/clusters`;
return http().get(url);
},
get_history: (org_identifier: string, startTime = null, endTime = null) => {
/** A null/empty stream_type defaults to "logs"; a falsy stream_name is omitted, scoping history to all streams of that type. */
get_history: (
org_identifier: string,
startTime = null,
endTime = null,
stream_type: string | null = null,
stream_name: string | null = null,
) => {
const payload: any = {
stream_type: "logs",
stream_type: stream_type || "logs",
org_identifier,
user_email: null,
};
@@ -398,6 +405,10 @@ const search = {
payload.end_time = endTime;
}
if (stream_name) {
payload.stream_name = stream_name;
}
return http().post(
`/api/${org_identifier}/_search_history`,
payload, // Send the payload as the request body