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: