diff --git a/.changeset/thin-lies-write.md b/.changeset/thin-lies-write.md new file mode 100644 index 0000000000..1ec8a32fe1 --- /dev/null +++ b/.changeset/thin-lies-write.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +add faceted filters to the sessions page diff --git a/packages/app/src/SessionsPage.tsx b/packages/app/src/SessionsPage.tsx index 67a13bbef6..e26a14f05a 100644 --- a/packages/app/src/SessionsPage.tsx +++ b/packages/app/src/SessionsPage.tsx @@ -16,8 +16,13 @@ import { useQueryStates, } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; +import { z } from 'zod'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; +import { buildSearchChartConfig } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { + BuilderChartConfigWithDateRange, + Filter, + FilterSchema, SearchCondition, SearchConditionLanguage, SourceKind, @@ -34,19 +39,25 @@ import { Tooltip, } from '@mantine/core'; import { + IconArrowBarToRight, IconDeviceLaptop, IconPlayerPlay, IconRefresh, } from '@tabler/icons-react'; import { useVirtualizer } from '@tanstack/react-virtual'; +import { ActiveFilterPills } from '@/components/ActiveFilterPills'; import EmptyState from '@/components/EmptyState'; +import { ErrorBoundary } from '@/components/Error/ErrorBoundary'; import { PageHeader } from '@/components/PageHeader'; import { PageLayout } from '@/components/PageLayout'; +import { SessionFilters } from '@/components/SessionFilters'; import { SourceSelectControlled } from '@/components/SourceSelect'; import { TimePicker } from '@/components/TimePicker'; import { useDashboardRefresh } from '@/hooks/useDashboardRefresh'; +import { useColumns, useResolvedDateTimeColumns } from '@/hooks/useMetadata'; import { useResolvedSourceParam } from '@/hooks/useResolvedSourceParam'; +import { useSearchPageFilterState } from '@/searchFilters'; import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; import OnboardingModal from './components/OnboardingModal'; @@ -54,13 +65,15 @@ import SearchWhereInput, { getStoredLanguage, } from './components/SearchInput/SearchWhereInput'; import { useBrandDisplayName } from './theme/ThemeProvider'; +import { parseAsJsonEncoded } from './utils/queryParsers'; import { withAppNav } from './layout'; import { Session, useSessions } from './sessions'; import SessionSidePanel from './SessionSidePanel'; import { useSource, useSources } from './source'; import { FormatTime } from './useFormatTime'; -import { formatDistanceToNowStrictShort } from './utils'; +import { formatDistanceToNowStrictShort, useLocalStorage } from './utils'; +import searchPageStyles from '@styles/SearchPage.module.scss'; import styles from '@styles/SessionsPage.module.scss'; function SessionCard({ @@ -238,6 +251,13 @@ const appliedConfigMap = { sessionSource: parseAsString, where: parseAsString.withDefault(''), whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), + // Validate the shape: a stale/hand-edited `?filters=5`/`{}`/`"x"` is valid + // JSON but not `Filter[]`, and would throw "not iterable" when spread into + // the chart config during render. Rejecting the wrong shape resolves it to + // the `[]` default instead of white-screening the page. + filters: parseAsJsonEncoded(v => + z.array(FilterSchema).parse(v), + ).withDefault([]), }; function SessionsPage() { const brandName = useBrandDisplayName(); @@ -255,6 +275,7 @@ function SessionsPage() { whereLanguage: appliedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', source: paramSource?.id ?? null, + filters: appliedConfig.filters ?? [], }, }); @@ -326,6 +347,84 @@ function SessionsPage() { })(); }, [handleSubmit, setAppliedConfig, onSearch, displayedTimeInputValue]); + // The sidebar filters run against the trace source, so its columns are what + // we quote against and read DateTime types from. + const { data: traceColumns } = useColumns( + { + databaseName: traceTrace?.from?.databaseName ?? '', + tableName: traceTrace?.from?.tableName ?? '', + connectionId: traceTrace?.connection ?? '', + }, + { enabled: !!traceTrace }, + ); + const knownColumns = useMemo( + () => + traceColumns ? new Set(traceColumns.map(c => c.name)) : new Set(), + [traceColumns], + ); + const { dateTimeColumns } = useResolvedDateTimeColumns(traceColumns); + + // Applying a facet filter re-runs the query immediately (against the current + // time range), mirroring the search page's debounced auto-submit. + const handleSetFilters = useCallback( + (newFilters: Filter[]) => { + setValue('filters', newFilters); + setAppliedConfig({ filters: newFilters }); + }, + [setValue, setAppliedConfig], + ); + + const filters = useWatch({ control, name: 'filters' }); + const searchFilters = useSearchPageFilterState({ + searchQuery: filters ?? undefined, + onFilterChange: handleSetFilters, + dateTimeColumns, + knownColumns, + }); + + // Chart config used by the filters sidebar to fetch facet values and + // distributions. Scoped to the trace source with the currently applied + // where + filters so counts reflect the filtered set. + const filtersChartConfig = useMemo(() => { + const overrides = { dateRange: searchedTimeRange } as const; + if (traceTrace == null) { + return { + timestampValueExpression: '', + connection: '', + from: { databaseName: '', tableName: '' }, + where: '', + select: '', + ...overrides, + }; + } + // Scope facets to RUM session spans only. Without this the facet sidebar + // queries the entire trace table (all services, tens of millions of spans), + // which times out and leaves the sidebar empty. The `indexHint` lets the + // `rum.sessionId` skip index prune to just the session rows. This is applied + // as an always-on SQL filter alongside the user's search + facet selections. + const rumSessionIdKey = `${traceTrace.resourceAttributesExpression}['rum.sessionId']`; + const rumScopeFilter: Filter = { + type: 'sql', + condition: `notEmpty(${rumSessionIdKey}) AND indexHint(mapContains(${traceTrace.resourceAttributesExpression}, 'rum.sessionId'))`, + }; + const config = buildSearchChartConfig(traceTrace, { + where: appliedConfig.where, + whereLanguage: + (appliedConfig.whereLanguage as SearchConditionLanguage) ?? 'lucene', + filters: [rumScopeFilter, ...(appliedConfig.filters ?? [])], + }); + return { ...config, ...overrides }; + }, [ + traceTrace, + appliedConfig.where, + appliedConfig.whereLanguage, + appliedConfig.filters, + searchedTimeRange, + ]); + + const [isFilterSidebarCollapsed, setIsFilterSidebarCollapsed] = + useLocalStorage('isSessionsFilterSidebarCollapsed', false); + // Auto submit when the source changes. Compared against the *resolved* param // only: while `?sessionSource=` is still resolving — or when it names no // source at all — there is nothing for the form to have diverged from, and @@ -390,11 +489,19 @@ function SessionsPage() { // TODO: if selectedSession is not null, we should filter by that session id where: appliedConfig.where as SearchCondition, whereLanguage: appliedConfig.whereLanguage as SearchConditionLanguage, + filters: appliedConfig.filters ?? undefined, }); const sessions = tableData?.data ?? []; const targetSession = sessions.find(s => s.sessionId === selectedSession?.id); + // Whether the user has an explicit query or filter applied. When they do, an + // empty result set means "no matches" rather than "not set up yet", so we + // show the results list (with its own empty state) instead of the setup + // instructions. + const hasActiveSearch = + !!appliedConfig.where || (appliedConfig.filters?.length ?? 0) > 0; + return ( <> @@ -447,6 +554,10 @@ function SessionsPage() { control={control} name="source" allowedSourceKinds={[SourceKind.Session]} + // Portal the dropdown to the body so it renders above the + // filter sidebar, which sits in its own `z-index` stacking + // context and would otherwise clip/cover the inline menu. + comboboxProps={{ withinPortal: true }} /> } - padded content={ - <> - {isSessionsLoading || isSessionSourceLoading ? ( - - - {isSessionSourceLoading - ? 'Loading...' - : 'Searching sessions...'} - - ) : ( - <> - {!sessions.length ? ( - - - - ) : ( -
- { - setSelectedSession(session); - }} - sessions={sessions} - isSessionLoading={isSessionsLoading} - /> -
+
+ {sessionSource != null && + traceTrace != null && + !isFilterSidebarCollapsed && ( + + setIsFilterSidebarCollapsed(true)} + {...searchFilters} + /> + + )} + + + {isFilterSidebarCollapsed && ( + + setIsFilterSidebarCollapsed(false)} + aria-label="Show filters" + > + + + )} - - )} - + + + {isSessionsLoading || isSessionSourceLoading ? ( + + + {isSessionSourceLoading + ? 'Loading...' + : 'Searching sessions...'} + + ) : !sessions.length && !hasActiveSearch ? ( + + + + ) : ( +
+ { + setSelectedSession(session); + }} + sessions={sessions} + isSessionLoading={isSessionsLoading} + /> +
+ )} +
+
} /> diff --git a/packages/app/src/__tests__/sessions.test.ts b/packages/app/src/__tests__/sessions.test.ts new file mode 100644 index 0000000000..2a94865b72 --- /dev/null +++ b/packages/app/src/__tests__/sessions.test.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; +import { Filter, FilterSchema } from '@hyperdx/common-utils/dist/types'; + +import { buildServiceScopeFilters } from '@/sessions'; +import { parseAsJsonEncoded } from '@/utils/queryParsers'; + +describe('buildServiceScopeFilters', () => { + it('returns no filter for an empty service list (falls back to unscoped scan)', () => { + expect(buildServiceScopeFilters([], 'ServiceName')).toEqual([]); + }); + + it('emits a single IN clause for one service', () => { + expect(buildServiceScopeFilters(['svc-a'], 'ServiceName')).toEqual([ + { type: 'sql', condition: "ServiceName IN ('svc-a')" }, + ]); + }); + + it('comma-joins multiple services', () => { + expect(buildServiceScopeFilters(['svc-a', 'svc-b'], 'ServiceName')).toEqual( + [{ type: 'sql', condition: "ServiceName IN ('svc-a', 'svc-b')" }], + ); + }); + + it('honors a custom service-name expression', () => { + expect( + buildServiceScopeFilters(['svc-a'], "ResourceAttributes['service.name']"), + ).toEqual([ + { + type: 'sql', + condition: "ResourceAttributes['service.name'] IN ('svc-a')", + }, + ]); + }); + + describe('escaping ingested ServiceName values', () => { + it('doubles single quotes', () => { + expect(buildServiceScopeFilters(["o'brien"], 'ServiceName')).toEqual([ + { type: 'sql', condition: "ServiceName IN ('o''brien')" }, + ]); + }); + + it('escapes backslashes (which would otherwise produce invalid SQL)', () => { + expect(buildServiceScopeFilters(['a\\b'], 'ServiceName')).toEqual([ + { type: 'sql', condition: "ServiceName IN ('a\\\\b')" }, + ]); + }); + + it('neutralizes a single-quote injection payload', () => { + // `x') OR 1=1 --` must stay inside the literal: the quote is doubled so it + // never terminates the string early. + expect( + buildServiceScopeFilters(["x') OR 1=1 --"], 'ServiceName'), + ).toEqual([ + { type: 'sql', condition: "ServiceName IN ('x'') OR 1=1 --')" }, + ]); + }); + + it('neutralizes a backslash+quote injection payload', () => { + // `x\') OR 1=1 --` relies on ClickHouse honoring backslash escapes; the + // backslash is doubled first, then the quote, so the value stays inert. + expect( + buildServiceScopeFilters(["x\\') OR 1=1 --"], 'ServiceName'), + ).toEqual([ + { type: 'sql', condition: "ServiceName IN ('x\\\\'') OR 1=1 --')" }, + ]); + }); + }); +}); + +describe('sessions ?filters= URL param', () => { + // Mirror the parser wired up in SessionsPage: validate the shape and coerce + // anything malformed to the empty default instead of white-screening. + const filtersParser = parseAsJsonEncoded(v => + z.array(FilterSchema).parse(v), + ).withDefault([]); + + const encode = (value: unknown) => encodeURIComponent(JSON.stringify(value)); + + it('parses a well-formed Filter[]', () => { + const value: Filter[] = [{ type: 'sql', condition: "ServiceName = 'x'" }]; + expect(filtersParser.parse(encode(value))).toEqual(value); + }); + + it('falls back to an empty array (never null → never spreads a non-iterable)', () => { + expect(filtersParser.defaultValue).toEqual([]); + }); + + it.each([ + ['a bare number (?filters=5)', 5], + ['an empty object (?filters={})', {}], + ['a bare string (?filters="x")', 'x'], + ['a null literal', null], + ['an array of malformed frames', [{ nope: true }]], + ])('rejects %s so the default applies', (_label, input) => { + expect(filtersParser.parse(encode(input))).toBeNull(); + }); + + it('rejects invalid JSON', () => { + expect(filtersParser.parse('not-json')).toBeNull(); + }); +}); diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 67a154aae7..ed771a836e 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -1062,29 +1062,40 @@ const DBSearchPageFiltersComponent = ({ setFilterValue: _setFilterValue, isLive, chartConfig, - analysisMode, - setAnalysisMode, + analysisMode = 'results', + setAnalysisMode = voidFunc, sourceId, - showDelta, - denoiseResults, - setDenoiseResults, + showDelta = false, + denoiseResults = false, + setDenoiseResults = voidFunc, setFilterRange, onColumnToggle, displayedColumns, onCollapse, + hideAnalysisMode = false, + forceExactFacetMode = false, }: { - analysisMode: 'results' | 'delta' | 'pattern'; - setAnalysisMode: (mode: 'results' | 'delta' | 'pattern') => void; + analysisMode?: 'results' | 'delta' | 'pattern'; + setAnalysisMode?: (mode: 'results' | 'delta' | 'pattern') => void; isLive: boolean; chartConfig: BuilderChartConfigWithDateRange; sourceId?: string; - showDelta: boolean; - denoiseResults: boolean; - setDenoiseResults: (denoiseResults: boolean) => void; + showDelta?: boolean; + denoiseResults?: boolean; + setDenoiseResults?: (denoiseResults: boolean) => void; setFilterRange: (key: string, range: { min: number; max: number }) => void; onColumnToggle?: (column: string) => void; displayedColumns?: string[]; onCollapse?: () => void; + // When true, hides the "Analysis Mode" header/tabs and the denoise toggle, + // leaving only the facet filters. Used by the sessions page. + hideAnalysisMode?: boolean; + // When true, always fetch facet values in "exact" mode (scoped to the + // chartConfig's where/filters) instead of honoring the global "show all + // values" toggle. The sessions page needs this: its `chartConfig` scopes the + // facets to RUM session spans, and "all" mode strips that scope and samples + // the entire (huge) trace table, which times out and yields no facets. + forceExactFacetMode?: boolean; } & FilterStateHook) => { const setFilterValue = useCallback( ( @@ -1182,7 +1193,7 @@ const DBSearchPageFiltersComponent = ({ chartConfig, sourceId: sourceId ?? null, dateRange, - mode: showAllValues ? 'all' : 'exact', + mode: forceExactFacetMode || !showAllValues ? 'exact' : 'all', filterState, showMoreFields, }); @@ -1622,9 +1633,11 @@ const DBSearchPageFiltersComponent = ({ > - - Analysis Mode - + {!hideAnalysisMode && ( + + Analysis Mode + + )} {showRefreshButton && ( - - setAnalysisMode(value as 'results' | 'delta' | 'pattern') - } - orientation="vertical" - w="100%" - placement="right" - > - - - Results Table - - {showDelta && ( - - Event Deltas - - )} - {!IS_CLICKHOUSE_BUILD && ( - - Event Patterns + {!hideAnalysisMode && ( + + setAnalysisMode(value as 'results' | 'delta' | 'pattern') + } + orientation="vertical" + w="100%" + placement="right" + > + + + Results Table - )} - - + {showDelta && ( + + Event Deltas + + )} + {!IS_CLICKHOUSE_BUILD && ( + + Event Patterns + + )} + + + )} {isSharedFiltersVisible && ( - {analysisMode === 'results' && ( + {!hideAnalysisMode && analysisMode === 'results' && ( )} - {source?.kind === SourceKind.Trace && + {!hideAnalysisMode && + source?.kind === SourceKind.Trace && source.parentSpanIdExpression && ( , + | 'analysisMode' + | 'setAnalysisMode' + | 'showDelta' + | 'denoiseResults' + | 'setDenoiseResults' + | 'isLive' + | 'hideAnalysisMode' + | 'forceExactFacetMode' +>; + +/** + * Sessions-specific filter sidebar: the same faceted filters as the search + * page, minus the "Analysis Mode" header/tabs and denoise toggle. A thin + * wrapper over {@link DBSearchPageFilters} so the two stay in sync without + * duplicating the facet machinery. + * + * `forceExactFacetMode` is on because the sessions `chartConfig` scopes facets + * to RUM session spans; "all" mode would strip that scope and sample the whole + * trace table, which times out and leaves the sidebar empty. + */ +export function SessionFilters(props: SessionFiltersProps) { + return ( + + ); +} diff --git a/packages/app/src/components/__tests__/SessionFilters.test.tsx b/packages/app/src/components/__tests__/SessionFilters.test.tsx new file mode 100644 index 0000000000..a2316f6900 --- /dev/null +++ b/packages/app/src/components/__tests__/SessionFilters.test.tsx @@ -0,0 +1,64 @@ +import { render } from '@testing-library/react'; + +import { SessionFilters } from '@/components/SessionFilters'; + +// Capture the props the wrapper hands to the (heavy) underlying component +// without rendering its facet machinery. +const dbSearchPageFilters = jest.fn(() => null); +jest.mock('@/components/DBSearchPageFilters', () => ({ + __esModule: true, + DBSearchPageFilters: (props: unknown) => dbSearchPageFilters(props), +})); + +function getForwardedProps() { + expect(dbSearchPageFilters).toHaveBeenCalledTimes(1); + return dbSearchPageFilters.mock.calls[0][0] as Record; +} + +describe('SessionFilters', () => { + beforeEach(() => { + dbSearchPageFilters.mockClear(); + }); + + const baseProps = { + chartConfig: { + from: { databaseName: 'otel', tableName: 'otel_traces' }, + }, + sourceId: 'trace-source', + filters: {}, + clearFilter: jest.fn(), + setFilterValue: jest.fn(), + setFilterRange: jest.fn(), + } as any; + + it('forces exact facet mode so the sessions RUM scope is not stripped', () => { + // "all" mode clears the chartConfig where/filters and samples the entire + // trace table, which times out and leaves the sidebar empty; the sessions + // sidebar must always fetch facet values in exact mode. + render(); + expect(getForwardedProps().forceExactFacetMode).toBe(true); + }); + + it('hides analysis mode and disables live tail (not applicable to sessions)', () => { + render(); + const props = getForwardedProps(); + expect(props.hideAnalysisMode).toBe(true); + expect(props.isLive).toBe(false); + }); + + it('overrides the sessions-specific flags even if a caller passes them', () => { + render(); + const props = getForwardedProps(); + expect(props.isLive).toBe(false); + expect(props.hideAnalysisMode).toBe(true); + expect(props.forceExactFacetMode).toBe(true); + }); + + it('forwards caller props (chartConfig, sourceId, filter state) through', () => { + render(); + const props = getForwardedProps(); + expect(props.chartConfig).toBe(baseProps.chartConfig); + expect(props.sourceId).toBe('trace-source'); + expect(props.filters).toBe(baseProps.filters); + }); +}); diff --git a/packages/app/src/sessions.ts b/packages/app/src/sessions.ts index e44849ed43..ee258f831c 100644 --- a/packages/app/src/sessions.ts +++ b/packages/app/src/sessions.ts @@ -3,8 +3,10 @@ import produce from 'immer'; import type { ResponseJSON } from '@hyperdx/common-utils/dist/clickhouse'; import { chSql } from '@hyperdx/common-utils/dist/clickhouse'; import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig'; +import { escapeSqlString } from '@hyperdx/common-utils/dist/core/utils'; import { DateRange, + Filter, pickSampleWeightExpressionProps, SearchCondition, SearchConditionLanguage, @@ -37,6 +39,37 @@ export type Session = { userName: string; }; +/** + * Build the `ServiceName IN (...)` scope filter for the trace aggregation. + * + * `serviceNames` come from ingested telemetry, so each value is escaped with + * `escapeSqlString` (backslash then single-quote) before being interpolated + * into the SQL literal — otherwise a crafted name (e.g. `x') OR 1=1 --`) could + * break out of the literal (second-order injection), and any backslash name + * would produce invalid SQL. Returns `[]` for an empty list so the caller + * emits no predicate and falls back to the unscoped scan. + * + * `serviceNameExpression` is source config (a column expression), not user + * input, so it is interpolated as-is. + */ +export function buildServiceScopeFilters( + serviceNames: string[], + serviceNameExpression: string, +): Filter[] { + if (serviceNames.length === 0) { + return []; + } + const inList = serviceNames + .map(name => `'${escapeSqlString(name)}'`) + .join(', '); + return [ + { + type: 'sql', + condition: `${serviceNameExpression} IN (${inList})`, + }, + ]; +} + export function useSessions( { traceSource, @@ -44,12 +77,19 @@ export function useSessions( dateRange, where, whereLanguage, + filters, }: { traceSource?: TTraceSource; sessionSource?: TSessionSource; dateRange: DateRange['dateRange']; where?: SearchCondition; whereLanguage?: SearchConditionLanguage; + /** + * Faceted filters from the sidebar (`DBSearchPageFilters`), applied to the + * trace source alongside the free-text `where`. Persisted in their quoted + * ClickHouse key form so they emit valid SQL verbatim. + */ + filters?: Filter[]; }, options?: Omit, 'queryKey'>, ) { @@ -77,6 +117,7 @@ export function useSessions( dateRange, where, whereLanguage, + filters, ], queryFn: async () => { if ( @@ -93,6 +134,110 @@ export function useSessions( 'rum.sessionId', ); + // Combine the free-text `where` with the sidebar's faceted filters into a + // single filter list applied to the trace source. When either is present + // we treat the query as an explicit user search (see `hasSearchQuery`). + const searchFilters: Filter[] = [ + ...(where + ? [ + { + type: + (whereLanguage === 'promql' ? 'lucene' : whereLanguage) ?? + 'lucene', + condition: where, + } as Filter, + ] + : []), + ...(filters ?? []), + ]; + const hasSearchQuery = searchFilters.length > 0; + + // Scope the trace aggregation to just the service(s) that emit RUM + // sessions. `otel_traces` is sorted by (ServiceName, SpanName, Timestamp), + // so with no ServiceName predicate the time filter can't prune via the + // primary index — the query scans the whole table's marks (~100k) to + // evaluate the `rum.sessionId` skip index. RUM traffic comes from a tiny + // set of services, which we read cheaply from the session source (sorted + // by time), turning the trace scan into a primary-key range (~50 marks, + // ~20x faster cold). + // + // Correctness assumption: the session and trace sources report the same + // `ServiceName` for RUM spans (the session source is the cheap, + // time-sorted projection of the same RUM traffic, which is why we read the + // list from there instead of re-scanning the trace table). If they diverge + // — a service present in trace RUM spans but absent from the session-source + // scan, or a differing service-name column — that service's sessions would + // be dropped. On any query failure or an empty result we fall back to the + // unscoped (correct) scan. + const serviceNameExpression = + traceSource.serviceNameExpression || 'ServiceName'; + // Enumerate RUM services over a window wider than the selected range. A + // session's row in the session source is timestamped at (near) its start, + // which can fall *before* `dateRange` even though its trace spans land + // inside it. Scoping to only services seen within the exact range would + // then drop those sessions. Over-inclusion is safe — an extra ServiceName + // only widens the primary-key range scanned; the `rum.sessionId` + // predicate + HAVING still filter the rows — so we look back a generous + // margin. Under-inclusion is the correctness bug we must avoid. + const SERVICE_SCOPE_LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + const serviceScopeDateRange: [Date, Date] = [ + new Date(dateRange[0].getTime() - SERVICE_SCOPE_LOOKBACK_MS), + dateRange[1], + ]; + // Resolve the scope AND serialize it inside the try: `ServiceName` is + // ingested telemetry, so a value with a quote/backslash must be escaped + // (see `buildServiceScopeFilters`). Keeping construction here means any + // failure — query or serialization — falls back to the unscoped (correct) + // scan rather than emitting broken/injectable SQL into the aggregation. + let serviceScopeFilters: Filter[] = []; + try { + const serviceNamesQuery = await renderChartConfig( + { + select: [ + { + valueExpression: `DISTINCT ${serviceNameExpression}`, + alias: 'serviceName', + }, + ], + from: sessionSource.from, + dateRange: serviceScopeDateRange, + where: `notEmpty(${getSessionsSourceFieldExpression( + sessionSource.resourceAttributesExpression ?? + 'ResourceAttributes', + 'rum.sessionId', + )})`, + whereLanguage: 'sql', + timestampValueExpression: sessionSource.timestampValueExpression, + connection: sessionSource.connection, + }, + metadata, + sessionSource.querySettings, + ); + const serviceNamesJson = await clickhouseClient + .query({ + query: serviceNamesQuery.sql, + query_params: serviceNamesQuery.params, + connectionId: sessionSource.connection, + }) + .then(res => res.json<{ serviceName: string }>()); + const rumServiceNames = (serviceNamesJson.data ?? []) + .map(row => row.serviceName) + .filter((name): name is string => !!name); + serviceScopeFilters = buildServiceScopeFilters( + rumServiceNames, + serviceNameExpression, + ); + } catch { + // Optimization only — fall back to an unscoped scan. + } + + const sessionsQueryFilters: Filter[] = [ + // Service scope kept separate from `hasSearchQuery` so it never flips + // the HAVING/CTE behavior below. + ...serviceScopeFilters, + ...(hasSearchQuery ? searchFilters : []), + ]; + const [ sessionsQuery, sessionIdsWithRecordingsQuery, @@ -153,15 +298,8 @@ export function useSessions( dateRange, where: `${traceSource.resourceAttributesExpression}.rum.sessionId:*`, whereLanguage: 'lucene', - ...(where && { - filters: [ - { - type: - (whereLanguage === 'promql' ? 'lucene' : whereLanguage) ?? - 'lucene', - condition: where, - }, - ], + ...(sessionsQueryFilters.length > 0 && { + filters: sessionsQueryFilters, }), timestampValueExpression: traceSource.timestampValueExpression, implicitColumnExpression: traceSource.implicitColumnExpression, @@ -232,16 +370,17 @@ export function useSessions( ${ // If the user is giving us an explicit query, we don't need to filter out sessions with no interactions // this is because the events that match the query might not be user interactions, and we'll just show 0 results otherwise. - where ? '' : 'HAVING interactionCount > 0 OR recordingCount > 0' + hasSearchQuery + ? '' + : 'HAVING interactionCount > 0 OR recordingCount > 0' } ORDER BY maxTimestamp DESC LIMIT 500 ) `; - const finalQuery = - where && where.length > 0 - ? chSql` + const finalQuery = hasSearchQuery + ? chSql` ${sessionsCTE}, sessionIdsWithRecordings AS (${sessionIdsWithRecordingsQuery}), sessionIdsWithUserActivity AS (${sessionIdsWithUserActivityQuery}) @@ -253,7 +392,7 @@ export function useSessions( SELECT sessionIdsWithUserActivity.sessionId FROM sessionIdsWithUserActivity ) ` - : chSql` + : chSql` ${sessionsCTE} SELECT * FROM ${SESSIONS_CTE_NAME}