Repository navigation
feat(app): surface trace total duration and span count in the trace waterfall view #3051
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b059a25
eefbd84
f1b2706
bb35ac6
d8b218e
7d62ee0
c395aba
d0cd1b5
8fd8e29
a8a4eb5
178fe7d
59ee0b9
0512625
3bb7f24
70450b6
f859e94
580ef40
63a21ad
2a7d8a1
9f4f58f
26c80d2
653fa94
6f0604e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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"> | ||
| {' '} | ||
| · Total duration: {formatDurationMs(totalDurationMs)} | ||
| {isTruncated ? '+' : ''} | ||
| </span> | ||
| </Tooltip> | ||
| ); | ||
| } | ||
|
|
||
| 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<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, | ||
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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 |
||
| } | ||
|
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, | ||
|
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(() => { | ||
|
|
@@ -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); | ||
|
|
@@ -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"> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.