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