fix: Rum side filter (#13542)

This commit is contained in:
Bhargav 2026-07-30 10:24:43 +05:30 committed by GitHub
parent dc83ec2947
commit 8b8406282f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 195 additions and 57 deletions

View File

@ -470,6 +470,64 @@ describe("FieldList.vue Comprehensive Coverage", () => {
);
});
it("should include baseFilter in sql when query prop is empty", async () => {
wrapper = createWrapper({ baseFilter: "type='error'" });
const vm = wrapper.vm as any;
await vm.openFilterCreator({
name: "test_field",
ftsKey: false,
});
expect(fieldValuesMocks.fetchFieldValues).toHaveBeenCalledWith(
expect.objectContaining({
sql: b64EncodeUnicode(`SELECT * FROM "test-stream" WHERE type='error'`),
}),
);
});
it("should AND baseFilter with a parenthesised query", async () => {
wrapper = createWrapper({
baseFilter: "type='error'",
query: "country='US' or country='IN'",
});
const vm = wrapper.vm as any;
await vm.openFilterCreator({
name: "test_field",
ftsKey: false,
});
expect(fieldValuesMocks.fetchFieldValues).toHaveBeenCalledWith(
expect.objectContaining({
sql: b64EncodeUnicode(
`SELECT * FROM "test-stream" WHERE type='error' AND (country='US' or country='IN')`,
),
}),
);
});
it("should refetch expanded fields when baseFilter changes", async () => {
wrapper = createWrapper({
baseFilter: "type='error'",
fields: [{ name: "test_field", showValues: true }],
});
const vm = wrapper.vm as any;
await vm.openFilterCreator({ name: "test_field", ftsKey: false });
fieldValuesMocks.fetchFieldValues.mockClear();
await wrapper.setProps({ baseFilter: "type='error' AND service='web'" });
await nextTick();
expect(fieldValuesMocks.fetchFieldValues).toHaveBeenCalledWith(
expect.objectContaining({
fields: ["test_field"],
sql: b64EncodeUnicode(`SELECT * FROM "test-stream" WHERE type='error' AND service='web'`),
}),
);
});
it("should pass query WHERE clause through handleSearchFieldValues", async () => {
const query = "country = 'US'";
wrapper = createWrapper({

View File

@ -249,6 +249,14 @@ const props = defineProps({
type: String,
default: "",
},
// Page-level WHERE fragment (e.g. `type='error'`) always ANDed into the
// field-values SQL so the counts match the rows the page itself queries.
// Deliberately separate from `query`: it is not user-editable, so it must
// not feed the include/exclude checkbox round-trip below.
baseFilter: {
type: String,
default: "",
},
showCount: {
type: Boolean,
default: false,
@ -465,6 +473,43 @@ const buildSql = (streamName: string, whereClause?: string) =>
b64EncodeUnicode(`SELECT * FROM "${streamName}"${whereClause ? ` WHERE ${whereClause}` : ""}`) ||
"";
// Base filter first, user query parenthesised after it the editor value may
// contain a top-level OR, which would otherwise swallow the base filter.
const valuesWhereClause = computed(() => {
const base = props.baseFilter.trim();
const userQuery = props.query.trim();
if (base && userQuery) return `${base} AND (${userQuery})`;
return base || userQuery;
});
// Single entry point for every value request (expand, search, load-more,
// base-filter refresh) so all four stay on the same SQL and time range.
// `streamOverride` carries the row's own stream when the caller already has the
// row in hand expansion can be driven with a row that is not in `fields`.
function requestFieldValues(
fieldName: string,
size: number,
keyword?: string,
streamOverride?: string,
) {
const row: any = (props.fields as any[]).find((f: any) => f.name === fieldName);
const resolvedStream = streamOverride || row?.stream_name || props.streamName;
const pinnedTime = fieldValuesTimeRange.value[fieldName];
fetchFieldValues({
fields: [fieldName],
size,
no_count: false,
start_time: pinnedTime?.start_time ?? (props.timeStamp as any).startTime,
end_time: pinnedTime?.end_time ?? (props.timeStamp as any).endTime,
stream_name: resolvedStream,
stream_type: props.streamType,
sql: buildSql(resolvedStream, valuesWhereClause.value || undefined),
keyword: keyword || undefined,
timeout: 30000,
use_cache: (globalThis as any).use_cache ?? true,
});
}
// Expansion handling
function isExpandable(row: any) {
@ -518,25 +563,13 @@ function openFilterCreator({ name, ftsKey, stream_name }: any) {
};
resetFieldValues(name, true);
const resolvedStream = stream_name || props.streamName;
fieldValuesCurrentSize.value[name] = defaultValuesCount.value;
expandedRows.value[name] = true;
if (!expandedIds.value.includes(name)) {
expandedIds.value = [...expandedIds.value, name];
}
fetchFieldValues({
fields: [name],
size: defaultValuesCount.value,
no_count: false,
start_time: (props.timeStamp as any).startTime,
end_time: (props.timeStamp as any).endTime,
stream_name: resolvedStream,
stream_type: props.streamType,
sql: buildSql(resolvedStream, (props as any).query || undefined),
timeout: 30000,
use_cache: (globalThis as any).use_cache ?? true,
});
requestFieldValues(name, defaultValuesCount.value, undefined, stream_name);
}
function closeField(fieldName: string) {
@ -573,8 +606,6 @@ function setPage(page: number) {
// FieldValuesPanel event handlers
const handleSearchFieldValues = (fieldName: string, term: string) => {
const row: any = (props.fields as any[]).find((f: any) => f.name === fieldName);
const resolvedStream = row?.stream_name || props.streamName;
currentKeyword.value[fieldName] = term;
currentSizePerField.value[fieldName] = defaultValuesCount.value;
fieldValuesCurrentSize.value[fieldName] = defaultValuesCount.value;
@ -582,47 +613,38 @@ const handleSearchFieldValues = (fieldName: string, term: string) => {
cancelFieldStream(fieldName);
resetFieldValues(fieldName, true);
const pinnedTime = fieldValuesTimeRange.value[fieldName];
fetchFieldValues({
fields: [fieldName],
size: defaultValuesCount.value,
no_count: false,
start_time: pinnedTime?.start_time ?? (props.timeStamp as any).startTime,
end_time: pinnedTime?.end_time ?? (props.timeStamp as any).endTime,
stream_name: resolvedStream,
stream_type: props.streamType,
sql: buildSql(resolvedStream, (props as any).query || undefined),
keyword: term || undefined,
timeout: 30000,
use_cache: (globalThis as any).use_cache ?? true,
});
requestFieldValues(fieldName, defaultValuesCount.value, term);
};
const handleLoadMoreValues = (fieldName: string) => {
const row: any = (props.fields as any[]).find((f: any) => f.name === fieldName);
const resolvedStream = row?.stream_name || props.streamName;
const newSize =
(currentSizePerField.value[fieldName] ?? defaultValuesCount.value) + defaultValuesCount.value;
currentSizePerField.value[fieldName] = newSize;
fieldValuesCurrentSize.value[fieldName] = newSize;
fieldValuesFinalizedValues.value[fieldName] = [...(fieldValues.value[fieldName]?.values || [])];
const pinnedTime = fieldValuesTimeRange.value[fieldName];
fetchFieldValues({
fields: [fieldName],
size: newSize,
no_count: false,
start_time: pinnedTime?.start_time ?? (props.timeStamp as any).startTime,
end_time: pinnedTime?.end_time ?? (props.timeStamp as any).endTime,
stream_name: resolvedStream,
stream_type: props.streamType,
sql: buildSql(resolvedStream, (props as any).query || undefined),
keyword: currentKeyword.value[fieldName] || undefined,
timeout: 30000,
use_cache: (globalThis as any).use_cache ?? true,
});
requestFieldValues(fieldName, newSize, currentKeyword.value[fieldName]);
};
// The base filter is page-owned (e.g. the service chip on error tracking), so a
// change to it silently invalidates every open value list. Refetch them in
// place, keeping each field's current size and search keyword.
watch(
() => props.baseFilter,
() => {
for (const fieldName of expandedIds.value) {
cancelFieldStream(fieldName);
delete fieldValuesFinalizedValues.value[fieldName];
resetFieldValues(fieldName, true);
requestFieldValues(
fieldName,
currentSizePerField.value[fieldName] ?? defaultValuesCount.value,
currentKeyword.value[fieldName],
);
}
},
);
const isNullValue = (v: string) =>
v === null || v === undefined || v === "" || v.toLowerCase() === "null";

View File

@ -328,7 +328,15 @@ const NO_DATA_STUB = {
const SEARCH_FIELD_LIST_STUB = {
name: "SearchFieldList",
template: '<div data-test="search-field-list-stub" />',
props: ["fields", "timeStamp", "streamName", "streamType", "enableGrouping", "query"],
props: [
"fields",
"timeStamp",
"streamName",
"streamType",
"enableGrouping",
"query",
"baseFilter",
],
emits: ["event-emitted"],
};
@ -875,6 +883,27 @@ describe("AppErrors", () => {
// After removal, error_type condition should be gone
expect(errorTrackingState.data.editorValue).not.toContain("error_type");
});
it("passes the error-row base filter to the sidebar", async () => {
({ wrapper, router } = await mountAppErrors());
await flushPromises();
const fieldList = wrapper.findComponent(SEARCH_FIELD_LIST_STUB);
expect(fieldList.props("baseFilter")).toBe("type='error'");
});
it("adds the service chip to the sidebar base filter", async () => {
({ wrapper, router } = await mountAppErrors());
await flushPromises();
await wrapper
.findComponent({ name: "ErrorsFilterBar" })
.vm.$emit("update:service", "check'out");
await flushPromises();
const fieldList = wrapper.findComponent(SEARCH_FIELD_LIST_STUB);
expect(fieldList.props("baseFilter")).toBe("type='error' AND service='check''out'");
});
});
// ─────────────────────────────────────────────────────────────────────────

View File

@ -111,6 +111,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
stream-type="logs"
:enable-grouping="true"
:query="errorTrackingState.data.editorValue"
:base-filter="fieldListBaseFilter"
@event-emitted="handleSidebarEvent"
/>
</div>
@ -261,7 +262,7 @@ import ErrorsFilterBar, {
import OTag from "@/lib/core/Badge/OTag.vue";
import useErrorTracking from "@/composables/useErrorTracking";
import useErrorIssuesData from "@/composables/rum/useErrorIssuesData";
import { issueKey, formatRelativeTime } from "@/utils/rum/errorIssueUtils";
import { issueKey, formatRelativeTime, escapeSqlString } from "@/utils/rum/errorIssueUtils";
import { addCommasToNumber } from "@/utils/formatters";
import { useStore } from "vuex";
import DateTime from "@/components/DateTime.vue";
@ -442,6 +443,19 @@ const onServiceFilterChange = (value: string) => {
runQuery();
};
// The sidebar shares `_rumdata` with sessions/views/actions, so without these
// its value counts are whole-stream counts and dwarf the issue table's. Mirror
// the non-editable part of the issues query (buildIssuesSql) `type='error'`
// plus the service chip. The status/type chips are excluded on purpose: they
// filter the grouped result set client-side, not the underlying rows.
const fieldListBaseFilter = computed(() => {
const clauses = ["type='error'"];
if (serviceFilter.value) {
clauses.push(`service='${escapeSqlString(serviceFilter.value)}'`);
}
return clauses.join(" AND ");
});
// Dynamic editor height based on content lines
const errorEditorHeight = computed(() => {
const lines = (errorTrackingState.data.editorValue.match(/\n/g) || []).length + 1;

View File

@ -222,9 +222,10 @@ describe("AppSessions.vue", () => {
emits: ["update:query"],
},
SearchFieldList: {
name: "SearchFieldList",
template:
'<div data-test="field-list" v-bind="$attrs" @event-emitted="$emit(\'event-emitted\', $event)"></div>',
props: ["fields", "timeStamp", "streamName"],
props: ["fields", "timeStamp", "streamName", "query", "baseFilter"],
emits: ["event-emitted"],
},
FrustrationBadge: {
@ -500,6 +501,19 @@ describe("AppSessions.vue", () => {
expect(mockSessionState.data.editorValue).toBe("env='staging'");
});
it("should pass the session-row base filter to the sidebar", () => {
const fieldList = wrapper.findComponent({ name: "SearchFieldList" });
expect(fieldList.props("baseFilter")).toBe("session_has_replay IS NOT NULL");
});
// The base filter used to be concatenated into `query`, which made the
// sidebar read it back as an active exclude filter and show session_id /
// session_has_replay permanently ticked.
it("should keep the base filter out of the sidebar query prop", () => {
const fieldList = wrapper.findComponent({ name: "SearchFieldList" });
expect(fieldList.props("query")).not.toContain("session_has_replay");
});
});
describe("URL Query Parameters", () => {

View File

@ -123,7 +123,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:stream-name="rumSessionStreamName"
stream-type="logs"
:enable-grouping="true"
:query="completeQuery"
:query="sessionState.data.editorValue"
:base-filter="fieldListBaseFilter"
:show-count="false"
@event-emitted="handleSidebarEvent"
/>
@ -522,14 +523,14 @@ const dateTime = ref({
});
const rumSessionStreamName = "_rumdata";
// Computed query that includes session_has_replay filter
const completeQuery = computed(() => {
let whereClause = "session_has_replay IS NOT NULL AND session_id is not null";
if (sessionState.data.editorValue.length) {
whereClause += " AND (" + sessionState.data.editorValue.trim() + ")";
}
return whereClause;
});
// Non-editable part of the sessions query, kept verbatim in step with the
// WHERE clause getSessions() builds so the sidebar's value counts describe the
// same rows the table does. Passed separately from the editor value: `query`
// drives the sidebar's include/exclude checkbox state, and a clause the user
// cannot edit would show there as permanently ticked.
// The health/type/device segments stay out they filter the fetched rows
// client-side (see tableRows), not the underlying query.
const fieldListBaseFilter = "session_has_replay IS NOT NULL";
// Dynamic editor height based on content lines
const queryEditorHeight = computed(() => {