diff --git a/web/src/components/common/sidebar/SearchFieldList.spec.ts b/web/src/components/common/sidebar/SearchFieldList.spec.ts index b32db5537a..4b8ba3e434 100644 --- a/web/src/components/common/sidebar/SearchFieldList.spec.ts +++ b/web/src/components/common/sidebar/SearchFieldList.spec.ts @@ -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({ diff --git a/web/src/components/common/sidebar/SearchFieldList.vue b/web/src/components/common/sidebar/SearchFieldList.vue index d54cfecac3..0752e79e75 100644 --- a/web/src/components/common/sidebar/SearchFieldList.vue +++ b/web/src/components/common/sidebar/SearchFieldList.vue @@ -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"; diff --git a/web/src/views/RUM/AppErrors.spec.ts b/web/src/views/RUM/AppErrors.spec.ts index 4c3ebd735e..e7f2b38281 100644 --- a/web/src/views/RUM/AppErrors.spec.ts +++ b/web/src/views/RUM/AppErrors.spec.ts @@ -328,7 +328,15 @@ const NO_DATA_STUB = { const SEARCH_FIELD_LIST_STUB = { name: "SearchFieldList", template: '
', - 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'"); + }); }); // ───────────────────────────────────────────────────────────────────────── diff --git a/web/src/views/RUM/AppErrors.vue b/web/src/views/RUM/AppErrors.vue index 112bbcce86..044b8e8f55 100644 --- a/web/src/views/RUM/AppErrors.vue +++ b/web/src/views/RUM/AppErrors.vue @@ -111,6 +111,7 @@ along with this program. If not, see . stream-type="logs" :enable-grouping="true" :query="errorTrackingState.data.editorValue" + :base-filter="fieldListBaseFilter" @event-emitted="handleSidebarEvent" />
@@ -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; diff --git a/web/src/views/RUM/AppSessions.spec.ts b/web/src/views/RUM/AppSessions.spec.ts index e4cf464969..b6d19cba6a 100644 --- a/web/src/views/RUM/AppSessions.spec.ts +++ b/web/src/views/RUM/AppSessions.spec.ts @@ -222,9 +222,10 @@ describe("AppSessions.vue", () => { emits: ["update:query"], }, SearchFieldList: { + name: "SearchFieldList", template: '
', - 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", () => { diff --git a/web/src/views/RUM/AppSessions.vue b/web/src/views/RUM/AppSessions.vue index 338494e939..c24a02dbd6 100644 --- a/web/src/views/RUM/AppSessions.vue +++ b/web/src/views/RUM/AppSessions.vue @@ -123,7 +123,8 @@ along with this program. If not, see . :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(() => {