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',
+ });
+ });
+});