diff --git a/.changeset/trace-total-duration-search-table.md b/.changeset/trace-total-duration-search-table.md new file mode 100644 index 0000000000..d550e91230 --- /dev/null +++ b/.changeset/trace-total-duration-search-table.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +feat: show a trace's total wall-clock duration in the waterfall controls bar \ No newline at end of file diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index 123177df02..35e116764f 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -53,6 +53,10 @@ import { TimelineMinimap, type TimelineViewportController, } from '@/components/TimelineChart'; +import { + TRACE_WATERFALL_FETCH_LIMIT, + TRACE_WATERFALL_ROW_LIMIT, +} from '@/components/traceWaterfallLimits'; import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery'; import useRowWhere, { WithClause } from '@/hooks/useRowWhere'; import useWaterfallSearchState from '@/hooks/useWaterfallSearchState'; @@ -66,6 +70,7 @@ import { useFormatTime } from '@/useFormatTime'; import { CATEGORICAL_PALETTE_TOKENS, COLORS, + formatDurationMs, getChartColorError, getChartColorSuccess, getChartColorSuccessHighlight, @@ -146,6 +151,35 @@ const SERVICE_COLORS = COLORS.filter( CATEGORICAL_PALETTE_TOKENS[i] !== 'chart-red', ); +// Stable empty fallback. `data ?? []` would allocate a new array every render +// and invalidate every memo downstream of the waterfall rows. +const EMPTY_ROWS: Record[] = []; + +export function TraceTotalDurationStat({ + totalDurationMs, + isTruncated, +}: { + totalDurationMs: number; + isTruncated: boolean; +}) { + return ( + + + {' '} + · Total duration: {formatDurationMs(totalDurationMs)} + {isTruncated ? '+' : ''} + + + ); +} + function getTableBody(tableModel: TSource) { if (tableModel?.kind === SourceKind.Trace) { return getSpanEventBody(tableModel) ?? ''; @@ -302,7 +336,7 @@ function getConfig( from: source.from, timestampValueExpression: source.timestampValueExpression, where: `${alias.TraceId} = ${SqlString.escape(traceId)}`, - limit: { limit: 50000 }, + limit: { limit: TRACE_WATERFALL_FETCH_LIMIT }, connection: source.connection, }; return { config, alias, type: source.kind }; @@ -383,16 +417,34 @@ export function useEventsAroundFocus({ const isFetching = isBeforeSpanFetching || isAfterSpanFetching; const meta = beforeSpanData?.meta ?? afterSpanData?.meta; const error = beforeSpanError || afterSpanError; + // LIMIT+1 is a sentinel: a complete window of exactly TRACE_WATERFALL_ROW_LIMIT + // rows must not show as a lower bound. Drop the extra row so the chart still + // renders at most the cap. Trim inside this memo so a parent re-render does + // not allocate fresh arrays and rebuild the span tree. + const beforeData = beforeSpanData?.data; + const afterData = afterSpanData?.data; + const { isTruncated, trimmedBefore, trimmedAfter } = useMemo(() => { + const beforeRows = beforeData ?? EMPTY_ROWS; + const afterRows = afterData ?? EMPTY_ROWS; + const beforeTruncated = beforeRows.length > TRACE_WATERFALL_ROW_LIMIT; + const afterTruncated = afterRows.length > TRACE_WATERFALL_ROW_LIMIT; + return { + isTruncated: beforeTruncated || afterTruncated, + trimmedBefore: beforeTruncated + ? beforeRows.slice(0, TRACE_WATERFALL_ROW_LIMIT) + : beforeRows, + trimmedAfter: afterTruncated + ? afterRows.slice(0, TRACE_WATERFALL_ROW_LIMIT) + : afterRows, + }; + }, [beforeData, afterData]); const getRowWhere = useRowWhere({ meta, aliasMap: alias }); const rows = useMemo(() => { // Sometimes meta has not loaded yet // DO NOT REMOVE, useRowWhere will error if no meta if (!meta || meta.length === 0) return []; - return [ - ...(beforeSpanData?.data ?? []), - ...(afterSpanData?.data ?? []), - ].map(cd => { + return [...trimmedBefore, ...trimmedAfter].map(cd => { // Omit SpanAttributes, SpanEvents and __hdx_hidden from rowWhere id generation. // SpanAttributes and SpanEvents can be large objects, and __hdx_hidden may be a lucene expression. const rowWhereResult = getRowWhere( @@ -402,9 +454,13 @@ export function useEventsAroundFocus({ return { // Keep all fields available for display ...cd, - // Added for typing + // Named fields are repeated for typing. Spreading Record + // drops the index signature once explicit keys are set, so Duration + // would otherwise be missing from the row type. Timestamp: cd?.Timestamp, SpanId: cd?.SpanId, + ParentSpanId: cd?.ParentSpanId, + Duration: cd?.Duration, __hdx_hidden: cd?.__hdx_hidden, type, id: rowWhereResult.where, @@ -414,13 +470,14 @@ export function useEventsAroundFocus({ aliasWith: [], }; }); - }, [afterSpanData, beforeSpanData, meta, getRowWhere, type]); + }, [trimmedAfter, trimmedBefore, meta, getRowWhere, type]); return { rows, meta, isFetching, error, + isTruncated, }; } @@ -445,6 +502,7 @@ function useFilteredEventsAroundFocus( isFetching: filtered.isFetching || fallback.isFetching, filterError: fallback.error ? undefined : filtered.error, fatalError: fallback.error, + isTruncated: fallback.isTruncated, }; } @@ -454,6 +512,7 @@ function useFilteredEventsAroundFocus( isFetching: filtered.isFetching, filterError: undefined, fatalError: filtered.error, + isTruncated: filtered.isTruncated, }; } @@ -666,6 +725,7 @@ export function DBTraceWaterfallChartContainer({ meta: traceRowsMeta, filterError: traceFilterError, fatalError: traceError, + isTruncated: traceIsTruncated, } = useFilteredEventsAroundFocus({ tableSource: traceTableSource, focusDate, @@ -723,6 +783,70 @@ export function DBTraceWaterfallChartContainer({ return nextRows; }, [traceRowsData, logRowsData]); + const validSpanIDs = useMemo(() => { + return new Set( + traceRowsData // only spans in traces can define valid span ids + ?.filter(row => _.isString(row.SpanId) && row.SpanId.length > 0) + .map(row => row.SpanId) ?? [], + ); + }, [traceRowsData]); + + const traceExtent = useMemo(() => { + if (traceRowsData.length === 0) return null; + + const windowEndMs = dateRange[1].getTime(); + + let minStartMs = Number.POSITIVE_INFINITY; + let maxEndMs = Number.NEGATIVE_INFINITY; + + let isStartClipped = false; + let isEndClipped = false; + + let isAnySpanValid = false; + for (const row of traceRowsData) { + // timestamp-nano accepts a non-date as a finite instant, which would + // stretch the envelope. Skip those before parsing. + if ( + typeof row.Timestamp !== 'string' || + Number.isNaN(Date.parse(row.Timestamp)) + ) { + continue; + } + + let startMs: number; + try { + startMs = parseTimestampToMs(row.Timestamp); + } catch { + continue; + } + if (!Number.isFinite(startMs)) continue; + + const durationSec = Number(row.Duration); + const endMs = + startMs + (Number.isFinite(durationSec) ? durationSec : 0) * 1000; + if (startMs < minStartMs) { + minStartMs = startMs; + + // The start is clipped if the earliest span has a parent that was not fetched + const parentId = + typeof row.ParentSpanId === 'string' ? row.ParentSpanId : ''; + isStartClipped = parentId.length > 0 && !validSpanIDs.has(parentId); + } + + if (endMs > maxEndMs) maxEndMs = endMs; + if (endMs >= windowEndMs) isEndClipped = true; + isAnySpanValid = true; + } + if (!isAnySpanValid) return null; + + return { + /** Wall-clock duration across every fetched span (min start to max end) */ + totalDurationMs: maxEndMs - minStartMs, + /** Whether the trace start or end is clipped by the time range */ + isWindowClipped: isStartClipped || isEndClipped, + }; + }, [traceRowsData, validSpanIDs, dateRange]); + // Map each distinct span service to a stable color. Sorting the names first // keeps a service's color stable across renders regardless of row ordering. const serviceColorMap = useMemo(() => { @@ -845,13 +969,6 @@ export function DBTraceWaterfallChartContainer({ children: SpanRow[]; aliasWith: WithClause[]; }; - const validSpanIDs = useMemo(() => { - return new Set( - traceRowsData // only spans in traces can define valid span ids - ?.filter(row => _.isString(row.SpanId) && row.SpanId.length > 0) - .map(row => row.SpanId) ?? [], - ); - }, [traceRowsData]); const [collapsedIds, setCollapsedIds] = useState>(new Set()); const [showSpanEvents, setShowSpanEvents] = useState(true); @@ -1410,6 +1527,12 @@ export function DBTraceWaterfallChartContainer({ {errorCountString} + {traceExtent != null && ( + + )} diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index f2fecf6a12..ee7e81ae14 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -17,13 +17,19 @@ import { DBTraceWaterfallChartContainer, getDescendantIds, SpanRow, + TraceTotalDurationStat, useEventsAroundFocus, } from '@/components/DBTraceWaterfallChart'; import { TimelineChart } from '@/components/TimelineChart'; +import { TRACE_WATERFALL_ROW_LIMIT } from '@/components/traceWaterfallLimits'; import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery'; import useRowWhere from '@/hooks/useRowWhere'; // Mock setup +jest.mock('@/components/traceWaterfallLimits', () => ({ + TRACE_WATERFALL_ROW_LIMIT: 3, + TRACE_WATERFALL_FETCH_LIMIT: 4, +})); jest.mock('@/components/TimelineChart', () => { const flattenText = (value: React.ReactNode): string => { if (value == null || typeof value === 'boolean') { @@ -146,11 +152,15 @@ describe('DBTraceWaterfallChartContainer', () => { connection: 'conn2', }; + // Wide enough to contain every span/log timestamp used by the tests below + // (06:00:00-07:00:00) with margin on both sides, so a span ending at the + // window's upper edge does not trip the lower-bound marker unless a test + // asks for that. const mockDateRange = [ - new Date('2024-01-01T00:00:00.000Z'), - new Date('2024-01-01T01:00:00.000Z'), + new Date('2024-01-01T05:00:00.000Z'), + new Date('2024-01-01T08:00:00.000Z'), ] as [Date, Date]; - const mockFocusDate = new Date('2024-01-01T00:30:00.000Z'); + const mockFocusDate = new Date('2024-01-01T06:30:00.000Z'); const mockTraceId = 'test-trace-id'; // Sample data @@ -312,6 +322,346 @@ describe('DBTraceWaterfallChartContainer', () => { expect(MockTimelineChart.latestProps.rows[1]).toBeTruthy(); // Log row }); + it('renders total duration computed from all fetched spans', async () => { + setupQueryMocks({ traceData: mockTraceData }); + renderComponent(null); // No log table source + await waitForLoading(); + + const stats = screen.getByTestId('trace-total-stats'); + // mockTraceData has one span, Duration: 0.1 (seconds) -> 100ms wall clock + expect(stats.textContent?.trim()).toBe('· Total duration: 100ms'); + }); + + it('excludes correlated logs from the total duration computation', async () => { + const laterLogData = { + data: [ + { + ...mockLogData.data[0], + // Far later than the span's own end (start + 100ms) -- if logs were + // wrongly included in the MAX(end) computation, this would blow up + // the reported duration. + Timestamp: '2024-01-01T07:00:00.000000000Z', + }, + ], + meta: [{ totalCount: 1 }], + }; + + setupQueryMocks({ + traceData: mockTraceData, + logData: laterLogData, + }); + + renderComponent(); // With log table source + await waitForLoading(); + + const stats = screen.getByTestId('trace-total-stats'); + expect(stats.textContent?.trim()).toBe('· Total duration: 100ms'); + }); + + it('computes total duration as the wall-clock span across multiple non-overlapping spans', async () => { + const multiSpanData = { + data: [ + { + Body: 'first span', + Timestamp: '2024-01-01T06:00:00.000000000Z', + Duration: 0.1, // ends at +100ms + SpanId: 'span-1', + ParentSpanId: '', + ServiceName: 'test-service', + HyperDXEventType: 'span' as const, + type: 'trace', + } as SpanRow, + { + Body: 'second span', + Timestamp: '2024-01-01T06:00:01.000000000Z', // starts 1s after the first + Duration: 0.5, // ends at +1.5s from the first span's start + SpanId: 'span-2', + ParentSpanId: '', + ServiceName: 'test-service', + HyperDXEventType: 'span' as const, + type: 'trace', + } as SpanRow, + ], + meta: [{ totalCount: 2 }], + }; + + setupQueryMocks({ traceData: multiSpanData }); + renderComponent(null); + await waitForLoading(); + + const stats = screen.getByTestId('trace-total-stats'); + // MIN(start) = 06:00:00.000, MAX(start+duration) = 06:00:01.500 -> 1500ms + expect(stats.textContent?.trim()).toBe('· Total duration: 1.5s'); + }); + + it('computes total duration as the true max end time even when a later-processed span ends earlier (overlapping spans)', async () => { + const overlappingSpanData = { + data: [ + { + Body: 'outer span', + Timestamp: '2024-01-01T06:00:00.000000000Z', + Duration: 1.0, // ends at +1000ms -- this is the actual max end + SpanId: 'span-1', + ParentSpanId: '', + ServiceName: 'test-service', + HyperDXEventType: 'span' as const, + type: 'trace', + } as SpanRow, + { + Body: 'inner span', + Timestamp: '2024-01-01T06:00:00.100000000Z', // starts 100ms after the outer span + Duration: 0.2, // ends at +300ms -- earlier than the outer span's end, but processed last + SpanId: 'span-2', + ParentSpanId: 'span-1', + ServiceName: 'test-service', + HyperDXEventType: 'span' as const, + type: 'trace', + } as SpanRow, + ], + meta: [{ totalCount: 2 }], + }; + + setupQueryMocks({ traceData: overlappingSpanData }); + renderComponent(null); + await waitForLoading(); + + const stats = screen.getByTestId('trace-total-stats'); + // A "last row wins" bug would report the inner span's earlier end (300ms) + // instead of the true MAX(end) across all spans (1000ms). + expect(stats.textContent?.trim()).toBe('· Total duration: 1s'); + }); + + it('does not change when the Spans chip hides spans from the visible timeline (reflects all fetched spans, not the visible subset)', async () => { + setupQueryMocks({ + traceData: mockTraceData, + logData: mockLogData, + }); + renderComponent(); // With log table source, so the chart stays mounted + await waitForLoading(); + + const statsBefore = screen.getByTestId('trace-total-stats').textContent; + expect(statsBefore?.trim()).toBe('· Total duration: 100ms'); + + await userEvent.click(screen.getByTestId('show-spans-chip')); + + await waitFor(() => { + expect(screen.getByTestId('trace-total-stats').textContent).toBe( + statsBefore, + ); + }); + }); + + it('does not render the total duration stat when there are no spans', async () => { + // A log with no matching span never becomes a visible node (correlated + // logs attach to a span), so this renders the existing empty state -- + // just confirms the new stat doesn't render in that case either. + setupQueryMocks({ logData: mockLogData }); + renderComponent(); + + await waitFor(() => { + expect( + screen.getByText('No matching spans or logs found'), + ).toBeInTheDocument(); + }); + expect(screen.queryByTestId('trace-total-stats')).not.toBeInTheDocument(); + }); + + it('appends + on the duration label when the fetch window was truncated', () => { + renderWithMantine( + , + ); + expect(screen.getByTestId('trace-total-stats').textContent?.trim()).toBe( + '· Total duration: 100ms+', + ); + }); + + it('shows the lower-bound marker when the trace query exceeds the row cap', async () => { + const span = { + Body: 'capped span', + Timestamp: '2024-01-01T06:00:00.000000000Z', + Duration: 0.1, + SpanId: 'span-1', + ParentSpanId: '', + ServiceName: 'test-service', + HyperDXEventType: 'span' as const, + type: 'trace', + }; + setupQueryMocks({ + traceData: { + data: Array.from({ length: TRACE_WATERFALL_ROW_LIMIT + 1 }, () => span), + meta: [{ totalCount: TRACE_WATERFALL_ROW_LIMIT + 1 }], + }, + }); + renderComponent(null); + await waitForLoading(); + + expect(screen.getByTestId('trace-total-stats').textContent?.trim()).toBe( + '· Total duration: 100ms+', + ); + }); + + it('marks the duration when the earliest span has a parent that was not loaded', async () => { + const childInsideWindow = { + data: [ + { + ...mockTraceData.data[0], + // Seconds inside 05:00:00-08:00:00. The parent started before the + // window, so the query never returned it. + Timestamp: '2024-01-01T06:00:00.000000000Z', + SpanId: 'span-child', + ParentSpanId: 'missing-root', + }, + { + ...mockTraceData.data[0], + Body: 'later span', + Timestamp: '2024-01-01T06:00:01.000000000Z', + Duration: 0.1, + SpanId: 'span-later', + ParentSpanId: 'span-child', + }, + ], + meta: [{ totalCount: 2 }], + }; + setupQueryMocks({ traceData: childInsideWindow }); + renderComponent(null); + await waitForLoading(); + + expect(screen.getByTestId('trace-total-stats').textContent?.trim()).toBe( + '· Total duration: 1.1s+', + ); + }); + + it('does not mark the duration when the earliest span has a parent that was loaded', async () => { + const skewedChildFirst = { + data: [ + { + ...mockTraceData.data[0], + Timestamp: '2024-01-01T06:00:00.000000000Z', + SpanId: 'span-child', + ParentSpanId: 'span-parent', + }, + { + ...mockTraceData.data[0], + Body: 'parent span', + Timestamp: '2024-01-01T06:00:00.050000000Z', + SpanId: 'span-parent', + ParentSpanId: '', + }, + ], + meta: [{ totalCount: 2 }], + }; + setupQueryMocks({ traceData: skewedChildFirst }); + renderComponent(null); + await waitForLoading(); + + expect(screen.getByTestId('trace-total-stats').textContent?.trim()).toBe( + '· Total duration: 150ms', + ); + }); + + it("marks the duration as a lower bound when a span ends at/after the fetch window's upper edge", async () => { + const spanAtWindowEdge = { + data: [ + { + ...mockTraceData.data[0], + // Ends exactly at mockDateRange's upper edge (08:00:00): starts at + // 07:59:59.900 + Duration 0.1s. + Timestamp: '2024-01-01T07:59:59.900000000Z', + }, + ], + meta: [{ totalCount: 1 }], + }; + setupQueryMocks({ traceData: spanAtWindowEdge }); + renderComponent(null); + await waitForLoading(); + + expect(screen.getByTestId('trace-total-stats').textContent?.trim()).toBe( + '· Total duration: 100ms+', + ); + }); + + it('skips an unparseable timestamp when another span is valid', async () => { + const mixed = { + data: [ + { + ...mockTraceData.data[0], + Timestamp: 'not-a-timestamp', + SpanId: 'span-bad', + ParentSpanId: '', + }, + { + ...mockTraceData.data[0], + Timestamp: '2024-01-01T06:00:00.000000000Z', + Duration: 0.1, + SpanId: 'span-good', + ParentSpanId: '', + }, + ], + meta: [{ totalCount: 2 }], + }; + setupQueryMocks({ traceData: mixed }); + renderComponent(null); + await waitForLoading(); + + expect(screen.getByTestId('trace-total-stats').textContent?.trim()).toBe( + '· Total duration: 100ms', + ); + }); + + it('does not render the total duration stat when every timestamp is unparseable', async () => { + const invalid = { + data: [ + { + ...mockTraceData.data[0], + Timestamp: 'not-a-timestamp', + }, + ], + meta: [{ totalCount: 1 }], + }; + setupQueryMocks({ traceData: invalid }); + renderComponent(null); + await waitForLoading(); + + expect(screen.queryByTestId('trace-total-stats')).not.toBeInTheDocument(); + }); + + it('keeps the fetched duration when a span is flagged hidden', async () => { + const hiddenSpanData = { + data: [ + { + Body: 'visible span', + Timestamp: '2024-01-01T06:00:00.000000000Z', + Duration: 0.1, + SpanId: 'span-visible', + ParentSpanId: '', + ServiceName: 'test-service', + HyperDXEventType: 'span' as const, + type: 'trace', + __hdx_hidden: false, + } as SpanRow, + { + Body: 'hidden span', + Timestamp: '2024-01-01T06:00:01.000000000Z', + Duration: 0.5, + SpanId: 'span-hidden', + ParentSpanId: '', + ServiceName: 'test-service', + HyperDXEventType: 'span' as const, + type: 'trace', + __hdx_hidden: true, + } as SpanRow, + ], + meta: [{ totalCount: 2 }], + }; + + setupQueryMocks({ traceData: hiddenSpanData }); + renderComponent(null); + await waitForLoading(); + + expect(screen.getByTestId('trace-total-stats').textContent?.trim()).toBe( + '· Total duration: 1.5s', + ); + }); it('renders empty state when no data is available', async () => { mockUseOffsetPaginatedQuery.mockReturnValue({ data: emptyData, @@ -689,6 +1039,60 @@ describe('useEventsAroundFocus', () => { const result = testEventsAroundFocus({ enabled: false }); expect(result.rows.length).toBe(0); }); + + it('does not flag isTruncated when a window returns exactly the row cap', () => { + const exactCapData = { + data: new Array(TRACE_WATERFALL_ROW_LIMIT).fill(null), + meta: [{ totalCount: TRACE_WATERFALL_ROW_LIMIT }], + }; + + const result = testEventsAroundFocus({ + beforeData: exactCapData, + afterData: { data: [], meta: [{ totalCount: 0 }] }, + }); + + expect(result.isTruncated).toBe(false); + expect(result.rows.length).toBe(TRACE_WATERFALL_ROW_LIMIT); + }); + + it('flags isTruncated when a window returns more rows than the cap, and drops the sentinel row', () => { + const overCapData = { + data: new Array(TRACE_WATERFALL_ROW_LIMIT + 1).fill(null), + meta: [{ totalCount: TRACE_WATERFALL_ROW_LIMIT + 1 }], + }; + + const result = testEventsAroundFocus({ + beforeData: overCapData, + afterData: { data: [], meta: [{ totalCount: 0 }] }, + }); + + expect(result.isTruncated).toBe(true); + expect(result.rows.length).toBe(TRACE_WATERFALL_ROW_LIMIT); + }); + + it('flags isTruncated when only the after window returns more rows than the cap', () => { + const overCapData = { + data: new Array(TRACE_WATERFALL_ROW_LIMIT + 1).fill(null), + meta: [{ totalCount: TRACE_WATERFALL_ROW_LIMIT + 1 }], + }; + + const result = testEventsAroundFocus({ + beforeData: { data: [{ Body: 'a' }], meta: [{ totalCount: 1 }] }, + afterData: overCapData, + }); + + expect(result.isTruncated).toBe(true); + expect(result.rows.length).toBe(TRACE_WATERFALL_ROW_LIMIT + 1); + }); + + it('does not flag isTruncated when both windows return fewer rows than the cap', () => { + const result = testEventsAroundFocus({ + beforeData: { data: [{ Body: 'a' }], meta: [{ totalCount: 1 }] }, + afterData: { data: [{ Body: 'b' }], meta: [{ totalCount: 1 }] }, + }); + + expect(result.isTruncated).toBe(false); + }); }); describe('getDescendantIds', () => { diff --git a/packages/app/src/components/traceWaterfallLimits.ts b/packages/app/src/components/traceWaterfallLimits.ts new file mode 100644 index 0000000000..40a9496dfd --- /dev/null +++ b/packages/app/src/components/traceWaterfallLimits.ts @@ -0,0 +1,7 @@ +// Per-window (before/after focus date) row cap for the trace waterfall query. +// Kept in its own module so tests can mock it down to a few rows instead of +// rendering 50k spans. +export const TRACE_WATERFALL_ROW_LIMIT = 50000; +// Fetch one extra row so a window that contains *exactly* the cap is not +// mistaken for truncation. A full page of LIMIT is ambiguous; LIMIT+1 is not. +export const TRACE_WATERFALL_FETCH_LIMIT = TRACE_WATERFALL_ROW_LIMIT + 1;