mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
@@ -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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] : "",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user