From 6b062b08c3fecd732e53adc2c496cfe0fc3ed478 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Tue, 14 Apr 2026 22:11:38 +0700 Subject: [PATCH] [HDX-3961] Add direct trace search redirect Allow manually constructed /trace URLs to land in the existing search experience with the trace viewer opened from URL state. This keeps trace deep links user-friendly while reusing the search page for source selection, not-found handling, and trace inspection. --- .changeset/thirty-students-exist.md | 5 + packages/app/pages/trace/[traceId].tsx | 41 ++ packages/app/src/DBSearchPage.tsx | 160 +++++++- .../DBSearchPage.directTrace.test.tsx | 373 ++++++++++++++++++ .../src/__tests__/TraceRedirectPage.test.tsx | 81 ++++ packages/app/src/components/DBTracePanel.tsx | 11 +- .../src/components/DBTraceWaterfallChart.tsx | 48 ++- .../Search/DirectTraceSidePanel.tsx | 148 +++++++ .../__tests__/DirectTraceSidePanel.test.tsx | 123 ++++++ .../__tests__/DBTracePanel.test.tsx | 75 ++++ .../__tests__/DBTraceWaterfallChart.test.tsx | 56 ++- .../src/utils/__tests__/directTrace.test.ts | 45 +++ packages/app/src/utils/directTrace.ts | 29 ++ 13 files changed, 1161 insertions(+), 34 deletions(-) create mode 100644 .changeset/thirty-students-exist.md create mode 100644 packages/app/pages/trace/[traceId].tsx create mode 100644 packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx create mode 100644 packages/app/src/__tests__/TraceRedirectPage.test.tsx create mode 100644 packages/app/src/components/Search/DirectTraceSidePanel.tsx create mode 100644 packages/app/src/components/Search/__tests__/DirectTraceSidePanel.test.tsx create mode 100644 packages/app/src/components/__tests__/DBTracePanel.test.tsx create mode 100644 packages/app/src/utils/__tests__/directTrace.test.ts create mode 100644 packages/app/src/utils/directTrace.ts diff --git a/.changeset/thirty-students-exist.md b/.changeset/thirty-students-exist.md new file mode 100644 index 0000000000..0237fb0b25 --- /dev/null +++ b/.changeset/thirty-students-exist.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Allow manually constructed /trace URLs to land in the existing search experience with the trace viewer opened from URL state. This keeps trace deep links user-friendly while reusing the search page for source selection, not-found handling, and trace inspection. diff --git a/packages/app/pages/trace/[traceId].tsx b/packages/app/pages/trace/[traceId].tsx new file mode 100644 index 0000000000..61a1df7154 --- /dev/null +++ b/packages/app/pages/trace/[traceId].tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { Center, Text } from '@mantine/core'; + +import { withAppNav } from '@/layout'; +import { buildTraceRedirectUrl } from '@/utils/directTrace'; + +export function TraceRedirectPage() { + const { isReady, query, replace } = useRouter(); + const traceIdParam = Array.isArray(query.traceId) + ? query.traceId[0] + : query.traceId; + + useEffect(() => { + if (!isReady) return; + + if (!traceIdParam) { + replace('/search'); + return; + } + + replace( + buildTraceRedirectUrl({ + traceId: traceIdParam, + search: window.location.search, + }), + ); + }, [isReady, replace, traceIdParam]); + + return ( +
+ + Redirecting to search... + +
+ ); +} + +TraceRedirectPage.getLayout = withAppNav; + +export default TraceRedirectPage; diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 204b47ce8d..5d174a8166 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -128,6 +128,7 @@ import { SQLPreview } from './components/ChartSQLPreview'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; +import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; import SourceSchemaPreview from './components/SourceSchemaPreview'; import { getRelativeTimeOptionLabel, @@ -135,6 +136,10 @@ import { } from './components/TimePicker/utils'; import { useTableMetadata } from './hooks/useMetadata'; import { useSqlSuggestions } from './hooks/useSqlSuggestions'; +import { + buildDirectTraceWhereClause, + getDefaultDirectTraceDateRange, +} from './utils/directTrace'; import { parseAsJsonEncoded, parseAsSortingStateString, @@ -804,7 +809,7 @@ const queryStateMap = { orderBy: parseAsStringEncoded, }; -function DBSearchPage() { +export function DBSearchPage() { const brandName = useBrandDisplayName(); // Next router is laggy behind window.location, which causes race // conditions with useQueryStates, so we'll parse it directly @@ -812,6 +817,10 @@ function DBSearchPage() { const savedSearchId = paths.length === 3 ? paths[2] : null; const [searchedConfig, setSearchedConfig] = useQueryStates(queryStateMap); + const [directTraceId, setDirectTraceId] = useQueryState( + 'traceId', + parseAsStringEncoded, + ); const { data: savedSearch } = useSavedSearch( { id: `${savedSearchId}` }, @@ -829,6 +838,14 @@ function DBSearchPage() { id: searchedConfig.source, kinds: [SourceKind.Log, SourceKind.Trace], }); + const directTraceSource = + directTraceId != null && searchedSource?.kind === SourceKind.Trace + ? searchedSource + : undefined; + const chartSourceId = + directTraceId != null && !directTraceSource + ? '' + : (searchedConfig.source ?? ''); const [analysisMode, setAnalysisMode] = useQueryState( 'mode', @@ -886,7 +903,9 @@ function DBSearchPage() { where: searchedConfig.where || '', whereLanguage: searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', - source: searchedConfig.source || (savedSearchId ? '' : defaultSourceId), + source: + searchedConfig.source || + (savedSearchId || directTraceId ? '' : defaultSourceId), filters: searchedConfig.filters ?? [], orderBy: searchedConfig.orderBy ?? '', }, @@ -985,6 +1004,10 @@ function DBSearchPage() { return; } + if (savedSearchId == null && directTraceId != null && !source) { + return; + } + // Landed on a new search - ensure we have a source selected if (savedSearchId == null && defaultSourceId && isSearchConfigEmpty) { setSearchedConfig({ @@ -1003,6 +1026,7 @@ function DBSearchPage() { setSearchedConfig, savedSearchId, defaultSourceId, + directTraceId, sources, ]); @@ -1125,9 +1149,28 @@ function DBSearchPage() { const [saveSearchModalState, setSaveSearchModalState] = useState< 'create' | 'update' | undefined >(undefined); + const chartSearchConfig = useMemo( + () => ({ + select: searchedConfig.select ?? '', + source: chartSourceId, + where: searchedConfig.where ?? '', + whereLanguage: + searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', + filters: searchedConfig.filters ?? [], + orderBy: searchedConfig.orderBy ?? '', + }), + [ + chartSourceId, + searchedConfig.filters, + searchedConfig.orderBy, + searchedConfig.select, + searchedConfig.where, + searchedConfig.whereLanguage, + ], + ); const { data: chartConfig, isLoading: isChartConfigLoading } = - useSearchedConfigToChartConfig(searchedConfig, defaultSearchConfig); + useSearchedConfigToChartConfig(chartSearchConfig, defaultSearchConfig); // query error handling const { hasQueryError, queryError } = useMemo(() => { @@ -1368,17 +1411,67 @@ function DBSearchPage() { const [isAlertModalOpen, { open: openAlertModal, close: closeAlertModal }] = useDisclosure(); + const directTraceRangeAppliedRef = useRef(null); + const directTraceFilterAppliedRef = useRef(null); + + useEffect(() => { + if (!isReady || !directTraceId) { + directTraceRangeAppliedRef.current = null; + return; + } + + const searchParams = new URLSearchParams(window.location.search); + if (searchParams.has('from') && searchParams.has('to')) { + return; + } + + if (directTraceRangeAppliedRef.current === directTraceId) { + return; + } + + directTraceRangeAppliedRef.current = directTraceId; + setIsLive(false); + const [start, end] = getDefaultDirectTraceDateRange(); + onTimeRangeSelect(start, end, null); + }, [directTraceId, isReady, onTimeRangeSelect, setIsLive]); + + useEffect(() => { + if (!directTraceId || !directTraceSource) { + directTraceFilterAppliedRef.current = null; + return; + } + + const nextKey = `${directTraceSource.id}:${directTraceId}`; + if (directTraceFilterAppliedRef.current === nextKey) { + return; + } + + directTraceFilterAppliedRef.current = nextKey; + setIsLive(false); + setSearchedConfig({ + source: directTraceSource.id, + where: buildDirectTraceWhereClause( + directTraceSource.traceIdExpression, + directTraceId, + ), + whereLanguage: 'sql', + filters: [], + }); + }, [directTraceId, directTraceSource, setIsLive, setSearchedConfig]); - // Add this effect to trigger initial search when component mounts useEffect(() => { if (isReady && queryReady && !isChartConfigLoading) { // Only trigger if we haven't searched yet (no time range in URL) const searchParams = new URLSearchParams(window.location.search); - if (!searchParams.has('from') && !searchParams.has('to')) { + if ( + directTraceId == null && + !searchParams.has('from') && + !searchParams.has('to') + ) { onSearch('Live Tail'); } } - }, [isReady, queryReady, isChartConfigLoading, onSearch]); + }, [directTraceId, isReady, queryReady, isChartConfigLoading, onSearch]); const { data: aliasMap } = useAliasMapFromChartConfig(dbSqlRowTableConfig); @@ -1546,6 +1639,52 @@ function DBSearchPage() { }, [setIsLive, setInterval, onTimeRangeSelect], ); + const directTraceFocusDate = useMemo( + () => + new Date( + (searchedTimeRange[0].getTime() + searchedTimeRange[1].getTime()) / 2, + ), + [searchedTimeRange], + ); + + const onDirectTraceSourceChange = useCallback( + (sourceId: string | null) => { + setIsLive(false); + if (sourceId == null) { + directTraceFilterAppliedRef.current = null; + setSearchedConfig({ + source: null, + where: '', + whereLanguage: getStoredLanguage() ?? 'lucene', + filters: [], + }); + return; + } + + const nextSource = sources?.find( + (source): source is Extract => + source.id === sourceId && isTraceSource(source), + ); + if (!nextSource || !directTraceId) { + return; + } + + setSearchedConfig({ + source: nextSource.id, + where: buildDirectTraceWhereClause( + nextSource.traceIdExpression, + directTraceId, + ), + whereLanguage: 'sql', + filters: [], + }); + }, + [directTraceId, setIsLive, setSearchedConfig, sources], + ); + + const closeDirectTraceSidePanel = useCallback(() => { + setDirectTraceId(null); + }, [setDirectTraceId]); const clearSaveSearchModalState = useCallback( () => setSaveSearchModalState(undefined), @@ -1841,6 +1980,15 @@ function DBSearchPage() { savedSearchId={savedSearchId} /> )} + = {}; +let mockSources: any[] = []; +let latestDirectTracePanelProps: Record | null = null; + +jest.mock('@/layout', () => ({ + withAppNav: (component: unknown) => component, +})); + +jest.mock('next/router', () => ({ + __esModule: true, + default: { + push: (...args: unknown[]) => mockRouterPush(...args), + }, +})); + +jest.mock('next/head', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +jest.mock('nuqs', () => ({ + parseAsBoolean: { + withDefault: () => 'parseAsBoolean', + }, + parseAsInteger: { + withDefault: () => 'parseAsInteger', + }, + parseAsString: 'parseAsString', + parseAsStringEnum: () => ({ + withDefault: () => 'parseAsStringEnum', + }), + useQueryState: (key: string) => { + switch (key) { + case 'traceId': + return [mockDirectTraceId, mockSetDirectTraceId]; + case 'mode': + return ['results', mockSetAnalysisMode]; + case 'isLive': + return [true, mockSetIsLive]; + case 'denoise': + return [false, jest.fn()]; + default: + return [null, jest.fn()]; + } + }, + useQueryStates: () => [mockSearchedConfig, mockSetSearchedConfig], +})); + +jest.mock('@/source', () => ({ + getEventBody: () => 'Body', + getFirstTimestampValueExpression: () => 'Timestamp', + useSources: () => ({ + data: mockSources, + }), + useSource: ({ id }: { id?: string | null }) => ({ + data: mockSources.find(source => source.id === id), + isLoading: false, + }), +})); + +jest.mock('@/timeQuery', () => ({ + parseRelativeTimeQuery: () => [new Date(0), new Date(1)], + parseTimeQuery: () => [new Date(0), new Date(1)], + useNewTimeQuery: () => ({ + isReady: true, + searchedTimeRange: [ + new Date('2024-04-01T00:00:00.000Z'), + new Date('2024-04-02T00:00:00.000Z'), + ], + onSearch: mockOnSearch, + onTimeRangeSelect: mockOnTimeRangeSelect, + }), +})); + +jest.mock('@/savedSearch', () => ({ + useCreateSavedSearch: () => ({ mutate: jest.fn() }), + useDeleteSavedSearch: () => ({ mutate: jest.fn() }), + useSavedSearch: () => ({ data: undefined }), + useUpdateSavedSearch: () => ({ mutate: jest.fn() }), +})); + +jest.mock('@/searchFilters', () => ({ + useSearchPageFilterState: () => ({ + filters: [], + whereSuggestions: [], + setFilterValue: jest.fn(), + clearAllFilters: jest.fn(), + }), +})); + +jest.mock('@/hooks/useChartConfig', () => ({ + useAliasMapFromChartConfig: () => ({ data: {} }), +})); + +jest.mock('@/hooks/useExplainQuery', () => ({ + useExplainQuery: () => ({}), +})); + +jest.mock('@/theme/ThemeProvider', () => ({ + useAppTheme: () => ({ themeName: 'hyperdx' }), + useBrandDisplayName: () => 'HyperDX', +})); + +jest.mock('../hooks/useMetadata', () => ({ + useTableMetadata: () => ({ + data: { sorting_key: 'Timestamp' }, + isLoading: false, + }), +})); + +jest.mock('../hooks/useSqlSuggestions', () => ({ + useSqlSuggestions: () => [], +})); + +jest.mock('../components/Search/DirectTraceSidePanel', () => ({ + __esModule: true, + default: (props: Record) => { + latestDirectTracePanelProps = props; + return ( +
+ + +
+ ); + }, +})); + +jest.mock('@/components/DBSearchPageFilters', () => ({ + DBSearchPageFilters: () =>
, +})); + +jest.mock('@/components/DBTimeChart', () => ({ + DBTimeChart: () =>
, +})); + +jest.mock('@/components/ActiveFilterPills', () => ({ + ActiveFilterPills: () =>
, +})); +jest.mock('@/components/ContactSupportText', () => ({ + ContactSupportText: () =>
, +})); +jest.mock('@/components/FavoriteButton', () => ({ + FavoriteButton: () =>
, +})); +jest.mock('@/components/InputControlled', () => ({ + InputControlled: () =>
, +})); +jest.mock('@/components/OnboardingModal', () => () =>
); +jest.mock('@/components/SearchInput/SearchWhereInput', () => ({ + __esModule: true, + default: () =>
, + getStoredLanguage: () => 'lucene', +})); +jest.mock('@/components/SearchPageActionBar', () => () =>
); +jest.mock('@/components/SearchTotalCountChart', () => () =>
); +jest.mock('@/components/Sources/SourceForm', () => ({ + TableSourceForm: () =>
, +})); +jest.mock('@/components/SourceSelect', () => ({ + SourceSelectControlled: () =>
, +})); +jest.mock('@/components/SQLEditor/SQLInlineEditor', () => ({ + SQLInlineEditorControlled: () =>
, +})); +jest.mock('@/components/Tags', () => ({ + Tags: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +jest.mock('@/components/TimePicker', () => ({ + TimePicker: () =>
, +})); +jest.mock('../components/ChartSQLPreview', () => ({ + SQLPreview: () =>
, +})); +jest.mock('../components/DBSqlRowTableWithSidebar', () => () =>
); +jest.mock('../components/PatternTable', () => () =>
); +jest.mock('../components/Search/DBSearchHeatmapChart', () => ({ + DBSearchHeatmapChart: () =>
, +})); +jest.mock('../components/SourceSchemaPreview', () => () =>
); +jest.mock('../components/Error/ErrorBoundary', () => ({ + ErrorBoundary: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + +jest.mock('../utils/queryParsers', () => ({ + parseAsJsonEncoded: () => 'parseAsJsonEncoded', + parseAsSortingStateString: { + parse: () => null, + }, + parseAsStringEncoded: 'parseAsStringEncoded', +})); + +jest.mock('../api', () => ({ + __esModule: true, + default: { + useMe: () => ({ + data: { team: {} }, + isSuccess: true, + }), + }, +})); + +jest.mock('@/utils', () => ({ + QUERY_LOCAL_STORAGE: 'query-local-storage', + useLocalStorage: (_key: string, initialValue: unknown) => [ + initialValue, + jest.fn(), + ], + usePrevious: (value: unknown) => value, +})); + +jest.mock('@tanstack/react-query', () => ({ + useIsFetching: () => 0, +})); + +describe('DBSearchPage direct trace flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + latestDirectTracePanelProps = null; + mockDirectTraceId = 'trace-123'; + mockSearchedConfig = { + source: undefined, + where: '', + select: '', + whereLanguage: undefined, + filters: [], + orderBy: '', + }; + mockSources = [ + { + id: 'trace-source', + kind: SourceKind.Trace, + name: 'Trace Source', + traceIdExpression: 'TraceId', + from: { databaseName: 'db', tableName: 'traces' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp', + implicitColumnExpression: 'Body', + connection: 'conn', + logSourceId: 'log-source', + }, + { + id: 'log-source', + kind: SourceKind.Log, + name: 'Log Source', + from: { databaseName: 'db', tableName: 'logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp', + implicitColumnExpression: 'Body', + connection: 'conn', + }, + ]; + }); + + it('opens the direct trace panel with no selected source when none is provided', async () => { + window.history.pushState({}, '', '/search?traceId=trace-123'); + + renderWithMantine(); + + await waitFor(() => { + expect(latestDirectTracePanelProps).toEqual( + expect.objectContaining({ + traceId: 'trace-123', + traceSourceId: null, + }), + ); + }); + }); + + it('applies a direct trace filter when a valid trace source is present', async () => { + mockSearchedConfig = { + ...mockSearchedConfig, + source: 'trace-source', + }; + window.history.pushState( + {}, + '', + '/search?traceId=trace-123&source=trace-source', + ); + + renderWithMantine(); + + await waitFor(() => { + expect(mockSetSearchedConfig).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'trace-source', + where: "TraceId = 'trace-123'", + whereLanguage: 'sql', + filters: [], + }), + ); + }); + }); + + it('applies the default 14-day range only when from/to are absent', () => { + window.history.pushState({}, '', '/search?traceId=trace-123'); + + renderWithMantine(); + + expect(mockOnTimeRangeSelect).toHaveBeenCalled(); + + jest.clearAllMocks(); + window.history.pushState({}, '', '/search?traceId=trace-123&from=1&to=2'); + + renderWithMantine(); + + expect(mockOnTimeRangeSelect).not.toHaveBeenCalled(); + }); + + it('lets the direct trace panel update the selected source', async () => { + window.history.pushState({}, '', '/search?traceId=trace-123'); + + renderWithMantine(); + + await waitFor(() => { + expect(screen.getByTestId('direct-trace-panel')).toBeInTheDocument(); + }); + + screen.getByText('select-trace-source').click(); + + expect(mockSetSearchedConfig).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'trace-source', + where: "TraceId = 'trace-123'", + whereLanguage: 'sql', + filters: [], + }), + ); + }); + + it('clears the direct trace mode when the panel closes', async () => { + window.history.pushState({}, '', '/search?traceId=trace-123'); + + renderWithMantine(); + + await waitFor(() => { + expect(screen.getByTestId('direct-trace-panel')).toBeInTheDocument(); + }); + + screen.getByText('close-trace').click(); + + expect(mockSetDirectTraceId).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/app/src/__tests__/TraceRedirectPage.test.tsx b/packages/app/src/__tests__/TraceRedirectPage.test.tsx new file mode 100644 index 0000000000..d819ae493a --- /dev/null +++ b/packages/app/src/__tests__/TraceRedirectPage.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { waitFor } from '@testing-library/react'; + +import { TraceRedirectPage } from '../../pages/trace/[traceId]'; + +const mockReplace = jest.fn(); + +let mockRouter = { + isReady: true, + query: { + traceId: 'trace-123', + }, + replace: mockReplace, +}; + +jest.mock('next/router', () => ({ + useRouter: () => mockRouter, +})); + +jest.mock('@/layout', () => ({ + withAppNav: (component: unknown) => component, +})); + +describe('TraceRedirectPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = { + isReady: true, + query: { + traceId: 'trace-123', + }, + replace: mockReplace, + }; + }); + + it('redirects to search with the trace id query param', async () => { + window.history.pushState({}, '', '/trace/trace-123'); + + renderWithMantine(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/search?traceId=trace-123'); + }); + }); + + it('preserves existing query params such as source', async () => { + window.history.pushState({}, '', '/trace/trace-123?source=trace-source'); + + renderWithMantine(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith( + '/search?source=trace-source&traceId=trace-123', + ); + }); + }); + + it('redirects after router readiness changes', async () => { + mockRouter = { + ...mockRouter, + isReady: false, + }; + window.history.pushState({}, '', '/trace/trace-123'); + + const { unmount } = renderWithMantine(); + + expect(mockReplace).not.toHaveBeenCalled(); + + mockRouter = { + ...mockRouter, + isReady: true, + }; + + unmount(); + renderWithMantine(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/search?traceId=trace-123'); + }); + }); +}); diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index 78c5d17bd7..998e1f73a2 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; import { useQueryState } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; @@ -49,6 +49,7 @@ export default function DBTracePanel({ focusDate, parentSourceId, initialRowHighlightHint, + emptyState, 'data-testid': dataTestId, }: { parentSourceId?: string | null; @@ -64,14 +65,19 @@ export default function DBTracePanel({ spanId: string; body: string; }; + emptyState?: ReactNode; 'data-testid'?: string; }) { - const { control } = useForm({ + const { control, setValue } = useForm({ defaultValues: { source: childSourceId, }, }); + useEffect(() => { + setValue('source', childSourceId ?? null); + }, [childSourceId, setValue]); + const sourceId = useWatch({ control, name: 'source' }); const { data: childSourceData, isLoading: isChildSourceDataLoading } = @@ -239,6 +245,7 @@ export default function DBTracePanel({ highlightedRowWhere={eventRowWhere?.id} onClick={setEventRowWhere} initialRowHighlightHint={initialRowHighlightHint} + emptyState={emptyState} /> )} {traceSourceData != null && eventRowWhere != null && ( diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index f0d5aceafa..dfca012dab 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import _, { omit } from 'lodash'; import { useForm } from 'react-hook-form'; +import SqlString from 'sqlstring'; import TimestampNano from 'timestamp-nano'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { @@ -83,6 +84,10 @@ export type SpanRow = { __hdx_hidden?: boolean | 1 | 0; }; +type TimestampedRow = { + Timestamp: string; +}; + function textColor(condition: { isError: boolean; isWarn: boolean }): string { const { isError, isWarn } = condition; if (isError) return 'text-danger'; @@ -269,7 +274,7 @@ function getConfig( select, from: source.from, timestampValueExpression: source.timestampValueExpression, - where: `${alias.TraceId} = '${traceId}'`, + where: `${alias.TraceId} = ${SqlString.escape(traceId)}`, limit: { limit: 50000 }, connection: source.connection, }; @@ -358,10 +363,12 @@ export function useEventsAroundFocus({ const rowWhereResult = getRowWhere( omit(cd, ['SpanAttributes', 'SpanEvents', '__hdx_hidden']), ); + return { // Keep all fields available for display ...cd, // Added for typing + Timestamp: cd?.Timestamp, SpanId: cd?.SpanId, __hdx_hidden: cd?.__hdx_hidden, type, @@ -423,6 +430,7 @@ export function DBTraceWaterfallChartContainer({ onClick, highlightedRowWhere, initialRowHighlightHint, + emptyState, }: { traceTableSource: TTraceSource; logTableSource: TLogSource | null; @@ -440,6 +448,7 @@ export function DBTraceWaterfallChartContainer({ spanId: string; body: string; }; + emptyState?: ReactNode; }) { const { size, startResize } = useResizable(30, 'bottom'); const formatTime = useFormatTime(); @@ -501,21 +510,24 @@ export function DBTraceWaterfallChartContainer({ const isFetching = traceIsFetching || logIsFetching; const error = traceError || logError; - const rows: any[] = useMemo( - () => [...traceRowsData, ...logRowsData], - [traceRowsData, logRowsData], - ); + const rows: any[] = useMemo(() => { + const nextRows: Array<(typeof traceRowsData)[number] & TimestampedRow> = [ + ...traceRowsData, + ...logRowsData, + ]; + nextRows.sort((a, b) => { + const aDate = TimestampNano.fromString(a.Timestamp); + const bDate = TimestampNano.fromString(b.Timestamp); + const secDiff = aDate.getTimeT() - bDate.getTimeT(); + if (secDiff === 0) { + return aDate.getNano() - bDate.getNano(); + } else { + return secDiff; + } + }); - rows.sort((a, b) => { - const aDate = TimestampNano.fromString(a.Timestamp); - const bDate = TimestampNano.fromString(b.Timestamp); - const secDiff = aDate.getTimeT() - bDate.getTimeT(); - if (secDiff === 0) { - return aDate.getNano() - bDate.getNano(); - } else { - return secDiff; - } - }); + return nextRows; + }, [traceRowsData, logRowsData]); const highlightedAttributeValues = useMemo(() => { const visibleTraceRowsData = traceRowsData?.filter( @@ -1060,7 +1072,9 @@ export function DBTraceWaterfallChartContainer({ An unknown error occurred.
) : flattenedNodes.length === 0 ? ( -
No matching spans or logs found
+ (emptyState ?? ( +
No matching spans or logs found
+ )) ) : ( void; + onSourceChange: (sourceId: string | null) => void; +} + +export default function DirectTraceSidePanel({ + opened, + traceId, + traceSourceId, + dateRange, + focusDate, + onClose, + onSourceChange, +}: DirectTraceSidePanelProps) { + const { control, setValue } = useForm<{ source: string | null }>({ + defaultValues: { + source: traceSourceId ?? null, + }, + }); + + useEffect(() => { + setValue('source', traceSourceId ?? null); + }, [setValue, traceSourceId]); + + const selectedSourceId = useWatch({ control, name: 'source' }); + + useEffect(() => { + if ((selectedSourceId ?? null) !== (traceSourceId ?? null)) { + onSourceChange(selectedSourceId ?? null); + } + }, [onSourceChange, selectedSourceId, traceSourceId]); + + const { + data: traceSource, + error: traceSourceError, + isLoading: isTraceSourceLoading, + } = useSource({ + id: selectedSourceId, + kinds: [SourceKind.Trace], + }); + + const emptyState = useMemo(() => { + let title = 'Select a trace source'; + let description = + 'Choose a trace source to open this trace in the sidebar.'; + + if (traceSourceError) { + title = 'Unable to load trace source'; + description = + 'There was a problem loading the selected trace source. Try again or choose a different source.'; + } else if (selectedSourceId && isTraceSourceLoading) { + title = 'Loading trace source'; + description = 'Resolving the selected trace source.'; + } else if (selectedSourceId && !traceSource) { + title = 'Trace source not found'; + description = + 'The requested source could not be loaded. Choose another trace source to continue.'; + } + + return ( + } + title={title} + description={description} + variant="card" + fullWidth + mt="md" + /> + ); + }, [isTraceSourceLoading, selectedSourceId, traceSource, traceSourceError]); + + const shouldRenderTracePanel = + opened && traceId.length > 0 && traceSource?.kind === SourceKind.Trace; + + return ( + + + Trace + + } + styles={{ + body: { + height: '100%', + overflowY: 'auto', + }, + }} + > + + + Trace Source + + + + + {opened ? ( + shouldRenderTracePanel ? ( + } + title="Trace not found" + description="No matching spans or correlated logs were found for this trace in the selected source and time range." + variant="card" + fullWidth + mt="md" + /> + } + /> + ) : ( + emptyState + ) + ) : null} + + + ); +} diff --git a/packages/app/src/components/Search/__tests__/DirectTraceSidePanel.test.tsx b/packages/app/src/components/Search/__tests__/DirectTraceSidePanel.test.tsx new file mode 100644 index 0000000000..91763d4737 --- /dev/null +++ b/packages/app/src/components/Search/__tests__/DirectTraceSidePanel.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; + +import DirectTraceSidePanel from '../DirectTraceSidePanel'; + +let mockSources: Record = {}; +let mockIsLoading = false; + +jest.mock('@/source', () => ({ + useSource: ({ id }: { id?: string | null }) => ({ + data: id ? mockSources[id] : undefined, + isLoading: mockIsLoading, + }), +})); + +jest.mock('@/components/DBTracePanel', () => ({ + __esModule: true, + default: ({ traceId }: { traceId: string }) => ( +
trace panel {traceId}
+ ), +})); + +jest.mock('@/components/SourceSelect', () => ({ + SourceSelectControlled: () =>
source select
, +})); + +describe('DirectTraceSidePanel', () => { + beforeEach(() => { + mockIsLoading = false; + mockSources = { + 'trace-source': { + id: 'trace-source', + kind: SourceKind.Trace, + name: 'Trace Source', + logSourceId: 'log-source', + }, + }; + }); + + it('renders DBTracePanel when the selected trace source is valid', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('trace panel trace-123')).toBeInTheDocument(); + }); + + it('shows a source selection empty state when no source is selected', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('Select a trace source')).toBeInTheDocument(); + }); + + it('shows a not found source state for an invalid source id', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('Trace source not found')).toBeInTheDocument(); + }); + + it('shows a loading state while the trace source is being resolved', () => { + mockIsLoading = true; + mockSources = {}; + + renderWithMantine( + , + ); + + expect(screen.getByText('Loading trace source')).toBeInTheDocument(); + }); + + it('renders a visible close button', () => { + renderWithMantine( + , + ); + + expect(screen.getAllByRole('button').length).toBeGreaterThan(0); + }); +}); diff --git a/packages/app/src/components/__tests__/DBTracePanel.test.tsx b/packages/app/src/components/__tests__/DBTracePanel.test.tsx new file mode 100644 index 0000000000..8594db58da --- /dev/null +++ b/packages/app/src/components/__tests__/DBTracePanel.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; + +import DBTracePanel from '../DBTracePanel'; + +let mockSources: Record = {}; + +jest.mock('nuqs', () => ({ + useQueryState: () => [null, jest.fn()], +})); + +jest.mock('@/utils/queryParsers', () => ({ + parseAsJsonEncoded: () => 'parseAsJsonEncoded', +})); + +jest.mock('@/source', () => ({ + useSource: ({ id }: { id?: string | null }) => ({ + data: id ? mockSources[id] : undefined, + isLoading: false, + }), + useUpdateSource: () => ({ + mutate: jest.fn(), + }), +})); + +jest.mock('@/components/DBTraceWaterfallChart', () => ({ + DBTraceWaterfallChartContainer: ({ + emptyState, + }: { + emptyState?: React.ReactNode; + }) =>
{emptyState ?? 'waterfall'}
, +})); + +jest.mock('../SourceSelect', () => ({ + SourceSelectControlled: () =>
source select
, +})); + +jest.mock('../SourceSchemaPreview', () => ({ + __esModule: true, + default: () =>
, +})); + +describe('DBTracePanel', () => { + beforeEach(() => { + mockSources = { + 'trace-source': { + id: 'trace-source', + kind: SourceKind.Trace, + traceIdExpression: 'TraceId', + logSourceId: 'log-source', + }, + 'log-source': { + id: 'log-source', + kind: SourceKind.Log, + traceIdExpression: 'TraceId', + }, + }; + }); + + it('passes through a custom empty state to the waterfall container', () => { + renderWithMantine( + Trace not found
} + />, + ); + + expect(screen.getByText('Trace not found')).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index 154660f052..debedf9c1b 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -21,9 +21,38 @@ import { // Mock setup jest.mock('@/components/TimelineChart', () => { + const flattenText = (value: React.ReactNode): string => { + if (value == null || typeof value === 'boolean') { + return ''; + } + + if (typeof value === 'string' || typeof value === 'number') { + return String(value); + } + + if (Array.isArray(value)) { + return value.map(flattenText).join(''); + } + + if (React.isValidElement<{ children?: React.ReactNode }>(value)) { + return flattenText(value.props.children); + } + + return ''; + }; + const mockComponent = function MockTimelineChart(props: any) { mockComponent.latestProps = props; - return
TimelineChart
; + return ( +
+ TimelineChart + {props.rows?.map((row: any) => ( +
+ {row.events?.map((event: any) => flattenText(event.body))} +
+ ))} +
+ ); }; mockComponent.latestProps = {}; return { TimelineChart: mockComponent }; @@ -136,13 +165,14 @@ describe('DBTraceWaterfallChartContainer', () => { // Helper functions const renderComponent = ( logTableSource: typeof mockLogTableSource | null = mockLogTableSource, + traceId: string = mockTraceId, ) => { return renderWithMantine( @@ -234,6 +264,17 @@ describe('DBTraceWaterfallChartContainer', () => { }); }); + it('escapes trace ids in the generated where clause', () => { + setupQueryMocks({ traceData: mockTraceData }); + + renderComponent(mockLogTableSource, "trace'with-quote"); + + expect(mockUseOffsetPaginatedQuery).toHaveBeenCalled(); + expect(mockUseOffsetPaginatedQuery.mock.calls[0][0].where).toBe( + "TraceId = 'trace\\'with-quote'", + ); + }); + it('renders HTTP spans with URL information', async () => { // HTTP span with URL and method information const mockHttpSpanData = { @@ -264,13 +305,10 @@ describe('DBTraceWaterfallChartContainer', () => { // Verify the chart received the HTTP span with URL expect(MockTimelineChart.latestProps.rows.length).toBe(1); - const row = MockTimelineChart.latestProps.rows[0]; - expect(row).toBeTruthy(); - - // Check the display text includes the URL - expect(row.events[0].body.props.children).toBe( - 'http span https://api.example.com/users', - ); + expect(MockTimelineChart.latestProps.rows[0]).toBeTruthy(); + expect( + screen.getByText('http span https://api.example.com/users'), + ).toBeInTheDocument(); }); }); diff --git a/packages/app/src/utils/__tests__/directTrace.test.ts b/packages/app/src/utils/__tests__/directTrace.test.ts new file mode 100644 index 0000000000..42b059a1aa --- /dev/null +++ b/packages/app/src/utils/__tests__/directTrace.test.ts @@ -0,0 +1,45 @@ +import { + buildDirectTraceWhereClause, + buildTraceRedirectUrl, + getDefaultDirectTraceDateRange, +} from '../directTrace'; + +describe('buildDirectTraceWhereClause', () => { + it('uses the provided trace id expression', () => { + expect(buildDirectTraceWhereClause('TraceId', 'abc123')).toBe( + "TraceId = 'abc123'", + ); + }); + + it('escapes quotes in trace ids', () => { + expect(buildDirectTraceWhereClause('TraceId', "abc'123")).toBe( + "TraceId = 'abc\\'123'", + ); + }); +}); + +describe('buildTraceRedirectUrl', () => { + it('maps a trace path to a search url', () => { + expect(buildTraceRedirectUrl({ traceId: 'trace-123', search: '' })).toBe( + '/search?traceId=trace-123', + ); + }); + + it('preserves optional source and time range query params', () => { + expect( + buildTraceRedirectUrl({ + traceId: 'trace-123', + search: '?source=trace-source&from=1&to=2', + }), + ).toBe('/search?source=trace-source&from=1&to=2&traceId=trace-123'); + }); +}); + +describe('getDefaultDirectTraceDateRange', () => { + it('returns a range ending at the current runtime time', () => { + expect(getDefaultDirectTraceDateRange(1_000_000)).toEqual([ + new Date(1_000_000 - 14 * 24 * 60 * 60 * 1000), + new Date(1_000_000), + ]); + }); +}); diff --git a/packages/app/src/utils/directTrace.ts b/packages/app/src/utils/directTrace.ts new file mode 100644 index 0000000000..93182acb8f --- /dev/null +++ b/packages/app/src/utils/directTrace.ts @@ -0,0 +1,29 @@ +import SqlString from 'sqlstring'; + +export function buildDirectTraceWhereClause( + traceIdExpression: string | undefined, + traceId: string, +): string { + return `${traceIdExpression ?? 'TraceId'} = ${SqlString.escape(traceId)}`; +} + +export function buildTraceRedirectUrl({ + traceId, + search, +}: { + traceId: string; + search: string; +}): string { + const params = new URLSearchParams(search); + params.set('traceId', traceId); + + const query = params.toString(); + return query ? `/search?${query}` : '/search'; +} + +export function getDefaultDirectTraceDateRange( + nowMs = performance.timeOrigin + performance.now(), +): [Date, Date] { + // between 14 days ago and now + return [new Date(nowMs - 14 * 24 * 60 * 60 * 1000), new Date(nowMs)]; +}