Skip to content

Trace drilldown from log side panel runs an unbounded TraceId/SpanId lookup (no partition pruning) — regression since 2.30.0 #2811

Description

@SuhChangMin

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:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingexternal

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions