mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
[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:
parent
a5869f0eb7
commit
07bb29e997
9 changed files with 469 additions and 217 deletions
5
.changeset/optimize-trace-waterfall.md
Normal file
5
.changeset/optimize-trace-waterfall.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/cli": patch
|
||||
---
|
||||
|
||||
Optimize event detail and trace waterfall queries; add trace detail page and waterfall scrolling
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
173
packages/cli/src/shared/traceConfig.ts
Normal file
173
packages/cli/src/shared/traceConfig.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue