Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b059a25
feat(app): surface trace total duration and span count in search results
milansanjeev Sep 1, 2026
eefbd84
chore: add changeset for trace duration search stats
cursoragent Sep 1, 2026
f1b2706
feat(app): move trace total duration/span count stat into the trace w…
milansanjeev Sep 2, 2026
bb35ac6
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Sep 2, 2026
d8b218e
fix(app): address review findings on trace total duration stat
milansanjeev Sep 2, 2026
7d62ee0
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Sep 2, 2026
c395aba
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Sep 3, 2026
d0cd1b5
fix(app): address remaining review notes on trace total duration
cursoragent Sep 3, 2026
8fd8e29
fix(app): resolve tsc type error and address remaining review feedbac…
milansanjeev Oct 1, 2026
a8a4eb5
Merge remote-tracking branch 'upstream/main' into feat/trace-total-du…
milansanjeev Oct 1, 2026
178fe7d
fix(app): mark clipped traces without a false lower-edge check
milansanjeev Oct 1, 2026
59ee0b9
fix(app): mark a clipped trace from the window edge, not a missing pa…
milansanjeev Oct 1, 2026
0512625
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Oct 1, 2026
3bb7f24
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Oct 1, 2026
70450b6
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Oct 2, 2026
f859e94
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Oct 2, 2026
580ef40
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Oct 2, 2026
63a21ad
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Oct 2, 2026
2a7d8a1
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Oct 2, 2026
9f4f58f
fix(app): treat a missing parent as a clipped trace start
milansanjeev Oct 2, 2026
26c80d2
chore: Update tests and formatting
pulpdrew Oct 2, 2026
653fa94
Merge branch 'main' into feat/trace-total-duration-in-search-table
milansanjeev Oct 3, 2026
6f0604e
test: Fix slow test
pulpdrew Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/trace-total-duration-search-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

feat: show a trace's total wall-clock duration in the waterfall controls bar
151 changes: 137 additions & 14 deletions packages/app/src/components/DBTraceWaterfallChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -66,6 +70,7 @@ import { useFormatTime } from '@/useFormatTime';
import {
CATEGORICAL_PALETTE_TOKENS,
COLORS,
formatDurationMs,
getChartColorError,
getChartColorSuccess,
getChartColorSuccessHighlight,
Expand Down Expand Up @@ -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
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
// and invalidate every memo downstream of the waterfall rows.
const EMPTY_ROWS: Record<string, any>[] = [];

export function TraceTotalDurationStat({
totalDurationMs,
isTruncated,
}: {
totalDurationMs: number;
isTruncated: boolean;
}) {
return (
<Tooltip
label={
isTruncated
? `This duration is a lower bound because some spans may not have been loaded. The trace may extend past the loaded time window, or contain more than ${TRACE_WATERFALL_ROW_LIMIT.toLocaleString()} spans.`
: 'Wall-clock duration from the earliest fetched span start to the latest span end. Independent of the applied filters.'
}
position="bottom"
>
<span data-testid="trace-total-stats">
{' '}
&middot; Total duration: {formatDurationMs(totalDurationMs)}
{isTruncated ? '+' : ''}
</span>
</Tooltip>
);
}

function getTableBody(tableModel: TSource) {
if (tableModel?.kind === SourceKind.Trace) {
return getSpanEventBody(tableModel) ?? '';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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(
Expand All @@ -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<string, any>
// 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,
Expand All @@ -414,13 +470,14 @@ export function useEventsAroundFocus({
aliasWith: [],
};
});
}, [afterSpanData, beforeSpanData, meta, getRowWhere, type]);
}, [trimmedAfter, trimmedBefore, meta, getRowWhere, type]);

return {
rows,
meta,
isFetching,
error,
isTruncated,
};
}

Expand All @@ -445,6 +502,7 @@ function useFilteredEventsAroundFocus(
isFetching: filtered.isFetching || fallback.isFetching,
filterError: fallback.error ? undefined : filtered.error,
fatalError: fallback.error,
isTruncated: fallback.isTruncated,
};
}

Expand All @@ -454,6 +512,7 @@ function useFilteredEventsAroundFocus(
isFetching: filtered.isFetching,
filterError: undefined,
fatalError: filtered.error,
isTruncated: filtered.isTruncated,
};
}

Expand Down Expand Up @@ -666,6 +725,7 @@ export function DBTraceWaterfallChartContainer({
meta: traceRowsMeta,
filterError: traceFilterError,
fatalError: traceError,
isTruncated: traceIsTruncated,
} = useFilteredEventsAroundFocus({
tableSource: traceTableSource,
focusDate,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — A missing parent on the earliest span marks the total as a lower bound even when nothing was clipped

isStartClipped assumes a parent was not loaded only because it started before the window. It is also true when an upstream hop (a proxy or gateway, or a browser with RUM off) passes on a traceparent but never exports its span. For those services every trace shows +, and the tooltip says some spans may not have been loaded. Return which check fired (row cap, start/parent, end) and show a tooltip for that reason. For the parent case, say the root span is not in the loaded data rather than that the trace extends past the window.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

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,
Comment thread
milansanjeev marked this conversation as resolved.
Resolved
/** 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(() => {
Expand Down Expand Up @@ -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<Set<string>>(new Set());
const [showSpanEvents, setShowSpanEvents] = useState(true);
Expand Down Expand Up @@ -1410,6 +1527,12 @@ export function DBTraceWaterfallChartContainer({
<span className={errorCount ? 'text-danger' : ''}>
{errorCountString}
</span>
{traceExtent != null && (
<TraceTotalDurationStat
totalDurationMs={traceExtent.totalDurationMs}
isTruncated={traceIsTruncated || traceExtent.isWindowClipped}
/>
)}
</Text>
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
Expand Down
Loading
Loading