fix: Rum side filter (#13542)
This commit is contained in:
parent
dc83ec2947
commit
8b8406282f
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue