Summary
Drilling into a trace from a log row's side panel ("View Trace →") makes useRowData issue
SELECT * , ... FROM otel_traces WHERE (TraceId = '…') AND (SpanId = '…') LIMIT 1
with no timestamp predicate. On a time-partitioned traces table this defeats partition pruning, so ClickHouse has to open every partition in the table for a LIMIT 1.
Measured on our production instance (~1.5B spans, 30-day retention, PARTITION BY toDate(Timestamp)), cold, using distinct trace IDs to avoid cache effects:
| Query |
Duration |
Rows read |
| as issued today (unbounded) |
48–50 s |
~3.7 M |
| identical query + a time bound |
0.7–1.0 s |
~0.6 M |
Root cause
packages/app/src/components/DBRowSidePanel.tsx builds traceSpanRowId from ID columns only:
const traceSpanRowId = useMemo(() => {
const clauses: string[] = [];
if (traceIdExpression && traceId) {
clauses.push(SqlString.format('?=?', [SqlString.raw(traceIdExpression), traceId]));
}
if (spanIdExpression && spanId) {
clauses.push(SqlString.format('?=?', [SqlString.raw(spanIdExpression), spanId]));
}
return clauses.length > 0 ? clauses.join(' AND ') : undefined;
}, [traceIdExpression, traceId, spanIdExpression, spanId]);
That string becomes the where of useRowData (packages/app/src/components/DBRowDataPanel.tsx), whose chart config deliberately omits dateRange / timestampValueExpression:
useQueriedChartConfig({
connection: source.connection,
select: [ /* … */ ],
where: rowId ?? '0=1',
from: source.from,
limit: { limit: 1 },
});
That omission is documented and is correct for every other caller, because their rowId comes from useRowWhere, which includes the primary key — and therefore the partition key Timestamp. From packages/cli/src/api/eventQuery.ts:
// Omit dateRange and timestampValueExpression — the WHERE clause
// already uniquely identifies the row so ClickHouse can use the
// filter directly without scanning time partitions.
// This matches the web frontend's useRowData in DBRowDataPanel.tsx.
traceSpanRowId (and handleOpenLinkedTrace for span links) is the one path that hands useRowData a WHERE containing no partition-key column, so the assumption silently breaks there.
Regression range
traceSpanRowId was introduced by #2541 ("feat: single drawer navigation"):
@hyperdx/app@2.29.0 — not present
@hyperdx/app@2.30.0 — present (first release containing commit f6dbdd14)
- still present on
main (2.33.0)
Before 2.30.0 the trace view was a tab inside the same side panel and was handed an explicit ±1h range:
<DBTracePanel traceId={traceId} dateRange={oneHourRange} focusDate={focusDate} />
ClickHouse system.query_log over a 7-day window on our instance, split by http_user_agent:
| Client |
unbounded TraceId+SpanId lookups |
avg |
max |
hyperdx 2.28.0 |
0 (all 72 trace queries time-bounded) |
0.4 s |
3.2 s |
hyperdx 2.33.0 |
25 |
24.2 s |
51.7 s |
Suggested fix
The timestamp needed for the bound is already available in the same component: timestampDate and oneHourRange (±60 min) are derived from the opened row and passed to DBTracePanel, ServiceMapSidePanel and useSessionId — just not to useRowData. Two options:
-
Append a time-range predicate to traceSpanRowId (and to handleOpenLinkedTrace). rowId is already a plain WHERE string, so this needs no type or schema changes:
if (timestampExpression && timestampDate) {
clauses.push(`${tsExpr} >= … AND ${tsExpr} <= …`); // e.g. oneHourRange
}
A range rather than an equality is what fits here: the opened row is a log, and the span it belongs to typically starts slightly earlier. oneHourRange is the same window the waterfall query already uses for the same trace, so this introduces no new assumption.
-
Or add an optional dateRange / timestampValueExpression pass-through to useRowData, and carry the origin row's timestamp on SourceFrameSchema (packages/app/src/components/DBRowSidePanel.types.ts), which currently has no timestamp slot — so the clicked row's timestamp is structurally dropped when the frame is pushed.
Happy to open a PR if that would help.
Environment
- HyperDX 2.33.0, self-hosted, external ClickHouse 26.4.3.37 (2 replicas)
otel_traces: PARTITION BY toDate(Timestamp), ORDER BY (ServiceName, SpanName, toDateTime(Timestamp)), bloom_filter skip index on TraceId
- 30-day retention; partitions older than 3 days live on S3-backed tiered storage. The tiering amplifies the per-partition latency, but the missing pruning is what makes the query touch all partitions in the first place.
Investigation was AI-assisted. All measurements come from our production instance and every claim above was verified manually against the source and system.query_log.
Summary
Drilling into a trace from a log row's side panel ("View Trace →") makes
useRowDataissuewith no timestamp predicate. On a time-partitioned traces table this defeats partition pruning, so ClickHouse has to open every partition in the table for a
LIMIT 1.Measured on our production instance (~1.5B spans, 30-day retention,
PARTITION BY toDate(Timestamp)), cold, using distinct trace IDs to avoid cache effects:Root cause
packages/app/src/components/DBRowSidePanel.tsxbuildstraceSpanRowIdfrom ID columns only:That string becomes the
whereofuseRowData(packages/app/src/components/DBRowDataPanel.tsx), whose chart config deliberately omitsdateRange/timestampValueExpression:That omission is documented and is correct for every other caller, because their
rowIdcomes fromuseRowWhere, which includes the primary key — and therefore the partition keyTimestamp. Frompackages/cli/src/api/eventQuery.ts:traceSpanRowId(andhandleOpenLinkedTracefor span links) is the one path that handsuseRowDataa WHERE containing no partition-key column, so the assumption silently breaks there.Regression range
traceSpanRowIdwas introduced by #2541 ("feat: single drawer navigation"):@hyperdx/app@2.29.0— not present@hyperdx/app@2.30.0— present (first release containing commitf6dbdd14)main(2.33.0)Before 2.30.0 the trace view was a tab inside the same side panel and was handed an explicit ±1h range:
ClickHouse
system.query_logover a 7-day window on our instance, split byhttp_user_agent:TraceId+SpanIdlookupshyperdx 2.28.0hyperdx 2.33.0Suggested fix
The timestamp needed for the bound is already available in the same component:
timestampDateandoneHourRange(±60 min) are derived from the opened row and passed toDBTracePanel,ServiceMapSidePanelanduseSessionId— just not touseRowData. Two options:Append a time-range predicate to
traceSpanRowId(and tohandleOpenLinkedTrace).rowIdis already a plain WHERE string, so this needs no type or schema changes:A range rather than an equality is what fits here: the opened row is a log, and the span it belongs to typically starts slightly earlier.
oneHourRangeis the same window the waterfall query already uses for the same trace, so this introduces no new assumption.Or add an optional
dateRange/timestampValueExpressionpass-through touseRowData, and carry the origin row's timestamp onSourceFrameSchema(packages/app/src/components/DBRowSidePanel.types.ts), which currently has no timestamp slot — so the clicked row's timestamp is structurally dropped when the frame is pushed.Happy to open a PR if that would help.
Environment
otel_traces:PARTITION BY toDate(Timestamp),ORDER BY (ServiceName, SpanName, toDateTime(Timestamp)),bloom_filterskip index onTraceIdInvestigation was AI-assisted. All measurements come from our production instance and every claim above was verified manually against the source and
system.query_log.