Skip to content

feat(traces): surface span links in the trace viewer#2463

Draft
alex-fedotyev wants to merge 2 commits into
mainfrom
alex/hdx-3191-span-links
Draft

feat(traces): surface span links in the trace viewer#2463
alex-fedotyev wants to merge 2 commits into
mainfrom
alex/hdx-3191-span-links

Conversation

@alex-fedotyev

@alex-fedotyev alex-fedotyev commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Span links are the OpenTelemetry way of pointing from one span to a span in a different trace: a producer/consumer hop, the source records behind a batch, a retried request. HyperDX ingests them in the standard Links column but never surfaced them, so in fan-out and batch flows the related spans just showed up as orphans.

This adds a "Span Links" section to the span detail. It renders only when the span actually has links. Each link is a compact row:

  • an "Open trace" action, with the full Trace and Span IDs on hover;
  • the trace state and any link attributes as chips;
  • a link with neither collapses to a single line, and links past the first five sit behind a "Show more" toggle.

"Open trace" jumps to the linked trace in place, through the same cross-source navigation #2541 introduced: the trace opens in the same panel, the breadcrumb trail picks up the hop, and back and the URL both reflect it. A span link is really the same kind of jump as the existing "View Trace" action (log to trace), just keyed off the link's trace and span ids instead of the current row's, so it reuses that mechanism directly instead of adding a second one.

The Links column is auto-detected from the standard OTel trace schema and read through a guarded select, so sources without it are untouched and the rest of the row-detail panel keeps working. This is a display-only field, so it adds no source-configuration UI and no external API surface.

Replaces #2440

Fresh branch replacing #2440, which I'm closing. That branch's history got tangled during a rebase. This one ships the same feature with a clean diff and trims the scope to what a display-only field actually needs: it drops the source-form field, the onboarding default, and the external-API and OpenAPI entries that the earlier draft threaded through.

Rebased onto #2541

This branch tried two navigation approaches while #2541 (single-drawer navigation) was still in review: a nested drawer, then a separate in-place flyout prototype for feedback. Now that #2541 shipped a shared mechanism for exactly this, jumping across sources in place with one breadcrumb trail, I rebased onto it and dropped both. "Open trace" is just another caller of that mechanism now, the same as "View Trace": it pushes the linked trace onto the shared stack instead of opening a second drawer.

Test plan

  • make ci-lint and make ci-unit pass: full app suite, common-utils.
  • 9 unit tests on the compact rows themselves: empty and malformed input, attribute chips, the trace-state chip, single-line collapse, and the "Open trace" callback.
  • 2 new unit tests on the navigation wiring after the rebase: clicking "Open trace" pushes the right trace source, row, and label onto the shared stack and switches to the Trace tab; it's a no-op when the current row has no resolvable trace source.
  • Storybook story (Default / SingleLink / Empty).
  • Section render-gating (present on a span with links, absent without) and light/dark theming were verified live before this rebase and are unchanged by it.
  • Live click-through of the rewired "Open trace" is still pending; I haven't driven the actual browser flow against the new shared-stack navigation yet.

Implements #1593

@changeset-bot

changeset-bot Bot commented Jun 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aa6f19d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/common-utils Patch
@hyperdx/app Patch
@hyperdx/api Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jun 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment Jul 8, 2026 1:01pm
hyperdx-storybook Ready Ready Preview, Comment Jul 8, 2026 1:01pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

🟡 Tier 3 — Standard

Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.

Why this tier:

  • Diff size: 432 production lines changed (Tier 2 max: < 250)
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api) + shared utils (packages/common-utils)
  • Touches API routes or data models — hidden complexity risk

Review process: Full human review — logic, architecture, edge cases.
SLA: First-pass feedback within 1 business day.

Stats
  • Production files changed: 9
  • Production lines changed: 432 (+ 114 in test files, excluded from tier calculation)
  • Branch: alex/hdx-3191-span-links
  • Author: alex-fedotyev

To override this classification, remove the review/tier-3 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR surfaces OpenTelemetry span links in the trace viewer by adding a new "Span Links" accordion section to the span detail panel. Each link renders as a compact row with an "Open trace" action (full IDs on hover) plus trace state and attribute chips, reusing the existing cross-source navigation stack introduced in #2541.

  • Schema and data: spanLinksValueExpression is added as an optional field to TraceSourceSchema (Zod, Mongoose) and auto-detected from the OTel Links.TraceId column — sources without it are untouched.
  • Component: SpanLinksSubpanel follows the same structure as SpanEventsSubpanel (type-filtered useMemo, useShowMoreRows, ErrorBoundary, empty-state fallback), and is gated behind a hasSpanLinks check in DBRowOverviewPanel.
  • Navigation: handleOpenLinkedTrace in DBRowSidePanelInner builds a rowId from the link's TraceId/SpanId using the current trace source's expressions and pushes it onto the shared source stack via handleSourceStackPush, the same mechanism "View Trace" uses.

Confidence Score: 5/5

Display-only additive change with no schema migrations; all new code paths are gated and existing paths are untouched.

The change is narrowly scoped: a new optional source field auto-detected on setup, a new component that mirrors an existing one, and a navigation callback that follows an already-proven pattern. All new code paths are gated (span links only fetched for trace sources with the expression set, section only renders when data is present, navigation only fires when a resolvable trace source exists). Eleven tests cover the key paths including the navigation wiring.

No files require special attention. The only findings are a minor type-guard gap in SpanLinksSubpanel.tsx (TraceState not validated) and a consistency note in DBRowOverviewPanel.tsx (string literal vs constant).

Important Files Changed

Filename Overview
packages/app/src/components/SpanLinksSubpanel.tsx New component rendering span links as compact rows with 'Open trace' action. Type guard doesn't assert TraceState is a string despite the SpanLinkData interface requiring it.
packages/app/src/components/DBRowOverviewPanel.tsx Adds Span Links accordion section and wires onOpenLinkedTrace from context; uses __hdx_span_links string literals instead of the ROW_DATA_ALIASES constant.
packages/app/src/components/DBRowSidePanel.tsx Adds handleOpenLinkedTrace callback and re-provides RowSidePanelContext with onOpenLinkedTrace; follows the same SQL-building pattern as existing traceSpanRowId.
packages/app/src/source.ts Auto-detects Links.TraceId column for spanLinksValueExpression, consistent with the existing Events.Timestamp detection for spanEventsValueExpression.
packages/common-utils/src/types.ts Adds optional spanLinksValueExpression field to TraceSourceSchema Zod definition; straightforward additive change.
packages/api/src/models/source.ts Adds spanLinksValueExpression: String to the Mongoose TraceSource discriminator schema, consistent with existing optional string fields.
packages/app/src/components/tests/DBRowSidePanel.spanLinks.test.tsx Two integration tests verifying the 'Open trace' push wiring end-to-end: correct stack push and no-op when trace source is absent.
packages/app/src/components/tests/SpanLinksSubpanel.test.tsx Nine unit tests covering empty state, type filtering, attribute/trace-state chips, 'Open trace' callback, and the 'Show more' collapse.
packages/app/src/components/DBRowDataPanel.tsx Adds SPAN_LINKS alias and conditionally includes spanLinksValueExpression in the select list for trace sources.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant SpanLinksSubpanel
    participant RowSidePanelContext
    participant DBRowSidePanelInner
    participant handleSourceStackPush
    participant URLState as URL / nuqs state

    Note over DBRowSidePanelInner: On mount, wraps subtree with<br/>RowSidePanelContext providing<br/>onOpenLinkedTrace
    DBRowSidePanelInner->>RowSidePanelContext: provide onOpenLinkedTrace
    RowSidePanelContext->>SpanLinksSubpanel: onOpenTrace prop (via DBRowOverviewPanel)

    User->>SpanLinksSubpanel: Click 'Open trace' on a link row
    SpanLinksSubpanel->>DBRowSidePanelInner: onOpenLinkedTrace(link)
    DBRowSidePanelInner->>DBRowSidePanelInner: Build rowId from TraceId+SpanId
    DBRowSidePanelInner->>handleSourceStackPush: "{ sourceId, rowId, label, sourceKind }"
    handleSourceStackPush->>URLState: setSourceStack([...current, newFrame])
    handleSourceStackPush->>URLState: setTab('trace')
    URLState-->>User: Linked span opens in same panel
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant SpanLinksSubpanel
    participant RowSidePanelContext
    participant DBRowSidePanelInner
    participant handleSourceStackPush
    participant URLState as URL / nuqs state

    Note over DBRowSidePanelInner: On mount, wraps subtree with<br/>RowSidePanelContext providing<br/>onOpenLinkedTrace
    DBRowSidePanelInner->>RowSidePanelContext: provide onOpenLinkedTrace
    RowSidePanelContext->>SpanLinksSubpanel: onOpenTrace prop (via DBRowOverviewPanel)

    User->>SpanLinksSubpanel: Click 'Open trace' on a link row
    SpanLinksSubpanel->>DBRowSidePanelInner: onOpenLinkedTrace(link)
    DBRowSidePanelInner->>DBRowSidePanelInner: Build rowId from TraceId+SpanId
    DBRowSidePanelInner->>handleSourceStackPush: "{ sourceId, rowId, label, sourceKind }"
    handleSourceStackPush->>URLState: setSourceStack([...current, newFrame])
    handleSourceStackPush->>URLState: setTab('trace')
    URLState-->>User: Linked span opens in same panel
Loading

Reviews (3): Last reviewed commit: "feat(traces): rewire span-link navigatio..." | Re-trigger Greptile

maxRows: 5,
});

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

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 Redundant null-guard on links

links is always an array — useMemo initialises it to [] and the filter always returns an array — so !links is permanently false and the branch can never be reached. The dead-code check could mislead future readers into thinking useMemo can return null.

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

Comment on lines +199 to +204
const hasSpanLinks = useMemo(() => {
return (
Array.isArray(firstRow?.__hdx_span_links) &&
firstRow?.__hdx_span_links.length > 0
);
}, [firstRow?.__hdx_span_links]);

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

Comment thread packages/app/src/components/DBRowOverviewPanel.tsx
@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 225 passed • 3 skipped • 1551s

Status Count
✅ Passed 225
❌ Failed 0
⚠️ Flaky 3
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

@alex-fedotyev alex-fedotyev marked this pull request as draft June 15, 2026 22:24
@alex-fedotyev

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit (4416036) with an alternative to the nested-drawer "Open trace" on a span link, so we can validate the navigation approach together.

What it does: instead of opening each linked trace in a new stacked drawer (which builds a tower once you follow a chain of links), it reuses the inline-split trace panel from #2402 and navigates trace to trace in the same flyout, with a back stack and a breadcrumb of the spans you followed. Back pops a level; a breadcrumb crumb jumps straight to any level. The linked span auto-focuses on arrival.

Scope: one optional onNavigateToLinkedTrace callback on RowOverviewPanel. Only the inline-split flyout passes it, so the search-results side panel and the Overview tab still open the nested drawer. That two-behavior split is the main thing I want a read on: do we take the whole feature to in-place navigation, keep the drawer everywhere, or land some mix.

Known gaps if we go this way: the Overview-tab "Open trace" still uses the drawer, there is no unit test yet for the nav stack, and no e2e for the multi-hop flow. I will fill those in once we pick a direction.

To try it: open a trace whose span carries outgoing links, click "Open trace", and follow a couple of hops.

@alex-fedotyev

Copy link
Copy Markdown
Contributor Author

Heads up on direction. #2541 reworks the side panel into a single drawer with a shared cross-source navigation stack (its new "View Trace" action pushes onto it) plus a shared breadcrumb component. That replaces the stacked/nested drawer pattern this PR's "Open trace" currently relies on.

Rather than ship navigation that #2541 then has to unwind, I'm going to rebuild "Open trace" on that shared stack: following a span link becomes a push of the linked span's trace frame, the same way "View Trace" works for logs. That reuses the breadcrumb trail, URL persistence, and back/jump behavior for free, and lets me drop the in-place trace-nav code in DBTracePanel and the nested-drawer path entirely. The Span Links display section and the schema auto-detection stay as they are.

Sequencing: let #2541 land first, then rebase this onto it and swap in the push call. I'll hold the navigation rewire until then. The display and schema parts are reviewable on their own in the meantime.

alex-fedotyev and others added 2 commits July 8, 2026 12:30
Show a "Span Links" section in the span detail when a span carries
outgoing OpenTelemetry links. Each link renders as a compact row: an
"Open trace" action, the trace state and attributes as chips, and the
full Trace and Span IDs on hover. A link with neither state nor
attributes collapses to a single line, and links past the first five
sit behind a "Show more" toggle.

"Open trace" opens the linked trace in the existing nested side panel
with breadcrumb back navigation, the same flow the Surrounding Context
tab uses, so it stacks above the trace drawer in both the search and
direct-trace entry points.

The Links column is auto-detected from the standard OTel trace schema
and read through a guarded select, so nothing changes for sources that
do not have it. This is a display-only field, so no source
configuration UI and no external API contract are added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rebase onto #2541 (single-drawer navigation) and replace the nested-drawer
"Open trace" path with a push onto the same cross-source stack "View Trace"
uses. Deletes the DBTracePanel in-place-nav prototype, the setOpenedLink
nested panel, the breadcrumbPath/onBreadcrumbClick threading, and the
DirectTraceSidePanel z-index seed that only existed to support the nested
drawer.

Adds onOpenLinkedTrace to RowSidePanelContext, computed in
DBRowSidePanelInner from the same traceIdExpression/spanIdExpression used
for "View Trace", and consumed by RowOverviewPanel -> SpanLinksSubpanel.
Also fixes a restricted-import lint violation in SpanLinksSubpanel.test.tsx
(parent-relative import instead of the @/ alias) hit while touching the
file.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant