-
-
-
+ <>
+ {/* Full-height vertical grid lines. No z-index + rendered before the
+ rows so the rows paint on top, keeping the lines behind row bars,
+ duration labels, and span-body text. */}
+
+
+ {/* Sticky header with the time labels. z-index keeps it above the rows
+ so labels stay visible while rows scroll underneath. */}
+
-
+ >
);
}
diff --git a/packages/app/src/components/__tests__/DBTracePanel.test.tsx b/packages/app/src/components/__tests__/DBTracePanel.test.tsx
index 70479e95a1..6f9a76111a 100644
--- a/packages/app/src/components/__tests__/DBTracePanel.test.tsx
+++ b/packages/app/src/components/__tests__/DBTracePanel.test.tsx
@@ -27,9 +27,16 @@ jest.mock('@/source', () => ({
jest.mock('@/components/DBTraceWaterfallChart', () => ({
DBTraceWaterfallChartContainer: ({
emptyState,
+ controlsExtra,
}: {
emptyState?: React.ReactNode;
- }) =>
{emptyState ?? 'waterfall'}
,
+ controlsExtra?: React.ReactNode;
+ }) => (
+
+ {controlsExtra}
+ {emptyState ?? 'waterfall'}
+
+ ),
}));
jest.mock('../SourceSelect', () => ({
@@ -86,4 +93,35 @@ describe('DBTracePanel', () => {
expect(screen.getByText('Trace not found')).toBeInTheDocument();
});
+
+ it('moves the correlated logs source selector into the waterfall controls', () => {
+ renderWithMantine(
+
,
+ );
+
+ // The selector (mocked) now renders inside the waterfall controls bar.
+ expect(screen.getByText('source select')).toBeInTheDocument();
+ expect(screen.getByText('Correlated logs')).toBeInTheDocument();
+ });
+
+ it('does not duplicate the trace id in the panel body', () => {
+ renderWithMantine(
+
,
+ );
+
+ // Trace id lives in the side-panel header now, not the trace panel body.
+ expect(screen.queryByText(/trace-123/)).not.toBeInTheDocument();
+ });
});
diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx
index b7df1ae082..b689f9f87a 100644
--- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx
+++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx
@@ -10,6 +10,10 @@ import userEvent from '@testing-library/user-event';
import { RowSidePanelContext } from '@/components/DBRowSidePanel';
import {
+ computeCollapseAll,
+ computeCollapseOneLevel,
+ computeExpandOneLevel,
+ computeToggleCollapse,
DBTraceWaterfallChartContainer,
getDescendantIds,
SpanRow,
@@ -71,17 +75,37 @@ jest.mock('../DBRowDataPanel', () => ({
getJSONColumnNames: jest.fn().mockReturnValue([]),
}));
+// Lightweight stub: the real SearchWhereInput renders SearchInputV2, which
+// pulls in useMe()/metadata hooks that require a QueryClientProvider not present
+// in this harness. We only care that the right inputs render, so stub it to a
+// plain element carrying the data-testid.
+jest.mock('@/components/SearchInput/SearchWhereInput', () => ({
+ __esModule: true,
+ default: ({ 'data-testid': dataTestId, name }: any) => (
+
{name}
+ ),
+ getStoredLanguage: () => 'lucene',
+}));
+
+const makeWaterfallSearchState = () => ({
+ traceWhere: '',
+ logWhere: '',
+ traceWhereLanguage: '',
+ logWhereLanguage: '',
+ clear: jest.fn(),
+ isFilterActive: false,
+ isFilterExpanded: false,
+ setIsFilterExpanded: jest.fn(),
+ onSubmit: jest.fn(),
+});
+
+const mockUseWaterfallSearchState: jest.Mock = jest.fn(
+ makeWaterfallSearchState,
+);
+
jest.mock('@/hooks/useWaterfallSearchState', () => ({
__esModule: true,
- default: () => ({
- traceWhere: '',
- logWhere: '',
- clear: jest.fn(),
- isFilterActive: false,
- isFilterExpanded: false,
- setIsFilterExpanded: jest.fn(),
- onSubmit: jest.fn(),
- }),
+ default: (...args: any[]) => mockUseWaterfallSearchState(...args),
}));
const mockUseOffsetPaginatedQuery = useOffsetPaginatedQuery as jest.Mock;
@@ -167,6 +191,7 @@ describe('DBTraceWaterfallChartContainer', () => {
jest.clearAllMocks();
mockUseRowWhere.mockReturnValue(() => ({ where: 'row-id', aliasWith: [] }));
MockTimelineChart.latestProps = {};
+ mockUseWaterfallSearchState.mockReturnValue(makeWaterfallSearchState());
});
// Helper functions
@@ -193,31 +218,62 @@ describe('DBTraceWaterfallChartContainer', () => {
});
};
+ // Content-based query mock. The waterfall runs, per source, a filtered query
+ // (with a computed `__hdx_hidden` column) plus an unfiltered fallback query
+ // that only fires when the filtered query errors. Keying responses off the
+ // query shape (table, before/after window, presence of the filter column,
+ // enabled flag) rather than call order keeps the mock stable as the number of
+ // queries changes.
+ //
+ // - `traceError`/`logError`: fatal — every query for that source fails.
+ // - `traceFilterError`/`logFilterError`: only the *filtered* query fails, so
+ // the unfiltered fallback still returns data.
const setupQueryMocks = (options: {
traceData?: typeof mockTraceData | typeof emptyData;
logData?: typeof mockLogData | typeof emptyData;
+ traceError?: Error;
+ logError?: Error;
+ traceFilterError?: Error;
+ logFilterError?: Error;
isFetching?: boolean;
}) => {
const {
traceData = emptyData,
logData = emptyData,
+ traceError,
+ logError,
+ traceFilterError,
+ logFilterError,
isFetching = false,
} = options;
mockUseOffsetPaginatedQuery.mockReset();
-
- // Use mockImplementation to handle all calls consistently
- let callCount = 0;
- mockUseOffsetPaginatedQuery.mockImplementation(() => {
- const responses = [
- { data: traceData, isFetching, error: undefined }, // trace before
- { data: emptyData, isFetching, error: undefined }, // trace after
- { data: logData, isFetching, error: undefined }, // log before
- { data: emptyData, isFetching, error: undefined }, // log after
- ];
- const response = responses[callCount % responses.length];
- callCount++;
- return response;
+ mockUseOffsetPaginatedQuery.mockImplementation((query: any, opts: any) => {
+ // Disabled queries never fire (mirrors react-query).
+ if (opts?.enabled === false) {
+ return { data: undefined, isFetching: false, error: undefined };
+ }
+ const isLog = query?.from?.tableName === 'log_table';
+ const isBefore = query?.dateRangeStartInclusive === true;
+ const isFiltered =
+ Array.isArray(query?.select) &&
+ query.select.some((s: any) => s?.alias === '__hdx_hidden');
+
+ const fatal = isLog ? logError : traceError;
+ if (fatal) {
+ return { data: undefined, isFetching, error: fatal };
+ }
+ const filterErr = isLog ? logFilterError : traceFilterError;
+ if (filterErr && isFiltered) {
+ return { data: undefined, isFetching, error: filterErr };
+ }
+
+ const data = isLog ? logData : traceData;
+ return {
+ data: isBefore ? data : emptyData,
+ isFetching,
+ error: undefined,
+ };
});
};
@@ -367,6 +423,158 @@ describe('DBTraceWaterfallChartContainer', () => {
expect(MockTimelineChart.latestProps.rows.length).toBe(1);
});
});
+
+ it('renders depth controls when the trace has collapsible spans', async () => {
+ // Distinct row ids per span so the parent/child tree is preserved (the
+ // default mock collapses every row onto the same id).
+ mockUseRowWhere.mockReturnValue((row: any) => ({
+ where: `where-${row?.SpanId ?? 'x'}`,
+ aliasWith: [],
+ }));
+
+ const nestedTraceData = {
+ data: [
+ {
+ Body: 'parent span',
+ Timestamp: '2024-01-01T06:00:00.000000000Z',
+ Duration: 0.2,
+ SpanId: 'span-1',
+ ParentSpanId: '',
+ ServiceName: 'svc-a',
+ HyperDXEventType: 'span' as const,
+ type: 'trace',
+ } as SpanRow,
+ {
+ Body: 'child span',
+ Timestamp: '2024-01-01T06:00:00.050000000Z',
+ Duration: 0.1,
+ SpanId: 'span-2',
+ ParentSpanId: 'span-1',
+ ServiceName: 'svc-b',
+ HyperDXEventType: 'span' as const,
+ type: 'trace',
+ } as SpanRow,
+ ],
+ meta: [{ totalCount: 2 }],
+ };
+
+ setupQueryMocks({ traceData: nestedTraceData });
+ renderComponent(null);
+ await waitForLoading();
+
+ expect(screen.getByLabelText('Expand all')).toBeInTheDocument();
+ expect(screen.getByLabelText('Collapse all')).toBeInTheDocument();
+ expect(screen.getByLabelText('Expand one level')).toBeInTheDocument();
+ expect(screen.getByLabelText('Collapse one level')).toBeInTheDocument();
+ });
+
+ it('does not render depth controls for a flat trace', async () => {
+ setupQueryMocks({ traceData: mockTraceData });
+ renderComponent(null);
+ await waitForLoading();
+
+ expect(screen.queryByLabelText('Expand all')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Collapse all')).not.toBeInTheDocument();
+ });
+
+ it('keeps rendering spans when the correlated-log filter errors', async () => {
+ // A log filter that references a column the log table lacks is the trigger.
+ mockUseWaterfallSearchState.mockReturnValue({
+ ...makeWaterfallSearchState(),
+ isFilterActive: true,
+ isFilterExpanded: true,
+ logWhere: "StatusCode = 'Error'",
+ });
+ setupQueryMocks({
+ traceData: mockTraceData,
+ logFilterError: new Error('Missing columns: StatusCode'),
+ });
+
+ renderComponent(); // with log source
+ await waitForLoading();
+
+ // Valid spans still render — the log failure did not blank the chart.
+ expect(MockTimelineChart.latestProps.rows.length).toBe(1);
+ // The full-chart error block is NOT shown.
+ expect(
+ screen.queryByText('An error occurred while fetching trace data:'),
+ ).not.toBeInTheDocument();
+ // The log failure is surfaced inline under the logs filter instead.
+ expect(screen.getByTestId('log-filter-error')).toBeInTheDocument();
+ });
+
+ it('keeps rendering spans (unfiltered) when the spans filter errors', async () => {
+ // A spans filter is a computed column inside the trace query, so an invalid
+ // one fails the query. We fall back to unfiltered spans instead of blanking.
+ mockUseWaterfallSearchState.mockReturnValue({
+ ...makeWaterfallSearchState(),
+ isFilterActive: true,
+ isFilterExpanded: true,
+ traceWhere: "StatusCode = 'Error'",
+ });
+ setupQueryMocks({
+ traceData: mockTraceData,
+ traceFilterError: new Error('Missing column: Nope'),
+ });
+
+ renderComponent(null); // no log source, to isolate the spans path
+ await waitForLoading();
+
+ // Spans still render (unfiltered fallback) — chart is not blanked.
+ expect(MockTimelineChart.latestProps.rows.length).toBe(1);
+ expect(
+ screen.queryByText('An error occurred while fetching trace data:'),
+ ).not.toBeInTheDocument();
+ // The span filter failure is surfaced inline under the spans filter.
+ expect(screen.getByTestId('trace-filter-error')).toBeInTheDocument();
+ });
+
+ it('renders the full-chart error block when the base trace query errors', async () => {
+ // No filter active, so a trace query error is fatal (nothing to show).
+ setupQueryMocks({
+ traceError: new Error('Trace query boom'),
+ logData: mockLogData,
+ });
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('An error occurred while fetching trace data:'),
+ ).toBeInTheDocument();
+ });
+ expect(screen.getByText('Trace query boom')).toBeInTheDocument();
+ // The chart itself is replaced by the error block.
+ expect(screen.queryByTestId('timeline-chart')).not.toBeInTheDocument();
+ });
+
+ it('renders separate span and log filter inputs when a log source exists', async () => {
+ mockUseWaterfallSearchState.mockReturnValue({
+ ...makeWaterfallSearchState(),
+ isFilterExpanded: true,
+ });
+ setupQueryMocks({ traceData: mockTraceData, logData: mockLogData });
+
+ renderComponent(); // with log source
+ await waitForLoading();
+
+ expect(screen.getByTestId('trace-search-input')).toBeInTheDocument();
+ expect(screen.getByTestId('log-search-input')).toBeInTheDocument();
+ });
+
+ it('renders only the span filter input when there is no log source', async () => {
+ mockUseWaterfallSearchState.mockReturnValue({
+ ...makeWaterfallSearchState(),
+ isFilterExpanded: true,
+ });
+ setupQueryMocks({ traceData: mockTraceData });
+
+ renderComponent(null); // no log source
+ await waitForLoading();
+
+ expect(screen.getByTestId('trace-search-input')).toBeInTheDocument();
+ expect(screen.queryByTestId('log-search-input')).not.toBeInTheDocument();
+ });
});
describe('useEventsAroundFocus', () => {
@@ -541,3 +749,193 @@ describe('getDescendantIds', () => {
expect(getDescendantIds(node)).toEqual(['only']);
});
});
+
+describe('depth control helpers', () => {
+ // Build a level -> parent-ids map. Keys are intentionally passed out of order
+ // in some tests to prove the helpers sort levels themselves.
+ const makeLevels = (entries: Array<[number, string[]]>) =>
+ new Map
>(
+ entries.map(([level, ids]) => [level, new Set(ids)]),
+ );
+
+ // A 3-deep tree of collapsible parents:
+ // level 0: root
+ // level 1: a, b
+ // level 2: a1
+ const threeLevels = () =>
+ makeLevels([
+ [2, ['a1']],
+ [0, ['root']],
+ [1, ['a', 'b']],
+ ]);
+
+ const asSorted = (set: Set) => [...set].sort();
+
+ describe('computeCollapseAll', () => {
+ it('returns an empty set for a trace with no collapsible parents', () => {
+ expect(computeCollapseAll(new Map())).toEqual(new Set());
+ });
+
+ it('flattens every parent id from every level into one set', () => {
+ expect(asSorted(computeCollapseAll(threeLevels()))).toEqual([
+ 'a',
+ 'a1',
+ 'b',
+ 'root',
+ ]);
+ });
+ });
+
+ describe('computeExpandOneLevel', () => {
+ it('returns the same set reference when nothing is collapsed', () => {
+ const collapsed = new Set();
+ const result = computeExpandOneLevel(collapsed, threeLevels());
+ // Identity is relied on by the caller to skip a redundant state update.
+ expect(result).toBe(collapsed);
+ });
+
+ it('expands the shallowest collapsed level first', () => {
+ const levels = threeLevels();
+ const fullyCollapsed = new Set(['root', 'a', 'b', 'a1']);
+
+ // level 0 (root) is shallowest -> expanded first.
+ const afterOne = computeExpandOneLevel(fullyCollapsed, levels);
+ expect(asSorted(afterOne)).toEqual(['a', 'a1', 'b']);
+
+ // next shallowest collapsed level is level 1 (a, b).
+ const afterTwo = computeExpandOneLevel(afterOne, levels);
+ expect(asSorted(afterTwo)).toEqual(['a1']);
+
+ // finally level 2 (a1).
+ const afterThree = computeExpandOneLevel(afterTwo, levels);
+ expect(asSorted(afterThree)).toEqual([]);
+ });
+
+ it('skips already-expanded shallow levels', () => {
+ // Only the deepest level remains collapsed.
+ const result = computeExpandOneLevel(new Set(['a1']), threeLevels());
+ expect(asSorted(result)).toEqual([]);
+ });
+
+ it('does not mutate the input set', () => {
+ const collapsed = new Set(['root', 'a', 'b', 'a1']);
+ computeExpandOneLevel(collapsed, threeLevels());
+ expect(asSorted(collapsed)).toEqual(['a', 'a1', 'b', 'root']);
+ });
+ });
+
+ describe('computeCollapseOneLevel', () => {
+ it('collapses the deepest expanded level first', () => {
+ const levels = threeLevels();
+
+ // Nothing collapsed -> deepest level (a1) collapses first.
+ const afterOne = computeCollapseOneLevel(new Set(), levels);
+ expect(asSorted(afterOne)).toEqual(['a1']);
+
+ // next deepest expanded level is level 1 (a, b).
+ const afterTwo = computeCollapseOneLevel(afterOne, levels);
+ expect(asSorted(afterTwo)).toEqual(['a', 'a1', 'b']);
+
+ // finally level 0 (root).
+ const afterThree = computeCollapseOneLevel(afterTwo, levels);
+ expect(asSorted(afterThree)).toEqual(['a', 'a1', 'b', 'root']);
+ });
+
+ it('skips already-collapsed deep levels', () => {
+ // Deepest level already collapsed -> collapse level 1 next.
+ const result = computeCollapseOneLevel(new Set(['a1']), threeLevels());
+ expect(asSorted(result)).toEqual(['a', 'a1', 'b']);
+ });
+
+ it('returns an equivalent set when everything is already collapsed', () => {
+ const fullyCollapsed = new Set(['root', 'a', 'b', 'a1']);
+ const result = computeCollapseOneLevel(fullyCollapsed, threeLevels());
+ expect(asSorted(result)).toEqual(['a', 'a1', 'b', 'root']);
+ });
+
+ it('does not mutate the input set', () => {
+ const collapsed = new Set(['a1']);
+ computeCollapseOneLevel(collapsed, threeLevels());
+ expect(asSorted(collapsed)).toEqual(['a1']);
+ });
+ });
+
+ it('expand and collapse one level are inverse across a full tree', () => {
+ const levels = threeLevels();
+
+ // Collapse everything one level at a time.
+ let state = new Set();
+ state = computeCollapseOneLevel(state, levels); // a1
+ state = computeCollapseOneLevel(state, levels); // + a, b
+ state = computeCollapseOneLevel(state, levels); // + root
+ expect(asSorted(state)).toEqual(['a', 'a1', 'b', 'root']);
+
+ // Expand everything one level at a time back to empty.
+ state = computeExpandOneLevel(state, levels); // - root
+ state = computeExpandOneLevel(state, levels); // - a, b
+ state = computeExpandOneLevel(state, levels); // - a1
+ expect(asSorted(state)).toEqual([]);
+ });
+
+ describe('computeToggleCollapse', () => {
+ // A parent `p` with a nested subtree: descendants are c1, c1a, c2.
+ const node = () => ({
+ id: 'p',
+ children: [
+ { id: 'c1', children: [{ id: 'c1a', children: [] }] },
+ { id: 'c2', children: [] },
+ ],
+ });
+
+ it('collapses an expanded node (adds its id)', () => {
+ expect(
+ asSorted(computeToggleCollapse(new Set(), 'p', node(), false)),
+ ).toEqual(['p']);
+ });
+
+ it('expands a collapsed node (removes its id)', () => {
+ expect(
+ asSorted(computeToggleCollapse(new Set(['p']), 'p', node(), false)),
+ ).toEqual([]);
+ });
+
+ it('collapses the whole subtree when includeDescendants is true', () => {
+ expect(
+ asSorted(computeToggleCollapse(new Set(), 'p', node(), true)),
+ ).toEqual(['c1', 'c1a', 'c2', 'p']);
+ });
+
+ it('expands the whole subtree when includeDescendants is true', () => {
+ const collapsed = new Set(['p', 'c1', 'c1a', 'c2']);
+ expect(
+ asSorted(computeToggleCollapse(collapsed, 'p', node(), true)),
+ ).toEqual([]);
+ });
+
+ it('leaves descendants untouched when includeDescendants is false', () => {
+ // c1 stays collapsed even though the parent is toggled.
+ expect(
+ asSorted(computeToggleCollapse(new Set(['c1']), 'p', node(), false)),
+ ).toEqual(['c1', 'p']);
+ });
+
+ it('toggles only the id when the node is not found', () => {
+ expect(
+ asSorted(computeToggleCollapse(new Set(), 'missing', undefined, true)),
+ ).toEqual(['missing']);
+ });
+
+ it('toggles only the id for a leaf node with no children', () => {
+ const leaf = { id: 'leaf', children: [] };
+ expect(
+ asSorted(computeToggleCollapse(new Set(), 'leaf', leaf, true)),
+ ).toEqual(['leaf']);
+ });
+
+ it('does not mutate the input set', () => {
+ const collapsed = new Set(['other']);
+ computeToggleCollapse(collapsed, 'p', node(), true);
+ expect(asSorted(collapsed)).toEqual(['other']);
+ });
+ });
+});
diff --git a/packages/app/src/hooks/useWaterfallSearchState.tsx b/packages/app/src/hooks/useWaterfallSearchState.tsx
index 9732919395..552cdd9bf0 100644
--- a/packages/app/src/hooks/useWaterfallSearchState.tsx
+++ b/packages/app/src/hooks/useWaterfallSearchState.tsx
@@ -8,27 +8,46 @@ export default function useWaterfallSearchState({
}) {
const [traceWhere, setTraceWhere] = useQueryState('traceWhere');
const [logWhere, setLogWhere] = useQueryState('logWhere');
+ const [traceWhereLanguage, setTraceWhereLanguage] =
+ useQueryState('traceWhereLanguage');
+ const [logWhereLanguage, setLogWhereLanguage] =
+ useQueryState('logWhereLanguage');
const isFilterActive = !!traceWhere || !!(hasLogSource && logWhere);
const [isFilterExpanded, setIsFilterExpanded] = useState(isFilterActive);
const onSubmit = useCallback(
- (data: { traceWhere: string; logWhere: string }) => {
+ (data: {
+ traceWhere: string;
+ logWhere: string;
+ traceWhereLanguage?: string;
+ logWhereLanguage?: string;
+ }) => {
setTraceWhere(data.traceWhere || null);
setLogWhere(data.logWhere || null);
+ if (data.traceWhereLanguage !== undefined) {
+ setTraceWhereLanguage(data.traceWhereLanguage || null);
+ }
+ if (data.logWhereLanguage !== undefined) {
+ setLogWhereLanguage(data.logWhereLanguage || null);
+ }
},
- [setTraceWhere, setLogWhere],
+ [setTraceWhere, setLogWhere, setTraceWhereLanguage, setLogWhereLanguage],
);
const clear = useCallback(() => {
setTraceWhere(null);
setLogWhere(null);
- }, [setTraceWhere, setLogWhere]);
+ setTraceWhereLanguage(null);
+ setLogWhereLanguage(null);
+ }, [setTraceWhere, setLogWhere, setTraceWhereLanguage, setLogWhereLanguage]);
return {
traceWhere,
logWhere,
+ traceWhereLanguage,
+ logWhereLanguage,
isFilterActive,
isFilterExpanded,
setIsFilterExpanded,
diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts
index fadce48ef6..49a02f959a 100644
--- a/packages/app/src/utils.ts
+++ b/packages/app/src/utils.ts
@@ -762,20 +762,6 @@ export function getChartColorSuccessHighlight(): string {
);
}
-export function getChartColorErrorHighlight(): string {
- return getSemanticChartColor(
- '--color-chart-error-highlight',
- 'errorHighlight',
- );
-}
-
-export function getChartColorWarningHighlight(): string {
- return getSemanticChartColor(
- '--color-chart-warning-highlight',
- 'warningHighlight',
- );
-}
-
// Try to match log levels to colors
export const semanticKeyedColor = (
key: string | number | undefined,