diff --git a/.changeset/span-links-trace-viewer.md b/.changeset/span-links-trace-viewer.md new file mode 100644 index 0000000000..79292b96a0 --- /dev/null +++ b/.changeset/span-links-trace-viewer.md @@ -0,0 +1,12 @@ +--- +'@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 place in the same +panel, with a breadcrumb trail you can step back through, and shows the link's +trace state and attributes 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..94db251ec4 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -20,6 +20,7 @@ import EventTag from './EventTag'; import { ExceptionSubpanel } from './ExceptionSubpanel'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; +import { getValidSpanLinks, SpanLinksSubpanel } from './SpanLinksSubpanel'; const EMPTY_OBJ = {}; export function RowOverviewPanel({ @@ -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(() => { @@ -183,6 +184,10 @@ export function RowOverviewPanel({ ); }, [firstRow?.__hdx_span_events]); + const hasSpanLinks = useMemo(() => { + return getValidSpanLinks(firstRow?.__hdx_span_links).length > 0; + }, [firstRow?.__hdx_span_links]); + const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) ? firstRow['__hdx_body'] @@ -213,6 +218,7 @@ export function RowOverviewPanel({ defaultValue={[ 'exception', 'spanEvents', + 'spanLinks', 'network', 'resourceAttributes', 'eventAttributes', @@ -259,21 +265,6 @@ export function RowOverviewPanel({ )} - {hasSpanEvents && ( - - - - Span Events - - - - - - - - - )} - {Object.keys(topLevelAttributes).length > 0 && ( @@ -312,6 +303,39 @@ export function RowOverviewPanel({ )} + {hasSpanEvents && ( + + + + Span Events + + + + + + + + + )} + + {hasSpanLinks && ( + + + + Span Links + + + + + + + + + )} + {Object.keys(resourceAttributes).length > 0 && ( diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 89a2fcc580..523ee7f78c 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import { add } from 'date-fns'; @@ -74,6 +75,7 @@ import { } from './DrawerUtils'; import LogLevel from './LogLevel'; import SidePanelBreadcrumbs, { BreadcrumbItem } from './SidePanelBreadcrumbs'; +import { SpanLinkData } from './SpanLinksSubpanel'; import styles from '@/../styles/LogSidePanel.module.scss'; @@ -104,6 +106,7 @@ export type RowSidePanelContextProps = { isChildModalOpen?: boolean; setChildModalOpen?: (open: boolean) => void; source?: TLogSource | TTraceSource; + onOpenLinkedTrace?: (link: SpanLinkData) => void; }; export const RowSidePanelContext = createContext({}); @@ -398,6 +401,24 @@ export const DBRowSidePanelInner = ({ } }, [mainContent, initialMainContent, hasActiveStacks]); + // Once the hop's row loads we learn the landed span name and use it as the crumb label. + const spanLinkFrameRowIdsRef = useRef>(new Set()); + const [resolvedFrameLabels, setResolvedFrameLabels] = useState< + Record + >({}); + + useEffect(() => { + if ( + activeRowId != null && + mainContent != null && + spanLinkFrameRowIdsRef.current.has(activeRowId) && + resolvedFrameLabels[activeRowId] !== mainContent + ) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setResolvedFrameLabels(prev => ({ ...prev, [activeRowId]: mainContent })); + } + }, [activeRowId, mainContent, resolvedFrameLabels]); + const highlightedAttributeValues = useMemo(() => { const attributeExpressions: NonNullable< (TLogSource | TTraceSource)['highlightedRowAttributeExpressions'] @@ -511,6 +532,44 @@ 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. + 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 '); + // Mark this frame so its breadcrumb switches from the `Trace ` + // fallback below to the landed span name once the row loads. + spanLinkFrameRowIdsRef.current.add(rowId); + handleSourceStackPush({ + sourceId: traceSourceData.id, + rowId, + label: `Trace ${link.TraceId.slice(0, 8)}`, + sourceKind: traceSourceData.kind as SourceKind, + aliasWith: [], + }); + }, + [ + traceSourceData, + traceIdExpression, + spanIdExpression, + handleSourceStackPush, + ], + ); + + const rowSidePanelContextValue = useMemo( + () => ({ ...parentContext, onOpenLinkedTrace: handleOpenLinkedTrace }), + [parentContext, handleOpenLinkedTrace], + ); + const { rumSessionId, rumServiceName } = useSessionId({ sourceId: traceSourceId, traceId, @@ -574,7 +633,9 @@ export const DBRowSidePanelInner = ({ const isLeafSource = i === crumbSourceStack.length - 1; const isCurrent = isLeafSource && crumbNavStack.length === 0; items.push({ - label: entry.label, + // Span-link hops resolve to the landed span name once loaded; every + // other frame keeps the label it was pushed with. + label: resolvedFrameLabels[entry.rowId] ?? entry.label, sourceKind: entry.sourceKind, onClick: isCurrent ? undefined @@ -608,6 +669,7 @@ export const DBRowSidePanelInner = ({ sourceIsTrace, mainContent, initialMainContent, + resolvedFrameLabels, source.kind, handleBreadcrumbNavigation, parentBreadcrumbs, @@ -698,7 +760,7 @@ export const DBRowSidePanelInner = ({ const showLogTraceActions = !sourceIsTrace && traceId && traceSourceId; return ( - <> + {controls} @@ -1036,7 +1098,7 @@ export const DBRowSidePanelInner = ({ )} - + ); }; diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index db5668fe5d..40891fa99b 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -269,7 +269,9 @@ export default function DBTracePanel({ // otherwise we'll show stale span details useEffect(() => { if (eventRowWhere != null && eventRowWhere.traceId !== traceId) { - setEventRowWhere(null); + setEventRowWhere(prev => + prev != null && prev.traceId !== traceId ? null : prev, + ); } }, [eventRowWhere, traceId, setEventRowWhere]); diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index 5ab9067317..77275c47e8 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -793,6 +793,11 @@ export function DBTraceWaterfallChartContainer({ // ourselves, so it never fires twice for the same hint; it re-fires only when // the hint genuinely changes (different originating span / trace). const appliedHighlightHintRef = useRef(null); + + useEffect(() => { + appliedHighlightHintRef.current = null; + }, [traceId]); + useEffect(() => { if (!initialRowHighlightHint || !onClick) { return; diff --git a/packages/app/src/components/OnboardingModal.tsx b/packages/app/src/components/OnboardingModal.tsx index f36ad76c7c..de7648fde3 100644 --- a/packages/app/src/components/OnboardingModal.tsx +++ b/packages/app/src/components/OnboardingModal.tsx @@ -134,6 +134,7 @@ async function addOtelDemoSources({ statusCodeExpression: 'StatusCode', statusMessageExpression: 'StatusMessage', spanEventsValueExpression: 'Events', + spanLinksValueExpression: 'Links', highlightedTraceAttributeExpressions: traceSourceHighlightedTraceAttributes, materializedViews: traceSourceMaterializedViews, diff --git a/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx b/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx index 3139897b8b..b1064f3404 100644 --- a/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx @@ -324,6 +324,21 @@ export function TraceTableModelForm(props: TableModelProps) { placeholder="Events" /> + + + ; + +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..e1d2a54b30 --- /dev/null +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -0,0 +1,179 @@ +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 raw `Links` element is a usable span link only when it has the string +// TraceId + SpanId the "Open trace" action needs and an Attributes object. +export function getValidSpanLinks( + spanLinks?: Record[] | null, +): SpanLinkData[] { + if (!Array.isArray(spanLinks) || spanLinks.length === 0) { + return []; + } + return spanLinks.filter((link): link is SpanLinkData => { + return ( + typeof link.TraceId === 'string' && + typeof link.SpanId === 'string' && + link.Attributes !== undefined + ); + }); +} + +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(() => getValidSpanLinks(spanLinks), [spanLinks]); + + const { handleToggleMoreRows, hiddenRowsCount, visibleRows, isExpanded } = + useShowMoreRows({ + rows: links, + maxRows: 5, + }); + + if (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__/DBRowSidePanel.spanLinks.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx new file mode 100644 index 0000000000..28e8031937 --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx @@ -0,0 +1,246 @@ +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, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + 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, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useSource: ({ id }: { id: string | null }) => + id === 'trace-src' ? { data: TRACE_SOURCE } : { data: undefined }, +})); + +jest.mock('../DBSessionPanel', () => ({ + __esModule: true, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + 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. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + 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__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx new file mode 100644 index 0000000000..2b7a988e3c --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinksBreadcrumb.test.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { TSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// Reactive replacement for nuqs' useQueryState: backing each param with a real +// useState so a span-link "Open trace" push actually re-renders the panel with +// the new source frame (the shared drawer stack behaves as it does in the app). +// This is what lets us observe the breadcrumb label resolve after the hop +// loads, which the sibling push-wiring test cannot do (it mocks the setters). +jest.mock('nuqs', () => { + const actualReact = jest.requireActual('react'); + const actual = jest.requireActual('nuqs'); + return { + ...actual, + useQueryState: (_key: string, parser?: { defaultValue?: unknown }) => { + const fallback = + parser && 'defaultValue' in parser ? parser.defaultValue : null; + const [value, setValue] = actualReact.useState(fallback ?? null); + return [value, setValue]; + }, + }; +}); + +// One span link on the root row. Its destination row (keyed on the link's ids) +// carries a span name in __hdx_body; the breadcrumb hop should show that name +// instead of the `Trace ` fallback the frame is pushed with. +const LINK = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: {}, +}; + +const ROOT_SPAN_NAME = 'POST /api/checkout'; +const LINKED_SPAN_NAME = 'consume order.created'; + +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + 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: () => [], +})); + +const TRACE_SOURCE = { + id: 'trace-src', + kind: 'trace', + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', +}; + +jest.mock('@/source', () => ({ + __esModule: true, + getEventBody: () => undefined, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useSource: ({ id }: { id: string | null }) => + id === 'trace-src' ? { data: TRACE_SOURCE } : { data: undefined }, +})); + +jest.mock('../DBSessionPanel', () => ({ + __esModule: true, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }), + DBSessionPanel: () => null, +})); + +jest.mock('@/utils/highlightedAttributes', () => ({ + __esModule: true, + getHighlightedAttributesFromData: () => [], +})); + +// Trace tab content is irrelevant here; only the breadcrumb bar is asserted. +// SidePanelBreadcrumbs is intentionally NOT mocked so its rendered labels can +// be read. +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('../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, +})); + +// Placed after the mock factories, matching the sibling span-link test. +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', + resourceAttributesExpression: 'ResourceAttributes', +} as TSource; + +function InnerHarness({ rowId }: { rowId: string }) { + const sidePanelStack = useSidePanelStack({ initialRowId: rowId }); + return ( + + ); +} + +describe('DBRowSidePanelInner, span-link breadcrumb labels (HDX-3191)', () => { + beforeEach(() => { + mockUseRowData.mockReset(); + // The root row (log) carries the span link; the linked trace row (keyed on + // the link's TraceId) carries the destination span name and no links. + mockUseRowData.mockImplementation((args: { rowId?: string }) => { + const rowId = args?.rowId ?? ''; + const isLinkedTrace = + typeof rowId === 'string' && rowId.includes(LINK.TraceId); + return { + data: { + data: [ + isLinkedTrace + ? { __hdx_body: LINKED_SPAN_NAME, __hdx_span_links: [] } + : { __hdx_body: ROOT_SPAN_NAME, __hdx_span_links: [LINK] }, + ], + meta: [], + }, + isLoading: false, + isSuccess: true, + isError: false, + error: null, + }; + }); + }); + + it('shows the landed span name for a span-link hop instead of the Trace-id fallback', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + // The hop crumb resolves to the destination span name once its row loads. + expect(await screen.findByText(LINKED_SPAN_NAME)).toBeInTheDocument(); + // The root crumb still shows the originating span name. + expect(screen.getByText(ROOT_SPAN_NAME)).toBeInTheDocument(); + // The `Trace ` fallback is no longer shown for the resolved hop. + expect( + screen.queryByText(new RegExp(`^Trace ${LINK.TraceId.slice(0, 8)}`)), + ).not.toBeInTheDocument(); + }); +}); 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..10ff4fd1c1 --- /dev/null +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -0,0 +1,183 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { + getValidSpanLinks, + SpanLinksSubpanel, +} from '@/components/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 two links to the same destination span as distinct rows', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // A span can link to the same destination span twice, differing only in + // attributes. Both rows must render and the row key must stay unique. + const duplicate = { + ...LINK_A, + Attributes: { 'link.kind': 'follows_from' }, + }; + renderWithMantine(); + + expect(screen.getAllByTestId('span-link-row')).toHaveLength(2); + + const sameKeyWarning = consoleError.mock.calls.find( + ([message]) => + typeof message === 'string' && message.includes('same key'), + ); + expect(sameKeyWarning).toBeUndefined(); + + consoleError.mockRestore(); + }); + + 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: Record = { + TraceId: 12345, + SpanId: 'deadbeefdeadbeef', + TraceState: '', + Attributes: {}, + }; + + 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: Record = { + SpanId: 'deadbeefdeadbeef', + Attributes: {}, + }; + + renderWithMantine(); + + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); +}); + +describe('getValidSpanLinks', () => { + it('returns [] for null, undefined, and empty input', () => { + expect(getValidSpanLinks(null)).toEqual([]); + expect(getValidSpanLinks(undefined)).toEqual([]); + expect(getValidSpanLinks([])).toEqual([]); + }); + + it('keeps well-formed links and preserves their order', () => { + expect(getValidSpanLinks([LINK_A, LINK_B, LINK_C])).toEqual([ + LINK_A, + LINK_B, + LINK_C, + ]); + }); + + it('drops links missing a string TraceId/SpanId or an Attributes object', () => { + const noTraceId: Record = { + SpanId: 'deadbeefdeadbeef', + TraceState: '', + Attributes: {}, + }; + const numericSpanId: Record = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: 42, + TraceState: '', + Attributes: {}, + }; + + expect(getValidSpanLinks([LINK_A, noTraceId, numericSpanId])).toEqual([ + LINK_A, + ]); + }); + + it('returns [] when every link is malformed (parent gate stays hidden)', () => { + const malformed: Record = { + SpanId: 'deadbeefdeadbeef', + }; + + expect(getValidSpanLinks([malformed])).toEqual([]); + }); +}); diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index 904626d093..feb5fb5f11 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -369,6 +369,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 = @@ -443,6 +446,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 9339300a78..8fd0246568 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1846,6 +1846,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(),