From e63dfd135fdbf0cc212faf6cdc71743f990baf3e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:18:43 +0000 Subject: [PATCH 1/2] feat(traces): surface span links in the trace viewer 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 --- .changeset/span-links-trace-viewer.md | 7 + packages/api/src/models/source.ts | 1 + .../app/src/components/DBRowDataPanel.tsx | 9 + .../app/src/components/DBRowOverviewPanel.tsx | 114 +++++++++-- .../app/src/components/DBRowSidePanel.tsx | 2 + .../components/SpanLinksSubpanel.stories.tsx | 37 ++++ .../app/src/components/SpanLinksSubpanel.tsx | 182 ++++++++++++++++++ .../__tests__/SpanLinksSubpanel.test.tsx | 114 +++++++++++ packages/app/src/source.ts | 4 + packages/common-utils/src/types.ts | 1 + 10 files changed, 452 insertions(+), 19 deletions(-) create mode 100644 .changeset/span-links-trace-viewer.md create mode 100644 packages/app/src/components/SpanLinksSubpanel.stories.tsx create mode 100644 packages/app/src/components/SpanLinksSubpanel.tsx create mode 100644 packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx diff --git a/.changeset/span-links-trace-viewer.md b/.changeset/span-links-trace-viewer.md new file mode 100644 index 0000000000..77f3d8011d --- /dev/null +++ b/.changeset/span-links-trace-viewer.md @@ -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. diff --git a/packages/api/src/models/source.ts b/packages/api/src/models/source.ts index bb0ce45758..221bb66138 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -197,6 +197,7 @@ export const TraceSource = Source.discriminator( resourceAttributesExpression: String, eventAttributesExpression: String, spanEventsValueExpression: String, + spanLinksValueExpression: String, implicitColumnExpression: String, knownColumnsListExpression: String, useTextIndexForImplicitColumn: { diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 1f631148ae..e0cea35350 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -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({ @@ -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', diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 1ea1e945dd..8d786a9e8f 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -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'; @@ -14,12 +19,16 @@ 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({ @@ -27,12 +36,16 @@ export function RowOverviewPanel({ 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 }); @@ -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]); + + // 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(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'] @@ -213,6 +251,7 @@ export function RowOverviewPanel({ defaultValue={[ 'exception', 'spanEvents', + 'spanLinks', 'network', 'resourceAttributes', 'eventAttributes', @@ -259,21 +298,6 @@ export function RowOverviewPanel({ )} - {hasSpanEvents && ( - - - - Span Events - - - - - - - - - )} - {Object.keys(topLevelAttributes).length > 0 && ( @@ -312,6 +336,39 @@ export function RowOverviewPanel({ )} + {hasSpanEvents && ( + + + + Span Events + + + + + + + + + )} + + {hasSpanLinks && ( + + + + Span Links + + + + + setOpenedLink(link)} + /> + + + + )} + {Object.keys(resourceAttributes).length > 0 && ( @@ -356,6 +413,25 @@ export function RowOverviewPanel({ )} + {openedLink && openedLinkWhere && ( + setOpenedLink(null)} + isNestedPanel + breadcrumbPath={[ + ...(breadcrumbPath ?? []), + { + label: + (typeof firstRow?.SpanName === 'string' && firstRow.SpanName) || + 'Span Link', + rowData: firstRow ?? {}, + }, + ]} + onBreadcrumbClick={onBreadcrumbClick} + /> + )} ); } diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 456d58469e..55980be43f 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -890,6 +890,8 @@ export const DBRowSidePanelInner = ({ rowId={activeRowId} aliasWith={activeAliasWith} hideHeader={true} + breadcrumbPath={breadcrumbPath} + onBreadcrumbClick={handleBreadcrumbClick} /> )} diff --git a/packages/app/src/components/SpanLinksSubpanel.stories.tsx b/packages/app/src/components/SpanLinksSubpanel.stories.tsx new file mode 100644 index 0000000000..c0dbdebcbd --- /dev/null +++ b/packages/app/src/components/SpanLinksSubpanel.stories.tsx @@ -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 = () => ( + {}} /> +); + +export const SingleLink = () => ( + {}} /> +); + +export const Empty = () => ; diff --git a/packages/app/src/components/SpanLinksSubpanel.tsx b/packages/app/src/components/SpanLinksSubpanel.tsx new file mode 100644 index 0000000000..b5e164b53f --- /dev/null +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -0,0 +1,182 @@ +import React, { useMemo } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { Anchor, Button, Flex, Stack, Tooltip } from '@mantine/core'; +import { + IconArrowUpRight, + IconChevronDown, + IconChevronUp, +} from '@tabler/icons-react'; + +import EventTag from './EventTag'; +import { SectionWrapper, useShowMoreRows } from './ExceptionSubpanel'; + +// Make sure SpanLinkData implements Record +export interface SpanLinkData extends Record { + TraceId: string; + SpanId: string; + TraceState: string; + Attributes: Record; +} + +// A single span link rendered as a compact row. The linked trace id is the +// widest, least-scannable part of a link, so it is not printed inline: the +// row leads with a labelled "Open trace" action (themed link color, visible +// at rest) and the full Trace ID / Span ID live in the hover tooltip. Trace +// state and attributes render below the action as uniform chips, so a link +// with no trace state and no attributes collapses to a single short line. +function SpanLinkRow({ + link, + onOpenTrace, +}: { + link: SpanLinkData; + onOpenTrace?: (link: SpanLinkData) => void; +}) { + const attributeEntries = Object.entries(link.Attributes ?? {}); + const hasTraceState = + typeof link.TraceState === 'string' && link.TraceState.length > 0; + const hasChips = hasTraceState || attributeEntries.length > 0; + + return ( + + +
Trace: {link.TraceId}
+
Span: {link.SpanId}
+ + } + > + onOpenTrace?.(link)} + size="sm" + fw={500} + className="d-inline-flex align-items-center" + > + + Open trace + +
+ + {hasChips ? ( + + {hasTraceState ? ( + + ) : null} + {attributeEntries.map(([key, value]) => ( + + ))} + + ) : null} +
+ ); +} + +export const SpanLinksSubpanel = ({ + spanLinks, + onOpenTrace, +}: { + spanLinks?: Record[] | null; + onOpenTrace?: (link: SpanLinkData) => void; +}) => { + 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 + ); + }); + }, [spanLinks]); + + const { handleToggleMoreRows, hiddenRowsCount, visibleRows, isExpanded } = + useShowMoreRows({ + rows: links, + maxRows: 5, + }); + + if (!links || links.length === 0) { + return ( +
+ No span links available for this trace +
+ ); + } + + return ( +
+ + { + console.error(err); + }} + fallbackRender={() => ( +
+ An error occurred while rendering span links +
+ )} + > + + {visibleRows.map((link, index) => ( +
0 ? 'pt-2 border-top border-dark' : undefined + } + > + +
+ ))} +
+
+ + {hiddenRowsCount ? ( + + ) : null} +
+
+ ); +}; diff --git a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx new file mode 100644 index 0000000000..34dec9fb6b --- /dev/null +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { SpanLinksSubpanel } from '../SpanLinksSubpanel'; + +const LINK_A = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: { 'link.kind': 'child_of' }, +}; + +const LINK_B = { + TraceId: 'eeee5555ffff6666aaaa7777bbbb8888', + SpanId: '5555666677778888', + TraceState: 'congo=t61rcWkgMzE', + Attributes: {}, +}; + +const LINK_C = { + TraceId: 'cccc9999dddd0000eeee1111ffff2222', + SpanId: '9999000011112222', + TraceState: '', + Attributes: {}, +}; + +describe('SpanLinksSubpanel', () => { + it('renders the empty state when spanLinks is undefined', () => { + renderWithMantine(); + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); + + it('renders the empty state when spanLinks is an empty array', () => { + renderWithMantine(); + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); + + it('renders an Open trace action and the link attributes as chips', () => { + renderWithMantine(); + + expect(screen.getByText('Open trace')).toBeInTheDocument(); + // Attributes render as compact EventTag pills (key: value). + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('calls onOpenTrace with the link when Open trace is clicked', () => { + const onOpenTrace = jest.fn(); + renderWithMantine( + , + ); + + fireEvent.click(screen.getByText('Open trace')); + + expect(onOpenTrace).toHaveBeenCalledTimes(1); + expect(onOpenTrace).toHaveBeenCalledWith( + expect.objectContaining({ + TraceId: LINK_A.TraceId, + SpanId: LINK_A.SpanId, + }), + ); + }); + + it('renders one Open trace action per link', () => { + renderWithMantine(); + + expect(screen.getAllByText('Open trace')).toHaveLength(2); + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('renders trace state as a labeled chip', () => { + renderWithMantine(); + + expect( + screen.getByText('trace state: congo=t61rcWkgMzE'), + ).toBeInTheDocument(); + }); + + it('collapses to just the action when a link has no trace state or attributes', () => { + renderWithMantine(); + + expect(screen.getByText('Open trace')).toBeInTheDocument(); + expect(screen.queryByText(/trace state:/)).not.toBeInTheDocument(); + }); + + it('filters out malformed links missing a string TraceId or SpanId', () => { + const malformed = { + TraceId: 12345, + SpanId: 'deadbeefdeadbeef', + TraceState: '', + Attributes: {}, + } as unknown as Record; + + renderWithMantine(); + + expect(screen.getAllByText('Open trace')).toHaveLength(1); + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('renders the empty state when every link is malformed', () => { + const malformed = { + SpanId: 'deadbeefdeadbeef', + Attributes: {}, + } as unknown as Record; + + renderWithMantine(); + + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index 5bf68b6947..acb0825775 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -358,6 +358,9 @@ export async function inferTableSourceConfig({ // Check if SpanEvents column is available const hasSpanEvents = columns.some(col => col.name === 'Events.Timestamp'); + // Check if span Links column is available + const hasSpanLinks = columns.some(col => col.name === 'Links.TraceId'); + // Check if metadata rollup tables exist and, if so, infer the bucketing // granularity from the key-rollup view's `as_select` const rollupMeta = @@ -435,6 +438,7 @@ export async function inferTableSourceConfig({ statusCodeExpression: 'StatusCode', statusMessageExpression: 'StatusMessage', ...(hasSpanEvents ? { spanEventsValueExpression: 'Events' } : {}), + ...(hasSpanLinks ? { spanLinksValueExpression: 'Links' } : {}), ...metadataMVsConfig, } : {}), diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 067ab8f267..326754a9c0 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1793,6 +1793,7 @@ export const TraceSourceSchema = BaseSourceSchema.extend({ resourceAttributesExpression: z.string().optional(), eventAttributesExpression: z.string().optional(), spanEventsValueExpression: z.string().optional(), + spanLinksValueExpression: z.string().optional(), implicitColumnExpression: z.string().optional(), knownColumnsListExpression: z.string().optional(), displayedTimestampValueExpression: z.string().optional(), From aa6f19d1759497d840938fdfb63fe2db16879b69 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:54:57 +0000 Subject: [PATCH 2/2] feat(traces): rewire span-link navigation onto the shared source stack 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 --- .../app/src/components/DBRowOverviewPanel.tsx | 63 +---- .../app/src/components/DBRowSidePanel.tsx | 55 +++- .../DBRowSidePanel.spanLinks.test.tsx | 242 ++++++++++++++++++ .../__tests__/SpanLinksSubpanel.test.tsx | 2 +- 4 files changed, 301 insertions(+), 61 deletions(-) create mode 100644 packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 8d786a9e8f..efcfe17962 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -1,12 +1,7 @@ -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useCallback, useContext, useMemo } from 'react'; import isString from 'lodash/isString'; import pickBy from 'lodash/pickBy'; -import SqlString from 'sqlstring'; -import { - isTraceSource, - SourceKind, - TSource, -} from '@hyperdx/common-utils/dist/types'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; import { Accordion, Box, Flex, Text } from '@mantine/core'; import { WithClause } from '@/hooks/useRowWhere'; @@ -19,16 +14,13 @@ import { useRowData, } from './DBRowDataPanel'; import { DBRowJsonViewer } from './DBRowJsonViewer'; -import DBRowSidePanel, { RowSidePanelContext } from './DBRowSidePanel'; -import DBRowSidePanelHeader, { - BreadcrumbNavigationCallback, - BreadcrumbPath, -} from './DBRowSidePanelHeader'; +import { RowSidePanelContext } from './DBRowSidePanel'; +import DBRowSidePanelHeader from './DBRowSidePanelHeader'; import EventTag from './EventTag'; import { ExceptionSubpanel } from './ExceptionSubpanel'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; -import { SpanLinkData, SpanLinksSubpanel } from './SpanLinksSubpanel'; +import { SpanLinksSubpanel } from './SpanLinksSubpanel'; const EMPTY_OBJ = {}; export function RowOverviewPanel({ @@ -36,20 +28,16 @@ export function RowOverviewPanel({ 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 }); - const { onPropertyAddClick, generateSearchUrl } = + const { onPropertyAddClick, generateSearchUrl, onOpenLinkedTrace } = useContext(RowSidePanelContext); const highlightedAttributeValues = useMemo(() => { @@ -203,24 +191,6 @@ export function RowOverviewPanel({ ); }, [firstRow?.__hdx_span_links]); - // 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(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'] @@ -362,7 +332,7 @@ export function RowOverviewPanel({ setOpenedLink(link)} + onOpenTrace={onOpenLinkedTrace} /> @@ -413,25 +383,6 @@ export function RowOverviewPanel({ )} - {openedLink && openedLinkWhere && ( - setOpenedLink(null)} - isNestedPanel - breadcrumbPath={[ - ...(breadcrumbPath ?? []), - { - label: - (typeof firstRow?.SpanName === 'string' && firstRow.SpanName) || - 'Span Link', - rowData: firstRow ?? {}, - }, - ]} - onBreadcrumbClick={onBreadcrumbClick} - /> - )} ); } diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 55980be43f..88772bfcad 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -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'; @@ -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({}); @@ -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, @@ -676,7 +725,7 @@ export const DBRowSidePanelInner = ({ const showLogTraceActions = !sourceIsTrace && traceId && traceSourceId; return ( - <> + )} @@ -1025,7 +1072,7 @@ export const DBRowSidePanelInner = ({ )} - + ); }; diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx new file mode 100644 index 0000000000..aa96ffd8da --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import SqlString from 'sqlstring'; +import { TSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// Controlled, in-memory replacement for nuqs' useQueryState so each side-panel +// URL param can be seeded and its setter inspected independently. Values are +// the already-parsed shapes the component consumes (arrays / strings), not URL +// strings. Prefixed with `mock` so jest.mock's factory may reference them. +const mockQueryStore: Record = {}; +const mockSetters: Record = {}; + +function setterFor(key: string) { + if (!mockSetters[key]) mockSetters[key] = jest.fn(); + return mockSetters[key]; +} +function resetQueryState() { + Object.keys(mockQueryStore).forEach(k => delete mockQueryStore[k]); + Object.keys(mockSetters).forEach(k => delete mockSetters[k]); +} + +jest.mock('nuqs', () => { + const actual = jest.requireActual('nuqs'); + return { + ...actual, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useQueryState: (key: string, parser?: { defaultValue?: unknown }) => { + const hasValue = Object.prototype.hasOwnProperty.call( + mockQueryStore, + key, + ); + const fallback = + parser && 'defaultValue' in parser ? parser.defaultValue : null; + const value = hasValue ? mockQueryStore[key] : (fallback ?? null); + const setters = mockSetters; + if (!setters[key]) setters[key] = jest.fn(); + return [value, setters[key]]; + }, + }; +}); + +// A single successful row carrying one span link and nothing else, so only +// the Span Links accordion section renders (Exception / Span Events / +// Resource / Event Attributes all stay gated off). RowOverviewPanel is left +// unmocked in this file so the real context wiring (DBRowSidePanelInner -> +// RowSidePanelContext.onOpenLinkedTrace -> RowOverviewPanel -> +// SpanLinksSubpanel.onOpenTrace) is exercised end to end. +const LINK = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: {}, +}; + +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + useRowData: (args: unknown) => mockUseRowData(args), + ROW_DATA_ALIASES: { + DURATION_MS: '__hdx_duration', + SPAN_KIND: '__hdx_span_kind', + SERVICE_NAME: '__hdx_service_name', + SEVERITY_TEXT: '__hdx_severity_text', + }, + rowHasK8sContext: () => false, + RowDataPanel: () => null, + getJSONColumnNames: () => [], + getMapColumnNames: () => [], +})); + +// The current row lives on a log source; the linked trace resolves against a +// separate trace source, the same split "View Trace" already relies on. +const TRACE_SOURCE = { + id: 'trace-src', + kind: 'trace', + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', +}; + +jest.mock('@/source', () => ({ + __esModule: true, + getEventBody: () => undefined, + useSource: ({ id }: { id: string | null }) => + id === 'trace-src' ? { data: TRACE_SOURCE } : { data: undefined }, +})); + +jest.mock('../DBSessionPanel', () => ({ + __esModule: true, + useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }), + DBSessionPanel: () => null, +})); + +jest.mock('@/utils/highlightedAttributes', () => ({ + __esModule: true, + getHighlightedAttributesFromData: () => [], +})); + +// Heavy leaf components / chart deps the panel imports but never renders for +// this row shape (no trace tab, no exception/resource/event attributes). +jest.mock('../DBTracePanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ContextSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../DBInfraPanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../DBRowSidePanelErrorState', () => ({ + __esModule: true, + DBRowSidePanelErrorState: () => null, +})); +jest.mock('../DBRowSidePanelHeader', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../SidePanelBreadcrumbs', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../LogLevel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ServiceMap/ServiceMapSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../TimelineChart/utils', () => ({ + __esModule: true, + renderMs: () => '', +})); +jest.mock('../DrawerUtils', () => ({ + __esModule: true, + DrawerFullWidthToggle: () => null, + INITIAL_DRAWER_WIDTH_PERCENT: 50, +})); +jest.mock('@/LogSidePanelElements', () => ({ + __esModule: true, + KeyboardShortcutsModal: () => null, +})); +jest.mock('@/TabBar', () => ({ __esModule: true, default: () => null })); +jest.mock('@/useFormatTime', () => ({ + __esModule: true, + FormatTime: () => null, +})); + +// NOTE: this import is intentionally placed after the mock factories above, +// which close over the `mock*` helpers declared at the top of this file. +import { DBRowSidePanelInner } from '@/components/DBRowSidePanel'; +import useSidePanelStack from '@/hooks/useSidePanelStack'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const ROOT_SOURCE = { + id: 'log-src', + kind: 'log', + traceSourceId: 'trace-src', + // Makes hasOverviewPanel true so a non-trace source still gets an Overview + // tab (and it is the default tab, since sourceIsTrace is false here). + resourceAttributesExpression: 'ResourceAttributes', +} as TSource; + +function InnerHarness({ + rowId, + source = ROOT_SOURCE, +}: { + rowId: string; + source?: TSource; +}) { + const sidePanelStack = useSidePanelStack({ initialRowId: rowId }); + return ( + + ); +} + +function renderInner(rowId: string) { + return render( + + + , + ); +} + +describe('DBRowSidePanelInner, span link "Open trace" push wiring (HDX-3191)', () => { + beforeEach(() => { + resetQueryState(); + mockUseRowData.mockReset(); + mockUseRowData.mockReturnValue({ + data: { data: [{ __hdx_span_links: [LINK] }], meta: [] }, + isLoading: false, + isSuccess: true, + isError: false, + error: null, + }); + }); + + it("pushes a trace-source frame keyed on the link's ids onto the shared source stack", () => { + renderInner('row-1'); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + const expectedRowId = [ + SqlString.format('?=?', [SqlString.raw('TraceId'), LINK.TraceId]), + SqlString.format('?=?', [SqlString.raw('SpanId'), LINK.SpanId]), + ].join(' AND '); + + expect(setterFor('sidePanelSourceStack')).toHaveBeenCalledTimes(1); + const pushedStack = setterFor('sidePanelSourceStack').mock.calls[0][0]; + expect(pushedStack).toHaveLength(1); + expect(pushedStack[0]).toMatchObject({ + sourceId: 'trace-src', + rowId: expectedRowId, + sourceKind: 'trace', + }); + expect(pushedStack[0].label).toContain(LINK.TraceId.slice(0, 8)); + + // Cross-source push: nav stack clears and the destination tab is Trace, + // matching handleSourceStackPush's Trace-vs-Overview routing. + expect(setterFor('sidePanelNavStack')).toHaveBeenCalledWith([]); + expect(setterFor('sidePanelTab')).toHaveBeenCalledWith('trace'); + }); + + it('does not push when the current row has no resolvable trace source', () => { + // Root source with no traceSourceId: traceSourceData never resolves, so + // the guard in handleOpenLinkedTrace should no-op instead of throwing. + const sourceWithoutTrace = { + ...ROOT_SOURCE, + traceSourceId: undefined, + } as TSource; + + render( + + + , + ); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + expect(setterFor('sidePanelSourceStack')).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx index 34dec9fb6b..4d753429b9 100644 --- a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, screen } from '@testing-library/react'; -import { SpanLinksSubpanel } from '../SpanLinksSubpanel'; +import { SpanLinksSubpanel } from '@/components/SpanLinksSubpanel'; const LINK_A = { TraceId: 'aaaa1111bbbb2222cccc3333dddd4444',