Skip to content

Commit fe3ab41

Browse files
authored
[HDX-3978] Add 'o' keybinding to open trace/span in browser from TUI (#2105)
## Summary Adds an `o` keybinding to the TUI that opens the currently expanded row in the HyperDX web app browser, deep-linking directly to the same view with the side panel open. **Linear ticket:** https://linear.app/hyperdx/issue/HDX-3978 ## What changed When a user expands a row in the TUI (via `l`/Enter) and presses `o`, the browser opens to the HyperDX web app's `/search` page with URL parameters that reproduce the exact TUI state: | URL Parameter | Description | |---|---| | `source` | Active source ID | | `where` | User's search query + `TraceId:<id>` for traces | | `from` / `to` | Current time range | | `rowWhere` | SQL WHERE clause identifying the specific row (double-encoded) | | `rowSource` | Source ID for the expanded row | | `sidePanelTab` | Maps TUI tab: `overview` → `overview`, `columns` → `parsed`, `trace` → `trace` | | `eventRowWhere` | Pre-selects the specific span in the trace waterfall (trace tab only, double-encoded JSON) | ### Tab behavior - **Overview tab** → Opens side panel to Overview for the specific row - **Column Values tab** → Opens side panel to Parsed (Column Values) for the specific row - **Trace tab** → Opens side panel to Trace with waterfall, pre-selecting the highlighted span ### Source support - **Trace sources**: URL includes `TraceId` filter combined with the user's search query - **Log sources with trace correlation**: Full trace waterfall deep-linking supported - **Log sources without trace**: Opens with the user's search query and row identification (no trace filter) ## Files changed | File | Change | |---|---| | `packages/cli/src/utils/openUrl.ts` | **New** — Cross-platform browser open utility | | `packages/cli/src/api/eventQuery.ts` | `buildFullRowQuery` returns `rowWhere` alongside `ChSql` | | `packages/cli/src/components/EventViewer/useEventData.ts` | Exposes `expandedRowWhere` state | | `packages/cli/src/components/TraceWaterfall/types.ts` | Added `onSelectedNodeChange` callback prop | | `packages/cli/src/components/TraceWaterfall/TraceWaterfall.tsx` | Calls `onSelectedNodeChange` when selected span changes | | `packages/cli/src/components/EventViewer/DetailPanel.tsx` | Threads `onTraceSelectedNodeChange` to TraceWaterfall | | `packages/cli/src/components/EventViewer/types.ts` | Added `appUrl` to `EventViewerProps` | | `packages/cli/src/components/EventViewer/EventViewer.tsx` | Threads `appUrl`, `expandedRowWhere`, `traceSelectedNode`, `submittedQuery` | | `packages/cli/src/components/EventViewer/utils.ts` | Added `buildBrowserUrl` (full URL builder) and `buildSpanEventRowWhere` | | `packages/cli/src/components/EventViewer/useKeybindings.ts` | Added `o` keybinding with all new params | | `packages/cli/src/components/EventViewer/SubComponents.tsx` | Added `o` to help screen | | `packages/cli/src/App.tsx` | Passes `appUrl` to EventViewer | | `packages/cli/AGENTS.md` | Added `o` to keybindings table | ## Testing - Type check: `npx tsc --noEmit` passes - Lint: `yarn lint:fix` — 0 errors
1 parent 07bb29e commit fe3ab41

14 files changed

Lines changed: 294 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/cli": patch
3+
---
4+
5+
Add `o` keybinding to open the current trace/span in the HyperDX web app from the TUI. Deep-links to the exact view with side panel, tab, and span selection preserved. Works for both trace and log sources.

packages/cli/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ Key expression mappings from the web frontend's `getConfig()`:
156156
| `s` | Edit SELECT clause in $EDITOR |
157157
| `t` | Edit time range in $EDITOR |
158158
| `f` | Toggle follow mode (live tail) |
159+
| `o` | Open trace in browser (detail panel) |
159160
| `w` | Toggle line wrap |
160161
| `A` (Shift+A) | Open alerts page |
161162
| `?` | Toggle help screen |

packages/cli/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export default function App({ appUrl, query, sourceName, follow }: AppProps) {
215215
<EventViewer
216216
clickhouseClient={client.createClickHouseClient()}
217217
metadata={client.createMetadata()}
218+
appUrl={currentAppUrl}
218219
source={selectedSource}
219220
sources={eventSources}
220221
savedSearches={savedSearches}

packages/cli/src/api/eventQuery.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ export interface FullRowQueryOptions {
181181
* WITH <aliasWith>
182182
* LIMIT 1
183183
*/
184+
export interface FullRowQueryResult {
185+
chSql: ChSql;
186+
/** The SQL WHERE clause that uniquely identifies the row (for browser URL) */
187+
rowWhere: string;
188+
}
189+
184190
export async function buildFullRowQuery(
185191
opts: FullRowQueryOptions & {
186192
/** The rendered ChSql from the table query (for alias resolution) */
@@ -189,7 +195,7 @@ export async function buildFullRowQuery(
189195
tableMeta: ColumnMetaType[];
190196
metadata: Metadata;
191197
},
192-
): Promise<ChSql> {
198+
): Promise<FullRowQueryResult> {
193199
const { source, row, tableChSql, tableMeta, metadata } = opts;
194200

195201
// Parse the rendered table SQL to get alias → expression mapping
@@ -223,5 +229,6 @@ export async function buildFullRowQuery(
223229
: {}),
224230
};
225231

226-
return renderChartConfig(config, metadata, source.querySettings);
232+
const chSql = await renderChartConfig(config, metadata, source.querySettings);
233+
return { chSql, rowWhere: rowWhereResult.where };
227234
}

packages/cli/src/components/EventViewer/DetailPanel.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import ColumnValues from '@/components/ColumnValues';
1111
import ErrorDisplay from '@/components/ErrorDisplay';
1212
import RowOverview from '@/components/RowOverview';
1313
import TraceWaterfall from '@/components/TraceWaterfall';
14+
import type { SpanNode } from '@/components/TraceWaterfall/types';
1415

1516
import type { FormattedRow } from './types';
1617
import { SearchBar } from './SubComponents';
@@ -52,6 +53,8 @@ type DetailPanelProps = {
5253
onTraceChSqlChange?: (
5354
chSql: { sql: string; params: Record<string, unknown> } | null,
5455
) => void;
56+
/** Callback when the selected span/log node in the trace waterfall changes */
57+
onTraceSelectedNodeChange?: (node: SpanNode | null) => void;
5558
};
5659

5760
export function DetailPanel({
@@ -82,6 +85,7 @@ export function DetailPanel({
8285
scrollOffset,
8386
expandedRow,
8487
onTraceChSqlChange,
88+
onTraceSelectedNodeChange,
8589
}: DetailPanelProps) {
8690
// Extract the event timestamp from the full row data (or the raw
8791
// table row) so we can scope the trace waterfall date range tightly
@@ -243,6 +247,7 @@ export function DetailPanel({
243247
detailScrollOffset={traceDetailScrollOffset}
244248
detailMaxRows={detailMaxRows}
245249
onChSqlChange={onTraceChSqlChange}
250+
onSelectedNodeChange={onTraceSelectedNodeChange}
246251
/>
247252
);
248253
})()}

packages/cli/src/components/EventViewer/EventViewer.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef, useMemo } from 'react';
22
import { Box, useStdout } from 'ink';
33

44
import type { TimeRange } from '@/utils/editor';
5+
import type { SpanNode } from '@/components/TraceWaterfall/types';
56

67
import type { EventViewerProps, SwitchItem } from './types';
78
import { getColumns, getDynamicColumns, formatDynamicRow } from './utils';
@@ -21,6 +22,7 @@ import { useKeybindings } from './useKeybindings';
2122
export default function EventViewer({
2223
clickhouseClient,
2324
metadata,
25+
appUrl,
2426
source,
2527
sources,
2628
savedSearches,
@@ -71,6 +73,9 @@ export default function EventViewer({
7173
null,
7274
);
7375
const [traceDetailExpanded, setTraceDetailExpanded] = useState(false);
76+
const [traceSelectedNode, setTraceSelectedNode] = useState<SpanNode | null>(
77+
null,
78+
);
7479
const [timeRange, setTimeRange] = useState<TimeRange>(() => {
7580
const now = new Date();
7681
return { start: new Date(now.getTime() - 60 * 60 * 1000), end: now };
@@ -90,6 +95,7 @@ export default function EventViewer({
9095
expandedRowError,
9196
expandedTraceId,
9297
expandedSpanId,
98+
expandedRowWhere,
9399
lastChSql,
94100
lastExpandedChSql,
95101
fetchNextPage,
@@ -178,7 +184,12 @@ export default function EventViewer({
178184
source,
179185
timeRange,
180186
customSelect,
187+
submittedQuery,
181188
fullDetailMaxRows,
189+
appUrl,
190+
expandedTraceId,
191+
expandedRowWhere,
192+
traceSelectedNode,
182193
switchItems,
183194
findActiveIndex,
184195
onSavedSearchSelect,
@@ -300,6 +311,7 @@ export default function EventViewer({
300311
scrollOffset={scrollOffset}
301312
expandedRow={expandedRow}
302313
onTraceChSqlChange={setTraceChSql}
314+
onTraceSelectedNodeChange={setTraceSelectedNode}
303315
/>
304316
) : (
305317
<TableView

packages/cli/src/components/EventViewer/SubComponents.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export const HelpScreen = React.memo(function HelpScreen() {
186186
['s', 'Edit select clause in $EDITOR'],
187187
['D', 'Show generated SQL'],
188188
['f', 'Toggle follow mode (live tail)'],
189+
['o', 'Open trace in browser'],
189190
['w', 'Toggle line wrap'],
190191
['A (Shift+A)', 'Open alerts page'],
191192
['?', 'Toggle this help'],

packages/cli/src/components/EventViewer/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import type { TimeRange } from '@/utils/editor';
1010
export interface EventViewerProps {
1111
clickhouseClient: ProxyClickhouseClient;
1212
metadata: Metadata;
13+
/** HyperDX app URL (e.g. http://localhost:8080) for opening in browser */
14+
appUrl: string;
1315
source: SourceResponse;
1416
sources: SourceResponse[];
1517
savedSearches: SavedSearchResponse[];

packages/cli/src/components/EventViewer/useEventData.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export interface UseEventDataReturn {
3636
expandedRowError: Error | null;
3737
expandedTraceId: string | null;
3838
expandedSpanId: string | null;
39+
/** SQL WHERE clause that uniquely identifies the expanded row (for browser URL) */
40+
expandedRowWhere: string | null;
3941
/** The last rendered ChSql (parameterized SQL + params) for the table query */
4042
lastChSql: { sql: string; params: Record<string, unknown> } | null;
4143
/** The last rendered ChSql for the expanded row (SELECT *) query */
@@ -72,6 +74,7 @@ export function useEventData({
7274
const [expandedRowError, setExpandedRowError] = useState<Error | null>(null);
7375
const [expandedTraceId, setExpandedTraceId] = useState<string | null>(null);
7476
const [expandedSpanId, setExpandedSpanId] = useState<string | null>(null);
77+
const [expandedRowWhere, setExpandedRowWhere] = useState<string | null>(null);
7578

7679
const lastTimestampRef = useRef<string | null>(null);
7780
const dateRangeRef = useRef<{ start: Date; end: Date } | null>(null);
@@ -239,6 +242,7 @@ export function useEventData({
239242
setExpandedRowError(null);
240243
setExpandedTraceId(null);
241244
setExpandedSpanId(null);
245+
setExpandedRowWhere(null);
242246
setLastExpandedChSql(null);
243247
return;
244248
}
@@ -256,14 +260,17 @@ export function useEventData({
256260
params: {},
257261
};
258262
const tableMeta = lastTableMetaRef.current ?? [];
259-
const chSql = await buildFullRowQuery({
263+
const { chSql, rowWhere } = await buildFullRowQuery({
260264
source,
261265
row: row as Record<string, unknown>,
262266
tableChSql,
263267
tableMeta,
264268
metadata,
265269
});
266270
setLastExpandedChSql(chSql);
271+
if (!cancelled) {
272+
setExpandedRowWhere(rowWhere);
273+
}
267274
const resultSet = await clickhouseClient.query({
268275
query: chSql.sql,
269276
query_params: chSql.params,
@@ -324,6 +331,7 @@ export function useEventData({
324331
expandedRowError,
325332
expandedTraceId,
326333
expandedSpanId,
334+
expandedRowWhere,
327335
lastChSql,
328336
lastExpandedChSql,
329337
fetchNextPage,

packages/cli/src/components/EventViewer/useKeybindings.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import {
77
openEditorForTimeRange,
88
type TimeRange,
99
} from '@/utils/editor';
10+
import { openUrl } from '@/utils/openUrl';
11+
12+
import type { SpanNode } from '@/components/TraceWaterfall/types';
1013

1114
import type { EventRow, SwitchItem } from './types';
15+
import { buildBrowserUrl, buildSpanEventRowWhere } from './utils';
1216

1317
// ---- Types ---------------------------------------------------------
1418

@@ -31,8 +35,16 @@ export interface KeybindingParams {
3135
source: SourceResponse;
3236
timeRange: TimeRange;
3337
customSelect: string | undefined;
38+
/** The user's submitted search query (Lucene) */
39+
submittedQuery: string;
3440
fullDetailMaxRows: number;
3541

42+
// Browser integration
43+
appUrl: string;
44+
expandedTraceId: string | null;
45+
expandedRowWhere: string | null;
46+
traceSelectedNode: SpanNode | null;
47+
3648
// Tab switching
3749
switchItems: SwitchItem[];
3850
findActiveIndex: () => number;
@@ -94,7 +106,12 @@ export function useKeybindings(params: KeybindingParams): void {
94106
source,
95107
timeRange,
96108
customSelect,
109+
submittedQuery,
97110
fullDetailMaxRows,
111+
appUrl,
112+
expandedTraceId,
113+
expandedRowWhere,
114+
traceSelectedNode,
98115
switchItems,
99116
findActiveIndex,
100117
onSavedSearchSelect,
@@ -376,6 +393,43 @@ export function useKeybindings(params: KeybindingParams): void {
376393
return;
377394
}
378395
if (input === 'w') setWrapLines(w => !w);
396+
// o = open current trace/span in the browser
397+
if (input === 'o' && expandedRow !== null) {
398+
// Build eventRowWhere for trace tab when a span is selected
399+
let eventRowWhere: {
400+
id: string;
401+
type: string;
402+
aliasWith: never[];
403+
} | null = null;
404+
if (detailTab === 'trace' && traceSelectedNode) {
405+
const traceSource =
406+
source.kind === 'trace'
407+
? source
408+
: // For log sources viewing the trace tab, use the trace source
409+
// expressions. The node's kind tells us which source it came from.
410+
null;
411+
// Only build eventRowWhere if we have a trace source to reference
412+
if (traceSource) {
413+
eventRowWhere = {
414+
id: buildSpanEventRowWhere(traceSelectedNode, traceSource),
415+
type: traceSelectedNode.kind === 'log' ? 'log' : 'trace',
416+
aliasWith: [] as never[],
417+
};
418+
}
419+
}
420+
const url = buildBrowserUrl({
421+
appUrl,
422+
source,
423+
traceId: expandedTraceId,
424+
searchQuery: submittedQuery,
425+
timeRange,
426+
rowWhere: expandedRowWhere,
427+
detailTab,
428+
eventRowWhere,
429+
});
430+
openUrl(url);
431+
return;
432+
}
379433
// f = toggle follow mode (disabled in detail panel — follow is
380434
// automatically paused on expand and restored on close)
381435
if (input === 'f' && expandedRow === null) {

0 commit comments

Comments
 (0)