[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
This commit is contained in:
Warren Lee 2026-04-13 15:29:41 -07:00 committed by GitHub
parent 07bb29e997
commit fe3ab41c43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 294 additions and 3 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/cli": patch
---
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.

View file

@ -156,6 +156,7 @@ Key expression mappings from the web frontend's `getConfig()`:
| `s` | Edit SELECT clause in $EDITOR |
| `t` | Edit time range in $EDITOR |
| `f` | Toggle follow mode (live tail) |
| `o` | Open trace in browser (detail panel) |
| `w` | Toggle line wrap |
| `A` (Shift+A) | Open alerts page |
| `?` | Toggle help screen |

View file

@ -215,6 +215,7 @@ export default function App({ appUrl, query, sourceName, follow }: AppProps) {
<EventViewer
clickhouseClient={client.createClickHouseClient()}
metadata={client.createMetadata()}
appUrl={currentAppUrl}
source={selectedSource}
sources={eventSources}
savedSearches={savedSearches}

View file

@ -181,6 +181,12 @@ export interface FullRowQueryOptions {
* WITH <aliasWith>
* LIMIT 1
*/
export interface FullRowQueryResult {
chSql: ChSql;
/** The SQL WHERE clause that uniquely identifies the row (for browser URL) */
rowWhere: string;
}
export async function buildFullRowQuery(
opts: FullRowQueryOptions & {
/** The rendered ChSql from the table query (for alias resolution) */
@ -189,7 +195,7 @@ export async function buildFullRowQuery(
tableMeta: ColumnMetaType[];
metadata: Metadata;
},
): Promise<ChSql> {
): Promise<FullRowQueryResult> {
const { source, row, tableChSql, tableMeta, metadata } = opts;
// Parse the rendered table SQL to get alias → expression mapping
@ -223,5 +229,6 @@ export async function buildFullRowQuery(
: {}),
};
return renderChartConfig(config, metadata, source.querySettings);
const chSql = await renderChartConfig(config, metadata, source.querySettings);
return { chSql, rowWhere: rowWhereResult.where };
}

View file

@ -11,6 +11,7 @@ import ColumnValues from '@/components/ColumnValues';
import ErrorDisplay from '@/components/ErrorDisplay';
import RowOverview from '@/components/RowOverview';
import TraceWaterfall from '@/components/TraceWaterfall';
import type { SpanNode } from '@/components/TraceWaterfall/types';
import type { FormattedRow } from './types';
import { SearchBar } from './SubComponents';
@ -52,6 +53,8 @@ type DetailPanelProps = {
onTraceChSqlChange?: (
chSql: { sql: string; params: Record<string, unknown> } | null,
) => void;
/** Callback when the selected span/log node in the trace waterfall changes */
onTraceSelectedNodeChange?: (node: SpanNode | null) => void;
};
export function DetailPanel({
@ -82,6 +85,7 @@ export function DetailPanel({
scrollOffset,
expandedRow,
onTraceChSqlChange,
onTraceSelectedNodeChange,
}: 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
@ -243,6 +247,7 @@ export function DetailPanel({
detailScrollOffset={traceDetailScrollOffset}
detailMaxRows={detailMaxRows}
onChSqlChange={onTraceChSqlChange}
onSelectedNodeChange={onTraceSelectedNodeChange}
/>
);
})()}

View file

@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef, useMemo } from 'react';
import { Box, useStdout } from 'ink';
import type { TimeRange } from '@/utils/editor';
import type { SpanNode } from '@/components/TraceWaterfall/types';
import type { EventViewerProps, SwitchItem } from './types';
import { getColumns, getDynamicColumns, formatDynamicRow } from './utils';
@ -21,6 +22,7 @@ import { useKeybindings } from './useKeybindings';
export default function EventViewer({
clickhouseClient,
metadata,
appUrl,
source,
sources,
savedSearches,
@ -71,6 +73,9 @@ export default function EventViewer({
null,
);
const [traceDetailExpanded, setTraceDetailExpanded] = useState(false);
const [traceSelectedNode, setTraceSelectedNode] = useState<SpanNode | null>(
null,
);
const [timeRange, setTimeRange] = useState<TimeRange>(() => {
const now = new Date();
return { start: new Date(now.getTime() - 60 * 60 * 1000), end: now };
@ -90,6 +95,7 @@ export default function EventViewer({
expandedRowError,
expandedTraceId,
expandedSpanId,
expandedRowWhere,
lastChSql,
lastExpandedChSql,
fetchNextPage,
@ -178,7 +184,12 @@ export default function EventViewer({
source,
timeRange,
customSelect,
submittedQuery,
fullDetailMaxRows,
appUrl,
expandedTraceId,
expandedRowWhere,
traceSelectedNode,
switchItems,
findActiveIndex,
onSavedSearchSelect,
@ -300,6 +311,7 @@ export default function EventViewer({
scrollOffset={scrollOffset}
expandedRow={expandedRow}
onTraceChSqlChange={setTraceChSql}
onTraceSelectedNodeChange={setTraceSelectedNode}
/>
) : (
<TableView

View file

@ -186,6 +186,7 @@ export const HelpScreen = React.memo(function HelpScreen() {
['s', 'Edit select clause in $EDITOR'],
['D', 'Show generated SQL'],
['f', 'Toggle follow mode (live tail)'],
['o', 'Open trace in browser'],
['w', 'Toggle line wrap'],
['A (Shift+A)', 'Open alerts page'],
['?', 'Toggle this help'],

View file

@ -10,6 +10,8 @@ import type { TimeRange } from '@/utils/editor';
export interface EventViewerProps {
clickhouseClient: ProxyClickhouseClient;
metadata: Metadata;
/** HyperDX app URL (e.g. http://localhost:8080) for opening in browser */
appUrl: string;
source: SourceResponse;
sources: SourceResponse[];
savedSearches: SavedSearchResponse[];

View file

@ -36,6 +36,8 @@ export interface UseEventDataReturn {
expandedRowError: Error | null;
expandedTraceId: string | null;
expandedSpanId: string | null;
/** SQL WHERE clause that uniquely identifies the expanded row (for browser URL) */
expandedRowWhere: string | null;
/** The last rendered ChSql (parameterized SQL + params) for the table query */
lastChSql: { sql: string; params: Record<string, unknown> } | null;
/** The last rendered ChSql for the expanded row (SELECT *) query */
@ -72,6 +74,7 @@ export function useEventData({
const [expandedRowError, setExpandedRowError] = useState<Error | null>(null);
const [expandedTraceId, setExpandedTraceId] = useState<string | null>(null);
const [expandedSpanId, setExpandedSpanId] = useState<string | null>(null);
const [expandedRowWhere, setExpandedRowWhere] = useState<string | null>(null);
const lastTimestampRef = useRef<string | null>(null);
const dateRangeRef = useRef<{ start: Date; end: Date } | null>(null);
@ -239,6 +242,7 @@ export function useEventData({
setExpandedRowError(null);
setExpandedTraceId(null);
setExpandedSpanId(null);
setExpandedRowWhere(null);
setLastExpandedChSql(null);
return;
}
@ -256,7 +260,7 @@ export function useEventData({
params: {},
};
const tableMeta = lastTableMetaRef.current ?? [];
const chSql = await buildFullRowQuery({
const { chSql, rowWhere } = await buildFullRowQuery({
source,
row: row as Record<string, unknown>,
tableChSql,
@ -264,6 +268,9 @@ export function useEventData({
metadata,
});
setLastExpandedChSql(chSql);
if (!cancelled) {
setExpandedRowWhere(rowWhere);
}
const resultSet = await clickhouseClient.query({
query: chSql.sql,
query_params: chSql.params,
@ -324,6 +331,7 @@ export function useEventData({
expandedRowError,
expandedTraceId,
expandedSpanId,
expandedRowWhere,
lastChSql,
lastExpandedChSql,
fetchNextPage,

View file

@ -7,8 +7,12 @@ import {
openEditorForTimeRange,
type TimeRange,
} from '@/utils/editor';
import { openUrl } from '@/utils/openUrl';
import type { SpanNode } from '@/components/TraceWaterfall/types';
import type { EventRow, SwitchItem } from './types';
import { buildBrowserUrl, buildSpanEventRowWhere } from './utils';
// ---- Types ---------------------------------------------------------
@ -31,8 +35,16 @@ export interface KeybindingParams {
source: SourceResponse;
timeRange: TimeRange;
customSelect: string | undefined;
/** The user's submitted search query (Lucene) */
submittedQuery: string;
fullDetailMaxRows: number;
// Browser integration
appUrl: string;
expandedTraceId: string | null;
expandedRowWhere: string | null;
traceSelectedNode: SpanNode | null;
// Tab switching
switchItems: SwitchItem[];
findActiveIndex: () => number;
@ -94,7 +106,12 @@ export function useKeybindings(params: KeybindingParams): void {
source,
timeRange,
customSelect,
submittedQuery,
fullDetailMaxRows,
appUrl,
expandedTraceId,
expandedRowWhere,
traceSelectedNode,
switchItems,
findActiveIndex,
onSavedSearchSelect,
@ -376,6 +393,43 @@ export function useKeybindings(params: KeybindingParams): void {
return;
}
if (input === 'w') setWrapLines(w => !w);
// o = open current trace/span in the browser
if (input === 'o' && expandedRow !== null) {
// Build eventRowWhere for trace tab when a span is selected
let eventRowWhere: {
id: string;
type: string;
aliasWith: never[];
} | null = null;
if (detailTab === 'trace' && traceSelectedNode) {
const traceSource =
source.kind === 'trace'
? source
: // For log sources viewing the trace tab, use the trace source
// expressions. The node's kind tells us which source it came from.
null;
// Only build eventRowWhere if we have a trace source to reference
if (traceSource) {
eventRowWhere = {
id: buildSpanEventRowWhere(traceSelectedNode, traceSource),
type: traceSelectedNode.kind === 'log' ? 'log' : 'trace',
aliasWith: [] as never[],
};
}
}
const url = buildBrowserUrl({
appUrl,
source,
traceId: expandedTraceId,
searchQuery: submittedQuery,
timeRange,
rowWhere: expandedRowWhere,
detailTab,
eventRowWhere,
});
openUrl(url);
return;
}
// f = toggle follow mode (disabled in detail panel — follow is
// automatically paused on expand and restored on close)
if (input === 'f' && expandedRow === null) {

View file

@ -1,4 +1,8 @@
import SqlString from 'sqlstring';
import type { SourceResponse } from '@/api/client';
import type { TimeRange } from '@/utils/editor';
import type { SpanNode } from '@/components/TraceWaterfall/types';
import type { Column, EventRow, FormattedRow } from './types';
// ---- Column definitions per source kind ----------------------------
@ -75,3 +79,155 @@ export function formatShortDate(d: Date): string {
.replace('T', ' ')
.replace(/\.\d{3}Z$/, '');
}
// ---- Tab name mapping (TUI → web app) ------------------------------
/** Map TUI detail tab names to the web app's `sidePanelTab` URL values. */
const SIDE_PANEL_TAB_MAP: Record<string, string> = {
overview: 'overview',
columns: 'parsed',
trace: 'trace',
};
// ---- Span event row WHERE builder -----------------------------------
/**
* Build a SQL WHERE clause that identifies a specific span/log in the
* trace waterfall, matching the web app's `eventRowWhere.id` format.
*
* The web app's waterfall query uses aliased columns. `processRowToWhereClause`
* resolves aliases back to raw expressions via the aliasMap. We replicate
* the same output using the source's expression mappings.
*
* @source packages/app/src/components/DBTraceWaterfallChart.tsx (getConfig + useEventsAroundFocus)
*/
export function buildSpanEventRowWhere(
node: SpanNode,
source: SourceResponse,
): string {
const spanNameExpr = source.spanNameExpression ?? 'SpanName';
const tsExpr =
source.displayedTimestampValueExpression ??
source.timestampValueExpression ??
'Timestamp';
const spanIdExpr = source.spanIdExpression ?? 'SpanId';
const serviceNameExpr = source.serviceNameExpression ?? 'ServiceName';
const durationExpr = source.durationExpression ?? 'Duration';
const precision = source.durationPrecision ?? 9;
const parentSpanIdExpr = source.parentSpanIdExpression ?? 'ParentSpanId';
const statusCodeExpr = source.statusCodeExpression ?? 'StatusCode';
// Convert raw duration to seconds (matching getDurationSecondsExpression)
const durationSeconds = node.Duration / Math.pow(10, precision);
const clauses = [
SqlString.format(`?=?`, [SqlString.raw(spanNameExpr), node.SpanName]),
SqlString.format(`?=parseDateTime64BestEffort(?, 9)`, [
SqlString.raw(tsExpr),
node.Timestamp,
]),
SqlString.format(`?=?`, [SqlString.raw(spanIdExpr), node.SpanId]),
SqlString.format(`?=?`, [SqlString.raw(serviceNameExpr), node.ServiceName]),
SqlString.format(`(?)/?=?`, [
SqlString.raw(durationExpr),
SqlString.raw(`1e${precision}`),
durationSeconds,
]),
SqlString.format(`?=?`, [
SqlString.raw(parentSpanIdExpr),
node.ParentSpanId,
]),
SqlString.format(`?=?`, [SqlString.raw(statusCodeExpr), node.StatusCode]),
];
return clauses.join(' AND ');
}
// ---- Browser URL builder -------------------------------------------
/**
* Build a URL that opens the current view in the HyperDX web app.
*
* Generates a `/search` URL with parameters that:
* - Filter to the trace (if available)
* - Open the side panel for the specific expanded row
* - Select the correct tab (overview / column values / trace)
* - Pre-select the specific span in the trace waterfall (if on trace tab)
*
* String/JSON URL values are double-encoded via `encodeURIComponent` to
* match the web app's `parseAsStringEncoded` / `parseAsJsonEncoded` parsers.
*
* @source packages/app/src/utils/queryParsers.ts
* @source packages/app/src/components/DBSqlRowTableWithSidebar.tsx
* @source packages/app/src/components/DBRowSidePanel.tsx
*/
export function buildBrowserUrl({
appUrl,
source,
traceId,
searchQuery,
timeRange,
rowWhere,
detailTab,
eventRowWhere,
}: {
appUrl: string;
source: SourceResponse;
traceId: string | null;
/** The user's current search query (Lucene) */
searchQuery: string;
timeRange: TimeRange;
/** SQL WHERE clause identifying the expanded row (from useEventData) */
rowWhere: string | null;
/** Current detail tab in the TUI */
detailTab: string;
/** Identifies the selected span in the trace waterfall (trace tab only) */
eventRowWhere: {
id: string;
type: string;
aliasWith: never[];
} | null;
}): string {
const params = new URLSearchParams({
source: source.id,
from: timeRange.start.getTime().toString(),
to: timeRange.end.getTime().toString(),
isLive: 'false',
});
// Build the where clause: combine the user's search query with a
// TraceId filter when viewing a trace.
const whereParts: string[] = [];
if (searchQuery) {
whereParts.push(searchQuery);
}
if (traceId) {
whereParts.push(`TraceId:${traceId}`);
}
if (whereParts.length > 0) {
params.set('where', whereParts.join(' '));
params.set('whereLanguage', 'lucene');
}
// Add row identification for the side panel.
// Values are pre-encoded with encodeURIComponent to match
// parseAsStringEncoded's serialize (double-encoding).
if (rowWhere) {
params.set('rowWhere', encodeURIComponent(rowWhere));
params.set('rowSource', source.id);
}
// Map TUI tab name to web app tab name
const webTab = SIDE_PANEL_TAB_MAP[detailTab] ?? 'overview';
params.set('sidePanelTab', webTab);
// Add trace waterfall span selection (trace tab only)
if (eventRowWhere) {
params.set(
'eventRowWhere',
encodeURIComponent(JSON.stringify(eventRowWhere)),
);
}
return `${appUrl}/search?${params.toString()}`;
}

View file

@ -49,6 +49,7 @@ export default function TraceWaterfall({
width: propWidth,
maxRows: propMaxRows,
onChSqlChange,
onSelectedNodeChange,
}: TraceWaterfallProps) {
const { stdout } = useStdout();
const termWidth = propWidth ?? stdout?.columns ?? 80;
@ -167,6 +168,11 @@ export default function TraceWaterfall({
fetchSelectedRow(selectedNode);
}, [selectedNode?.SpanId, selectedNode?.Timestamp, selectedNode?.kind]);
// Notify parent when selected node changes (used for browser URL)
useEffect(() => {
onSelectedNodeChange?.(selectedNode ?? null);
}, [selectedNode?.SpanId, selectedNode?.Timestamp, selectedNode?.kind]);
// ---- Render ------------------------------------------------------
if (loading) {

View file

@ -67,4 +67,6 @@ export interface TraceWaterfallProps {
onChSqlChange?: (
chSql: { sql: string; params: Record<string, unknown> } | null,
) => void;
/** Callback when the selected span/log node changes (for browser URL) */
onSelectedNodeChange?: (node: SpanNode | null) => void;
}

View file

@ -0,0 +1,31 @@
import { exec } from 'child_process';
/**
* Open a URL in the user's default browser.
*
* Uses platform-specific commands:
* - macOS: `open <url>`
* - Linux: `xdg-open <url>`
* - Windows: `start "" "<url>"`
*
* Fire-and-forget errors are silently ignored so the TUI
* is never disrupted if the browser fails to launch.
*/
export function openUrl(url: string): void {
const platform = process.platform;
let cmd: string;
if (platform === 'darwin') {
cmd = `open ${JSON.stringify(url)}`;
} else if (platform === 'win32') {
cmd = `start "" ${JSON.stringify(url)}`;
} else {
// Linux and other Unix-like systems
cmd = `xdg-open ${JSON.stringify(url)}`;
}
exec(cmd, () => {
// Intentionally swallow errors — headless servers, missing
// display, etc. should not crash the TUI.
});
}