From 1901232417533ef0ef7e457201e38bbdb34ca30b Mon Sep 17 00:00:00 2001 From: vinzee Date: Mon, 24 Aug 2026 17:51:26 -0700 Subject: [PATCH] feat(app): add faceted filters to the sessions page Bring the search page's faceted filter sidebar to /sessions and wire the selected filters into the session aggregation query. - Add a SessionFilters wrapper around DBSearchPageFilters that hides the analysis-mode tabs, denoise, and root-spans-only toggles that don't apply to sessions. - Persist sidebar filters in the URL and thread them through useSessions so they constrain the trace aggregation alongside the free-text query. Validate the ?filters= param against FilterSchema with a [] default so a stale/hand-edited param resolves to the default instead of white-screening. - Speed up the aggregation: resolve the RUM service name(s) from the session source and constrain the otel_traces scan by ServiceName, turning a ~100k-mark full scan into a small primary-key range. Enumerate those services over a window wider than the selected range so sessions that started just before the range aren't dropped, and on any failure or when no services resolve, fall back to the unscoped (still correct) scan. - Harden the ServiceName scope: escape ingested names with escapeSqlString (backslash then quote) in a dedicated buildServiceScopeFilters helper and build it inside the try/catch, so a crafted/backslash name can't inject into or break the aggregation. - Fix the empty filter sidebar: scope facet queries to RUM session spans via a rum.sessionId indexHint and force exact facet mode, so the 'show all values' path can't strip the scope and sample the whole trace table (which timed out and left the sidebar empty). - Portal the source-select dropdown so it renders above the filter sidebar instead of being clipped by its stacking context. - Add unit tests: SessionFilters prop forwarding, buildServiceScopeFilters escaping/fallback, and the ?filters= param validator. Co-authored-by: Cursor --- .changeset/thin-lies-write.md | 5 + packages/app/src/SessionsPage.tsx | 219 +++++++++++++++--- packages/app/src/__tests__/sessions.test.ts | 101 ++++++++ .../src/components/DBSearchPageFilters.tsx | 96 ++++---- .../app/src/components/SessionFilters.tsx | 39 ++++ .../__tests__/SessionFilters.test.tsx | 64 +++++ packages/app/src/sessions.ts | 167 +++++++++++-- 7 files changed, 601 insertions(+), 90 deletions(-) create mode 100644 .changeset/thin-lies-write.md create mode 100644 packages/app/src/__tests__/sessions.test.ts create mode 100644 packages/app/src/components/SessionFilters.tsx create mode 100644 packages/app/src/components/__tests__/SessionFilters.test.tsx 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}