(null);
+ const wasFetchingRef = useRef(false);
+ // Whether the in-flight cycle began as a live-tail refresh, captured on the
+ // rising edge so a mid-cycle isLive flip can't change how it's treated.
+ const cycleIsLiveRef = useRef(false);
+
+ // Snapshot latency_ms and source_id together so a later sourceId change does
+ // not cause the emission effect to re-fire with stale latency data. `emit`
+ // records whether this cycle should be reported to telemetry (user-initiated
+ // searches only); latency is still surfaced for display in every case.
+ const [completedSearch, setCompletedSearch] = useState<{
+ latency_ms: number;
+ source_id: string;
+ emit: boolean;
+ } | null>(null);
+
+ useEffect(() => {
+ if (isAnyQueryFetching) {
+ // Start the timer once per fetch cycle (for live tail too — we display
+ // its elapsed time, we just don't emit telemetry for it).
+ if (!wasFetchingRef.current) {
+ searchStartTimeRef.current = performance.now();
+ cycleIsLiveRef.current = isLive;
+ // Only blank the displayed timer for user-initiated searches. During
+ // live tail we keep the previous value so it doesn't flicker between
+ // background refreshes.
+ if (!isLive) {
+ setCompletedSearch(null);
+ }
+ }
+ wasFetchingRef.current = true;
+ } else {
+ if (searchStartTimeRef.current != null) {
+ setCompletedSearch({
+ latency_ms: Math.round(
+ performance.now() - searchStartTimeRef.current,
+ ),
+ source_id: sourceId ?? '',
+ emit: !cycleIsLiveRef.current,
+ });
+ searchStartTimeRef.current = null;
+ }
+ wasFetchingRef.current = false;
+ }
+ }, [isAnyQueryFetching, isLive, sourceId]);
+
+ // completedSearch is the only dep here — sourceId was snapshotted at
+ // completion time so changing source after a finished search does not
+ // re-emit the previous run's latency against the new source. Live-tail
+ // cycles are recorded for display but flagged emit=false so they never flood
+ // telemetry.
+ useEffect(() => {
+ if (completedSearch == null || !completedSearch.emit) return;
+ HyperDX.addAction('search executed', {
+ latency_ms: completedSearch.latency_ms,
+ source_id: completedSearch.source_id,
+ });
+ }, [completedSearch]);
+
+ return { searchElapsedMs: completedSearch?.latency_ms ?? null };
+}
+
export function DBSearchPage() {
const brandName = useBrandDisplayName();
// Next router is laggy behind window.location, which causes race
@@ -1339,6 +1482,12 @@ export function DBSearchPage() {
queryKey: [QUERY_KEY_PREFIX],
}) > 0;
+ const { searchElapsedMs } = useSearchTelemetry({
+ isAnyQueryFetching,
+ isLive: isLive ?? false,
+ sourceId: chartConfig?.source ?? null,
+ });
+
const isTabVisible = useDocumentVisibility();
// State for collapsing all expanded rows when resuming live tail
@@ -2193,7 +2342,11 @@ export function DBSearchPage() {
...chartConfig,
dateRange: searchedTimeRange,
}}
+ sqlConfig={histogramTimeChartConfig ?? undefined}
enabled={isReady}
+ searchElapsedMs={searchElapsedMs}
+ isSearching={isAnyQueryFetching}
+ isLiveTail={isLive ?? false}
/>
@@ -2287,7 +2440,11 @@ export function DBSearchPage() {
...chartConfig,
dateRange: searchedTimeRange,
}}
+ sqlConfig={histogramTimeChartConfig ?? undefined}
enabled={isReady}
+ searchElapsedMs={searchElapsedMs}
+ isSearching={isAnyQueryFetching}
+ isLiveTail={isLive ?? false}
/>
diff --git a/packages/app/src/__tests__/useSearchTelemetry.test.tsx b/packages/app/src/__tests__/useSearchTelemetry.test.tsx
new file mode 100644
index 0000000000..15892b5b67
--- /dev/null
+++ b/packages/app/src/__tests__/useSearchTelemetry.test.tsx
@@ -0,0 +1,558 @@
+import React from 'react';
+import { act, fireEvent, renderHook, screen } from '@testing-library/react';
+
+import { SearchNumRows, 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) },
+}));
+
+let mockExplainData: unknown[] | undefined = undefined;
+let mockExplainIsLoading = false;
+let mockExplainError: Error | null = null;
+
+jest.mock('@/hooks/useExplainQuery', () => ({
+ useExplainQuery: () => ({
+ data: mockExplainData,
+ isLoading: mockExplainIsLoading,
+ error: mockExplainError,
+ }),
+}));
+
+// Capture the last rendered config so we can assert on it without fighting portals
+let lastSQLPreviewConfig: unknown = undefined;
+jest.mock('../components/ChartSQLPreview', () => ({
+ __esModule: true,
+ default: (props: { config: unknown }) => {
+ lastSQLPreviewConfig = props.config;
+ return ;
+ },
+ SQLPreview: () => ,
+}));
+
+// Render Mantine Modal content inline (no portal / no transition) so jsdom can see it
+jest.mock('@mantine/core', () => {
+ const actual = jest.requireActual('@mantine/core');
+ return {
+ ...actual,
+ Modal: ({ opened, onClose, title, children }: any) =>
+ opened ? (
+
+ {title}
+
+ {children}
+
+ ) : null,
+ };
+});
+
+// ---------------------------------------------------------------------------
+// useSearchTelemetry
+// ---------------------------------------------------------------------------
+
+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, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: true,
+ isLive: false,
+ sourceId: 'my-source',
+ },
+ },
+ );
+
+ await act(async () => {
+ rerender({
+ isAnyQueryFetching: false,
+ isLive: 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('does NOT re-emit when sourceId changes after a completed search (P0 fix)', async () => {
+ const { rerender } = renderHook(
+ ({ isAnyQueryFetching, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: true,
+ isLive: false,
+ sourceId: 'src-a',
+ },
+ },
+ );
+
+ // Complete the search
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src-a' });
+ });
+
+ expect(mockAddAction).toHaveBeenCalledTimes(1);
+ expect(mockAddAction).toHaveBeenCalledWith('search executed', {
+ latency_ms: expect.any(Number),
+ source_id: 'src-a',
+ });
+
+ // Change sourceId after search is done — must NOT fire again
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src-b' });
+ });
+
+ expect(mockAddAction).toHaveBeenCalledTimes(1); // still just 1
+ });
+
+ it('does NOT emit during live-tail ticks (P0 fix)', async () => {
+ const { rerender } = renderHook(
+ ({ isAnyQueryFetching, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: true,
+ isLive: true,
+ sourceId: 'src',
+ },
+ },
+ );
+
+ // Live-tail cycle completes
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: true, sourceId: 'src' });
+ });
+
+ expect(mockAddAction).not.toHaveBeenCalled();
+ });
+
+ it('measures elapsed time during live tail for display but does NOT emit', async () => {
+ const { result, rerender } = renderHook(
+ ({ isAnyQueryFetching, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: true,
+ isLive: true,
+ sourceId: 'src',
+ },
+ },
+ );
+
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: true, sourceId: 'src' });
+ });
+
+ // Latency is surfaced for display ...
+ expect(result.current.searchElapsedMs).toBeGreaterThanOrEqual(0);
+ // ... but live-tail cycles are never reported to telemetry.
+ expect(mockAddAction).not.toHaveBeenCalled();
+ });
+
+ it('keeps the previous elapsed value when a live-tail poll starts (no blink)', async () => {
+ const { result, rerender } = renderHook(
+ ({ isAnyQueryFetching, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: true,
+ isLive: true,
+ sourceId: 'src',
+ },
+ },
+ );
+
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: true, sourceId: 'src' });
+ });
+ const firstElapsed = result.current.searchElapsedMs;
+ expect(firstElapsed).toBeGreaterThanOrEqual(0);
+
+ // Next poll begins — the displayed value must NOT reset to null, otherwise
+ // the timer would flicker between live-tail refreshes.
+ await act(async () => {
+ rerender({ isAnyQueryFetching: true, isLive: true, sourceId: 'src' });
+ });
+ expect(result.current.searchElapsedMs).toBe(firstElapsed);
+ });
+
+ it('does NOT re-stamp the start clock if isAnyQueryFetching is already true (P2 fix)', async () => {
+ const { result, rerender } = renderHook(
+ ({ isAnyQueryFetching, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: true,
+ isLive: false,
+ sourceId: 'src',
+ },
+ },
+ );
+
+ // Queries temporarily dip to 0 then back up before true completion
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src' });
+ });
+ const firstElapsed = result.current.searchElapsedMs;
+
+ await act(async () => {
+ rerender({ isAnyQueryFetching: true, isLive: false, sourceId: 'src' });
+ });
+ // completedSearch is reset to null while re-fetching
+ expect(result.current.searchElapsedMs).toBeNull();
+
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src' });
+ });
+
+ // Two emits (one per false transition) — the clock was re-anchored
+ // only on the first genuine false→true, so elapsed on both are valid numbers
+ expect(mockAddAction).toHaveBeenCalledTimes(2);
+ expect(firstElapsed).toBeGreaterThanOrEqual(0);
+ expect(result.current.searchElapsedMs).toBeGreaterThanOrEqual(0);
+ });
+
+ it('resets elapsed time and does not emit when a new search starts', async () => {
+ const { result, rerender } = renderHook(
+ ({ isAnyQueryFetching, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: false,
+ isLive: false,
+ sourceId: 'src',
+ },
+ },
+ );
+
+ await act(async () => {
+ rerender({ isAnyQueryFetching: true, isLive: false, 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, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: true,
+ isLive: false,
+ sourceId: null as string | null,
+ },
+ },
+ );
+
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: 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 () => {
+ const { rerender } = renderHook(
+ ({ isAnyQueryFetching, isLive, sourceId }) =>
+ useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }),
+ {
+ initialProps: {
+ isAnyQueryFetching: false,
+ isLive: false,
+ sourceId: 'src',
+ },
+ },
+ );
+
+ await act(async () => {
+ rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src' });
+ });
+
+ expect(mockAddAction).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// SearchNumRows
+// ---------------------------------------------------------------------------
+
+const baseConfig = {
+ source: 'test-source',
+ dateRange: { from: new Date('2024-01-01'), to: new Date('2024-01-02') },
+} as any;
+
+describe('SearchNumRows', () => {
+ beforeEach(() => {
+ mockExplainData = undefined;
+ mockExplainIsLoading = false;
+ mockExplainError = null;
+ });
+
+ it('renders nothing when enabled=false', () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.queryByText(/Scanned Rows/)).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /show generated sql/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows loading state while explain query is in flight', () => {
+ mockExplainIsLoading = true;
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText('Scanned Rows ...')).toBeInTheDocument();
+ });
+
+ it('keeps the SQL icon visible while explain is loading (no flicker on poll)', () => {
+ mockExplainIsLoading = true;
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.getByRole('button', { name: /show generated sql/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders empty text on error', () => {
+ mockExplainError = new Error('fail');
+ renderWithMantine(
+ ,
+ );
+ // No row count text shown on error
+ expect(screen.queryByText(/Scanned Rows:/)).not.toBeInTheDocument();
+ });
+
+ it('shows formatted scanned row count when data is present', () => {
+ mockExplainData = [{ rows: 1482447 }];
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText('Scanned Rows: 1,482,447')).toBeInTheDocument();
+ });
+
+ it('shows "Elapsed Time: ..." while searching', () => {
+ mockExplainData = [{ rows: 100 }];
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText('Elapsed Time: ...')).toBeInTheDocument();
+ });
+
+ it('does not flash the "..." elapsed loading state during live tail', () => {
+ mockExplainData = [{ rows: 100 }];
+ renderWithMantine(
+ ,
+ );
+ // While live and fetching, keep showing the last value, not the "..."
+ expect(screen.queryByText('Elapsed Time: ...')).not.toBeInTheDocument();
+ expect(screen.getByText(/Elapsed Time:/)).toBeInTheDocument();
+ });
+
+ it('hides elapsed during live tail before the first measurement', () => {
+ mockExplainData = [{ rows: 100 }];
+ renderWithMantine(
+ ,
+ );
+ expect(screen.queryByText(/Elapsed Time:/)).not.toBeInTheDocument();
+ });
+
+ it('shows formatted elapsed time after search completes', () => {
+ mockExplainData = [{ rows: 100 }];
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText(/Elapsed Time:/)).toBeInTheDocument();
+ expect(screen.queryByText('Elapsed Time: ...')).not.toBeInTheDocument();
+ });
+
+ it('hides elapsed time section when there is no elapsed value and not searching', () => {
+ mockExplainData = [{ rows: 100 }];
+ renderWithMantine(
+ ,
+ );
+ expect(screen.queryByText(/Elapsed Time:/)).not.toBeInTheDocument();
+ });
+
+ it('still shows elapsed time and SQL icon when the explain query fails', () => {
+ mockExplainError = new Error('explain timed out');
+ renderWithMantine(
+ ,
+ );
+ // Elapsed time is independent of the explain query and must remain visible
+ expect(screen.getByText(/Elapsed Time:/)).toBeInTheDocument();
+ // SQL preview is also independent of explain data
+ expect(
+ screen.getByRole('button', { name: /show generated sql/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('shows elapsed time while searching even when explain has no data yet', () => {
+ mockExplainData = undefined;
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText('Elapsed Time: ...')).toBeInTheDocument();
+ });
+
+ it('opens the modal when the SQL button is clicked', async () => {
+ mockExplainData = [{ rows: 50 }];
+ renderWithMantine(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: /show generated sql/i }),
+ );
+ });
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByTestId('chart-sql-preview')).toBeInTheDocument();
+ });
+
+ it('uses sqlConfig in the modal when provided — title says "Timeline" and content uses sqlConfig', async () => {
+ mockExplainData = [{ rows: 50 }];
+ const sqlConfig = { ...baseConfig, source: 'timeline-source' } as any;
+
+ renderWithMantine(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: /show generated sql/i }),
+ );
+ });
+
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Generated SQL (Timeline)',
+ );
+ expect(lastSQLPreviewConfig).toEqual(sqlConfig);
+ });
+
+ it('uses plain "Generated SQL" title and rows config when no sqlConfig is passed', async () => {
+ mockExplainData = [{ rows: 50 }];
+ const rowsConfig = { ...baseConfig, source: 'rows-source' } as any;
+
+ renderWithMantine(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: /show generated sql/i }),
+ );
+ });
+
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Generated SQL',
+ );
+ expect(
+ screen.queryByText('Generated SQL (Timeline)'),
+ ).not.toBeInTheDocument();
+ expect(lastSQLPreviewConfig).toEqual(rowsConfig);
+ });
+});
diff --git a/packages/app/src/components/ChartSQLPreview.tsx b/packages/app/src/components/ChartSQLPreview.tsx
index a07333cf8f..86663577c9 100644
--- a/packages/app/src/components/ChartSQLPreview.tsx
+++ b/packages/app/src/components/ChartSQLPreview.tsx
@@ -4,6 +4,7 @@ import { format } from '@hyperdx/common-utils/dist/sqlFormatter';
import { ChartConfigWithOptDateRange } from '@hyperdx/common-utils/dist/types';
import { Button, Paper, Text, useMantineColorScheme } from '@mantine/core';
import { IconCheck, IconCopy } from '@tabler/icons-react';
+import { keepPreviousData } from '@tanstack/react-query';
import CodeMirror, { EditorView } from '@uiw/react-codemirror';
import { useRenderedSqlChartConfig } from '@/hooks/useChartConfig';
@@ -97,7 +98,12 @@ export default function ChartSQLPreview({
config: ChartConfigWithOptDateRange;
enableCopy?: boolean;
}) {
- const { data, error, isLoading } = useRenderedSqlChartConfig(config);
+ const { data, error, isLoading } = useRenderedSqlChartConfig(config, {
+ // Keep the previously rendered SQL visible while a new one is generated so
+ // the preview doesn't flicker when the config changes (e.g. live tail
+ // refreshes the dateRange each poll).
+ placeholderData: keepPreviousData,
+ });
return (
- {isLoading ? (
+ {data ? (
+ // Prefer showing the (possibly placeholder) SQL over the loading state
+ // so the preview doesn't flicker when the query re-runs — e.g. live tail
+ // refreshes the dateRange each poll, and builder configs briefly report
+ // isLoading via the MV-optimization lookup.
+
+ ) : isLoading ? (
Loading query preview...
diff --git a/packages/app/src/hooks/useChartConfig.tsx b/packages/app/src/hooks/useChartConfig.tsx
index ac2a78c82c..e4393dc780 100644
--- a/packages/app/src/hooks/useChartConfig.tsx
+++ b/packages/app/src/hooks/useChartConfig.tsx
@@ -460,7 +460,7 @@ export function useQueriedChartConfig(
export function useRenderedSqlChartConfig(
config: ChartConfigWithOptDateRange,
- options?: UseQueryOptions,
+ options?: Partial>,
) {
const { enabled = true } = options ?? {};
diff --git a/packages/app/src/hooks/useExplainQuery.tsx b/packages/app/src/hooks/useExplainQuery.tsx
index 0ead671e6f..8e1e5d078d 100644
--- a/packages/app/src/hooks/useExplainQuery.tsx
+++ b/packages/app/src/hooks/useExplainQuery.tsx
@@ -11,6 +11,8 @@ export function useExplainQuery(
_config: ChartConfigWithOptDateRange,
options?: Omit, 'queryKey' | 'queryFn'>,
) {
+ const { enabled, ...restOptions } = options ?? {};
+
const config = {
..._config,
with: undefined,
@@ -42,7 +44,7 @@ export function useExplainQuery(
},
retry: false,
staleTime: 1000 * 60,
- enabled: !isSourceLoading,
- ...options,
+ enabled: enabled && !isSourceLoading,
+ ...restOptions,
});
}