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/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index e7a257d397..80c97ff5d8 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -122,7 +122,10 @@ import { useSavedSearch, useUpdateSavedSearch, } from '@/savedSearch'; -import { useSearchPageFilterState } from '@/searchFilters'; +import { + formatDroppedFiltersMessage, + useSearchPageFilterState, +} from '@/searchFilters'; import { getEventBody, useSource, useSources } from '@/source'; import { useAppTheme, usePageTitle } from '@/theme/ThemeProvider'; import { @@ -890,12 +893,6 @@ export function useDefaultOrderBy(sourceID: string | undefined | null) { }, [source, tableMetadata]); } -function formatDroppedFiltersMessage(count: number): string { - const noun = count === 1 ? 'filter' : 'filters'; - const verb = count === 1 ? 'was' : 'were'; - return `${count} ${noun} didn't apply to this source and ${verb} removed.`; -} - // This is outside as it needs to be a stable reference const queryStateMap = { source: parseAsString, diff --git a/packages/app/src/SessionsPage.tsx b/packages/app/src/SessionsPage.tsx index 51c6be1ff5..51679e8d56 100644 --- a/packages/app/src/SessionsPage.tsx +++ b/packages/app/src/SessionsPage.tsx @@ -17,7 +17,10 @@ import { } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; +import { buildSearchChartConfig } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { + BuilderChartConfigWithDateRange, + Filter, SearchCondition, SearchConditionLanguage, SourceKind, @@ -33,20 +36,32 @@ import { Stepper, Tooltip, } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { + IconArrowBarToRight, IconDeviceLaptop, IconPlayerPlay, IconRefresh, + IconSearchOff, } 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 useFieldExpressionGenerator from '@/hooks/useFieldExpressionGenerator'; +import { useColumns, useResolvedDateTimeColumns } from '@/hooks/useMetadata'; import { useResolvedSourceParam } from '@/hooks/useResolvedSourceParam'; +import { + formatDroppedFiltersMessage, + useSearchPageFilterState, +} from '@/searchFilters'; import { useDefaultTimeRange, useNewTimeQuery } from '@/timeQuery'; import OnboardingModal from './components/OnboardingModal'; @@ -55,12 +70,20 @@ import SearchWhereInput, { } from './components/SearchInput/SearchWhereInput'; import { useBrandDisplayName, usePageTitle } from './theme/ThemeProvider'; import { withAppNavForSurface } from './layout'; -import { Session, useSessions } from './sessions'; +import { + buildSessionsScope, + buildSessionsSearchCondition, + hasSessionsSearch, + Session, + sessionFiltersParser, + 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({ @@ -235,6 +258,7 @@ const appliedConfigMap = { sessionSource: parseAsString, where: parseAsString.withDefault(''), whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), + filters: sessionFiltersParser, }; const DEFAULT_INTERVAL = 'Past 1h'; @@ -255,6 +279,10 @@ function SessionsPage() { whereLanguage: appliedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', source: paramSource?.id ?? null, + filters: appliedConfig.filters, + }, + resetOptions: { + keepDirtyValues: true, }, }); @@ -325,13 +353,105 @@ function SessionsPage() { })(); }, [handleSubmit, setAppliedConfig, onSearch, displayedTimeInputValue]); + const { data: traceColumns } = useColumns(tcFromSource(traceTrace), { + enabled: !!traceTrace, + }); + const knownColumns = useMemo( + () => + traceColumns ? new Set(traceColumns.map(c => c.name)) : new Set(), + [traceColumns], + ); + const { dateTimeColumns } = useResolvedDateTimeColumns(traceColumns); + + 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, + }); + + const { getFieldExpression: getTraceFieldExpression } = + useFieldExpressionGenerator(traceTrace); + const traceResourceAttributesExpression = + traceTrace?.resourceAttributesExpression ?? 'ResourceAttributes'; + const traceSessionIdExpression = getTraceFieldExpression?.( + traceResourceAttributesExpression, + 'rum.sessionId', + ); + const sessionsSearchCondition = useMemo( + () => + traceSessionIdExpression == null + ? undefined + : buildSessionsSearchCondition({ + resourceAttributesExpression: traceResourceAttributesExpression, + sessionIdExpression: traceSessionIdExpression, + where: appliedConfig.where, + whereLanguage: appliedConfig.whereLanguage ?? undefined, + }), + [ + traceSessionIdExpression, + traceResourceAttributesExpression, + appliedConfig.where, + appliedConfig.whereLanguage, + ], + ); + const rumScopeFilters = useMemo( + () => [ + { + type: 'lucene', + condition: buildSessionsScope(traceResourceAttributesExpression), + }, + ], + [traceResourceAttributesExpression], + ); + + const filtersChartConfig = useMemo(() => { + const overrides = { dateRange: searchedTimeRange } as const; + if (traceTrace == null || sessionsSearchCondition == null) { + return { + timestampValueExpression: '', + connection: '', + from: { databaseName: '', tableName: '' }, + where: '', + select: '', + ...overrides, + }; + } + const config = buildSearchChartConfig(traceTrace, { + ...sessionsSearchCondition, + filters: appliedConfig.filters, + }); + return { ...config, ...overrides }; + }, [ + traceTrace, + sessionsSearchCondition, + 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 // submitting would write the empty form source over the param (losing both the // link and the "Source not found" warning) and reset the searched time range. + const pendingFilterReconcileRef = useRef(null); const submitOnSourceChange = useEffectEvent(() => { if (sourceId !== (paramSource?.id ?? null)) { + if (paramSource != null) { + pendingFilterReconcileRef.current = sourceId; + } onSubmit(); } }); @@ -339,6 +459,28 @@ function SessionsPage() { submitOnSourceChange(); }, [sourceId]); + const retainCompatibleFilters = useEffectEvent(() => { + if ( + pendingFilterReconcileRef.current == null || + pendingFilterReconcileRef.current !== sessionSource?.id || + traceTrace?.id !== sessionSource?.traceSourceId || + traceColumns == null + ) { + return; + } + pendingFilterReconcileRef.current = null; + const dropped = searchFilters.retainFiltersByColumns(knownColumns); + if (dropped.length > 0) { + notifications.show({ + color: 'yellow', + message: formatDroppedFiltersMessage(dropped.length), + }); + } + }); + useEffect(() => { + retainCompatibleFilters(); + }, [sessionSource?.id, traceTrace?.id, traceColumns]); + const [selectedSessionQuery, setSelectedSessionQuery] = useQueryStates( selectedSessionQueryStateMap, { @@ -389,11 +531,23 @@ 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, }); const sessions = tableData?.data ?? []; const targetSession = sessions.find(s => s.sessionId === selectedSession?.id); + // With a query applied, no results means "no matches", not "not set up yet". + const canShowFilterSidebar = + sessionSource != null && + traceTrace != null && + sessionsSearchCondition != null; + + const hasActiveSearch = hasSessionsSearch( + appliedConfig.where, + appliedConfig.filters, + ); + return ( <> @@ -447,6 +601,8 @@ function SessionsPage() { control={control} name="source" allowedSourceKinds={[SourceKind.Session]} + // The filter sidebar's stacking context covers an inline menu. + comboboxProps={{ withinPortal: true }} /> } - padded content={ - <> - {isSessionsLoading || isSessionSourceLoading ? ( - - - {isSessionSourceLoading - ? 'Loading...' - : 'Searching sessions...'} - - ) : ( - <> - {!sessions.length ? ( - - - - ) : ( -
- { - setSelectedSession(session); - }} - sessions={sessions} - isSessionLoading={isSessionsLoading} - /> -
- )} - +
+ {canShowFilterSidebar && !isFilterSidebarCollapsed && ( + + setIsFilterSidebarCollapsed(true)} + {...searchFilters} + /> + )} - + + + {canShowFilterSidebar && isFilterSidebarCollapsed && ( + + setIsFilterSidebarCollapsed(false)} + aria-label="Show filters" + > + + + + )} + + + {isSessionsLoading || isSessionSourceLoading ? ( + + + {isSessionSourceLoading + ? 'Loading...' + : 'Searching sessions...'} + + ) : !sessions.length && !hasActiveSearch ? ( + + + + ) : !sessions.length ? ( + } + title="No matching sessions" + description="Try a different query, time range, or filters." + > + {appliedConfig.filters.length > 0 && ( + + )} + + ) : ( +
+ { + setSelectedSession(session); + }} + sessions={sessions} + isSessionLoading={isSessionsLoading} + /> +
+ )} +
+
} /> diff --git a/packages/app/src/__tests__/sessions.test.ts b/packages/app/src/__tests__/sessions.test.ts index fcb2a9738e..038ee89de6 100644 --- a/packages/app/src/__tests__/sessions.test.ts +++ b/packages/app/src/__tests__/sessions.test.ts @@ -5,6 +5,7 @@ import { } from '@hyperdx/common-utils/dist/core/metadata'; import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig'; import { + Filter, SourceKind, TSessionSource, TTraceSource, @@ -12,8 +13,10 @@ import { import { buildRRWebStreamChartConfig, - buildSessionsQueryFilters, + buildSessionsSearchCondition, fetchServiceScopeFilters, + hasSessionsSearch, + sessionFiltersParser, } from '@/sessions'; jest.mock('@hyperdx/common-utils/dist/core/renderChartConfig', () => ({ @@ -249,30 +252,124 @@ describe('fetchServiceScopeFilters', () => { }); }); -describe('buildSessionsQueryFilters', () => { - const scope = [{ type: 'sql', condition: "ServiceName IN ('web')" }] as const; +describe('sessions ?filters= URL param', () => { + const encode = (value: unknown) => encodeURIComponent(JSON.stringify(value)); - it('puts the scope before a lucene query', () => { - expect(buildSessionsQueryFilters([...scope], 'foo', 'lucene')).toEqual([ - ...scope, - { type: 'lucene', condition: 'foo' }, - ]); + it('parses a well-formed Filter[]', () => { + const value: Filter[] = [{ type: 'sql', condition: "ServiceName = 'x'" }]; + expect(sessionFiltersParser.parse(encode(value))).toEqual(value); }); - it('keeps a SQL query as SQL', () => { - expect(buildSessionsQueryFilters([...scope], 'a = 1', 'sql')).toEqual([ - ...scope, - { type: 'sql', condition: 'a = 1' }, - ]); + it('falls back to an empty array (never null → never spreads a non-iterable)', () => { + expect(sessionFiltersParser.defaultValue).toEqual([]); }); - it('treats a missing language as lucene', () => { - expect(buildSessionsQueryFilters([], 'foo', undefined)).toEqual([ - { type: 'lucene', condition: 'foo' }, - ]); + 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(sessionFiltersParser.parse(encode(input))).toBeNull(); + }); + + it('rejects invalid JSON', () => { + expect(sessionFiltersParser.parse('not-json')).toBeNull(); + }); +}); + +describe('buildSessionsSearchCondition', () => { + const base = { + resourceAttributesExpression: 'ResourceAttributes', + sessionIdExpression: "ResourceAttributes['rum.sessionId']", + }; + + it('scopes to RUM sessions with no query', () => { + expect( + buildSessionsSearchCondition({ + ...base, + where: '', + whereLanguage: 'sql', + }), + ).toEqual({ + where: 'ResourceAttributes.rum.sessionId:*', + whereLanguage: 'lucene', + }); + }); + + it('ANDs a lucene query with the scope', () => { + expect( + buildSessionsSearchCondition({ + ...base, + where: 'foo OR bar', + whereLanguage: 'lucene', + }), + ).toEqual({ + where: 'ResourceAttributes.rum.sessionId:* AND (foo OR bar)', + whereLanguage: 'lucene', + }); }); - it('returns no filters with no scope and no query', () => { - expect(buildSessionsQueryFilters([], '', 'lucene')).toEqual([]); + it('ANDs a SQL query with a SQL scope', () => { + expect( + buildSessionsSearchCondition({ + ...base, + where: "a = 1 OR b = 'x'", + whereLanguage: 'sql', + }), + ).toEqual({ + where: + "notEmpty(ResourceAttributes['rum.sessionId']) AND (a = 1 OR b = 'x'\n)", + whereLanguage: 'sql', + }); + }); + + it('keeps the closing paren after a trailing SQL comment', () => { + expect( + buildSessionsSearchCondition({ + ...base, + where: 'a = 1 -- note', + whereLanguage: 'sql', + }).where, + ).toBe( + "notEmpty(ResourceAttributes['rum.sessionId']) AND (a = 1 -- note\n)", + ); + }); + + it('ignores a whitespace-only query', () => { + expect( + buildSessionsSearchCondition({ + ...base, + where: ' ', + whereLanguage: 'sql', + }), + ).toEqual({ + where: 'ResourceAttributes.rum.sessionId:*', + whereLanguage: 'lucene', + }); + }); + + it('defaults the resource attributes column', () => { + expect( + buildSessionsSearchCondition({ + ...base, + resourceAttributesExpression: undefined, + where: undefined, + whereLanguage: undefined, + }).where, + ).toBe('ResourceAttributes.rum.sessionId:*'); + }); +}); + +describe('hasSessionsSearch', () => { + it.each<[string | undefined, Filter[] | undefined, boolean]>([ + ['', [], false], + [undefined, undefined, false], + ['foo', [], true], + [' ', [], false], + ['', [{ type: 'sql', condition: 'a = 1' }], true], + ])('where=%p filters=%p -> %p', (where, filters, expected) => { + expect(hasSessionsSearch(where, filters)).toBe(expected); }); }); diff --git a/packages/app/src/components/ActiveFilterPills.tsx b/packages/app/src/components/ActiveFilterPills.tsx index 86d8234951..c7416465a3 100644 --- a/packages/app/src/components/ActiveFilterPills.tsx +++ b/packages/app/src/components/ActiveFilterPills.tsx @@ -1,5 +1,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; +import type { + BuilderChartConfigWithDateRange, + Filter, +} from '@hyperdx/common-utils/dist/types'; import { Flex, FlexProps, Text } from '@mantine/core'; import { FilterPill, FilterValueEditor } from '@/components/FilterPill'; @@ -77,6 +80,7 @@ function SearchFilterPill({ isInvalid, invalidReason, chartConfig, + pickerFilters, onRemove, onTogglePolarity, onReplaceValue, @@ -85,6 +89,7 @@ function SearchFilterPill({ isInvalid?: boolean; invalidReason?: string; chartConfig: BuilderChartConfigWithDateRange; + pickerFilters?: Filter[]; onRemove: () => void; onTogglePolarity: () => void; onReplaceValue: (value: string) => void; @@ -102,8 +107,8 @@ function SearchFilterPill({ // list all of the field's values in range, like the sidebar facet list's // default "Show All Values" behavior. const valueChartConfig = useMemo( - () => ({ ...chartConfig, where: '', filters: [] }), - [chartConfig], + () => ({ ...chartConfig, where: '', filters: pickerFilters ?? [] }), + [chartConfig, pickerFilters], ); const { data: keyValues, isFetching: isFetchingValues } = useGetKeyValues( @@ -152,6 +157,7 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({ invalidFields, invalidFieldReason, chartConfig, + pickerFilters, dateTimeColumns = EMPTY_DATE_TIME_COLUMNS, ...flexProps }: { @@ -179,6 +185,8 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({ * in-pill value picker can list the field's values. */ chartConfig: BuilderChartConfigWithDateRange; + /** Filters the in-pill value picker keeps when it clears where + filters. */ + pickerFilters?: Filter[]; } & FlexProps) { const { filters, @@ -282,6 +290,7 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({ isInvalid ? invalidFieldReason?.(pill.field) : undefined } chartConfig={chartConfig} + pickerFilters={pickerFilters} onRemove={() => handleRemove(pill)} onTogglePolarity={() => handleTogglePolarity(pill)} onReplaceValue={value => handleReplaceValue(pill, value)} diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 4ac0ea29e7..3c05b331b0 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -1108,29 +1108,35 @@ 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; + hideAnalysisMode?: boolean; + // Ignore the "show all values" setting; "all" mode drops chartConfig.where, + // which callers may rely on to scope facets. + forceExactFacetMode?: boolean; } & FilterStateHook) => { const setFilterValue = useCallback( ( @@ -1228,7 +1234,7 @@ const DBSearchPageFiltersComponent = ({ chartConfig, sourceId: sourceId ?? null, dateRange, - mode: showAllValues ? 'all' : 'exact', + mode: forceExactFacetMode || !showAllValues ? 'exact' : 'all', filterState, showMoreFields, }); @@ -1670,10 +1676,15 @@ const DBSearchPageFiltersComponent = ({ }} > - - - Analysis Mode - + + {!hideAnalysisMode && ( + + Analysis Mode + + )} {showRefreshButton && ( {onCollapse && ( @@ -1711,31 +1723,33 @@ const DBSearchPageFiltersComponent = ({ )} - - 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 && ( void; @@ -52,6 +53,7 @@ export function FilterSettingsPanel({ onResetSharedFilters: VoidFunction; showAllValues: boolean; onShowAllValuesChange: (show: boolean) => void; + hideShowAllValues?: boolean; }) { const showResetSection = hasPersonalPins || hasSharedPins; @@ -99,24 +101,26 @@ export function FilterSettingsPanel({ checked={showFilterCounts} onChange={e => onShowFilterCountsChange(e.currentTarget.checked)} /> - - onShowAllValuesChange(e.currentTarget.checked)} - /> - + {!hideShowAllValues && ( + + onShowAllValuesChange(e.currentTarget.checked)} + /> + + )} {showResetSection && ( <> diff --git a/packages/app/src/components/SessionFilters.tsx b/packages/app/src/components/SessionFilters.tsx new file mode 100644 index 0000000000..9c36706d9b --- /dev/null +++ b/packages/app/src/components/SessionFilters.tsx @@ -0,0 +1,39 @@ +import { ComponentProps } from 'react'; + +import { DBSearchPageFilters } from './DBSearchPageFilters'; + +// Props the sessions page controls. The analysis-mode toggles, delta view, and +// live-tail behavior don't apply to sessions, so they're baked in here instead +// of being threaded through the page. +type SessionFiltersProps = Omit< + ComponentProps, + | '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__/ActiveFilterPills.test.tsx b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx index 8829c9ec1f..11222f8442 100644 --- a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx +++ b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx @@ -511,6 +511,30 @@ describe('ActiveFilterPills', () => { expect(passedConfig.filters).toEqual([]); }); + it('keeps pickerFilters on the picker query', () => { + const pickerFilters = [ + { type: 'lucene', condition: 'ResourceAttributes.rum.sessionId:*' }, + ] as const; + const searchFilters = makeSearchFilters({ + status: { + included: new Set(['200']), + excluded: new Set(), + }, + }); + renderWithMantine( + , + ); + + const { chartConfig: passedConfig } = + mockedUseGetKeyValues.mock.lastCall[0]; + expect(passedConfig.where).toBe(''); + expect(passedConfig.filters).toEqual(pickerFilters); + }); + it('shows a value picker populated from useGetKeyValues', async () => { jest.useRealTimers(); mockedUseGetKeyValues.mockReturnValue({ 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..4c96fd98ea --- /dev/null +++ b/packages/app/src/components/__tests__/SessionFilters.test.tsx @@ -0,0 +1,77 @@ +import { ComponentProps } from 'react'; +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((_props: Record) => null); +jest.mock('@/components/DBSearchPageFilters', () => ({ + __esModule: true, + DBSearchPageFilters: (props: Record) => + dbSearchPageFilters(props), +})); + +function getForwardedProps() { + expect(dbSearchPageFilters).toHaveBeenCalledTimes(1); + return dbSearchPageFilters.mock.calls[0][0]; +} + +describe('SessionFilters', () => { + beforeEach(() => { + dbSearchPageFilters.mockClear(); + }); + + const baseProps: ComponentProps = { + chartConfig: { + from: { databaseName: 'otel', tableName: 'otel_traces' }, + connection: 'conn', + select: '', + where: '', + timestampValueExpression: 'Timestamp', + dateRange: [new Date(0), new Date(1000)], + }, + sourceId: 'trace-source', + filters: {}, + setFilters: jest.fn(), + setFilterValue: jest.fn(), + setOnlyFilters: jest.fn(), + replaceFilterValue: jest.fn(), + setFilterRange: jest.fn(), + clearFilter: jest.fn(), + clearAllFilters: jest.fn(), + retainFiltersByColumns: jest.fn(() => []), + }; + + 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', () => { + const callerFlags = { isLive: true, hideAnalysisMode: false }; + 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/searchFilters.tsx b/packages/app/src/searchFilters.tsx index ac193b7f26..5448c7a699 100644 --- a/packages/app/src/searchFilters.tsx +++ b/packages/app/src/searchFilters.tsx @@ -78,6 +78,12 @@ export const areFiltersEqual = (a: FilterState, b: FilterState) => { // filtersToQuery; re-export so existing `@/searchFilters` importers keep working. export { parseQuery }; +export function formatDroppedFiltersMessage(count: number): string { + const noun = count === 1 ? 'filter' : 'filters'; + const verb = count === 1 ? 'was' : 'were'; + return `${count} ${noun} didn't apply to this source and ${verb} removed.`; +} + export const useSearchPageFilterState = ({ searchQuery = [], onFilterChange, diff --git a/packages/app/src/sessions.ts b/packages/app/src/sessions.ts index 825e2fc036..8d1c3c594d 100644 --- a/packages/app/src/sessions.ts +++ b/packages/app/src/sessions.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import produce from 'immer'; +import { z } from 'zod'; import type { ResponseJSON } from '@hyperdx/common-utils/dist/clickhouse'; import { chSql, @@ -12,6 +13,7 @@ import { filtersToQuery } from '@hyperdx/common-utils/dist/filters'; import { DateRange, Filter, + FilterSchema, pickSampleWeightExpressionProps, SearchCondition, SearchConditionLanguage, @@ -22,6 +24,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import { useQueryAttribution } from '@/queryAttribution'; import { usePrevious } from '@/utils'; +import { parseAsJsonEncoded } from '@/utils/queryParsers'; import useFieldExpressionGenerator, { FieldExpressionGenerator, @@ -117,22 +120,49 @@ export async function fetchServiceScopeFilters({ } } -export function buildSessionsQueryFilters( - serviceScopeFilters: Filter[], - where: SearchCondition | undefined, - whereLanguage: SearchConditionLanguage | undefined, -): Filter[] { - return [ - ...serviceScopeFilters, - ...(where - ? [ - { - type: whereLanguage === 'sql' ? 'sql' : 'lucene', - condition: where, - } satisfies Filter, - ] - : []), - ]; +export const sessionFiltersParser = parseAsJsonEncoded(v => + z.array(FilterSchema).parse(v), +).withDefault([]); + +export function hasSessionsSearch( + where: string | undefined, + filters: Filter[] | undefined, +): boolean { + return !!where?.trim() || (filters?.length ?? 0) > 0; +} + +export function buildSessionsScope( + resourceAttributesExpression: string | undefined, +): string { + return `${resourceAttributesExpression ?? 'ResourceAttributes'}.rum.sessionId:*`; +} + +// Combines the RUM session scope with the user's query into one `where`, so +// callers that replace `filters` (e.g. facet "load more") keep both. +export function buildSessionsSearchCondition({ + resourceAttributesExpression, + sessionIdExpression, + where, + whereLanguage, +}: { + resourceAttributesExpression: string | undefined; + sessionIdExpression: string; + where: string | undefined; + whereLanguage: SearchConditionLanguage | undefined; +}): { where: string; whereLanguage: 'sql' | 'lucene' } { + const query = where?.trim(); + if (query && whereLanguage === 'sql') { + return { + // The newline keeps a trailing `--` comment from swallowing the `)`. + where: `notEmpty(${sessionIdExpression}) AND (${query}\n)`, + whereLanguage: 'sql', + }; + } + const scope = buildSessionsScope(resourceAttributesExpression); + return { + where: query ? `${scope} AND (${query})` : scope, + whereLanguage: 'lucene', + }; } export function useSessions( @@ -142,12 +172,14 @@ export function useSessions( dateRange, where, whereLanguage, + filters, }: { traceSource?: TTraceSource; sessionSource?: TSessionSource; dateRange: DateRange['dateRange']; where?: SearchCondition; whereLanguage?: SearchConditionLanguage; + filters?: Filter[]; }, options?: Omit, 'queryKey'>, ) { @@ -175,6 +207,7 @@ export function useSessions( dateRange, where, whereLanguage, + filters, ], queryFn: async () => { if ( @@ -198,12 +231,14 @@ export function useSessions( metadata, clickhouseClient, }); - - const sessionsQueryFilters = buildSessionsQueryFilters( - serviceScopeFilters, + const searchCondition = buildSessionsSearchCondition({ + resourceAttributesExpression: traceSource.resourceAttributesExpression, + sessionIdExpression: traceSessionIdExpression, where, whereLanguage, - ); + }); + const hasSearchQuery = hasSessionsSearch(where, filters); + const traceQueryFilters = [...serviceScopeFilters, ...(filters ?? [])]; const [ sessionsQuery, @@ -263,11 +298,8 @@ export function useSessions( ], from: traceSource.from, dateRange, - where: `${traceSource.resourceAttributesExpression}.rum.sessionId:*`, - whereLanguage: 'lucene', - ...(sessionsQueryFilters.length > 0 && { - filters: sessionsQueryFilters, - }), + ...searchCondition, + ...(traceQueryFilters.length > 0 && { filters: traceQueryFilters }), timestampValueExpression: traceSource.timestampValueExpression, implicitColumnExpression: traceSource.implicitColumnExpression, useTextIndexForImplicitColumn: @@ -337,16 +369,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}) @@ -358,7 +391,7 @@ export function useSessions( SELECT sessionIdsWithUserActivity.sessionId FROM sessionIdsWithUserActivity ) ` - : chSql` + : chSql` ${sessionsCTE} SELECT * FROM ${SESSIONS_CTE_NAME}