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