Skip to content
Draft
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. 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.
1 change: 1 addition & 0 deletions packages/api/src/models/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export const TraceSource = Source.discriminator<ITraceSource>(
resourceAttributesExpression: String,
eventAttributesExpression: String,
spanEventsValueExpression: String,
spanLinksValueExpression: String,
implicitColumnExpression: String,
knownColumnsListExpression: String,
useTextIndexForImplicitColumn: {
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 @@ -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({
Expand Down Expand Up @@ -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',
Expand Down
59 changes: 43 additions & 16 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 All @@ -36,7 +37,7 @@ export function RowOverviewPanel({
'data-testid'?: string;
}) {
const { data } = useRowData({ source, rowId, aliasWith });
const { onPropertyAddClick, generateSearchUrl } =
const { onPropertyAddClick, generateSearchUrl, onOpenLinkedTrace } =
useContext(RowSidePanelContext);

const highlightedAttributeValues = useMemo(() => {
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]);
Comment on lines +187 to +192

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 "Span Links" accordion visible with empty-state message for malformed data

hasSpanLinks is true whenever __hdx_span_links is a non-empty array, but SpanLinksSubpanel applies its own stricter type-filter inside useMemo (requires string TraceId, string SpanId, and a defined Attributes). If every object in the array passes the array check but fails the type-filter, the accordion section renders with a "Span Links" header but shows "No span links available for this trace" inside it — a contradictory UX. In practice real OTel data won't hit this, but the defensive check would be cheap: mirror the same SpanLinkData type guard in hasSpanLinks (or just reuse the filtered links array length).

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


const mainContentColumn = getEventBody(source);
const mainContent = isString(firstRow?.['__hdx_body'])
? firstRow['__hdx_body']
Expand Down Expand Up @@ -213,6 +221,7 @@ export function RowOverviewPanel({
defaultValue={[
'exception',
'spanEvents',
'spanLinks',
'network',
'resourceAttributes',
'eventAttributes',
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -259,21 +268,6 @@ export function RowOverviewPanel({
</Accordion.Item>
)}

{hasSpanEvents && (
<Accordion.Item value="spanEvents">
<Accordion.Control>
<Text size="sm" ps="md">
Span Events
</Text>
</Accordion.Control>
<Accordion.Panel>
<Box px="md">
<SpanEventsSubpanel spanEvents={firstRow?.__hdx_span_events} />
</Box>
</Accordion.Panel>
</Accordion.Item>
)}

{Object.keys(topLevelAttributes).length > 0 && (
<Accordion.Item value="topLevelAttributes">
<Accordion.Control>
Expand Down Expand Up @@ -312,6 +306,39 @@ export function RowOverviewPanel({
</Accordion.Item>
)}

{hasSpanEvents && (
<Accordion.Item value="spanEvents">
<Accordion.Control>
<Text size="sm" ps="md">
Span Events
</Text>
</Accordion.Control>
<Accordion.Panel>
<Box px="md">
<SpanEventsSubpanel spanEvents={firstRow?.__hdx_span_events} />
</Box>
</Accordion.Panel>
</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}
onOpenTrace={onOpenLinkedTrace}
/>
</Box>
</Accordion.Panel>
</Accordion.Item>
)}

{Object.keys(resourceAttributes).length > 0 && (
<Accordion.Item value="resourceAttributes">
<Accordion.Control>
Expand Down
53 changes: 51 additions & 2 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<RowSidePanelContextProps>({});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -676,7 +725,7 @@ export const DBRowSidePanelInner = ({
const showLogTraceActions = !sourceIsTrace && traceId && traceSourceId;

return (
<>
<RowSidePanelContext value={rowSidePanelContextValue}>
<Box px="sm" pt="sm" pb="xs">
<Flex align="center" justify="space-between" gap="sm" mb={8}>
<SidePanelBreadcrumbs
Expand Down Expand Up @@ -1023,7 +1072,7 @@ export const DBRowSidePanelInner = ({
</Box>
</ErrorBoundary>
)}
</>
</RowSidePanelContext>
);
};

Expand Down
37 changes: 37 additions & 0 deletions packages/app/src/components/SpanLinksSubpanel.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<SpanLinksSubpanel spanLinks={mockSpanLinks} onOpenTrace={() => {}} />
);

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

export const Empty = () => <SpanLinksSubpanel spanLinks={[]} />;
Loading
Loading