[HDX-3963] Optimize event detail and trace waterfall queries (#2104)

## Summary

Optimizes the queries powering the event details panel and trace waterfall chart in the TUI, and improves the trace waterfall UX.

Fixes HDX-3963
Linear: https://linear.app/clickhouse/issue/HDX-3963

### Query optimizations

**Full row fetch (`SELECT *`)** — Removed the 1-year `dateRange` from `buildFullRowQuery`. The WHERE clause already uniquely identifies the row, so ClickHouse can use the filter directly without scanning time partitions. Matches the web frontend's `useRowData` pattern in `DBRowDataPanel.tsx`.

**Trace waterfall queries** — Replaced raw SQL builders (`buildTraceSpansSql`/`buildTraceLogsSql`) with `renderChartConfig`-based async builders. This enables:
- Time partition pruning via a tight ±1h `dateRange` derived from the event timestamp
- Materialized field optimization
- Query parameterization

**Trace span detail fetch** — Replaced raw SQL `SELECT * FROM ... WHERE ... LIMIT 1` with `renderChartConfig` via `buildTraceRowDetailConfig`, omitting `dateRange`/`timestampValueExpression` so ClickHouse uses the WHERE clause directly.

**Shared logic** — Extracted all trace config construction into `packages/cli/src/shared/traceConfig.ts`.

### UX improvements

**Trace Event Details → dedicated page** — Previously Event Details was rendered inline below the waterfall, consuming screen space. Now:
- Waterfall view gets the full terminal height
- Press `l`/`Enter` to drill into a span's full Event Details
- Press `h`/`Esc` to return to the waterfall
- `Ctrl+D/U` scrolls the detail view using full terminal height

**Trace waterfall scrolling** — Previously the waterfall truncated at `maxRows` with no way to see remaining spans. Now `j`/`k` scrolls the viewport when the cursor reaches the edge, with a scroll position indicator showing spans above/below.

### Files changed

| File | Change |
|---|---|
| `packages/cli/src/shared/traceConfig.ts` | NEW — shared trace config builders |
| `packages/cli/src/api/eventQuery.ts` | Replace raw SQL with `renderChartConfig`; remove date range from full row fetch |
| `packages/cli/src/components/TraceWaterfall/TraceWaterfall.tsx` | Split into waterfall + detail views; add scroll support |
| `packages/cli/src/components/TraceWaterfall/useTraceData.ts` | Use async builders + parameterized queries |
| `packages/cli/src/components/TraceWaterfall/types.ts` | Add `metadata`, `eventTimestamp`, `detailExpanded` props |
| `packages/cli/src/components/EventViewer/useKeybindings.ts` | Add `l`/`h` keybindings for trace detail navigation |
| `packages/cli/src/components/EventViewer/DetailPanel.tsx` | Thread `metadata`, `eventTimestamp`, `traceDetailExpanded` |
| `packages/cli/src/components/EventViewer/EventViewer.tsx` | Add `traceDetailExpanded` state; pass new props |
This commit is contained in:
Warren Lee 2026-04-13 14:39:41 -07:00 committed by GitHub
parent a5869f0eb7
commit 07bb29e997
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 469 additions and 217 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/cli": patch
---
Optimize event detail and trace waterfall queries; add trace detail page and waterfall scrolling

View file

@ -11,15 +11,19 @@ import { chSqlToAliasMap } from '@hyperdx/common-utils/dist/clickhouse';
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import type { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
import { DisplayType } from '@hyperdx/common-utils/dist/types';
import type { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import SqlString from 'sqlstring';
import type {
BuilderChartConfigWithDateRange,
BuilderChartConfigWithOptDateRange,
} from '@hyperdx/common-utils/dist/types';
import type { SourceResponse } from './client';
import {
getFirstTimestampValueExpression,
getDisplayedTimestampValueExpression,
} from '@/shared/source';
import { getFirstTimestampValueExpression } from '@/shared/source';
import { buildRowDataSelectList } from '@/shared/rowDataPanel';
import {
buildTraceSpansConfig,
buildTraceLogsConfig,
buildTraceRowDetailConfig,
} from '@/shared/traceConfig';
import { buildColumnMap, getRowWhere } from '@/shared/useRowWhere';
export interface SearchQueryOptions {
@ -104,94 +108,55 @@ export async function buildEventSearchQuery(
// ---- Full row fetch (SELECT *) -------------------------------------
// ---- Trace waterfall query (all spans for a traceId) ----------------
// ---- Trace waterfall queries ----------------------------------------
export interface TraceSpansQueryOptions {
source: SourceResponse;
traceId: string;
dateRange?: [Date, Date];
}
/**
* Build a raw SQL query to fetch all spans for a given traceId.
* Returns columns needed for the waterfall chart.
* Build a query to fetch all spans for a given traceId using
* renderChartConfig. Enables time partition pruning when dateRange
* is provided and materialized field optimisation.
*/
export function buildTraceSpansSql(opts: TraceSpansQueryOptions): {
sql: string;
connectionId: string;
} {
const { source, traceId } = opts;
const db = source.from.databaseName;
const table = source.from.tableName;
const traceIdExpr = source.traceIdExpression ?? 'TraceId';
const spanIdExpr = source.spanIdExpression ?? 'SpanId';
const parentSpanIdExpr = source.parentSpanIdExpression ?? 'ParentSpanId';
const spanNameExpr = source.spanNameExpression ?? 'SpanName';
const serviceNameExpr = source.serviceNameExpression ?? 'ServiceName';
const durationExpr = source.durationExpression ?? 'Duration';
const statusCodeExpr = source.statusCodeExpression ?? 'StatusCode';
const tsExpr = getDisplayedTimestampValueExpression(source);
const cols = [
`${tsExpr} AS Timestamp`,
`${traceIdExpr} AS TraceId`,
`${spanIdExpr} AS SpanId`,
`${parentSpanIdExpr} AS ParentSpanId`,
`${spanNameExpr} AS SpanName`,
`${serviceNameExpr} AS ServiceName`,
`${durationExpr} AS Duration`,
`${statusCodeExpr} AS StatusCode`,
];
const escapedTraceId = SqlString.escape(traceId);
const sql = `SELECT ${cols.join(', ')} FROM ${db}.${table} WHERE ${traceIdExpr} = ${escapedTraceId} ORDER BY ${tsExpr} ASC LIMIT 10000`;
return {
sql,
connectionId: source.connection,
};
export async function buildTraceSpansQuery(
opts: TraceSpansQueryOptions,
metadata: Metadata,
): Promise<ChSql> {
const config = buildTraceSpansConfig(opts);
return renderChartConfig(config, metadata, opts.source.querySettings);
}
/**
* Build a raw SQL query to fetch correlated log events for a given traceId.
* Returns columns matching the SpanRow shape used by the waterfall chart.
* Logs are linked to spans via their SpanId.
* Build a query to fetch correlated log events for a given traceId
* using renderChartConfig.
*/
export function buildTraceLogsSql(opts: TraceSpansQueryOptions): {
sql: string;
connectionId: string;
} {
const { source, traceId } = opts;
export async function buildTraceLogsQuery(
opts: TraceSpansQueryOptions,
metadata: Metadata,
): Promise<ChSql> {
const config = buildTraceLogsConfig(opts);
return renderChartConfig(config, metadata, opts.source.querySettings);
}
const db = source.from.databaseName;
const table = source.from.tableName;
const traceIdExpr = source.traceIdExpression ?? 'TraceId';
const spanIdExpr = source.spanIdExpression ?? 'SpanId';
const bodyExpr = source.bodyExpression ?? 'Body';
const serviceNameExpr = source.serviceNameExpression ?? 'ServiceName';
const sevExpr = source.severityTextExpression ?? 'SeverityText';
const tsExpr = getDisplayedTimestampValueExpression(source);
const cols = [
`${tsExpr} AS Timestamp`,
`${traceIdExpr} AS TraceId`,
`${spanIdExpr} AS SpanId`,
`'' AS ParentSpanId`,
`${bodyExpr} AS SpanName`,
`${serviceNameExpr} AS ServiceName`,
`0 AS Duration`,
`${sevExpr} AS StatusCode`,
];
const escapedTraceId = SqlString.escape(traceId);
const sql = `SELECT ${cols.join(', ')} FROM ${db}.${table} WHERE ${traceIdExpr} = ${escapedTraceId} ORDER BY ${tsExpr} ASC LIMIT 10000`;
return {
sql,
connectionId: source.connection,
};
/**
* Build a query to fetch a single span/log row (SELECT *) from the
* trace waterfall detail panel. Omits dateRange so ClickHouse uses
* the WHERE clause directly.
*/
export async function buildTraceRowDetailQuery(
opts: {
source: SourceResponse;
traceId: string;
spanId?: string;
timestamp: string;
},
metadata: Metadata,
): Promise<ChSql> {
const config = buildTraceRowDetailConfig(opts);
return renderChartConfig(config, metadata, opts.source.querySettings);
}
export interface FullRowQueryOptions {
@ -242,17 +207,13 @@ export async function buildFullRowQuery(
const selectList = buildRowDataSelectList(source);
// Use a very wide date range — the WHERE clause already uniquely
// identifies the row, so the time range is just a safety net
const now = new Date();
const yearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
const config: BuilderChartConfigWithDateRange = {
// 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.
const config: BuilderChartConfigWithOptDateRange = {
connection: source.connection,
from: source.from,
timestampValueExpression:
source.timestampValueExpression ?? 'TimestampTime',
dateRange: [yearAgo, now],
select: selectList,
where: rowWhereResult.where,
limit: { limit: 1 },

View file

@ -2,7 +2,11 @@ import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
import type { SourceResponse, ProxyClickhouseClient } from '@/api/client';
import { ROW_DATA_ALIASES } from '@/shared/rowDataPanel';
import { getDisplayedTimestampValueExpression } from '@/shared/source';
import ColumnValues from '@/components/ColumnValues';
import ErrorDisplay from '@/components/ErrorDisplay';
import RowOverview from '@/components/RowOverview';
@ -18,6 +22,7 @@ type DetailPanelProps = {
source: SourceResponse;
sources: SourceResponse[];
clickhouseClient: ProxyClickhouseClient;
metadata: Metadata;
detailTab: DetailTab;
expandedRowData: Record<string, unknown> | null;
expandedRowLoading: boolean;
@ -25,6 +30,7 @@ type DetailPanelProps = {
expandedTraceId: string | null;
expandedSpanId: string | null;
traceSelectedIndex: number | null;
traceDetailExpanded: boolean;
onTraceSelectedIndexChange: (index: number | null) => void;
detailSearchQuery: string;
focusDetailSearch: boolean;
@ -52,6 +58,7 @@ export function DetailPanel({
source,
sources,
clickhouseClient,
metadata,
detailTab,
expandedRowData,
expandedRowLoading,
@ -59,6 +66,7 @@ export function DetailPanel({
expandedTraceId,
expandedSpanId,
traceSelectedIndex,
traceDetailExpanded,
onTraceSelectedIndexChange,
detailSearchQuery,
focusDetailSearch,
@ -75,6 +83,17 @@ export function DetailPanel({
expandedRow,
onTraceChSqlChange,
}: DetailPanelProps) {
// Extract the event timestamp from the full row data (or the raw
// table row) so we can scope the trace waterfall date range tightly
// for partition pruning.
const eventTimestamp = (() => {
const tsExpr = getDisplayedTimestampValueExpression(source);
const data = expandedRowData ?? expandedFormattedRow?.raw;
if (!data) return undefined;
const val = data[ROW_DATA_ALIASES.TIMESTAMP] ?? data[tsExpr] ?? undefined;
return val != null ? String(val) : undefined;
})();
const hasTrace =
source.kind === 'trace' || (source.kind === 'log' && source.traceSourceId);
@ -202,12 +221,15 @@ export function DetailPanel({
return (
<TraceWaterfall
clickhouseClient={clickhouseClient}
metadata={metadata}
source={traceSource}
logSource={logSource}
traceId={expandedTraceId}
eventTimestamp={eventTimestamp}
searchQuery={detailSearchQuery}
selectedIndex={traceSelectedIndex}
onSelectedIndexChange={onTraceSelectedIndexChange}
detailExpanded={traceDetailExpanded}
maxRows={waterfallMaxRows}
highlightHint={
expandedSpanId

View file

@ -70,6 +70,7 @@ export default function EventViewer({
const [traceSelectedIndex, setTraceSelectedIndex] = useState<number | null>(
null,
);
const [traceDetailExpanded, setTraceDetailExpanded] = useState(false);
const [timeRange, setTimeRange] = useState<TimeRange>(() => {
const now = new Date();
return { start: new Date(now.getTime() - 60 * 60 * 1000), end: now };
@ -166,6 +167,7 @@ export default function EventViewer({
showSql,
expandedRow,
detailTab,
traceDetailExpanded,
selectedRow,
scrollOffset,
isFollowing,
@ -176,7 +178,6 @@ export default function EventViewer({
source,
timeRange,
customSelect,
detailMaxRows,
fullDetailMaxRows,
switchItems,
findActiveIndex,
@ -191,6 +192,7 @@ export default function EventViewer({
setScrollOffset,
setExpandedRow,
setDetailTab,
setTraceDetailExpanded,
setIsFollowing,
setWrapLines,
setDetailSearchQuery,
@ -272,6 +274,7 @@ export default function EventViewer({
source={source}
sources={sources}
clickhouseClient={clickhouseClient}
metadata={metadata}
detailTab={detailTab}
expandedRowData={expandedRowData}
expandedRowLoading={expandedRowLoading}
@ -279,6 +282,7 @@ export default function EventViewer({
expandedTraceId={expandedTraceId}
expandedSpanId={expandedSpanId}
traceSelectedIndex={traceSelectedIndex}
traceDetailExpanded={traceDetailExpanded}
onTraceSelectedIndexChange={setTraceSelectedIndex}
detailSearchQuery={detailSearchQuery}
focusDetailSearch={focusDetailSearch}

View file

@ -20,6 +20,7 @@ export interface KeybindingParams {
showSql: boolean;
expandedRow: number | null;
detailTab: 'overview' | 'columns' | 'trace';
traceDetailExpanded: boolean;
selectedRow: number;
scrollOffset: number;
isFollowing: boolean;
@ -30,7 +31,6 @@ export interface KeybindingParams {
source: SourceResponse;
timeRange: TimeRange;
customSelect: string | undefined;
detailMaxRows: number;
fullDetailMaxRows: number;
// Tab switching
@ -53,6 +53,7 @@ export interface KeybindingParams {
setDetailTab: React.Dispatch<
React.SetStateAction<'overview' | 'columns' | 'trace'>
>;
setTraceDetailExpanded: React.Dispatch<React.SetStateAction<boolean>>;
setIsFollowing: React.Dispatch<React.SetStateAction<boolean>>;
setWrapLines: React.Dispatch<React.SetStateAction<boolean>>;
@ -82,6 +83,7 @@ export function useKeybindings(params: KeybindingParams): void {
showSql,
expandedRow,
detailTab,
traceDetailExpanded,
selectedRow,
scrollOffset,
isFollowing,
@ -92,7 +94,6 @@ export function useKeybindings(params: KeybindingParams): void {
source,
timeRange,
customSelect,
detailMaxRows,
fullDetailMaxRows,
switchItems,
findActiveIndex,
@ -107,6 +108,7 @@ export function useKeybindings(params: KeybindingParams): void {
setScrollOffset,
setExpandedRow,
setDetailTab,
setTraceDetailExpanded,
setIsFollowing,
setWrapLines,
setDetailSearchQuery,
@ -194,28 +196,55 @@ export function useKeybindings(params: KeybindingParams): void {
}
return;
}
// j/k in Trace tab: navigate spans/log events in the waterfall
// Ctrl+D/U: scroll Event Details section
// ---- Trace tab keybindings ----------------------------------------
if (expandedRow !== null && detailTab === 'trace') {
// When detail view is expanded (full-page Event Details):
// h/Esc = collapse back to waterfall, Ctrl+D/U = scroll
if (traceDetailExpanded) {
if (input === 'h' || key.escape) {
setTraceDetailExpanded(false);
return;
}
const detailHalfPage = Math.max(1, Math.floor(fullDetailMaxRows / 2));
if (key.ctrl && input === 'd') {
setTraceDetailScrollOffset(prev => prev + detailHalfPage);
return;
}
if (key.ctrl && input === 'u') {
setTraceDetailScrollOffset(prev =>
Math.max(0, prev - detailHalfPage),
);
return;
}
// Block other keys while in detail view
if (input === 'q') process.exit(0);
if (input === '/') {
setFocusDetailSearch(true);
return;
}
if (input === 'w') {
setWrapLines(w => !w);
return;
}
return;
}
// Waterfall view: j/k navigate spans, l expands detail
if (input === 'j' || key.downArrow) {
setTraceSelectedIndex(prev => (prev === null ? 0 : prev + 1));
setTraceDetailScrollOffset(0); // reset detail scroll on span change
setTraceDetailScrollOffset(0);
return;
}
if (input === 'k' || key.upArrow) {
setTraceSelectedIndex(prev =>
prev === null ? 0 : Math.max(0, prev - 1),
);
setTraceDetailScrollOffset(0); // reset detail scroll on span change
setTraceDetailScrollOffset(0);
return;
}
const detailHalfPage = Math.max(1, Math.floor(detailMaxRows / 2));
if (key.ctrl && input === 'd') {
setTraceDetailScrollOffset(prev => prev + detailHalfPage);
return;
}
if (key.ctrl && input === 'u') {
setTraceDetailScrollOffset(prev => Math.max(0, prev - detailHalfPage));
if (input === 'l' || key.return) {
setTraceDetailExpanded(true);
setTraceDetailScrollOffset(0);
return;
}
}
@ -284,6 +313,7 @@ export function useKeybindings(params: KeybindingParams): void {
if (expandedRow !== null) {
setExpandedRow(null);
setDetailTab('columns');
setTraceDetailExpanded(false);
// Restore follow mode if it was active before expanding
if (wasFollowingRef.current) {
setIsFollowing(true);
@ -328,6 +358,7 @@ export function useKeybindings(params: KeybindingParams): void {
? ['overview', 'columns', 'trace']
: ['overview', 'columns'];
setTraceSelectedIndex(null);
setTraceDetailExpanded(false);
setTraceDetailScrollOffset(0);
setColumnValuesScrollOffset(0);
setDetailTab(prev => {

View file

@ -33,13 +33,16 @@ import { useTraceData } from './useTraceData';
export default function TraceWaterfall({
clickhouseClient,
metadata,
source,
logSource,
traceId,
eventTimestamp,
searchQuery,
highlightHint,
highlightHint: _highlightHint,
selectedIndex,
onSelectedIndexChange,
detailExpanded = false,
wrapLines,
detailScrollOffset = 0,
detailMaxRows,
@ -63,7 +66,14 @@ export default function TraceWaterfall({
selectedRowError,
lastTraceChSql,
fetchSelectedRow,
} = useTraceData({ clickhouseClient, source, logSource, traceId });
} = useTraceData({
clickhouseClient,
metadata,
source,
logSource,
traceId,
eventTimestamp,
});
// Notify parent when the trace SQL changes
useEffect(() => {
@ -109,44 +119,49 @@ export default function TraceWaterfall({
const totalDurationMs = maxMs - minMs;
const visibleNodesForIndex = useMemo(
() => filteredNodes.slice(0, propMaxRows ?? 50),
[filteredNodes, propMaxRows],
);
// Determine the effective highlighted index:
// - If selectedIndex is set (j/k navigation), use it (clamped)
// - Otherwise, find the highlightHint row
// Determine the effective selected index over ALL filtered nodes.
// Default to 0 (first span) so the cursor always starts at the top.
const effectiveIndex = useMemo(() => {
if (filteredNodes.length === 0) return null;
if (selectedIndex != null) {
return Math.max(
0,
Math.min(selectedIndex, visibleNodesForIndex.length - 1),
);
return Math.max(0, Math.min(selectedIndex, filteredNodes.length - 1));
}
if (highlightHint) {
const idx = visibleNodesForIndex.findIndex(
n => n.SpanId === highlightHint.spanId && n.kind === highlightHint.kind,
);
return idx >= 0 ? idx : null;
}
return null;
}, [selectedIndex, highlightHint, visibleNodesForIndex]);
return 0;
}, [selectedIndex, filteredNodes]);
// Clamp selectedIndex if it exceeds bounds
useEffect(() => {
if (
selectedIndex != null &&
visibleNodesForIndex.length > 0 &&
selectedIndex >= visibleNodesForIndex.length
filteredNodes.length > 0 &&
selectedIndex >= filteredNodes.length
) {
onSelectedIndexChange?.(visibleNodesForIndex.length - 1);
onSelectedIndexChange?.(filteredNodes.length - 1);
}
}, [selectedIndex, visibleNodesForIndex.length, onSelectedIndexChange]);
}, [selectedIndex, filteredNodes.length, onSelectedIndexChange]);
// Derive scroll offset so the selected row stays in the viewport.
// The viewport shows `maxRows` rows starting at `scrollOffset`.
const scrollOffset = useMemo(() => {
if (effectiveIndex == null) return 0;
// Keep the selected row visible — scroll just enough
if (effectiveIndex < maxRows) return 0;
// Centre-ish: put selected row near the middle of the viewport
return Math.min(
effectiveIndex - Math.floor(maxRows / 2),
Math.max(0, filteredNodes.length - maxRows),
);
}, [effectiveIndex, maxRows, filteredNodes.length]);
// The visible window of nodes for rendering
const visibleNodes = useMemo(
() => filteredNodes.slice(scrollOffset, scrollOffset + maxRows),
[filteredNodes, scrollOffset, maxRows],
);
// Fetch SELECT * for the selected span/log
const selectedNode =
effectiveIndex != null ? visibleNodesForIndex[effectiveIndex] : null;
effectiveIndex != null ? filteredNodes[effectiveIndex] : null;
useEffect(() => {
fetchSelectedRow(selectedNode);
@ -184,10 +199,11 @@ export default function TraceWaterfall({
}
return n.StatusCode === '2' || n.StatusCode === 'Error';
}).length;
const visibleNodes = filteredNodes.slice(0, maxRows);
const truncated = filteredNodes.length > maxRows;
return (
// ---- Waterfall view ----------------------------------------------
const waterfallView = (
<Box flexDirection="column">
{/* Summary */}
<Box>
@ -255,7 +271,7 @@ export default function TraceWaterfall({
const statusColor = getStatusColor(node);
const barClr = getBarColor(node);
const isHighlighted = effectiveIndex === i;
const isHighlighted = effectiveIndex === scrollOffset + i;
return (
<Box key={`${node.SpanId}-${node.kind}-${i}`} overflowX="hidden">
@ -299,43 +315,62 @@ export default function TraceWaterfall({
{truncated && (
<Text dimColor>
and {filteredNodes.length - maxRows} more (showing first {maxRows})
{scrollOffset + maxRows < filteredNodes.length
? `${filteredNodes.length - scrollOffset - maxRows} more below`
: ''}
{scrollOffset > 0
? `${scrollOffset + maxRows < filteredNodes.length ? ' | ' : ''}${scrollOffset} above`
: ''}
{` (${filteredNodes.length} total)`}
</Text>
)}
{/* Event Details for selected span/log — fixed height viewport */}
<Box
flexDirection="column"
marginTop={1}
height={(detailMaxRows ?? 10) + 3}
overflowY="hidden"
>
<Text bold>Event Details</Text>
<Text dimColor>{'─'.repeat(termWidth - 2)}</Text>
{selectedRowLoading ? (
<Text>
<Spinner type="dots" /> Loading event details
</Text>
) : selectedRowError ? (
<ErrorDisplay
error={selectedRowError}
severity="warning"
detail="Could not load event details for this span."
/>
) : selectedRowData ? (
<ColumnValues
data={selectedRowData}
searchQuery={searchQuery}
wrapLines={wrapLines}
maxRows={detailMaxRows}
scrollOffset={detailScrollOffset}
/>
) : effectiveIndex == null ? (
<Text dimColor>Use j/k to select a span or log event.</Text>
) : (
<Text dimColor>No details available.</Text>
)}
</Box>
{/* Hint for entering detail view */}
{effectiveIndex != null && !detailExpanded && (
<Text dimColor>l=expand details</Text>
)}
</Box>
);
// ---- Detail view (full-page Event Details) -----------------------
const detailView = (
<Box flexDirection="column">
<Text dimColor>h=back to waterfall</Text>
<Box marginTop={1}>
<Text bold>Event Details</Text>
{selectedNode && (
<Text dimColor>
{' '}
{selectedNode.ServiceName ? `${selectedNode.ServiceName} > ` : ''}
{selectedNode.SpanName || '(unknown)'}
</Text>
)}
</Box>
<Text dimColor>{'─'.repeat(termWidth - 2)}</Text>
{selectedRowLoading ? (
<Text>
<Spinner type="dots" /> Loading event details
</Text>
) : selectedRowError ? (
<ErrorDisplay
error={selectedRowError}
severity="warning"
detail="Could not load event details for this span."
/>
) : selectedRowData ? (
<ColumnValues
data={selectedRowData}
searchQuery={searchQuery}
wrapLines={wrapLines}
maxRows={detailMaxRows}
scrollOffset={detailScrollOffset}
/>
) : (
<Text dimColor>No details available.</Text>
)}
</Box>
);
return detailExpanded ? detailView : waterfallView;
}

View file

@ -1,3 +1,5 @@
import type { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
import type { ProxyClickhouseClient, SourceResponse } from '@/api/client';
export interface SpanRow {
@ -23,10 +25,17 @@ export interface SpanNode extends TaggedSpanRow {
export interface TraceWaterfallProps {
clickhouseClient: ProxyClickhouseClient;
metadata: Metadata;
source: SourceResponse;
/** Correlated log source (optional) */
logSource?: SourceResponse | null;
traceId: string;
/**
* Timestamp of the originating event row. Used to derive a tight
* dateRange for trace span queries so ClickHouse can prune time
* partitions instead of scanning the entire table.
*/
eventTimestamp?: string;
/** Fuzzy filter query for span/log names */
searchQuery?: string;
/** Hint to identify the initial row to highlight in the waterfall */
@ -40,6 +49,12 @@ export interface TraceWaterfallProps {
onSelectedIndexChange?: (index: number | null) => void;
/** Toggle line wrap in Event Details */
wrapLines?: boolean;
/**
* When true, the waterfall hides and a full-page Event Details view
* is shown for the selected span/log. Toggled via l (expand) and
* h/Esc (collapse) keybindings.
*/
detailExpanded?: boolean;
/** Scroll offset for Event Details */
detailScrollOffset?: number;
/** Max visible rows for Event Details */

View file

@ -1,8 +1,14 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import SqlString from 'sqlstring';
import { useState, useEffect, useRef } from 'react';
import type { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
import type { ProxyClickhouseClient, SourceResponse } from '@/api/client';
import { buildTraceSpansSql, buildTraceLogsSql } from '@/api/eventQuery';
import {
buildTraceSpansQuery,
buildTraceLogsQuery,
buildTraceRowDetailQuery,
} from '@/api/eventQuery';
import { deriveDateRange } from '@/shared/traceConfig';
import type { SpanRow, TaggedSpanRow, SpanNode } from './types';
@ -10,9 +16,15 @@ import type { SpanRow, TaggedSpanRow, SpanNode } from './types';
export interface UseTraceDataParams {
clickhouseClient: ProxyClickhouseClient;
metadata: Metadata;
source: SourceResponse;
logSource?: SourceResponse | null;
traceId: string;
/**
* Timestamp of the originating event row. Used to derive a tight
* dateRange for partition pruning.
*/
eventTimestamp?: string;
}
export interface UseTraceDataReturn {
@ -32,9 +44,11 @@ export interface UseTraceDataReturn {
export function useTraceData({
clickhouseClient,
metadata,
source,
logSource,
traceId,
eventTimestamp,
}: UseTraceDataParams): UseTraceDataReturn {
const [traceSpans, setTraceSpans] = useState<TaggedSpanRow[]>([]);
const [logEvents, setLogEvents] = useState<TaggedSpanRow[]>([]);
@ -46,18 +60,10 @@ export function useTraceData({
> | null>(null);
const [selectedRowLoading, setSelectedRowLoading] = useState(false);
const [selectedRowError, setSelectedRowError] = useState<Error | null>(null);
// Compute the trace query eagerly so the SQL is available on the first
// render (before the async query dispatches). buildTraceSpansSql is
// synchronous.
const traceQuery = useMemo(
() => buildTraceSpansSql({ source, traceId }),
[source, traceId],
);
const lastTraceChSql = useMemo(
() => ({ sql: traceQuery.sql, params: {} as Record<string, unknown> }),
[traceQuery],
);
const [lastTraceChSql, setLastTraceChSql] = useState<{
sql: string;
params: Record<string, unknown>;
} | null>(null);
const fetchIdRef = useRef(0);
@ -68,13 +74,25 @@ export function useTraceData({
setLoading(true);
setError(null);
const dateRange = deriveDateRange(eventTimestamp);
(async () => {
try {
// Fetch trace spans — reuse the memoized query
// Build and execute trace spans query via renderChartConfig
const traceChSql = await buildTraceSpansQuery(
{ source, traceId, dateRange },
metadata,
);
if (!cancelled) {
setLastTraceChSql(traceChSql);
}
const traceResultSet = await clickhouseClient.query({
query: traceQuery.sql,
query: traceChSql.sql,
query_params: traceChSql.params,
format: 'JSON',
connectionId: traceQuery.connectionId,
connectionId: source.connection,
});
const traceJson = await traceResultSet.json<SpanRow>();
const spans = ((traceJson.data ?? []) as SpanRow[]).map(r => ({
@ -85,14 +103,15 @@ export function useTraceData({
// Fetch correlated log events (if log source exists)
let logs: TaggedSpanRow[] = [];
if (logSource) {
const logQuery = buildTraceLogsSql({
source: logSource,
traceId,
});
const logChSql = await buildTraceLogsQuery(
{ source: logSource, traceId, dateRange },
metadata,
);
const logResultSet = await clickhouseClient.query({
query: logQuery.sql,
query: logChSql.sql,
query_params: logChSql.params,
format: 'JSON',
connectionId: logQuery.connectionId,
connectionId: logSource.connection,
});
const logJson = await logResultSet.json<SpanRow>();
logs = ((logJson.data ?? []) as SpanRow[]).map(r => ({
@ -118,7 +137,7 @@ export function useTraceData({
return () => {
cancelled = true;
};
}, [clickhouseClient, source, logSource, traceId, traceQuery]);
}, [clickhouseClient, metadata, source, logSource, traceId, eventTimestamp]);
// ---- Fetch SELECT * for the selected span/log --------------------
// Stable scalar deps (SpanId, Timestamp, kind) are used to avoid
@ -155,29 +174,6 @@ export function useTraceData({
const isLog = kind === 'log';
const rowSource = isLog && logSource ? logSource : source;
const db = rowSource.from.databaseName;
const table = rowSource.from.tableName;
const spanIdExpr = rowSource.spanIdExpression ?? 'SpanId';
const tsExpr =
rowSource.displayedTimestampValueExpression ??
rowSource.timestampValueExpression ??
'TimestampTime';
const traceIdExpr = rowSource.traceIdExpression ?? 'TraceId';
const escapedTs = SqlString.escape(timestamp);
const escapedTraceId = SqlString.escape(traceId);
// Build WHERE: always use TraceId + Timestamp; add SpanId if available
const clauses = [
`${traceIdExpr} = ${escapedTraceId}`,
`${tsExpr} = parseDateTime64BestEffort(${escapedTs}, 9)`,
];
if (spanId) {
clauses.push(`${spanIdExpr} = ${SqlString.escape(spanId)}`);
}
const where = clauses.join(' AND ');
const sql = `SELECT * FROM ${db}.${table} WHERE ${where} LIMIT 1`;
const fetchId = ++fetchIdRef.current;
// Don't clear existing data or set loading — keep old data visible
// while fetching to avoid flashing
@ -185,8 +181,18 @@ export function useTraceData({
(async () => {
try {
const chSql = await buildTraceRowDetailQuery(
{
source: rowSource,
traceId,
spanId: spanId ?? undefined,
timestamp,
},
metadata,
);
const resultSet = await clickhouseClient.query({
query: sql,
query: chSql.sql,
query_params: chSql.params,
format: 'JSON',
connectionId: rowSource.connection,
});

View file

@ -0,0 +1,173 @@
/**
* Trace waterfall config builder.
*
* Builds a renderChartConfig-compatible config for fetching trace
* spans and correlated log events, matching the web frontend's
* getConfig pattern in DBTraceWaterfallChart.tsx.
*
* Using renderChartConfig instead of raw SQL enables:
* - Time partition pruning via dateRange
* - Materialized field optimisation
* - Query parameterisation
*
* @source packages/app/src/components/DBTraceWaterfallChart.tsx (getConfig)
*/
import type { SelectList } from '@hyperdx/common-utils/dist/types';
import type { SourceResponse } from '@/api/client';
import { getDisplayedTimestampValueExpression, getEventBody } from './source';
/** Default window (±1 hour) around the event timestamp for partition pruning. */
const DEFAULT_WINDOW_MS = 60 * 60 * 1000;
/**
* Derive a dateRange from an optional event timestamp.
* If no timestamp is available, returns undefined so the query runs
* without time bounds (still correct, just slower).
*/
export function deriveDateRange(
eventTimestamp: string | undefined,
): [Date, Date] | undefined {
if (!eventTimestamp) return undefined;
const ts = new Date(eventTimestamp).getTime();
if (isNaN(ts)) return undefined;
return [new Date(ts - DEFAULT_WINDOW_MS), new Date(ts + DEFAULT_WINDOW_MS)];
}
/**
* Build a renderChartConfig-compatible config for trace span queries.
*/
export function buildTraceSpansConfig(opts: {
source: SourceResponse;
traceId: string;
dateRange?: [Date, Date];
}) {
const { source, traceId, dateRange } = opts;
const tsExpr = getDisplayedTimestampValueExpression(source);
const traceIdExpr = source.traceIdExpression ?? 'TraceId';
const spanIdExpr = source.spanIdExpression ?? 'SpanId';
const parentSpanIdExpr = source.parentSpanIdExpression ?? 'ParentSpanId';
const spanNameExpr = source.spanNameExpression ?? 'SpanName';
const serviceNameExpr = source.serviceNameExpression ?? 'ServiceName';
const durationExpr = source.durationExpression ?? 'Duration';
const statusCodeExpr = source.statusCodeExpression ?? 'StatusCode';
const select: SelectList = [
{ valueExpression: tsExpr, alias: 'Timestamp' },
{ valueExpression: traceIdExpr, alias: 'TraceId' },
{ valueExpression: spanIdExpr, alias: 'SpanId' },
{ valueExpression: parentSpanIdExpr, alias: 'ParentSpanId' },
{ valueExpression: spanNameExpr, alias: 'SpanName' },
...(serviceNameExpr
? [{ valueExpression: serviceNameExpr, alias: 'ServiceName' }]
: []),
{ valueExpression: durationExpr, alias: 'Duration' },
...(statusCodeExpr
? [{ valueExpression: statusCodeExpr, alias: 'StatusCode' }]
: []),
];
return {
select,
from: source.from,
where: `${traceIdExpr} = '${traceId}'`,
limit: { limit: 10000 },
connection: source.connection,
...(dateRange != null
? {
timestampValueExpression:
source.timestampValueExpression ?? 'TimestampTime',
dateRange,
}
: {}),
};
}
/**
* Build a renderChartConfig-compatible config for correlated log queries.
* Logs are linked to spans via their SpanId.
*/
export function buildTraceLogsConfig(opts: {
source: SourceResponse;
traceId: string;
dateRange?: [Date, Date];
}) {
const { source, traceId, dateRange } = opts;
const tsExpr = getDisplayedTimestampValueExpression(source);
const traceIdExpr = source.traceIdExpression ?? 'TraceId';
const spanIdExpr = source.spanIdExpression ?? 'SpanId';
const bodyExpr = getEventBody(source) ?? source.bodyExpression ?? 'Body';
const serviceNameExpr = source.serviceNameExpression ?? 'ServiceName';
const sevExpr = source.severityTextExpression ?? 'SeverityText';
const select: SelectList = [
{ valueExpression: tsExpr, alias: 'Timestamp' },
{ valueExpression: traceIdExpr, alias: 'TraceId' },
{ valueExpression: spanIdExpr, alias: 'SpanId' },
{ valueExpression: "''", alias: 'ParentSpanId' },
{ valueExpression: bodyExpr, alias: 'SpanName' },
...(serviceNameExpr
? [{ valueExpression: serviceNameExpr, alias: 'ServiceName' }]
: []),
{ valueExpression: '0', alias: 'Duration' },
...(sevExpr ? [{ valueExpression: sevExpr, alias: 'StatusCode' }] : []),
];
return {
select,
from: source.from,
where: `${traceIdExpr} = '${traceId}'`,
limit: { limit: 10000 },
connection: source.connection,
...(dateRange != null
? {
timestampValueExpression:
source.timestampValueExpression ?? 'TimestampTime',
dateRange,
}
: {}),
};
}
/**
* Build a renderChartConfig-compatible config for fetching a single
* span/log row (SELECT *) from the trace waterfall detail panel.
*
* Omits dateRange/timestampValueExpression so ClickHouse uses the
* WHERE clause directly without scanning time partitions. This matches
* the web frontend's useRowData pattern in DBRowDataPanel.tsx.
*/
export function buildTraceRowDetailConfig(opts: {
source: SourceResponse;
traceId: string;
spanId?: string;
timestamp: string;
}) {
const { source, traceId, spanId, timestamp } = opts;
const traceIdExpr = source.traceIdExpression ?? 'TraceId';
const spanIdExpr = source.spanIdExpression ?? 'SpanId';
const tsExpr =
source.displayedTimestampValueExpression ??
source.timestampValueExpression ??
'TimestampTime';
const clauses = [
`${traceIdExpr} = '${traceId}'`,
`${tsExpr} = parseDateTime64BestEffort('${timestamp}', 9)`,
];
if (spanId) {
clauses.push(`${spanIdExpr} = '${spanId}'`);
}
return {
select: [{ valueExpression: '*' }] as SelectList,
from: source.from,
where: clauses.join(' AND '),
limit: { limit: 1 },
connection: source.connection,
};
}