From 4f38c8a07aa9f18bacaf629c5bda41d004d6d703 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] 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 + .../Search/DirectTraceSidePanel.tsx | 82 ++++---- .../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 + 11 files changed, 499 insertions(+), 54 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 b7e7d55d33..b96d26fef6 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -195,6 +195,7 @@ export const TraceSource = Source.discriminator( resourceAttributesExpression: String, eventAttributesExpression: String, spanEventsValueExpression: String, + spanLinksValueExpression: String, implicitColumnExpression: String, useTextIndexForImplicitColumn: { type: String, diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 5ca3a6194e..bedfe1fdab 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -27,6 +27,7 @@ export enum ROW_DATA_ALIASES { EVENT_ATTRIBUTES = '__hdx_event_attributes', EVENTS_EXCEPTION_ATTRIBUTES = '__hdx_events_exception_attributes', SPAN_EVENTS = '__hdx_span_events', + SPAN_LINKS = '__hdx_span_links', } export function useRowData({ @@ -144,6 +145,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 2d8560fff9..fa2bae5db9 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'] @@ -214,6 +252,7 @@ export function RowOverviewPanel({ defaultValue={[ 'exception', 'spanEvents', + 'spanLinks', 'network', 'resourceAttributes', 'eventAttributes', @@ -260,21 +299,6 @@ export function RowOverviewPanel({ )} - {hasSpanEvents && ( - - - - Span Events - - - - - - - - - )} - {Object.keys(topLevelAttributes).length > 0 && ( @@ -313,6 +337,39 @@ export function RowOverviewPanel({ )} + {hasSpanEvents && ( + + + + Span Events + + + + + + + + + )} + + {hasSpanLinks && ( + + + + Span Links + + + + + setOpenedLink(link)} + /> + + + + )} + {Object.keys(resourceAttributes).length > 0 && ( @@ -357,6 +414,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 1b1929dc28..c79180ab4a 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -403,6 +403,8 @@ const DBRowSidePanel = ({ rowId={rowId} aliasWith={aliasWith} hideHeader={true} + breadcrumbPath={breadcrumbPath} + onBreadcrumbClick={handleBreadcrumbClick} /> )} diff --git a/packages/app/src/components/Search/DirectTraceSidePanel.tsx b/packages/app/src/components/Search/DirectTraceSidePanel.tsx index 6e38791916..449609e298 100644 --- a/packages/app/src/components/Search/DirectTraceSidePanel.tsx +++ b/packages/app/src/components/Search/DirectTraceSidePanel.tsx @@ -8,6 +8,7 @@ import DBTracePanel from '@/components/DBTracePanel'; import EmptyState from '@/components/EmptyState'; import { SourceSelectControlled } from '@/components/SourceSelect'; import { useSource } from '@/source'; +import { useZIndex, ZIndexContext } from '@/zIndex'; interface DirectTraceSidePanelProps { opened: boolean; @@ -88,12 +89,21 @@ export default function DirectTraceSidePanel({ const shouldRenderTracePanel = opened && traceId.length > 0 && traceSource?.kind === SourceKind.Trace; + // Mantine's Drawer renders on the modal layer (z-index 200). Seed + // ZIndexContext with the drawer's z-index so panels opened from inside the + // trace view (for example a linked span via Span Links) stack above this + // drawer. Without this they inherit the base context value (0) and compute + // base + 10, rendering behind the trace drawer. Mirrors DBRowSidePanel. + const contextZIndex = useZIndex(); + const drawerZIndex = Math.max(contextZIndex + 10, 200); + return ( @@ -107,42 +117,44 @@ export default function DirectTraceSidePanel({ }, }} > - - - Trace Source - - - - - {opened ? ( - shouldRenderTracePanel ? ( - } - title="Trace not found" - description="No matching spans or correlated logs were found for this trace in the selected source and time range." - variant="card" - fullWidth - mt="md" - /> - } + + + + Trace Source + - ) : ( - emptyState - ) - ) : null} - + + + + {opened ? ( + shouldRenderTracePanel ? ( + } + title="Trace not found" + description="No matching spans or correlated logs were found for this trace in the selected source and time range." + variant="card" + fullWidth + mt="md" + /> + } + /> + ) : ( + emptyState + ) + ) : null} + + ); } 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 d47df406c6..e8bb11f574 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 28d38ec500..af4ff4c801 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1748,6 +1748,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(), displayedTimestampValueExpression: z.string().optional(), highlightedTraceAttributeExpressions: