diff --git a/.changeset/span-links-trace-viewer.md b/.changeset/span-links-trace-viewer.md new file mode 100644 index 0000000000..d3cf9b3e2c --- /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 listing each linked trace ID, span ID, trace state and attributes. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index d043230d7e..39de608016 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -3450,6 +3450,11 @@ "nullable": true, "example": "Events" }, + "spanLinksValueExpression": { + "type": "string", + "description": "Expression to extract span links. Used to surface links to related spans in the trace view. Expected to be Nested ( TraceId String, SpanId String, TraceState String, Attributes Map(LowCardinality(String), String)", + "example": "Links" + }, "implicitColumnExpression": { "type": "string", "description": "Column used for full text search if no property is specified in a Lucene-based search. Typically the message body of a log.", 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/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index 0903dccf0e..3e300cb93a 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -523,6 +523,10 @@ function formatExternalSource(source: SourceDocument) { * description: Expression to extract span events. Used to capture events associated with spans. Expected to be Nested ( Timestamp DateTime64(9), Name LowCardinality(String), Attributes Map(LowCardinality(String), String) * nullable: true * example: Events + * spanLinksValueExpression: + * type: string + * description: Expression to extract span links. Used to surface links to related spans in the trace view. Expected to be Nested ( TraceId String, SpanId String, TraceState String, Attributes Map(LowCardinality(String), String) + * example: Links * implicitColumnExpression: * type: string * description: Column used for full text search if no property is specified in a Lucene-based search. Typically the message body of a log. 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..82592e08db 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -20,6 +20,7 @@ import EventTag from './EventTag'; import { ExceptionSubpanel } from './ExceptionSubpanel'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; +import { SpanLinksSubpanel } from './SpanLinksSubpanel'; const EMPTY_OBJ = {}; export function RowOverviewPanel({ @@ -183,6 +184,13 @@ 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]); + const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) ? firstRow['__hdx_body'] @@ -214,6 +222,7 @@ export function RowOverviewPanel({ defaultValue={[ 'exception', 'spanEvents', + 'spanLinks', 'network', 'resourceAttributes', 'eventAttributes', @@ -275,6 +284,21 @@ export function RowOverviewPanel({ )} + {hasSpanLinks && ( + + + + Span Links + + + + + + + + + )} + {Object.keys(topLevelAttributes).length > 0 && ( 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.tsx b/packages/app/src/components/Sources/SourceForm.tsx index fe21d2ad87..365aadd2e6 100644 --- a/packages/app/src/components/Sources/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm.tsx @@ -1790,6 +1790,21 @@ function TraceTableModelForm(props: TableModelProps) { placeholder="Events" /> + + + ; + +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..c875792ee4 --- /dev/null +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -0,0 +1,143 @@ +import React, { useMemo } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { Box, Button } from '@mantine/core'; +import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'; +import { ColumnDef } from '@tanstack/react-table'; + +import { DBRowJsonViewer } from './DBRowJsonViewer'; +import { SectionWrapper, useShowMoreRows } from './ExceptionSubpanel'; +import { Table } from './Table'; + +// Make sure SpanLinkData implements Record +interface SpanLinkData extends Record { + TraceId: string; + SpanId: string; + TraceState: string; + Attributes: Record; +} + +const spanLinkColumns: ColumnDef[] = [ + { + accessorKey: 'TraceId', + header: 'Trace ID', + size: 280, + cell: ({ row }) => ( + {row.original.TraceId} + ), + }, + { + accessorKey: 'SpanId', + header: 'Span ID', + size: 160, + cell: ({ row }) => ( + {row.original.SpanId} + ), + }, + { + accessorKey: 'TraceState', + header: 'Trace State', + size: 120, + cell: ({ row }) => + row.original.TraceState ? ( + {row.original.TraceState} + ) : ( + Empty + ), + }, + { + accessorKey: 'Attributes', + header: 'Attributes', + size: 400, + cell: ({ row }) => { + const attributes = row.original.Attributes; + if (attributes && Object.keys(attributes).length > 0) { + return ( + + + + ); + } + return Empty; + }, + }, +]; + +export const SpanLinksSubpanel = ({ + spanLinks, +}: { + spanLinks?: Record[] | null; +}) => { + 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 +
+ )} + > + + + + {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..db9c3b3275 --- /dev/null +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -0,0 +1,99 @@ +import { screen } from '@testing-library/react'; + +import { SpanLinksSubpanel } from '../SpanLinksSubpanel'; + +// Stub the JSON viewer so the assertions focus on the subpanel's row shaping, +// column rendering and empty-state logic rather than the viewer internals. +jest.mock('../DBRowJsonViewer', () => ({ + DBRowJsonViewer: ({ data }: { data: unknown }) => ( +
{JSON.stringify(data)}
+ ), +})); + +const LINK_A = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: { 'link.kind': 'child_of' }, +}; + +const LINK_B = { + TraceId: 'eeee5555ffff6666aaaa7777bbbb8888', + SpanId: '5555666677778888', + TraceState: 'congo=t61rcWkgMzE', + 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 the column headers and a single link', () => { + renderWithMantine(); + + expect(screen.getByText('Trace ID')).toBeInTheDocument(); + expect(screen.getByText('Span ID')).toBeInTheDocument(); + expect(screen.getByText('Trace State')).toBeInTheDocument(); + expect(screen.getByText('Attributes')).toBeInTheDocument(); + + expect(screen.getByText(LINK_A.TraceId)).toBeInTheDocument(); + expect(screen.getByText(LINK_A.SpanId)).toBeInTheDocument(); + // Attributes flow through the JSON viewer. + expect(screen.getByTestId('json-viewer')).toHaveTextContent('link.kind'); + }); + + it('renders multiple links', () => { + renderWithMantine(); + + expect(screen.getByText(LINK_A.TraceId)).toBeInTheDocument(); + expect(screen.getByText(LINK_B.TraceId)).toBeInTheDocument(); + expect(screen.getByText(LINK_B.TraceState)).toBeInTheDocument(); + }); + + it('shows the Empty placeholder for a link with no attributes', () => { + renderWithMantine(); + + // LINK_B has an empty Attributes map and an empty-by-default Trace State + // is not present here, so only the Attributes cell renders "Empty". + expect(screen.getByText('Empty')).toBeInTheDocument(); + expect(screen.queryByTestId('json-viewer')).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.getByText(LINK_A.TraceId)).toBeInTheDocument(); + expect(screen.queryByText('12345')).not.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: