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 @@ -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
127 changes: 108 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,40 @@ 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,
onNavigateToLinkedTrace,
'data-testid': dataTestId,
}: {
source: TSource;
rowId: string | undefined | null;
aliasWith?: WithClause[];
hideHeader?: boolean;
breadcrumbPath?: BreadcrumbPath;
onBreadcrumbClick?: BreadcrumbNavigationCallback;
// Prototype (HDX-3191 demo): when provided, "Open trace" on a span link
// navigates the enclosing trace flyout in place (trace-level back stack)
// instead of opening a nested drawer. Only the inline-split DBTracePanel
// passes this; every other caller leaves it undefined and keeps the
// nested-drawer behavior.
onNavigateToLinkedTrace?: (link: SpanLinkData) => void;
'data-testid'?: string;
}) {
const { data } = useRowData({ source, rowId, aliasWith });
Expand Down Expand Up @@ -183,6 +203,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 +206 to +211

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 +259,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 @@ -260,21 +306,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 +344,45 @@ 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 => {
if (onNavigateToLinkedTrace) {
onNavigateToLinkedTrace(link);
} else {
setOpenedLink(link);
}
}}
/>
</Box>
</Accordion.Panel>
</Accordion.Item>
)}

{Object.keys(resourceAttributes).length > 0 && (
<Accordion.Item value="resourceAttributes">
<Accordion.Control>
Expand Down Expand Up @@ -357,6 +427,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
Loading
Loading