Skip to content
Open
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 @@ -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
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
114 changes: 95 additions & 19 deletions packages/app/src/components/DBRowOverviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,25 +19,33 @@ 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({
source,
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 });
Expand Down Expand Up @@ -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]);
Comment on lines +199 to +204

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 "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


// 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<SpanLinkData | null>(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']
Expand Down Expand Up @@ -214,6 +252,7 @@ export function RowOverviewPanel({
defaultValue={[
'exception',
'spanEvents',
'spanLinks',
'network',
'resourceAttributes',
'eventAttributes',
Comment on lines 252 to 258

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 'spanLinks' unconditionally in accordion defaultValue

'spanLinks' is always included in the initial defaultValue array regardless of whether the span has any links. Mantine silently ignores values that don't match a rendered item, so this causes no visual bug, but it is inconsistent with the other entries ('exception', 'spanEvents') which are also always present despite their items being conditionally rendered. No change needed unless the team starts using this array for logic — just flagging for awareness.

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

Expand Down Expand Up @@ -260,21 +299,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 @@ -313,6 +337,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={link => setOpenedLink(link)}
/>
</Box>
</Accordion.Panel>
</Accordion.Item>
)}

{Object.keys(resourceAttributes).length > 0 && (
<Accordion.Item value="resourceAttributes">
<Accordion.Control>
Expand Down Expand Up @@ -357,6 +414,25 @@ export function RowOverviewPanel({
</Accordion.Item>
)}
</Accordion>
{openedLink && openedLinkWhere && (
<DBRowSidePanel
source={source}
rowId={openedLinkWhere}
aliasWith={[]}
onClose={() => setOpenedLink(null)}
isNestedPanel
breadcrumbPath={[
...(breadcrumbPath ?? []),
{
label:
(typeof firstRow?.SpanName === 'string' && firstRow.SpanName) ||
'Span Link',
rowData: firstRow ?? {},
},
]}
onBreadcrumbClick={onBreadcrumbClick}
/>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ const DBRowSidePanel = ({
rowId={rowId}
aliasWith={aliasWith}
hideHeader={true}
breadcrumbPath={breadcrumbPath}
onBreadcrumbClick={handleBreadcrumbClick}
/>
</ErrorBoundary>
)}
Expand Down
82 changes: 47 additions & 35 deletions packages/app/src/components/Search/DirectTraceSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="75vw"
zIndex={drawerZIndex}
title={
<Group gap="xs">
<IconConnection size={16} />
Expand All @@ -107,42 +117,44 @@ export default function DirectTraceSidePanel({
},
}}
>
<Flex justify="flex-end" mb="sm">
<Group gap="sm" align="center">
<Text size="sm">Trace Source</Text>
<SourceSelectControlled
control={control}
name="source"
size="xs"
allowedSourceKinds={[SourceKind.Trace]}
/>
</Group>
</Flex>
<Box h="100%">
{opened ? (
shouldRenderTracePanel ? (
<DBTracePanel
traceId={traceId}
parentSourceId={traceSource.id}
childSourceId={traceSource.logSourceId}
dateRange={dateRange}
focusDate={focusDate}
emptyState={
<EmptyState
icon={<IconConnection size={24} />}
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"
/>
}
<ZIndexContext.Provider value={drawerZIndex}>
<Flex justify="flex-end" mb="sm">
<Group gap="sm" align="center">
<Text size="sm">Trace Source</Text>
<SourceSelectControlled
control={control}
name="source"
size="xs"
allowedSourceKinds={[SourceKind.Trace]}
/>
) : (
emptyState
)
) : null}
</Box>
</Group>
</Flex>
<Box h="100%">
{opened ? (
shouldRenderTracePanel ? (
<DBTracePanel
traceId={traceId}
parentSourceId={traceSource.id}
childSourceId={traceSource.logSourceId}
dateRange={dateRange}
focusDate={focusDate}
emptyState={
<EmptyState
icon={<IconConnection size={24} />}
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}
</Box>
</ZIndexContext.Provider>
</Drawer>
);
}
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