diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 2862d3b857..79ad1f8476 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -25,6 +25,7 @@ import { import { useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; +import HyperDX from '@hyperdx/browser'; import { ClickHouseQueryError, ColumnMeta, @@ -72,6 +73,7 @@ import { notifications } from '@mantine/notifications'; import { IconArrowBarToRight, IconBolt, + IconCode, IconPlayerPlay, IconPlus, IconStack2, @@ -120,9 +122,14 @@ import { parseTimeQuery, useNewTimeQuery, } from '@/timeQuery'; -import { QUERY_LOCAL_STORAGE, useLocalStorage, usePrevious } from '@/utils'; +import { + formatDurationMs, + QUERY_LOCAL_STORAGE, + useLocalStorage, + usePrevious, +} from '@/utils'; -import { SQLPreview } from './components/ChartSQLPreview'; +import ChartSQLPreview, { SQLPreview } from './components/ChartSQLPreview'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; @@ -328,28 +335,74 @@ function SearchResultsCountGroup({ function SearchNumRows({ config, + sqlConfig, enabled, + searchElapsedMs, + isSearching, }: { config: ChartConfigWithDateRange; + sqlConfig?: ChartConfigWithDateRange; enabled: boolean; + searchElapsedMs: number | null; + isSearching: boolean; }) { - const { data, isLoading, error } = useExplainQuery(config, { - enabled, - }); + const [statsOpened, { open: openStats, close: closeStats }] = + useDisclosure(false); + const { data, isLoading, error } = useExplainQuery(config, { enabled }); if (!enabled) { return null; } - const numRows = data?.[0]?.rows; + const explainRow = data?.[0]; + const numRows = explainRow?.rows; + const hasData = !isLoading && !error && numRows != null; + return ( - - {isLoading - ? 'Scanned Rows ...' - : error || !numRows - ? '' - : `Scanned Rows: ${Number.parseInt(numRows)?.toLocaleString()}`} - + <> + + + + + + {isLoading + ? 'Scanned Rows ...' + : error || numRows == null + ? '' + : `Scanned Rows: ${Number(numRows).toLocaleString()}`} + + {hasData && (isSearching || searchElapsedMs != null) && ( + <> + + | + + + {isSearching + ? 'Elapsed Time: ...' + : `Elapsed Time: ${formatDurationMs(searchElapsedMs!)}`} + + + )} + {hasData && ( + + + + + + )} + + ); } @@ -806,6 +859,39 @@ const queryStateMap = { orderBy: parseAsStringEncoded, }; +export function useSearchTelemetry({ + isAnyQueryFetching, + sourceId, +}: { + isAnyQueryFetching: boolean; + sourceId: string | null; +}) { + const searchStartTimeRef = useRef(null); + const [searchElapsedMs, setSearchElapsedMs] = useState(null); + + useEffect(() => { + if (isAnyQueryFetching) { + searchStartTimeRef.current = performance.now(); + setSearchElapsedMs(null); + } else if (searchStartTimeRef.current != null) { + setSearchElapsedMs( + Math.round(performance.now() - searchStartTimeRef.current), + ); + searchStartTimeRef.current = null; + } + }, [isAnyQueryFetching]); + + useEffect(() => { + if (searchElapsedMs == null) return; + HyperDX.addAction('search executed', { + latency_ms: searchElapsedMs, + source_id: sourceId ?? '', + }); + }, [searchElapsedMs, sourceId]); + + return { searchElapsedMs }; +} + export function DBSearchPage() { const brandName = useBrandDisplayName(); // Next router is laggy behind window.location, which causes race @@ -1298,6 +1384,11 @@ export function DBSearchPage() { queryKey: [QUERY_KEY_PREFIX], }) > 0; + const { searchElapsedMs } = useSearchTelemetry({ + isAnyQueryFetching, + sourceId: chartConfig?.source ?? null, + }); + const isTabVisible = useDocumentVisibility(); // State for collapsing all expanded rows when resuming live tail @@ -2147,7 +2238,10 @@ export function DBSearchPage() { ...chartConfig, dateRange: searchedTimeRange, }} + sqlConfig={histogramTimeChartConfig ?? undefined} enabled={isReady} + searchElapsedMs={searchElapsedMs} + isSearching={isAnyQueryFetching} /> @@ -2231,7 +2325,10 @@ export function DBSearchPage() { ...chartConfig, dateRange: searchedTimeRange, }} + sqlConfig={histogramTimeChartConfig ?? undefined} enabled={isReady} + searchElapsedMs={searchElapsedMs} + isSearching={isAnyQueryFetching} /> diff --git a/packages/app/src/__tests__/useSearchTelemetry.test.tsx b/packages/app/src/__tests__/useSearchTelemetry.test.tsx new file mode 100644 index 0000000000..b4385ee165 --- /dev/null +++ b/packages/app/src/__tests__/useSearchTelemetry.test.tsx @@ -0,0 +1,111 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useSearchTelemetry } from '../DBSearchPage'; + +jest.mock('@/layout', () => ({ + withAppNav: (component: unknown) => component, +})); + +const mockAddAction = jest.fn(); +jest.mock('@hyperdx/browser', () => ({ + __esModule: true, + default: { addAction: (...args: unknown[]) => mockAddAction(...args) }, +})); + +describe('useSearchTelemetry', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('emits "search executed" action with latency_ms and source_id when a search completes', async () => { + const { result, rerender } = renderHook( + ({ isAnyQueryFetching, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, sourceId }), + { initialProps: { isAnyQueryFetching: true, sourceId: 'my-source' } }, + ); + + // Simulate search completing + await act(async () => { + rerender({ isAnyQueryFetching: false, sourceId: 'my-source' }); + }); + + expect(result.current.searchElapsedMs).toBeGreaterThanOrEqual(0); + expect(mockAddAction).toHaveBeenCalledTimes(1); + expect(mockAddAction).toHaveBeenCalledWith('search executed', { + latency_ms: expect.any(Number), + source_id: 'my-source', + }); + }); + + it('resets elapsed time and does not emit when a new search starts', async () => { + const { result, rerender } = renderHook( + ({ isAnyQueryFetching, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, sourceId }), + { initialProps: { isAnyQueryFetching: false, sourceId: 'src' } }, + ); + + // Start fetching again + await act(async () => { + rerender({ isAnyQueryFetching: true, sourceId: 'src' }); + }); + + expect(result.current.searchElapsedMs).toBeNull(); + expect(mockAddAction).not.toHaveBeenCalled(); + }); + + it('falls back to empty string source_id when sourceId is null', async () => { + const { rerender } = renderHook( + ({ isAnyQueryFetching, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, sourceId }), + { + initialProps: { + isAnyQueryFetching: true, + sourceId: null as string | null, + }, + }, + ); + + await act(async () => { + rerender({ isAnyQueryFetching: false, sourceId: null }); + }); + + expect(mockAddAction).toHaveBeenCalledWith('search executed', { + latency_ms: expect.any(Number), + source_id: '', + }); + }); + + it('does not emit when fetching stops without a prior start', async () => { + // isAnyQueryFetching starts false so no start time is recorded + const { rerender } = renderHook( + ({ isAnyQueryFetching, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, sourceId }), + { initialProps: { isAnyQueryFetching: false, sourceId: 'src' } }, + ); + + await act(async () => { + rerender({ isAnyQueryFetching: false, sourceId: 'src' }); + }); + + expect(mockAddAction).not.toHaveBeenCalled(); + }); + + it('emits once per search cycle even if sourceId changes mid-flight', async () => { + const { rerender } = renderHook( + ({ isAnyQueryFetching, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, sourceId }), + { initialProps: { isAnyQueryFetching: true, sourceId: 'src-a' } }, + ); + + await act(async () => { + rerender({ isAnyQueryFetching: false, sourceId: 'src-b' }); + }); + + // Only one call; sourceId at completion time is used + expect(mockAddAction).toHaveBeenCalledTimes(1); + expect(mockAddAction).toHaveBeenCalledWith('search executed', { + latency_ms: expect.any(Number), + source_id: 'src-b', + }); + }); +});