Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 110 additions & 13 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -72,6 +73,7 @@ import { notifications } from '@mantine/notifications';
import {
IconArrowBarToRight,
IconBolt,
IconCode,
IconPlayerPlay,
IconPlus,
IconStack2,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<Text size="xs">
{isLoading
? 'Scanned Rows ...'
: error || !numRows
? ''
: `Scanned Rows: ${Number.parseInt(numRows)?.toLocaleString()}`}
</Text>
<>
<Modal
opened={statsOpened}
onClose={closeStats}
title="Generated SQL"
size="xl"
>
<ChartSQLPreview config={sqlConfig ?? config} enableCopy />
</Modal>
<Group gap={4} align="center">
<Text size="xs">
{isLoading
? 'Scanned Rows ...'
: error || numRows == null
? ''
: `Scanned Rows: ${Number(numRows).toLocaleString()}`}
</Text>
{hasData && (isSearching || searchElapsedMs != null) && (
<>
<Text size="xs" c="dimmed">
|
</Text>
<Text size="xs">
{isSearching
? 'Elapsed Time: ...'
: `Elapsed Time: ${formatDurationMs(searchElapsedMs!)}`}
</Text>
</>
)}
{hasData && (
<Tooltip label="Show Generated SQL" position="top">
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={openStats}
aria-label="Show Generated SQL"
>
<IconCode size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</>
);
}

Expand Down Expand Up @@ -806,6 +859,39 @@ const queryStateMap = {
orderBy: parseAsStringEncoded,
};

export function useSearchTelemetry({
isAnyQueryFetching,
sourceId,
}: {
isAnyQueryFetching: boolean;
sourceId: string | null;
}) {
const searchStartTimeRef = useRef<number | null>(null);
const [searchElapsedMs, setSearchElapsedMs] = useState<number | null>(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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2147,7 +2238,10 @@ export function DBSearchPage() {
...chartConfig,
dateRange: searchedTimeRange,
}}
sqlConfig={histogramTimeChartConfig ?? undefined}
enabled={isReady}
searchElapsedMs={searchElapsedMs}
isSearching={isAnyQueryFetching}
/>
</Group>
</Box>
Expand Down Expand Up @@ -2231,7 +2325,10 @@ export function DBSearchPage() {
...chartConfig,
dateRange: searchedTimeRange,
}}
sqlConfig={histogramTimeChartConfig ?? undefined}
enabled={isReady}
searchElapsedMs={searchElapsedMs}
isSearching={isAnyQueryFetching}
/>
</Group>
</Group>
Expand Down
111 changes: 111 additions & 0 deletions packages/app/src/__tests__/useSearchTelemetry.test.tsx
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Loading