Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/span-links-trace-viewer.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/models/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export const TraceSource = Source.discriminator<ITraceSource>(
resourceAttributesExpression: String,
eventAttributesExpression: String,
spanEventsValueExpression: String,
spanLinksValueExpression: String,
implicitColumnExpression: String,
useTextIndexForImplicitColumn: {
type: String,
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/routers/external-api/v2/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/components/DBRowDataPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down
24 changes: 24 additions & 0 deletions packages/app/src/components/DBRowOverviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -214,6 +222,7 @@ export function RowOverviewPanel({
defaultValue={[
'exception',
'spanEvents',
'spanLinks',
'network',
'resourceAttributes',
'eventAttributes',
Expand Down Expand Up @@ -275,6 +284,21 @@ export function RowOverviewPanel({
</Accordion.Item>
)}

{hasSpanLinks && (
<Accordion.Item value="spanLinks">
<Accordion.Control>
<Text size="sm" ps="md">
Span Links
</Text>
</Accordion.Control>
<Accordion.Panel>
<Box px="md">
<SpanLinksSubpanel spanLinks={firstRow?.__hdx_span_links} />
</Box>
</Accordion.Panel>
</Accordion.Item>
)}

{Object.keys(topLevelAttributes).length > 0 && (
<Accordion.Item value="topLevelAttributes">
<Accordion.Control>
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/OnboardingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ async function addOtelDemoSources({
statusCodeExpression: 'StatusCode',
statusMessageExpression: 'StatusMessage',
spanEventsValueExpression: 'Events',
spanLinksValueExpression: 'Links',
highlightedTraceAttributeExpressions:
traceSourceHighlightedTraceAttributes,
materializedViews: traceSourceMaterializedViews,
Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/components/Sources/SourceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1790,6 +1790,21 @@ function TraceTableModelForm(props: TableModelProps) {
placeholder="Events"
/>
</FormRow>
<FormRow
label={'Span Links Expression'}
helpText="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)"
>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanLinksValueExpression"
placeholder="Links"
/>
</FormRow>
<ExpressionFormRow
control={control}
setValue={setValue}
Expand Down
34 changes: 34 additions & 0 deletions packages/app/src/components/SpanLinksSubpanel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';

import { SpanLinksSubpanel } from './SpanLinksSubpanel';

export default {
title: 'Components/SpanLinksSubpanel',
component: SpanLinksSubpanel,
};

const mockSpanLinks = [
{
TraceId: '6d4a1f0d2c3b4a5e6f7081929394a5b6',
SpanId: '1a2b3c4d5e6f7081',
TraceState: '',
Attributes: {
'link.kind': 'follows_from',
'messaging.system': 'kafka',
},
},
{
TraceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90',
SpanId: '90a1b2c3d4e5f607',
TraceState: 'rojo=00f067aa0ba902b7',
Attributes: {},
},
];

export const Default = () => <SpanLinksSubpanel spanLinks={mockSpanLinks} />;

export const SingleLink = () => (
<SpanLinksSubpanel spanLinks={[mockSpanLinks[0]]} />
);

export const Empty = () => <SpanLinksSubpanel spanLinks={[]} />;
143 changes: 143 additions & 0 deletions packages/app/src/components/SpanLinksSubpanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
interface SpanLinkData extends Record<string, unknown> {
TraceId: string;
SpanId: string;
TraceState: string;
Attributes: Record<string, string>;
}

const spanLinkColumns: ColumnDef<SpanLinkData>[] = [
{
accessorKey: 'TraceId',
header: 'Trace ID',
size: 280,
cell: ({ row }) => (
<span className="font-monospace text-break">{row.original.TraceId}</span>
),
},
{
accessorKey: 'SpanId',
header: 'Span ID',
size: 160,
cell: ({ row }) => (
<span className="font-monospace text-break">{row.original.SpanId}</span>
),
},
{
accessorKey: 'TraceState',
header: 'Trace State',
size: 120,
cell: ({ row }) =>
row.original.TraceState ? (
<span className="text-break">{row.original.TraceState}</span>
) : (
<span className="text-muted">Empty</span>
),
},
{
accessorKey: 'Attributes',
header: 'Attributes',
size: 400,
cell: ({ row }) => {
const attributes = row.original.Attributes;
if (attributes && Object.keys(attributes).length > 0) {
return (
<Box>
<DBRowJsonViewer data={attributes} />
</Box>
);
}
return <span className="text-muted">Empty</span>;
},
},
];

export const SpanLinksSubpanel = ({
spanLinks,
}: {
spanLinks?: Record<string, unknown>[] | 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
);
});
Comment on lines +78 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The type guard validates TraceId and SpanId as strings but doesn't check TraceState. SpanLinkData declares TraceState: string, so a row where TraceState is missing or non-string would pass the cast but violate the interface contract. The cell render handles it gracefully via a truthiness check, but the type safety is incomplete relative to the other two fields.

Suggested change
return spanLinks.filter((link): link is SpanLinkData => {
return (
typeof link.TraceId === 'string' &&
typeof link.SpanId === 'string' &&
link.Attributes !== undefined
);
});
return spanLinks.filter((link): link is SpanLinkData => {
return (
typeof link.TraceId === 'string' &&
typeof link.SpanId === 'string' &&
typeof link.TraceState === 'string' &&
link.Attributes !== undefined
);
});

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

}, [spanLinks]);

const { handleToggleMoreRows, hiddenRowsCount, visibleRows, isExpanded } =
useShowMoreRows({
rows: links,
maxRows: 5,
});

if (!links || links.length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 links is always an array (never null or undefined) because useMemo initializes it to [] in every branch. The !links arm is unreachable dead code — the sibling SpanEventsSubpanel has the same pattern, so this may be copy-paste noise worth cleaning up here.

Suggested change
if (!links || links.length === 0) {
if (links.length === 0) {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

return (
<div className="p-3 text-muted fs-7">
No span links available for this trace
</div>
);
}

return (
<div>
<SectionWrapper>
<ErrorBoundary
onError={err => {
console.error(err);
}}
fallbackRender={() => (
<div className="text-danger px-2 py-1 m-2 fs-7 font-monospace bg-danger-transparent p-4">
An error occurred while rendering span links
</div>
)}
>
<Table
columns={spanLinkColumns}
data={visibleRows}
emptyMessage="No span links found"
/>
</ErrorBoundary>

{hiddenRowsCount ? (
<Button
variant="secondary"
size="xs"
my="sm"
onClick={handleToggleMoreRows}
>
{isExpanded ? (
<>
<IconChevronUp size={14} className="me-2" /> Hide links
</>
) : (
<>
<IconChevronDown size={14} className="me-2" />
Show {hiddenRowsCount} more links
</>
)}
</Button>
) : null}
</SectionWrapper>
</div>
);
};
Loading
Loading