From f26a422a878796513af311041c5c274c3a555c67 Mon Sep 17 00:00:00 2001 From: Vansh98789 Date: Wed, 12 Aug 2026 00:33:53 +0530 Subject: [PATCH 1/4] fix: show spans referencing the current span (#2828) --- .../app/src/components/DBRowOverviewPanel.tsx | 45 +++++++- .../components/SpansReverseLinksSubpanel.tsx | 68 ++++++++++++ .../SpansReverseLinksSubpanel.test.tsx | 102 ++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/SpansReverseLinksSubpanel.tsx create mode 100644 packages/app/src/components/__tests__/SpansReverseLinksSubpanel.test.tsx diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 65f216e575..6a1d2000b7 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -21,6 +21,7 @@ import { ExceptionSubpanel } from './ExceptionSubpanel'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; import { getValidSpanLinks, SpanLinksSubpanel } from './SpanLinksSubpanel'; +import { SpansReverseLinksSubpanel } from './SpansReverseLinksSubpanel'; const EMPTY_OBJ = {}; export function RowOverviewPanel({ @@ -50,7 +51,7 @@ export function RowOverviewPanel({ const highlightedAttributeValues = useMemo(() => { const attributeExpressions = source.kind === SourceKind.Trace || source.kind === SourceKind.Log - ? (source.highlightedRowAttributeExpressions ?? []) + ? source.highlightedRowAttributeExpressions ?? [] : []; return data @@ -195,6 +196,29 @@ export function RowOverviewPanel({ return getValidSpanLinks(firstRow?.__hdx_span_links).length > 0; }, [firstRow?.__hdx_span_links]); + const hasReverseSpanLinks = useMemo(() => { + if (!firstRow?.SpanId || !Array.isArray(data?.data)) { + return false; + } + const currentSpanId = String(firstRow.SpanId); + for (const row of data.data) { + if (!row || typeof row !== 'object') continue; + const sl = row.__hdx_span_links; + if (!Array.isArray(sl)) continue; + for (const link of sl) { + if ( + link && + typeof link === 'object' && + 'SpanId' in link && + link.SpanId === currentSpanId + ) { + return true; + } + } + } + return false; + }, [data?.data, firstRow?.SpanId]); + const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) ? firstRow['__hdx_body'] @@ -226,6 +250,7 @@ export function RowOverviewPanel({ 'exception', 'spanEvents', 'spanLinks', + 'reverseSpanLinks', 'network', 'resourceAttributes', 'eventAttributes', @@ -342,6 +367,24 @@ export function RowOverviewPanel({ )} + {hasReverseSpanLinks && ( + + + + Referenced By + + + + + + + + + )} {Object.keys(resourceAttributes).length > 0 && ( diff --git a/packages/app/src/components/SpansReverseLinksSubpanel.tsx b/packages/app/src/components/SpansReverseLinksSubpanel.tsx new file mode 100644 index 0000000000..2470be7186 --- /dev/null +++ b/packages/app/src/components/SpansReverseLinksSubpanel.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; + +import { + getValidSpanLinks, + SpanLinkData, + SpanLinksSubpanel, +} from './SpanLinksSubpanel'; + +export function getReverseSpanLinks( + rows: Record[] | null | undefined, + currentSpanId: string | undefined, +): SpanLinkData[] { + if (!Array.isArray(rows) || !currentSpanId) { + return []; + } + const results: SpanLinkData[] = []; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + const spanLinks = row.__hdx_span_links; + const validLinks = getValidSpanLinks( + spanLinks as Record[] | null | undefined, + ); + if (validLinks.length === 0) continue; + const pointsToCurrent = validLinks.some( + link => link.SpanId === currentSpanId, + ); + if (!pointsToCurrent) continue; + results.push({ + TraceId: String(row.TraceId ?? ''), + SpanId: String(row.SpanId ?? ''), + TraceState: '', + Attributes: { + 'span.name': String(row.SpanName ?? ''), + 'service.name': String(row.ServiceName ?? ''), + 'span.kind': String(row.SpanKind ?? ''), + }, + }); + } + return results; +} + +export const SpansReverseLinksSubpanel = ({ + rows, + currentSpanId, + onOpenTrace, +}: { + rows?: Record[] | null; + currentSpanId?: string; + onOpenTrace?: (link: SpanLinkData) => void; +}) => { + const reverseLinks = useMemo( + () => getReverseSpanLinks(rows, currentSpanId), + [rows, currentSpanId], + ); + if (reverseLinks.length === 0) { + return ( +
+ No spans reference this span +
+ ); + } + return ( + []} + onOpenTrace={onOpenTrace} + /> + ); +}; diff --git a/packages/app/src/components/__tests__/SpansReverseLinksSubpanel.test.tsx b/packages/app/src/components/__tests__/SpansReverseLinksSubpanel.test.tsx new file mode 100644 index 0000000000..bcad3ca942 --- /dev/null +++ b/packages/app/src/components/__tests__/SpansReverseLinksSubpanel.test.tsx @@ -0,0 +1,102 @@ +import { getReverseSpanLinks } from '../SpansReverseLinksSubpanel'; + +const SPAN_ID_A = 'aaaaaaaaaaaa'; +const SPAN_ID_B = 'bbbbbbbbbbbb'; +const SPAN_ID_C = 'cccccccccccc'; + +const SPAN_A_WITH_LINKS = { + SpanId: SPAN_ID_A, + TraceId: 'trace-111', + ServiceName: 'service-a', + SpanName: 'GET /api/users', + SpanKind: 'Server', + __hdx_span_links: [ + { + TraceId: 'trace-111', + SpanId: SPAN_ID_B, + TraceState: '', + Attributes: { 'link.kind': 'child_of' }, + }, + ], +}; + +const SPAN_B_WITH_LINKS = { + SpanId: SPAN_ID_B, + TraceId: 'trace-111', + ServiceName: 'service-b', + SpanName: 'query-db', + SpanKind: 'Internal', + __hdx_span_links: [], +}; + +const SPAN_C_WITH_LINKS_TO_B = { + SpanId: SPAN_ID_C, + TraceId: 'trace-111', + ServiceName: 'service-c', + SpanName: 'POST /api/notify', + SpanKind: 'Server', + __hdx_span_links: [ + { + TraceId: 'trace-111', + SpanId: SPAN_ID_B, + TraceState: '', + Attributes: { 'link.kind': 'follows_from' }, + }, + ], +}; + +const SPAN_D_NO_LINKS = { + SpanId: 'dddddddddddd', + TraceId: 'trace-111', + ServiceName: 'service-d', + SpanName: 'health-check', + SpanKind: 'Internal', +}; + +describe('getReverseSpanLinks', () => { + it('returns [] for null/undefined rows', () => { + expect(getReverseSpanLinks(null, SPAN_ID_B)).toEqual([]); + expect(getReverseSpanLinks(undefined, SPAN_ID_B)).toEqual([]); + }); + + it('returns [] when currentSpanId is undefined', () => { + expect(getReverseSpanLinks([SPAN_A_WITH_LINKS], undefined)).toEqual([]); + }); + + it('returns [] if no span links to the current span', () => { + const result = getReverseSpanLinks( + [SPAN_A_WITH_LINKS, SPAN_D_NO_LINKS], + SPAN_ID_C, + ); + expect(result).toEqual([]); + }); + + it('finds spans that link to the given SpanId', () => { + const result = getReverseSpanLinks( + [SPAN_A_WITH_LINKS, SPAN_B_WITH_LINKS, SPAN_C_WITH_LINKS_TO_B], + SPAN_ID_B, + ); + + expect(result).toHaveLength(2); + expect(result.map(r => r.SpanId)).toEqual( + expect.arrayContaining([SPAN_ID_A, SPAN_ID_C]), + ); + + // Each reverse link should have Attributes describing the source span + for (const link of result) { + expect(link.Attributes).toMatchObject({ + 'span.name': expect.any(String), + 'service.name': expect.any(String), + 'span.kind': expect.any(String), + }); + } + }); + + it('handles spans with missing __hdx_span_links gracefully', () => { + const result = getReverseSpanLinks( + [SPAN_D_NO_LINKS, SPAN_B_WITH_LINKS], + SPAN_ID_B, + ); + expect(result).toHaveLength(0); + }); +}); From 14ad9bc6570c93d72692c415fb771d5bcfa26c1b Mon Sep 17 00:00:00 2001 From: Vansh98789 Date: Wed, 12 Aug 2026 00:53:15 +0530 Subject: [PATCH 2/4] fix: scan full trace rows for reverse span links --- .changeset/reverse-span-links.md | 5 +++ .../app/src/components/DBRowOverviewPanel.tsx | 38 ++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 .changeset/reverse-span-links.md diff --git a/.changeset/reverse-span-links.md b/.changeset/reverse-span-links.md new file mode 100644 index 0000000000..311ee38386 --- /dev/null +++ b/.changeset/reverse-span-links.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: show spans referencing the current span in the trace span detail panel ("Referenced By" section) \ No newline at end of file diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 6a1d2000b7..74ba644ec8 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -24,15 +24,8 @@ import { getValidSpanLinks, SpanLinksSubpanel } from './SpanLinksSubpanel'; import { SpansReverseLinksSubpanel } from './SpansReverseLinksSubpanel'; const EMPTY_OBJ = {}; -export function RowOverviewPanel({ - source, - rowId, - aliasWith, - dateRange, - hideHeader = false, - flush = false, - 'data-testid': dataTestId, -}: { + +interface DBRowOverviewPanelProps { source: TSource; rowId: string | undefined | null; aliasWith?: WithClause[]; @@ -42,7 +35,22 @@ export function RowOverviewPanel({ // surrounding chrome (e.g. the tab bar in the trace span detail panel). flush?: boolean; 'data-testid'?: string; -}) { + // All spans belonging to the current trace, used to find spans that + // reference the currently selected span (reverse span links). This must + // be the full trace's span list, NOT just the single selected row. + allTraceRows?: Array>; +} + +export function RowOverviewPanel({ + source, + rowId, + aliasWith, + dateRange, + hideHeader = false, + flush = false, + 'data-testid': dataTestId, + allTraceRows, +}: DBRowOverviewPanelProps) { const contentPx = flush ? 0 : 'md'; const { data } = useRowData({ source, rowId, aliasWith, dateRange }); const { onPropertyAddClick, generateSearchUrl, onOpenLinkedTrace } = @@ -196,12 +204,14 @@ export function RowOverviewPanel({ return getValidSpanLinks(firstRow?.__hdx_span_links).length > 0; }, [firstRow?.__hdx_span_links]); + // P1 fix: scan the FULL trace's spans (allTraceRows), not data.data, + // which only ever contains the single selected row from useRowData. const hasReverseSpanLinks = useMemo(() => { - if (!firstRow?.SpanId || !Array.isArray(data?.data)) { + if (!firstRow?.SpanId || !Array.isArray(allTraceRows)) { return false; } const currentSpanId = String(firstRow.SpanId); - for (const row of data.data) { + for (const row of allTraceRows) { if (!row || typeof row !== 'object') continue; const sl = row.__hdx_span_links; if (!Array.isArray(sl)) continue; @@ -217,7 +227,7 @@ export function RowOverviewPanel({ } } return false; - }, [data?.data, firstRow?.SpanId]); + }, [allTraceRows, firstRow?.SpanId]); const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) @@ -377,7 +387,7 @@ export function RowOverviewPanel({ From 04e4ea9d75b219380d537233665b7cc7bede03ce Mon Sep 17 00:00:00 2001 From: Vansh98789 Date: Wed, 12 Aug 2026 01:14:20 +0530 Subject: [PATCH 3/4] fix: pass full trace rows to RowOverviewPanel for reverse span links --- packages/app/src/components/DBTracePanel.tsx | 43 +++++++++++++------ .../src/components/DBTraceWaterfallChart.tsx | 24 +++++------ 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index 35a7ea8774..8606989609 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -34,15 +34,16 @@ import { IconLayoutSidebarRight, IconX, } from '@tabler/icons-react'; - -import { DBTraceWaterfallChartContainer } from '@/components/DBTraceWaterfallChart'; +import { + DBTraceWaterfallChartContainer, + useFilteredEventsAroundFocus, +} from '@/components/DBTraceWaterfallChart'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import useResizable from '@/hooks/useResizable'; import { WithClause } from '@/hooks/useRowWhere'; import { useSource, useUpdateSource } from '@/source'; import TabBar from '@/TabBar'; import { parseAsJsonEncoded } from '@/utils/queryParsers'; - import DBInfraPanel from './DBInfraPanel'; import { RowDataPanel, rowHasK8sContext, useRowData } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; @@ -54,7 +55,6 @@ import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './SourceSchemaPreview'; import { SourceSelectControlled } from './SourceSelect'; - import resizeStyles from '@/../styles/ResizablePanel.module.scss'; type EventRowWhere = { @@ -75,6 +75,7 @@ const eventRowWhereSchema = z.object({ aliasWith: z.array(WithClauseSchema), traceId: z.string().optional(), }); + const eventRowWhereParser = parseAsJsonEncoded( eventRowWhereSchema.parse, ); @@ -105,6 +106,7 @@ function SpanDetailPanel({ onClose, isSideLayout, onToggleLayout, + allTraceRows, }: { source: TSource; rowId: string; @@ -112,14 +114,17 @@ function SpanDetailPanel({ onClose: () => void; isSideLayout: boolean; onToggleLayout: () => void; + // All spans belonging to the current trace (not just the selected span), + // used by RowOverviewPanel to render the "Referenced By" reverse-span-link + // section. Passed down from DBTracePanel, where the trace's full span list + // is already fetched for the waterfall chart. + allTraceRows?: Array>; }) { const [displayedTab, setDisplayedTab] = useState( SpanDetailTab.Overview, ); - const { data: rowData } = useRowData({ source, rowId, aliasWith }); const normalizedRow = rowData?.data?.[0]; - // The selected event may come from a different source than the search this // panel was opened from (e.g. a log event on a trace opened in the Traces // view). Rebind search-url generation to the event's own source and drop @@ -129,12 +134,10 @@ function SpanDetailPanel({ () => deriveRowSidePanelContextForSource(parentContext, source), [parentContext, source], ); - const hasK8sContext = useMemo( () => rowHasK8sContext(source, normalizedRow), [source, normalizedRow], ); - // If the selected span loses k8s context (e.g. switching spans) while the // Infrastructure tab is active, fall back to Overview so we don't show a // blank panel. Derived rather than synced via an effect. @@ -217,6 +220,7 @@ function SpanDetailPanel({ source={source} rowId={rowId} aliasWith={aliasWith} + allTraceRows={allTraceRows} flush /> )} @@ -289,6 +293,7 @@ export default function DBTracePanel({ : childSourceData?.kind === SourceKind.Log ? childSourceData : null; + const traceSourceData = parentSourceData?.kind === SourceKind.Trace ? parentSourceData @@ -335,6 +340,7 @@ export default function DBTracePanel({ '', }, }); + useEffect(() => { if ( parentSourceData && @@ -363,10 +369,8 @@ export default function DBTracePanel({ const { size: rightPanelSize, startResize: startHorizontalResize } = useResizable(35, 'right'); - const { size: bottomPanelSize, startResize: startVerticalResize } = useResizable(40, 'top'); - const detailPanelSize = isSideLayout ? rightPanelSize : bottomPanelSize; const handleCloseSpanDetails = useCallback(() => { @@ -381,6 +385,22 @@ export default function DBTracePanel({ return traceSourceData; }, [selectedSpan, logSourceData, traceSourceData]); + // Fetch the full set of spans for this trace so the span detail panel can + // determine which spans reference the currently selected span ("Referenced + // By" / reverse span links). This reuses the same hook the waterfall chart + // uses for its trace-side rows, so we're not issuing a materially different + // query. Only enabled once we have a real trace source + traceId; the + // `tableSource` fallback below only exists to satisfy the hook's non-null + // parameter type while disabled and is never actually queried. + const isTraceReady = traceSourceData?.kind === SourceKind.Trace && !!traceId; + const { rows: allTraceRows } = useFilteredEventsAroundFocus({ + tableSource: traceSourceData ?? logSourceData ?? ({} as any), + focusDate, + dateRange, + traceId: traceId ?? '', + enabled: isTraceReady, + }); + return (
)}
- {selectedSpan != null && ( )} - {traceSourceData != null && selectedSpan != null && selectedSpanSource != null && ( @@ -531,6 +549,7 @@ export default function DBTracePanel({ onToggleLayout={() => setDetailLayout(isSideLayout ? 'bottom' : 'side') } + allTraceRows={allTraceRows} /> )} diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index 77275c47e8..51bf5336f3 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -173,21 +173,17 @@ function getConfig( SpanId: source.spanIdExpression ?? '', ParentSpanId: source.kind === SourceKind.Trace - ? (source.parentSpanIdExpression ?? '') + ? source.parentSpanIdExpression ?? '' : '', StatusCode: - source.kind === SourceKind.Trace - ? (source.statusCodeExpression ?? '') - : '', + source.kind === SourceKind.Trace ? source.statusCodeExpression ?? '' : '', ServiceName: source.serviceNameExpression ?? '', SeverityText: - source.kind === SourceKind.Log - ? (source.severityTextExpression ?? '') - : '', + source.kind === SourceKind.Log ? source.severityTextExpression ?? '' : '', SpanAttributes: source.eventAttributesExpression ?? '', SpanEvents: source.kind === SourceKind.Trace - ? (source.spanEventsValueExpression ?? '') + ? source.spanEventsValueExpression ?? '' : '', }; @@ -424,7 +420,7 @@ export function useEventsAroundFocus({ }; } -function useFilteredEventsAroundFocus( +export function useFilteredEventsAroundFocus( args: Parameters[0], ) { const filtered = useEventsAroundFocus(args); @@ -625,11 +621,11 @@ export function DBTraceWaterfallChartContainer({ traceWhereLanguage: traceWhereLanguage === 'sql' || traceWhereLanguage === 'lucene' ? traceWhereLanguage - : (getStoredLanguage() ?? 'lucene'), + : getStoredLanguage() ?? 'lucene', logWhereLanguage: logWhereLanguage === 'sql' || logWhereLanguage === 'lucene' ? logWhereLanguage - : (getStoredLanguage() ?? 'lucene'), + : getStoredLanguage() ?? 'lucene', }, }); @@ -1093,7 +1089,7 @@ export function DBTraceWaterfallChartContainer({ type === SourceKind.Log ? getChartColorSuccess() : serviceName - ? (serviceColorMap.get(serviceName) ?? '#6A7077') + ? serviceColorMap.get(serviceName) ?? '#6A7077' : '#6A7077'; return { @@ -1515,9 +1511,9 @@ export function DBTraceWaterfallChartContainer({ flattenedNodes.length > 0 ? (
All items are hidden by filters
) : ( - (emptyState ?? ( + emptyState ?? (
No matching spans or logs found
- )) + ) ) ) : ( Date: Thu, 13 Aug 2026 22:33:35 +0530 Subject: [PATCH 4/4] fix: select TraceId/SpanKind/SpanLinks in trace query, fix field mapping, dedupe fetch --- packages/app/src/components/DBTracePanel.tsx | 28 +++++---------- .../src/components/DBTraceWaterfallChart.tsx | 35 +++++++++++++++++++ .../components/SpansReverseLinksSubpanel.tsx | 2 +- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index 8606989609..4018d13da7 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -34,10 +34,7 @@ import { IconLayoutSidebarRight, IconX, } from '@tabler/icons-react'; -import { - DBTraceWaterfallChartContainer, - useFilteredEventsAroundFocus, -} from '@/components/DBTraceWaterfallChart'; +import { DBTraceWaterfallChartContainer } from '@/components/DBTraceWaterfallChart'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import useResizable from '@/hooks/useResizable'; import { WithClause } from '@/hooks/useRowWhere'; @@ -385,21 +382,13 @@ export default function DBTracePanel({ return traceSourceData; }, [selectedSpan, logSourceData, traceSourceData]); - // Fetch the full set of spans for this trace so the span detail panel can - // determine which spans reference the currently selected span ("Referenced - // By" / reverse span links). This reuses the same hook the waterfall chart - // uses for its trace-side rows, so we're not issuing a materially different - // query. Only enabled once we have a real trace source + traceId; the - // `tableSource` fallback below only exists to satisfy the hook's non-null - // parameter type while disabled and is never actually queried. - const isTraceReady = traceSourceData?.kind === SourceKind.Trace && !!traceId; - const { rows: allTraceRows } = useFilteredEventsAroundFocus({ - tableSource: traceSourceData ?? logSourceData ?? ({} as any), - focusDate, - dateRange, - traceId: traceId ?? '', - enabled: isTraceReady, - }); + // Populated via onTraceRowsChange from DBTraceWaterfallChartContainer, + // which already fetches this trace's full span list for the waterfall. + // Reusing it here (instead of issuing a second, duplicate query) is what + // lets the span detail panel compute "Referenced By" reverse span links. + const [allTraceRows, setAllTraceRows] = useState< + Record[] | undefined + >(undefined); return (
[] | undefined) => void; }) { const formatTime = useFormatTime(); @@ -671,6 +702,10 @@ export function DBTraceWaterfallChartContainer({ hiddenRowExpressionLanguage: traceFilterLanguage, enabled: true, }); + useEffect(() => { + onTraceRowsChange?.(traceRowsData); + }, [traceRowsData, onTraceRowsChange]); + const { rows: logRowsData, isFetching: logIsFetching, diff --git a/packages/app/src/components/SpansReverseLinksSubpanel.tsx b/packages/app/src/components/SpansReverseLinksSubpanel.tsx index 2470be7186..4995ee9588 100644 --- a/packages/app/src/components/SpansReverseLinksSubpanel.tsx +++ b/packages/app/src/components/SpansReverseLinksSubpanel.tsx @@ -30,7 +30,7 @@ export function getReverseSpanLinks( SpanId: String(row.SpanId ?? ''), TraceState: '', Attributes: { - 'span.name': String(row.SpanName ?? ''), + 'span.name': String(row.Body ?? ''), 'service.name': String(row.ServiceName ?? ''), 'span.kind': String(row.SpanKind ?? ''), },