From e63dfd135fdbf0cc212faf6cdc71743f990baf3e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:18:43 +0000 Subject: [PATCH 1/6] feat(traces): surface span links in the trace viewer Show a "Span Links" section in the span detail when a span carries outgoing OpenTelemetry links. Each link renders as a compact row: an "Open trace" action, the trace state and attributes as chips, and the full Trace and Span IDs on hover. A link with neither state nor attributes collapses to a single line, and links past the first five sit behind a "Show more" toggle. "Open trace" opens the linked trace in the existing nested side panel with breadcrumb back navigation, the same flow the Surrounding Context tab uses, so it stacks above the trace drawer in both the search and direct-trace entry points. The Links column is auto-detected from the standard OTel trace schema and read through a guarded select, so nothing changes for sources that do not have it. This is a display-only field, so no source configuration UI and no external API contract are added. Co-Authored-By: Claude Opus 4.7 --- .changeset/span-links-trace-viewer.md | 7 + packages/api/src/models/source.ts | 1 + .../app/src/components/DBRowDataPanel.tsx | 9 + .../app/src/components/DBRowOverviewPanel.tsx | 114 +++++++++-- .../app/src/components/DBRowSidePanel.tsx | 2 + .../components/SpanLinksSubpanel.stories.tsx | 37 ++++ .../app/src/components/SpanLinksSubpanel.tsx | 182 ++++++++++++++++++ .../__tests__/SpanLinksSubpanel.test.tsx | 114 +++++++++++ packages/app/src/source.ts | 4 + packages/common-utils/src/types.ts | 1 + 10 files changed, 452 insertions(+), 19 deletions(-) create mode 100644 .changeset/span-links-trace-viewer.md create mode 100644 packages/app/src/components/SpanLinksSubpanel.stories.tsx create mode 100644 packages/app/src/components/SpanLinksSubpanel.tsx create mode 100644 packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx diff --git a/.changeset/span-links-trace-viewer.md b/.changeset/span-links-trace-viewer.md new file mode 100644 index 0000000000..77f3d8011d --- /dev/null +++ b/.changeset/span-links-trace-viewer.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/app": patch +"@hyperdx/api": patch +--- + +feat: surface OpenTelemetry span links in the trace view. Trace sources gain an optional `spanLinksValueExpression` field (auto-detected from the OTel `Links` column), and the span detail panel shows a new "Span Links" section. Each link has an "Open trace" action that opens the linked trace in a stacked panel, with its trace state and attributes shown as chips. diff --git a/packages/api/src/models/source.ts b/packages/api/src/models/source.ts index bb0ce45758..221bb66138 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -197,6 +197,7 @@ export const TraceSource = Source.discriminator( resourceAttributesExpression: String, eventAttributesExpression: String, spanEventsValueExpression: String, + spanLinksValueExpression: String, implicitColumnExpression: String, knownColumnsListExpression: String, useTextIndexForImplicitColumn: { diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 1f631148ae..e0cea35350 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -34,6 +34,7 @@ export enum ROW_DATA_ALIASES { SPAN_EVENTS = '__hdx_span_events', DURATION_MS = '__hdx_duration_ms', SPAN_KIND = '__hdx_span_kind', + SPAN_LINKS = '__hdx_span_links', } export function useRowData({ @@ -176,6 +177,14 @@ export function useRowData({ }, ] : []), + ...(source.kind === SourceKind.Trace && source.spanLinksValueExpression + ? [ + { + valueExpression: source.spanLinksValueExpression, + alias: ROW_DATA_ALIASES.SPAN_LINKS, + }, + ] + : []), ...selectHighlightedRowAttributes, ], where: rowId ?? '0=1', diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 1ea1e945dd..8d786a9e8f 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -1,7 +1,12 @@ -import { useCallback, useContext, useMemo } from 'react'; +import { useCallback, useContext, useMemo, useState } from 'react'; import isString from 'lodash/isString'; import pickBy from 'lodash/pickBy'; -import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; +import SqlString from 'sqlstring'; +import { + isTraceSource, + SourceKind, + TSource, +} from '@hyperdx/common-utils/dist/types'; import { Accordion, Box, Flex, Text } from '@mantine/core'; import { WithClause } from '@/hooks/useRowWhere'; @@ -14,12 +19,16 @@ import { useRowData, } from './DBRowDataPanel'; import { DBRowJsonViewer } from './DBRowJsonViewer'; -import { RowSidePanelContext } from './DBRowSidePanel'; -import DBRowSidePanelHeader from './DBRowSidePanelHeader'; +import DBRowSidePanel, { RowSidePanelContext } from './DBRowSidePanel'; +import DBRowSidePanelHeader, { + BreadcrumbNavigationCallback, + BreadcrumbPath, +} from './DBRowSidePanelHeader'; import EventTag from './EventTag'; import { ExceptionSubpanel } from './ExceptionSubpanel'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; +import { SpanLinkData, SpanLinksSubpanel } from './SpanLinksSubpanel'; const EMPTY_OBJ = {}; export function RowOverviewPanel({ @@ -27,12 +36,16 @@ export function RowOverviewPanel({ rowId, aliasWith, hideHeader = false, + breadcrumbPath, + onBreadcrumbClick, 'data-testid': dataTestId, }: { source: TSource; rowId: string | undefined | null; aliasWith?: WithClause[]; hideHeader?: boolean; + breadcrumbPath?: BreadcrumbPath; + onBreadcrumbClick?: BreadcrumbNavigationCallback; 'data-testid'?: string; }) { const { data } = useRowData({ source, rowId, aliasWith }); @@ -183,6 +196,31 @@ export function RowOverviewPanel({ ); }, [firstRow?.__hdx_span_events]); + const hasSpanLinks = useMemo(() => { + return ( + Array.isArray(firstRow?.__hdx_span_links) && + firstRow?.__hdx_span_links.length > 0 + ); + }, [firstRow?.__hdx_span_links]); + + // Open a linked span in a stacked side panel one level deeper, reusing the + // same nested-drawer flow the Surrounding Context tab uses, instead of + // navigating the page to the linked trace. The linked span is identified by + // the link's TraceId + SpanId against the trace source's id expressions. + const [openedLink, setOpenedLink] = useState(null); + + const openedLinkWhere = useMemo(() => { + if (!openedLink || !isTraceSource(source)) { + return null; + } + return SqlString.format('?=? AND ?=?', [ + SqlString.raw(source.spanIdExpression), + openedLink.SpanId, + SqlString.raw(source.traceIdExpression), + openedLink.TraceId, + ]); + }, [openedLink, source]); + const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) ? firstRow['__hdx_body'] @@ -213,6 +251,7 @@ export function RowOverviewPanel({ defaultValue={[ 'exception', 'spanEvents', + 'spanLinks', 'network', 'resourceAttributes', 'eventAttributes', @@ -259,21 +298,6 @@ export function RowOverviewPanel({ )} - {hasSpanEvents && ( - - - - Span Events - - - - - - - - - )} - {Object.keys(topLevelAttributes).length > 0 && ( @@ -312,6 +336,39 @@ export function RowOverviewPanel({ )} + {hasSpanEvents && ( + + + + Span Events + + + + + + + + + )} + + {hasSpanLinks && ( + + + + Span Links + + + + + setOpenedLink(link)} + /> + + + + )} + {Object.keys(resourceAttributes).length > 0 && ( @@ -356,6 +413,25 @@ export function RowOverviewPanel({ )} + {openedLink && openedLinkWhere && ( + setOpenedLink(null)} + isNestedPanel + breadcrumbPath={[ + ...(breadcrumbPath ?? []), + { + label: + (typeof firstRow?.SpanName === 'string' && firstRow.SpanName) || + 'Span Link', + rowData: firstRow ?? {}, + }, + ]} + onBreadcrumbClick={onBreadcrumbClick} + /> + )} ); } diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 456d58469e..55980be43f 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -890,6 +890,8 @@ export const DBRowSidePanelInner = ({ rowId={activeRowId} aliasWith={activeAliasWith} hideHeader={true} + breadcrumbPath={breadcrumbPath} + onBreadcrumbClick={handleBreadcrumbClick} /> )} diff --git a/packages/app/src/components/SpanLinksSubpanel.stories.tsx b/packages/app/src/components/SpanLinksSubpanel.stories.tsx new file mode 100644 index 0000000000..c0dbdebcbd --- /dev/null +++ b/packages/app/src/components/SpanLinksSubpanel.stories.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { SpanLinksSubpanel } from './SpanLinksSubpanel'; + +export default { + title: 'Components/SpanLinksSubpanel', + component: SpanLinksSubpanel, +}; + +const mockSpanLinks = [ + { + TraceId: '6d4a1f0d2c3b4a5e6f7081929394a5b6', + SpanId: '1a2b3c4d5e6f7081', + TraceState: 'rojo=00f067aa0ba902b7', + Attributes: { + 'messaging.system': 'kafka', + 'messaging.kafka.partition': '3', + 'order.id': 'A-7741', + }, + }, + { + TraceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + SpanId: '90a1b2c3d4e5f607', + TraceState: '', + Attributes: {}, + }, +]; + +export const Default = () => ( + {}} /> +); + +export const SingleLink = () => ( + {}} /> +); + +export const Empty = () => ; diff --git a/packages/app/src/components/SpanLinksSubpanel.tsx b/packages/app/src/components/SpanLinksSubpanel.tsx new file mode 100644 index 0000000000..b5e164b53f --- /dev/null +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -0,0 +1,182 @@ +import React, { useMemo } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { Anchor, Button, Flex, Stack, Tooltip } from '@mantine/core'; +import { + IconArrowUpRight, + IconChevronDown, + IconChevronUp, +} from '@tabler/icons-react'; + +import EventTag from './EventTag'; +import { SectionWrapper, useShowMoreRows } from './ExceptionSubpanel'; + +// Make sure SpanLinkData implements Record +export interface SpanLinkData extends Record { + TraceId: string; + SpanId: string; + TraceState: string; + Attributes: Record; +} + +// A single span link rendered as a compact row. The linked trace id is the +// widest, least-scannable part of a link, so it is not printed inline: the +// row leads with a labelled "Open trace" action (themed link color, visible +// at rest) and the full Trace ID / Span ID live in the hover tooltip. Trace +// state and attributes render below the action as uniform chips, so a link +// with no trace state and no attributes collapses to a single short line. +function SpanLinkRow({ + link, + onOpenTrace, +}: { + link: SpanLinkData; + onOpenTrace?: (link: SpanLinkData) => void; +}) { + const attributeEntries = Object.entries(link.Attributes ?? {}); + const hasTraceState = + typeof link.TraceState === 'string' && link.TraceState.length > 0; + const hasChips = hasTraceState || attributeEntries.length > 0; + + return ( + + +
Trace: {link.TraceId}
+
Span: {link.SpanId}
+ + } + > + onOpenTrace?.(link)} + size="sm" + fw={500} + className="d-inline-flex align-items-center" + > + + Open trace + +
+ + {hasChips ? ( + + {hasTraceState ? ( + + ) : null} + {attributeEntries.map(([key, value]) => ( + + ))} + + ) : null} +
+ ); +} + +export const SpanLinksSubpanel = ({ + spanLinks, + onOpenTrace, +}: { + spanLinks?: Record[] | null; + onOpenTrace?: (link: SpanLinkData) => void; +}) => { + const links = useMemo(() => { + if (!spanLinks || spanLinks.length === 0) { + return []; + } + + // Ensure links have the right shape with type checking. Span links carry + // no timestamp, so they keep the order ClickHouse returns them in (the + // order they appear in the span's Links column). + return spanLinks.filter((link): link is SpanLinkData => { + return ( + typeof link.TraceId === 'string' && + typeof link.SpanId === 'string' && + link.Attributes !== undefined + ); + }); + }, [spanLinks]); + + const { handleToggleMoreRows, hiddenRowsCount, visibleRows, isExpanded } = + useShowMoreRows({ + rows: links, + maxRows: 5, + }); + + if (!links || links.length === 0) { + return ( +
+ No span links available for this trace +
+ ); + } + + return ( +
+ + { + console.error(err); + }} + fallbackRender={() => ( +
+ An error occurred while rendering span links +
+ )} + > + + {visibleRows.map((link, index) => ( +
0 ? 'pt-2 border-top border-dark' : undefined + } + > + +
+ ))} +
+
+ + {hiddenRowsCount ? ( + + ) : null} +
+
+ ); +}; diff --git a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx new file mode 100644 index 0000000000..34dec9fb6b --- /dev/null +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { SpanLinksSubpanel } from '../SpanLinksSubpanel'; + +const LINK_A = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: { 'link.kind': 'child_of' }, +}; + +const LINK_B = { + TraceId: 'eeee5555ffff6666aaaa7777bbbb8888', + SpanId: '5555666677778888', + TraceState: 'congo=t61rcWkgMzE', + Attributes: {}, +}; + +const LINK_C = { + TraceId: 'cccc9999dddd0000eeee1111ffff2222', + SpanId: '9999000011112222', + TraceState: '', + Attributes: {}, +}; + +describe('SpanLinksSubpanel', () => { + it('renders the empty state when spanLinks is undefined', () => { + renderWithMantine(); + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); + + it('renders the empty state when spanLinks is an empty array', () => { + renderWithMantine(); + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); + + it('renders an Open trace action and the link attributes as chips', () => { + renderWithMantine(); + + expect(screen.getByText('Open trace')).toBeInTheDocument(); + // Attributes render as compact EventTag pills (key: value). + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('calls onOpenTrace with the link when Open trace is clicked', () => { + const onOpenTrace = jest.fn(); + renderWithMantine( + , + ); + + fireEvent.click(screen.getByText('Open trace')); + + expect(onOpenTrace).toHaveBeenCalledTimes(1); + expect(onOpenTrace).toHaveBeenCalledWith( + expect.objectContaining({ + TraceId: LINK_A.TraceId, + SpanId: LINK_A.SpanId, + }), + ); + }); + + it('renders one Open trace action per link', () => { + renderWithMantine(); + + expect(screen.getAllByText('Open trace')).toHaveLength(2); + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('renders trace state as a labeled chip', () => { + renderWithMantine(); + + expect( + screen.getByText('trace state: congo=t61rcWkgMzE'), + ).toBeInTheDocument(); + }); + + it('collapses to just the action when a link has no trace state or attributes', () => { + renderWithMantine(); + + expect(screen.getByText('Open trace')).toBeInTheDocument(); + expect(screen.queryByText(/trace state:/)).not.toBeInTheDocument(); + }); + + it('filters out malformed links missing a string TraceId or SpanId', () => { + const malformed = { + TraceId: 12345, + SpanId: 'deadbeefdeadbeef', + TraceState: '', + Attributes: {}, + } as unknown as Record; + + renderWithMantine(); + + expect(screen.getAllByText('Open trace')).toHaveLength(1); + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('renders the empty state when every link is malformed', () => { + const malformed = { + SpanId: 'deadbeefdeadbeef', + Attributes: {}, + } as unknown as Record; + + renderWithMantine(); + + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index 5bf68b6947..acb0825775 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -358,6 +358,9 @@ export async function inferTableSourceConfig({ // Check if SpanEvents column is available const hasSpanEvents = columns.some(col => col.name === 'Events.Timestamp'); + // Check if span Links column is available + const hasSpanLinks = columns.some(col => col.name === 'Links.TraceId'); + // Check if metadata rollup tables exist and, if so, infer the bucketing // granularity from the key-rollup view's `as_select` const rollupMeta = @@ -435,6 +438,7 @@ export async function inferTableSourceConfig({ statusCodeExpression: 'StatusCode', statusMessageExpression: 'StatusMessage', ...(hasSpanEvents ? { spanEventsValueExpression: 'Events' } : {}), + ...(hasSpanLinks ? { spanLinksValueExpression: 'Links' } : {}), ...metadataMVsConfig, } : {}), diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 067ab8f267..326754a9c0 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1793,6 +1793,7 @@ export const TraceSourceSchema = BaseSourceSchema.extend({ resourceAttributesExpression: z.string().optional(), eventAttributesExpression: z.string().optional(), spanEventsValueExpression: z.string().optional(), + spanLinksValueExpression: z.string().optional(), implicitColumnExpression: z.string().optional(), knownColumnsListExpression: z.string().optional(), displayedTimestampValueExpression: z.string().optional(), From aa6f19d1759497d840938fdfb63fe2db16879b69 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:54:57 +0000 Subject: [PATCH 2/6] feat(traces): rewire span-link navigation onto the shared source stack Rebase onto #2541 (single-drawer navigation) and replace the nested-drawer "Open trace" path with a push onto the same cross-source stack "View Trace" uses. Deletes the DBTracePanel in-place-nav prototype, the setOpenedLink nested panel, the breadcrumbPath/onBreadcrumbClick threading, and the DirectTraceSidePanel z-index seed that only existed to support the nested drawer. Adds onOpenLinkedTrace to RowSidePanelContext, computed in DBRowSidePanelInner from the same traceIdExpression/spanIdExpression used for "View Trace", and consumed by RowOverviewPanel -> SpanLinksSubpanel. Also fixes a restricted-import lint violation in SpanLinksSubpanel.test.tsx (parent-relative import instead of the @/ alias) hit while touching the file. Co-Authored-By: Claude --- .../app/src/components/DBRowOverviewPanel.tsx | 63 +---- .../app/src/components/DBRowSidePanel.tsx | 55 +++- .../DBRowSidePanel.spanLinks.test.tsx | 242 ++++++++++++++++++ .../__tests__/SpanLinksSubpanel.test.tsx | 2 +- 4 files changed, 301 insertions(+), 61 deletions(-) create mode 100644 packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 8d786a9e8f..efcfe17962 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -1,12 +1,7 @@ -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useCallback, useContext, useMemo } from 'react'; import isString from 'lodash/isString'; import pickBy from 'lodash/pickBy'; -import SqlString from 'sqlstring'; -import { - isTraceSource, - SourceKind, - TSource, -} from '@hyperdx/common-utils/dist/types'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; import { Accordion, Box, Flex, Text } from '@mantine/core'; import { WithClause } from '@/hooks/useRowWhere'; @@ -19,16 +14,13 @@ import { useRowData, } from './DBRowDataPanel'; import { DBRowJsonViewer } from './DBRowJsonViewer'; -import DBRowSidePanel, { RowSidePanelContext } from './DBRowSidePanel'; -import DBRowSidePanelHeader, { - BreadcrumbNavigationCallback, - BreadcrumbPath, -} from './DBRowSidePanelHeader'; +import { RowSidePanelContext } from './DBRowSidePanel'; +import DBRowSidePanelHeader from './DBRowSidePanelHeader'; import EventTag from './EventTag'; import { ExceptionSubpanel } from './ExceptionSubpanel'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; -import { SpanLinkData, SpanLinksSubpanel } from './SpanLinksSubpanel'; +import { SpanLinksSubpanel } from './SpanLinksSubpanel'; const EMPTY_OBJ = {}; export function RowOverviewPanel({ @@ -36,20 +28,16 @@ export function RowOverviewPanel({ rowId, aliasWith, hideHeader = false, - breadcrumbPath, - onBreadcrumbClick, 'data-testid': dataTestId, }: { source: TSource; rowId: string | undefined | null; aliasWith?: WithClause[]; hideHeader?: boolean; - breadcrumbPath?: BreadcrumbPath; - onBreadcrumbClick?: BreadcrumbNavigationCallback; 'data-testid'?: string; }) { const { data } = useRowData({ source, rowId, aliasWith }); - const { onPropertyAddClick, generateSearchUrl } = + const { onPropertyAddClick, generateSearchUrl, onOpenLinkedTrace } = useContext(RowSidePanelContext); const highlightedAttributeValues = useMemo(() => { @@ -203,24 +191,6 @@ export function RowOverviewPanel({ ); }, [firstRow?.__hdx_span_links]); - // Open a linked span in a stacked side panel one level deeper, reusing the - // same nested-drawer flow the Surrounding Context tab uses, instead of - // navigating the page to the linked trace. The linked span is identified by - // the link's TraceId + SpanId against the trace source's id expressions. - const [openedLink, setOpenedLink] = useState(null); - - const openedLinkWhere = useMemo(() => { - if (!openedLink || !isTraceSource(source)) { - return null; - } - return SqlString.format('?=? AND ?=?', [ - SqlString.raw(source.spanIdExpression), - openedLink.SpanId, - SqlString.raw(source.traceIdExpression), - openedLink.TraceId, - ]); - }, [openedLink, source]); - const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) ? firstRow['__hdx_body'] @@ -362,7 +332,7 @@ export function RowOverviewPanel({ setOpenedLink(link)} + onOpenTrace={onOpenLinkedTrace} /> @@ -413,25 +383,6 @@ export function RowOverviewPanel({ )} - {openedLink && openedLinkWhere && ( - setOpenedLink(null)} - isNestedPanel - breadcrumbPath={[ - ...(breadcrumbPath ?? []), - { - label: - (typeof firstRow?.SpanName === 'string' && firstRow.SpanName) || - 'Span Link', - rowData: firstRow ?? {}, - }, - ]} - onBreadcrumbClick={onBreadcrumbClick} - /> - )} ); } diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 55980be43f..88772bfcad 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -73,6 +73,7 @@ import { } from './DrawerUtils'; import LogLevel from './LogLevel'; import SidePanelBreadcrumbs, { BreadcrumbItem } from './SidePanelBreadcrumbs'; +import { SpanLinkData } from './SpanLinksSubpanel'; import styles from '@/../styles/LogSidePanel.module.scss'; @@ -103,6 +104,12 @@ export type RowSidePanelContextProps = { isChildModalOpen?: boolean; setChildModalOpen?: (open: boolean) => void; source?: TLogSource | TTraceSource; + // Jump to the trace a span link points at, in place, via the same + // cross-source stack "View Trace" uses. Provided by DBRowSidePanelInner + // for its own subtree; absent for callers outside a trace-aware side + // panel (for example the standalone direct-trace drawer), where "Open + // trace" on a span link has no in-place destination to push to. + onOpenLinkedTrace?: (link: SpanLinkData) => void; }; export const RowSidePanelContext = createContext({}); @@ -505,6 +512,48 @@ export const DBRowSidePanelInner = ({ [traceSourceData, handleSourceStackPush, mainContent], ); + // "Open trace" on a span link: the link carries only the linked span's + // TraceId + SpanId, so resolve it against the current trace source, the + // same assumption the Span Links display already makes. This is the same + // cross-source push "View Trace" uses above, just keyed off the link's + // ids instead of the current row's. + const handleOpenLinkedTrace = useCallback( + (link: SpanLinkData) => { + if (!traceSourceData || !traceIdExpression || !spanIdExpression) { + return; + } + const rowId = [ + SqlString.format('?=?', [ + SqlString.raw(traceIdExpression), + link.TraceId, + ]), + SqlString.format('?=?', [SqlString.raw(spanIdExpression), link.SpanId]), + ].join(' AND '); + handleSourceStackPush({ + sourceId: traceSourceData.id, + rowId, + label: `Trace ${link.TraceId.slice(0, 8)}`, + sourceKind: traceSourceData.kind as SourceKind, + aliasWith: [], + }); + }, + [ + traceSourceData, + traceIdExpression, + spanIdExpression, + handleSourceStackPush, + ], + ); + + // Re-provide RowSidePanelContext for this subtree so RowOverviewPanel (in + // both the Overview tab here and the trace tab's span detail inside + // DBTracePanel) can reach onOpenLinkedTrace, while still inheriting + // whatever the enclosing table/session view already put on the context. + const rowSidePanelContextValue = useMemo( + () => ({ ...parentContext, onOpenLinkedTrace: handleOpenLinkedTrace }), + [parentContext, handleOpenLinkedTrace], + ); + const { rumSessionId, rumServiceName } = useSessionId({ sourceId: traceSourceId, traceId, @@ -676,7 +725,7 @@ export const DBRowSidePanelInner = ({ const showLogTraceActions = !sourceIsTrace && traceId && traceSourceId; return ( - <> + )} @@ -1025,7 +1072,7 @@ export const DBRowSidePanelInner = ({ )} - + ); }; diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx new file mode 100644 index 0000000000..aa96ffd8da --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import SqlString from 'sqlstring'; +import { TSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// Controlled, in-memory replacement for nuqs' useQueryState so each side-panel +// URL param can be seeded and its setter inspected independently. Values are +// the already-parsed shapes the component consumes (arrays / strings), not URL +// strings. Prefixed with `mock` so jest.mock's factory may reference them. +const mockQueryStore: Record = {}; +const mockSetters: Record = {}; + +function setterFor(key: string) { + if (!mockSetters[key]) mockSetters[key] = jest.fn(); + return mockSetters[key]; +} +function resetQueryState() { + Object.keys(mockQueryStore).forEach(k => delete mockQueryStore[k]); + Object.keys(mockSetters).forEach(k => delete mockSetters[k]); +} + +jest.mock('nuqs', () => { + const actual = jest.requireActual('nuqs'); + return { + ...actual, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useQueryState: (key: string, parser?: { defaultValue?: unknown }) => { + const hasValue = Object.prototype.hasOwnProperty.call( + mockQueryStore, + key, + ); + const fallback = + parser && 'defaultValue' in parser ? parser.defaultValue : null; + const value = hasValue ? mockQueryStore[key] : (fallback ?? null); + const setters = mockSetters; + if (!setters[key]) setters[key] = jest.fn(); + return [value, setters[key]]; + }, + }; +}); + +// A single successful row carrying one span link and nothing else, so only +// the Span Links accordion section renders (Exception / Span Events / +// Resource / Event Attributes all stay gated off). RowOverviewPanel is left +// unmocked in this file so the real context wiring (DBRowSidePanelInner -> +// RowSidePanelContext.onOpenLinkedTrace -> RowOverviewPanel -> +// SpanLinksSubpanel.onOpenTrace) is exercised end to end. +const LINK = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: {}, +}; + +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + useRowData: (args: unknown) => mockUseRowData(args), + ROW_DATA_ALIASES: { + DURATION_MS: '__hdx_duration', + SPAN_KIND: '__hdx_span_kind', + SERVICE_NAME: '__hdx_service_name', + SEVERITY_TEXT: '__hdx_severity_text', + }, + rowHasK8sContext: () => false, + RowDataPanel: () => null, + getJSONColumnNames: () => [], + getMapColumnNames: () => [], +})); + +// The current row lives on a log source; the linked trace resolves against a +// separate trace source, the same split "View Trace" already relies on. +const TRACE_SOURCE = { + id: 'trace-src', + kind: 'trace', + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', +}; + +jest.mock('@/source', () => ({ + __esModule: true, + getEventBody: () => undefined, + useSource: ({ id }: { id: string | null }) => + id === 'trace-src' ? { data: TRACE_SOURCE } : { data: undefined }, +})); + +jest.mock('../DBSessionPanel', () => ({ + __esModule: true, + useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }), + DBSessionPanel: () => null, +})); + +jest.mock('@/utils/highlightedAttributes', () => ({ + __esModule: true, + getHighlightedAttributesFromData: () => [], +})); + +// Heavy leaf components / chart deps the panel imports but never renders for +// this row shape (no trace tab, no exception/resource/event attributes). +jest.mock('../DBTracePanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ContextSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../DBInfraPanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../DBRowSidePanelErrorState', () => ({ + __esModule: true, + DBRowSidePanelErrorState: () => null, +})); +jest.mock('../DBRowSidePanelHeader', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../SidePanelBreadcrumbs', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../LogLevel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ServiceMap/ServiceMapSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../TimelineChart/utils', () => ({ + __esModule: true, + renderMs: () => '', +})); +jest.mock('../DrawerUtils', () => ({ + __esModule: true, + DrawerFullWidthToggle: () => null, + INITIAL_DRAWER_WIDTH_PERCENT: 50, +})); +jest.mock('@/LogSidePanelElements', () => ({ + __esModule: true, + KeyboardShortcutsModal: () => null, +})); +jest.mock('@/TabBar', () => ({ __esModule: true, default: () => null })); +jest.mock('@/useFormatTime', () => ({ + __esModule: true, + FormatTime: () => null, +})); + +// NOTE: this import is intentionally placed after the mock factories above, +// which close over the `mock*` helpers declared at the top of this file. +import { DBRowSidePanelInner } from '@/components/DBRowSidePanel'; +import useSidePanelStack from '@/hooks/useSidePanelStack'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const ROOT_SOURCE = { + id: 'log-src', + kind: 'log', + traceSourceId: 'trace-src', + // Makes hasOverviewPanel true so a non-trace source still gets an Overview + // tab (and it is the default tab, since sourceIsTrace is false here). + resourceAttributesExpression: 'ResourceAttributes', +} as TSource; + +function InnerHarness({ + rowId, + source = ROOT_SOURCE, +}: { + rowId: string; + source?: TSource; +}) { + const sidePanelStack = useSidePanelStack({ initialRowId: rowId }); + return ( + + ); +} + +function renderInner(rowId: string) { + return render( + + + , + ); +} + +describe('DBRowSidePanelInner, span link "Open trace" push wiring (HDX-3191)', () => { + beforeEach(() => { + resetQueryState(); + mockUseRowData.mockReset(); + mockUseRowData.mockReturnValue({ + data: { data: [{ __hdx_span_links: [LINK] }], meta: [] }, + isLoading: false, + isSuccess: true, + isError: false, + error: null, + }); + }); + + it("pushes a trace-source frame keyed on the link's ids onto the shared source stack", () => { + renderInner('row-1'); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + const expectedRowId = [ + SqlString.format('?=?', [SqlString.raw('TraceId'), LINK.TraceId]), + SqlString.format('?=?', [SqlString.raw('SpanId'), LINK.SpanId]), + ].join(' AND '); + + expect(setterFor('sidePanelSourceStack')).toHaveBeenCalledTimes(1); + const pushedStack = setterFor('sidePanelSourceStack').mock.calls[0][0]; + expect(pushedStack).toHaveLength(1); + expect(pushedStack[0]).toMatchObject({ + sourceId: 'trace-src', + rowId: expectedRowId, + sourceKind: 'trace', + }); + expect(pushedStack[0].label).toContain(LINK.TraceId.slice(0, 8)); + + // Cross-source push: nav stack clears and the destination tab is Trace, + // matching handleSourceStackPush's Trace-vs-Overview routing. + expect(setterFor('sidePanelNavStack')).toHaveBeenCalledWith([]); + expect(setterFor('sidePanelTab')).toHaveBeenCalledWith('trace'); + }); + + it('does not push when the current row has no resolvable trace source', () => { + // Root source with no traceSourceId: traceSourceData never resolves, so + // the guard in handleOpenLinkedTrace should no-op instead of throwing. + const sourceWithoutTrace = { + ...ROOT_SOURCE, + traceSourceId: undefined, + } as TSource; + + render( + + + , + ); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + expect(setterFor('sidePanelSourceStack')).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx index 34dec9fb6b..4d753429b9 100644 --- a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, screen } from '@testing-library/react'; -import { SpanLinksSubpanel } from '../SpanLinksSubpanel'; +import { SpanLinksSubpanel } from '@/components/SpanLinksSubpanel'; const LINK_A = { TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', From 086ead9e4ec363db5ec3e25a944e2e9c7cea0433 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:43:14 +0000 Subject: [PATCH 3/6] feat(traces): name span-link breadcrumb hops; tighten link gating Span-link "Open trace" hops now label their breadcrumb with the landed span name once the destination row loads, instead of a "Trace " fallback. The name is resolved into a small in-memory store keyed by the hop's row id; the shared side-panel navigation hook is untouched and the "View Trace" hop keeps its existing label. Address review feedback on the span-links section: - Gate the "Span Links" section on at least one valid link, so a non-empty but all-malformed Links array no longer opens an empty section. The validity check is now one exported getValidSpanLinks helper reused by both the subpanel and the overview gate. - Remove a dead null-guard in SpanLinksSubpanel (the memoized list is always an array). Tests: getValidSpanLinks unit cases, a breadcrumb-label integration test that drives the real panel tree through an Open-trace hop, and the existing span-link suites. Co-Authored-By: Claude Opus 4.8 --- .changeset/span-links-trace-viewer.md | 13 +- .../app/src/components/DBRowOverviewPanel.tsx | 11 +- .../app/src/components/DBRowSidePanel.tsx | 34 +++- .../components/SpanLinksSubpanel.stories.tsx | 6 +- .../app/src/components/SpanLinksSubpanel.tsx | 43 ++-- .../DBRowSidePanel.spanLinks.test.tsx | 4 + ...BRowSidePanel.spanLinksBreadcrumb.test.tsx | 192 ++++++++++++++++++ .../__tests__/SpanLinksSubpanel.test.tsx | 55 ++++- 8 files changed, 321 insertions(+), 37 deletions(-) create mode 100644 packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx diff --git a/.changeset/span-links-trace-viewer.md b/.changeset/span-links-trace-viewer.md index 77f3d8011d..79292b96a0 100644 --- a/.changeset/span-links-trace-viewer.md +++ b/.changeset/span-links-trace-viewer.md @@ -1,7 +1,12 @@ --- -"@hyperdx/common-utils": patch -"@hyperdx/app": patch -"@hyperdx/api": patch +'@hyperdx/common-utils': patch +'@hyperdx/app': patch +'@hyperdx/api': patch --- -feat: surface OpenTelemetry span links in the trace view. Trace sources gain an optional `spanLinksValueExpression` field (auto-detected from the OTel `Links` column), and the span detail panel shows a new "Span Links" section. Each link has an "Open trace" action that opens the linked trace in a stacked panel, with its trace state and attributes shown as chips. +feat: surface OpenTelemetry span links in the trace view. Trace sources gain an +optional `spanLinksValueExpression` field (auto-detected from the OTel `Links` +column), and the span detail panel shows a new "Span Links" section. Each link +has an "Open trace" action that opens the linked trace in place in the same +panel, with a breadcrumb trail you can step back through, and shows the link's +trace state and attributes as chips. diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index efcfe17962..73b798580f 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -20,7 +20,7 @@ import EventTag from './EventTag'; import { ExceptionSubpanel } from './ExceptionSubpanel'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; -import { SpanLinksSubpanel } from './SpanLinksSubpanel'; +import { getValidSpanLinks, SpanLinksSubpanel } from './SpanLinksSubpanel'; const EMPTY_OBJ = {}; export function RowOverviewPanel({ @@ -184,11 +184,12 @@ export function RowOverviewPanel({ ); }, [firstRow?.__hdx_span_events]); + // Gate on the presence of at least one *valid* link, matching what + // SpanLinksSubpanel actually renders. A non-empty array of malformed + // entries resolves to zero valid links, so the section stays hidden + // instead of showing an empty-state message inside an open accordion. const hasSpanLinks = useMemo(() => { - return ( - Array.isArray(firstRow?.__hdx_span_links) && - firstRow?.__hdx_span_links.length > 0 - ); + return getValidSpanLinks(firstRow?.__hdx_span_links).length > 0; }, [firstRow?.__hdx_span_links]); const mainContentColumn = getEventBody(source); diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index cc1cac1f7c..1306fc8654 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import { add } from 'date-fns'; @@ -400,6 +401,31 @@ export const DBRowSidePanelInner = ({ } }, [mainContent, initialMainContent, hasActiveStacks]); + // A span-link hop is pushed onto the source stack before its destination + // span name is known (the link carries only a Trace/Span id), so its + // breadcrumb starts as a `Trace ` fallback. Once the hop's row loads we + // learn the landed span name and use it as the crumb label. Only span-link + // frames are tracked here, so the "View Trace" hop (which already labels + // itself from its originating row) is left untouched. In-memory only: after a + // hard reload a hop shows its `Trace ` label until revisited, which is + // acceptable for a display-only convenience. + const spanLinkFrameRowIdsRef = useRef>(new Set()); + const [resolvedFrameLabels, setResolvedFrameLabels] = useState< + Record + >({}); + + useEffect(() => { + if ( + activeRowId != null && + mainContent != null && + spanLinkFrameRowIdsRef.current.has(activeRowId) && + resolvedFrameLabels[activeRowId] !== mainContent + ) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setResolvedFrameLabels(prev => ({ ...prev, [activeRowId]: mainContent })); + } + }, [activeRowId, mainContent, resolvedFrameLabels]); + const highlightedAttributeValues = useMemo(() => { const attributeExpressions: NonNullable< (TLogSource | TTraceSource)['highlightedRowAttributeExpressions'] @@ -530,6 +556,9 @@ export const DBRowSidePanelInner = ({ ]), SqlString.format('?=?', [SqlString.raw(spanIdExpression), link.SpanId]), ].join(' AND '); + // Mark this frame so its breadcrumb switches from the `Trace ` + // fallback below to the landed span name once the row loads. + spanLinkFrameRowIdsRef.current.add(rowId); handleSourceStackPush({ sourceId: traceSourceData.id, rowId, @@ -618,7 +647,9 @@ export const DBRowSidePanelInner = ({ const isLeafSource = i === crumbSourceStack.length - 1; const isCurrent = isLeafSource && crumbNavStack.length === 0; items.push({ - label: entry.label, + // Span-link hops resolve to the landed span name once loaded; every + // other frame keeps the label it was pushed with. + label: resolvedFrameLabels[entry.rowId] ?? entry.label, sourceKind: entry.sourceKind, onClick: isCurrent ? undefined @@ -652,6 +683,7 @@ export const DBRowSidePanelInner = ({ sourceIsTrace, mainContent, initialMainContent, + resolvedFrameLabels, source.kind, handleBreadcrumbNavigation, parentBreadcrumbs, diff --git a/packages/app/src/components/SpanLinksSubpanel.stories.tsx b/packages/app/src/components/SpanLinksSubpanel.stories.tsx index c0dbdebcbd..e951790caf 100644 --- a/packages/app/src/components/SpanLinksSubpanel.stories.tsx +++ b/packages/app/src/components/SpanLinksSubpanel.stories.tsx @@ -26,12 +26,10 @@ const mockSpanLinks = [ }, ]; -export const Default = () => ( - {}} /> -); +export const Default = () => ; export const SingleLink = () => ( - {}} /> + ); export const Empty = () => ; diff --git a/packages/app/src/components/SpanLinksSubpanel.tsx b/packages/app/src/components/SpanLinksSubpanel.tsx index b5e164b53f..42d91716de 100644 --- a/packages/app/src/components/SpanLinksSubpanel.tsx +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -18,6 +18,28 @@ export interface SpanLinkData extends Record { Attributes: Record; } +// A raw `Links` element is a usable span link only when it has the string +// TraceId + SpanId the "Open trace" action needs and an Attributes object. +// Exported so the parent can gate the "Span Links" section on the same notion +// of "valid" this component renders by, rather than on the raw array length +// (a non-empty array of malformed entries would otherwise show an empty +// section). Span links carry no timestamp, so they keep the order ClickHouse +// returns them in (the order they appear in the span's Links column). +export function getValidSpanLinks( + spanLinks?: Record[] | null, +): SpanLinkData[] { + if (!Array.isArray(spanLinks) || spanLinks.length === 0) { + return []; + } + return spanLinks.filter((link): link is SpanLinkData => { + return ( + typeof link.TraceId === 'string' && + typeof link.SpanId === 'string' && + link.Attributes !== undefined + ); + }); +} + // A single span link rendered as a compact row. The linked trace id is the // widest, least-scannable part of a link, so it is not printed inline: the // row leads with a labelled "Open trace" action (themed link color, visible @@ -98,22 +120,7 @@ export const SpanLinksSubpanel = ({ spanLinks?: Record[] | null; onOpenTrace?: (link: SpanLinkData) => void; }) => { - const links = useMemo(() => { - if (!spanLinks || spanLinks.length === 0) { - return []; - } - - // Ensure links have the right shape with type checking. Span links carry - // no timestamp, so they keep the order ClickHouse returns them in (the - // order they appear in the span's Links column). - return spanLinks.filter((link): link is SpanLinkData => { - return ( - typeof link.TraceId === 'string' && - typeof link.SpanId === 'string' && - link.Attributes !== undefined - ); - }); - }, [spanLinks]); + const links = useMemo(() => getValidSpanLinks(spanLinks), [spanLinks]); const { handleToggleMoreRows, hiddenRowsCount, visibleRows, isExpanded } = useShowMoreRows({ @@ -121,7 +128,7 @@ export const SpanLinksSubpanel = ({ maxRows: 5, }); - if (!links || links.length === 0) { + if (links.length === 0) { return (
No span links available for this trace @@ -145,7 +152,7 @@ export const SpanLinksSubpanel = ({ {visibleRows.map((link, index) => (
0 ? 'pt-2 border-top border-dark' : undefined diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx index aa96ffd8da..28e8031937 100644 --- a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx @@ -56,6 +56,7 @@ const LINK = { const mockUseRowData = jest.fn(); jest.mock('../DBRowDataPanel', () => ({ __esModule: true, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix useRowData: (args: unknown) => mockUseRowData(args), ROW_DATA_ALIASES: { DURATION_MS: '__hdx_duration', @@ -81,12 +82,14 @@ const TRACE_SOURCE = { jest.mock('@/source', () => ({ __esModule: true, getEventBody: () => undefined, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix useSource: ({ id }: { id: string | null }) => id === 'trace-src' ? { data: TRACE_SOURCE } : { data: undefined }, })); jest.mock('../DBSessionPanel', () => ({ __esModule: true, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }), DBSessionPanel: () => null, })); @@ -224,6 +227,7 @@ describe('DBRowSidePanelInner, span link "Open trace" push wiring (HDX-3191)', ( it('does not push when the current row has no resolvable trace source', () => { // Root source with no traceSourceId: traceSourceData never resolves, so // the guard in handleOpenLinkedTrace should no-op instead of throwing. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const sourceWithoutTrace = { ...ROOT_SOURCE, traceSourceId: undefined, diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx new file mode 100644 index 0000000000..2b7a988e3c --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { TSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// Reactive replacement for nuqs' useQueryState: backing each param with a real +// useState so a span-link "Open trace" push actually re-renders the panel with +// the new source frame (the shared drawer stack behaves as it does in the app). +// This is what lets us observe the breadcrumb label resolve after the hop +// loads, which the sibling push-wiring test cannot do (it mocks the setters). +jest.mock('nuqs', () => { + const actualReact = jest.requireActual('react'); + const actual = jest.requireActual('nuqs'); + return { + ...actual, + useQueryState: (_key: string, parser?: { defaultValue?: unknown }) => { + const fallback = + parser && 'defaultValue' in parser ? parser.defaultValue : null; + const [value, setValue] = actualReact.useState(fallback ?? null); + return [value, setValue]; + }, + }; +}); + +// One span link on the root row. Its destination row (keyed on the link's ids) +// carries a span name in __hdx_body; the breadcrumb hop should show that name +// instead of the `Trace ` fallback the frame is pushed with. +const LINK = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: {}, +}; + +const ROOT_SPAN_NAME = 'POST /api/checkout'; +const LINKED_SPAN_NAME = 'consume order.created'; + +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useRowData: (args: unknown) => mockUseRowData(args), + ROW_DATA_ALIASES: { + DURATION_MS: '__hdx_duration', + SPAN_KIND: '__hdx_span_kind', + SERVICE_NAME: '__hdx_service_name', + SEVERITY_TEXT: '__hdx_severity_text', + }, + rowHasK8sContext: () => false, + RowDataPanel: () => null, + getJSONColumnNames: () => [], + getMapColumnNames: () => [], +})); + +const TRACE_SOURCE = { + id: 'trace-src', + kind: 'trace', + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', +}; + +jest.mock('@/source', () => ({ + __esModule: true, + getEventBody: () => undefined, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useSource: ({ id }: { id: string | null }) => + id === 'trace-src' ? { data: TRACE_SOURCE } : { data: undefined }, +})); + +jest.mock('../DBSessionPanel', () => ({ + __esModule: true, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }), + DBSessionPanel: () => null, +})); + +jest.mock('@/utils/highlightedAttributes', () => ({ + __esModule: true, + getHighlightedAttributesFromData: () => [], +})); + +// Trace tab content is irrelevant here; only the breadcrumb bar is asserted. +// SidePanelBreadcrumbs is intentionally NOT mocked so its rendered labels can +// be read. +jest.mock('../DBTracePanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ContextSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../DBInfraPanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../DBRowSidePanelErrorState', () => ({ + __esModule: true, + DBRowSidePanelErrorState: () => null, +})); +jest.mock('../DBRowSidePanelHeader', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../LogLevel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ServiceMap/ServiceMapSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../TimelineChart/utils', () => ({ + __esModule: true, + renderMs: () => '', +})); +jest.mock('../DrawerUtils', () => ({ + __esModule: true, + DrawerFullWidthToggle: () => null, + INITIAL_DRAWER_WIDTH_PERCENT: 50, +})); +jest.mock('@/LogSidePanelElements', () => ({ + __esModule: true, + KeyboardShortcutsModal: () => null, +})); +jest.mock('@/TabBar', () => ({ __esModule: true, default: () => null })); +jest.mock('@/useFormatTime', () => ({ + __esModule: true, + FormatTime: () => null, +})); + +// Placed after the mock factories, matching the sibling span-link test. +import { DBRowSidePanelInner } from '@/components/DBRowSidePanel'; +import useSidePanelStack from '@/hooks/useSidePanelStack'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const ROOT_SOURCE = { + id: 'log-src', + kind: 'log', + traceSourceId: 'trace-src', + resourceAttributesExpression: 'ResourceAttributes', +} as TSource; + +function InnerHarness({ rowId }: { rowId: string }) { + const sidePanelStack = useSidePanelStack({ initialRowId: rowId }); + return ( + + ); +} + +describe('DBRowSidePanelInner, span-link breadcrumb labels (HDX-3191)', () => { + beforeEach(() => { + mockUseRowData.mockReset(); + // The root row (log) carries the span link; the linked trace row (keyed on + // the link's TraceId) carries the destination span name and no links. + mockUseRowData.mockImplementation((args: { rowId?: string }) => { + const rowId = args?.rowId ?? ''; + const isLinkedTrace = + typeof rowId === 'string' && rowId.includes(LINK.TraceId); + return { + data: { + data: [ + isLinkedTrace + ? { __hdx_body: LINKED_SPAN_NAME, __hdx_span_links: [] } + : { __hdx_body: ROOT_SPAN_NAME, __hdx_span_links: [LINK] }, + ], + meta: [], + }, + isLoading: false, + isSuccess: true, + isError: false, + error: null, + }; + }); + }); + + it('shows the landed span name for a span-link hop instead of the Trace-id fallback', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + // The hop crumb resolves to the destination span name once its row loads. + expect(await screen.findByText(LINKED_SPAN_NAME)).toBeInTheDocument(); + // The root crumb still shows the originating span name. + expect(screen.getByText(ROOT_SPAN_NAME)).toBeInTheDocument(); + // The `Trace ` fallback is no longer shown for the resolved hop. + expect( + screen.queryByText(new RegExp(`^Trace ${LINK.TraceId.slice(0, 8)}`)), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx index 4d753429b9..ca3acc85d5 100644 --- a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -1,6 +1,9 @@ import { fireEvent, screen } from '@testing-library/react'; -import { SpanLinksSubpanel } from '@/components/SpanLinksSubpanel'; +import { + getValidSpanLinks, + SpanLinksSubpanel, +} from '@/components/SpanLinksSubpanel'; const LINK_A = { TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', @@ -86,12 +89,12 @@ describe('SpanLinksSubpanel', () => { }); it('filters out malformed links missing a string TraceId or SpanId', () => { - const malformed = { + const malformed: Record = { TraceId: 12345, SpanId: 'deadbeefdeadbeef', TraceState: '', Attributes: {}, - } as unknown as Record; + }; renderWithMantine(); @@ -100,10 +103,10 @@ describe('SpanLinksSubpanel', () => { }); it('renders the empty state when every link is malformed', () => { - const malformed = { + const malformed: Record = { SpanId: 'deadbeefdeadbeef', Attributes: {}, - } as unknown as Record; + }; renderWithMantine(); @@ -112,3 +115,45 @@ describe('SpanLinksSubpanel', () => { ).toBeInTheDocument(); }); }); + +describe('getValidSpanLinks', () => { + it('returns [] for null, undefined, and empty input', () => { + expect(getValidSpanLinks(null)).toEqual([]); + expect(getValidSpanLinks(undefined)).toEqual([]); + expect(getValidSpanLinks([])).toEqual([]); + }); + + it('keeps well-formed links and preserves their order', () => { + expect(getValidSpanLinks([LINK_A, LINK_B, LINK_C])).toEqual([ + LINK_A, + LINK_B, + LINK_C, + ]); + }); + + it('drops links missing a string TraceId/SpanId or an Attributes object', () => { + const noTraceId: Record = { + SpanId: 'deadbeefdeadbeef', + TraceState: '', + Attributes: {}, + }; + const numericSpanId: Record = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: 42, + TraceState: '', + Attributes: {}, + }; + + expect(getValidSpanLinks([LINK_A, noTraceId, numericSpanId])).toEqual([ + LINK_A, + ]); + }); + + it('returns [] when every link is malformed (parent gate stays hidden)', () => { + const malformed: Record = { + SpanId: 'deadbeefdeadbeef', + }; + + expect(getValidSpanLinks([malformed])).toEqual([]); + }); +}); From fb97673b3503113e159c168a708bb6de3e0a37cf Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:42:59 +0000 Subject: [PATCH 4/6] fix(traces): keep span-link row keys unique for duplicate destinations Address Greptile review on #2463. A span can carry two links to the same destination span (same TraceId and SpanId, different attributes). The row key was TraceId-SpanId, which collides in that case, so React warns and can drop a row. Add the list index to the key; the span-link list is append-only and never reordered, so the index stays stable across renders. Adds a regression test that renders two links to the same destination span and asserts both rows render with no duplicate-key warning. Co-Authored-By: Claude Opus 4.8 --- .../app/src/components/SpanLinksSubpanel.tsx | 7 +++++- .../__tests__/SpanLinksSubpanel.test.tsx | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/SpanLinksSubpanel.tsx b/packages/app/src/components/SpanLinksSubpanel.tsx index 42d91716de..2bc1b7821c 100644 --- a/packages/app/src/components/SpanLinksSubpanel.tsx +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -152,7 +152,12 @@ export const SpanLinksSubpanel = ({ {visibleRows.map((link, index) => (
0 ? 'pt-2 border-top border-dark' : undefined diff --git a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx index ca3acc85d5..10ff4fd1c1 100644 --- a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -73,6 +73,30 @@ describe('SpanLinksSubpanel', () => { expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); }); + it('renders two links to the same destination span as distinct rows', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // A span can link to the same destination span twice, differing only in + // attributes. Both rows must render and the row key must stay unique. + const duplicate = { + ...LINK_A, + Attributes: { 'link.kind': 'follows_from' }, + }; + renderWithMantine(); + + expect(screen.getAllByTestId('span-link-row')).toHaveLength(2); + + const sameKeyWarning = consoleError.mock.calls.find( + ([message]) => + typeof message === 'string' && message.includes('same key'), + ); + expect(sameKeyWarning).toBeUndefined(); + + consoleError.mockRestore(); + }); + it('renders trace state as a labeled chip', () => { renderWithMantine(); From e313a6ed82841463dfe0939f71922a8d5f99362e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:19:49 +0000 Subject: [PATCH 5/6] feat(traces): make Span Links Expression configurable in the source form Add a Span Links Expression field to the trace source form and an onboarding default, mirroring Span Events Expression. The OTel Links column stays auto-detected out of the box, so this only exposes the value and lets users override it for non-standard schemas. External-API OpenAPI doc parity for the field is left as a follow-up; the field already round-trips through the v2 sources API at runtime via SourceSchema. Co-Authored-By: Claude Opus 4.8 --- packages/app/src/components/OnboardingModal.tsx | 1 + .../Sources/SourceForm/TraceTableModelForm.tsx | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/app/src/components/OnboardingModal.tsx b/packages/app/src/components/OnboardingModal.tsx index f36ad76c7c..de7648fde3 100644 --- a/packages/app/src/components/OnboardingModal.tsx +++ b/packages/app/src/components/OnboardingModal.tsx @@ -134,6 +134,7 @@ async function addOtelDemoSources({ statusCodeExpression: 'StatusCode', statusMessageExpression: 'StatusMessage', spanEventsValueExpression: 'Events', + spanLinksValueExpression: 'Links', highlightedTraceAttributeExpressions: traceSourceHighlightedTraceAttributes, materializedViews: traceSourceMaterializedViews, diff --git a/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx b/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx index 3139897b8b..b1064f3404 100644 --- a/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx @@ -324,6 +324,21 @@ export function TraceTableModelForm(props: TableModelProps) { placeholder="Events" /> + + + Date: Wed, 22 Jul 2026 10:29:57 +0200 Subject: [PATCH 6/6] select linked span when going forward or back, tidy comments --- .../app/src/components/DBRowOverviewPanel.tsx | 4 ---- .../app/src/components/DBRowSidePanel.tsx | 23 ++----------------- packages/app/src/components/DBTracePanel.tsx | 4 +++- .../src/components/DBTraceWaterfallChart.tsx | 5 ++++ .../app/src/components/SpanLinksSubpanel.tsx | 15 ------------ 5 files changed, 10 insertions(+), 41 deletions(-) diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 73b798580f..94db251ec4 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -184,10 +184,6 @@ export function RowOverviewPanel({ ); }, [firstRow?.__hdx_span_events]); - // Gate on the presence of at least one *valid* link, matching what - // SpanLinksSubpanel actually renders. A non-empty array of malformed - // entries resolves to zero valid links, so the section stays hidden - // instead of showing an empty-state message inside an open accordion. const hasSpanLinks = useMemo(() => { return getValidSpanLinks(firstRow?.__hdx_span_links).length > 0; }, [firstRow?.__hdx_span_links]); diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 34170c1a23..523ee7f78c 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -106,11 +106,6 @@ export type RowSidePanelContextProps = { isChildModalOpen?: boolean; setChildModalOpen?: (open: boolean) => void; source?: TLogSource | TTraceSource; - // Jump to the trace a span link points at, in place, via the same - // cross-source stack "View Trace" uses. Provided by DBRowSidePanelInner - // for its own subtree; absent for callers outside a trace-aware side - // panel (for example the standalone direct-trace drawer), where "Open - // trace" on a span link has no in-place destination to push to. onOpenLinkedTrace?: (link: SpanLinkData) => void; }; @@ -406,14 +401,7 @@ export const DBRowSidePanelInner = ({ } }, [mainContent, initialMainContent, hasActiveStacks]); - // A span-link hop is pushed onto the source stack before its destination - // span name is known (the link carries only a Trace/Span id), so its - // breadcrumb starts as a `Trace ` fallback. Once the hop's row loads we - // learn the landed span name and use it as the crumb label. Only span-link - // frames are tracked here, so the "View Trace" hop (which already labels - // itself from its originating row) is left untouched. In-memory only: after a - // hard reload a hop shows its `Trace ` label until revisited, which is - // acceptable for a display-only convenience. + // Once the hop's row loads we learn the landed span name and use it as the crumb label. const spanLinkFrameRowIdsRef = useRef>(new Set()); const [resolvedFrameLabels, setResolvedFrameLabels] = useState< Record @@ -545,10 +533,7 @@ export const DBRowSidePanelInner = ({ ); // "Open trace" on a span link: the link carries only the linked span's - // TraceId + SpanId, so resolve it against the current trace source, the - // same assumption the Span Links display already makes. This is the same - // cross-source push "View Trace" uses above, just keyed off the link's - // ids instead of the current row's. + // TraceId + SpanId, so resolve it against the current trace source. const handleOpenLinkedTrace = useCallback( (link: SpanLinkData) => { if (!traceSourceData || !traceIdExpression || !spanIdExpression) { @@ -580,10 +565,6 @@ export const DBRowSidePanelInner = ({ ], ); - // Re-provide RowSidePanelContext for this subtree so RowOverviewPanel (in - // both the Overview tab here and the trace tab's span detail inside - // DBTracePanel) can reach onOpenLinkedTrace, while still inheriting - // whatever the enclosing table/session view already put on the context. const rowSidePanelContextValue = useMemo( () => ({ ...parentContext, onOpenLinkedTrace: handleOpenLinkedTrace }), [parentContext, handleOpenLinkedTrace], diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index db5668fe5d..40891fa99b 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -269,7 +269,9 @@ export default function DBTracePanel({ // otherwise we'll show stale span details useEffect(() => { if (eventRowWhere != null && eventRowWhere.traceId !== traceId) { - setEventRowWhere(null); + setEventRowWhere(prev => + prev != null && prev.traceId !== traceId ? null : prev, + ); } }, [eventRowWhere, traceId, setEventRowWhere]); diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index 5ab9067317..77275c47e8 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -793,6 +793,11 @@ export function DBTraceWaterfallChartContainer({ // ourselves, so it never fires twice for the same hint; it re-fires only when // the hint genuinely changes (different originating span / trace). const appliedHighlightHintRef = useRef(null); + + useEffect(() => { + appliedHighlightHintRef.current = null; + }, [traceId]); + useEffect(() => { if (!initialRowHighlightHint || !onClick) { return; diff --git a/packages/app/src/components/SpanLinksSubpanel.tsx b/packages/app/src/components/SpanLinksSubpanel.tsx index 2bc1b7821c..e1d2a54b30 100644 --- a/packages/app/src/components/SpanLinksSubpanel.tsx +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -20,11 +20,6 @@ export interface SpanLinkData extends Record { // A raw `Links` element is a usable span link only when it has the string // TraceId + SpanId the "Open trace" action needs and an Attributes object. -// Exported so the parent can gate the "Span Links" section on the same notion -// of "valid" this component renders by, rather than on the raw array length -// (a non-empty array of malformed entries would otherwise show an empty -// section). Span links carry no timestamp, so they keep the order ClickHouse -// returns them in (the order they appear in the span's Links column). export function getValidSpanLinks( spanLinks?: Record[] | null, ): SpanLinkData[] { @@ -40,12 +35,6 @@ export function getValidSpanLinks( }); } -// A single span link rendered as a compact row. The linked trace id is the -// widest, least-scannable part of a link, so it is not printed inline: the -// row leads with a labelled "Open trace" action (themed link color, visible -// at rest) and the full Trace ID / Span ID live in the hover tooltip. Trace -// state and attributes render below the action as uniform chips, so a link -// with no trace state and no attributes collapses to a single short line. function SpanLinkRow({ link, onOpenTrace, @@ -152,10 +141,6 @@ export const SpanLinksSubpanel = ({ {visibleRows.map((link, index) => (