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 65f216e575..74ba644ec8 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -21,17 +21,11 @@ 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({ - source, - rowId, - aliasWith, - dateRange, - hideHeader = false, - flush = false, - 'data-testid': dataTestId, -}: { + +interface DBRowOverviewPanelProps { source: TSource; rowId: string | undefined | null; aliasWith?: WithClause[]; @@ -41,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 } = @@ -50,7 +59,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 +204,31 @@ 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(allTraceRows)) { + return false; + } + const currentSpanId = String(firstRow.SpanId); + for (const row of allTraceRows) { + 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; + }, [allTraceRows, firstRow?.SpanId]); + const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) ? firstRow['__hdx_body'] @@ -226,6 +260,7 @@ export function RowOverviewPanel({ 'exception', 'spanEvents', 'spanLinks', + 'reverseSpanLinks', 'network', 'resourceAttributes', 'eventAttributes', @@ -342,6 +377,24 @@ export function RowOverviewPanel({ )} + {hasReverseSpanLinks && ( + + + + Referenced By + + + + + + + + + )} {Object.keys(resourceAttributes).length > 0 && ( diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index 35a7ea8774..4018d13da7 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -34,7 +34,6 @@ import { IconLayoutSidebarRight, IconX, } from '@tabler/icons-react'; - import { DBTraceWaterfallChartContainer } from '@/components/DBTraceWaterfallChart'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import useResizable from '@/hooks/useResizable'; @@ -42,7 +41,6 @@ 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 +52,6 @@ import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './SourceSchemaPreview'; import { SourceSelectControlled } from './SourceSelect'; - import resizeStyles from '@/../styles/ResizablePanel.module.scss'; type EventRowWhere = { @@ -75,6 +72,7 @@ const eventRowWhereSchema = z.object({ aliasWith: z.array(WithClauseSchema), traceId: z.string().optional(), }); + const eventRowWhereParser = parseAsJsonEncoded( eventRowWhereSchema.parse, ); @@ -105,6 +103,7 @@ function SpanDetailPanel({ onClose, isSideLayout, onToggleLayout, + allTraceRows, }: { source: TSource; rowId: string; @@ -112,14 +111,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 +131,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 +217,7 @@ function SpanDetailPanel({ source={source} rowId={rowId} aliasWith={aliasWith} + allTraceRows={allTraceRows} flush /> )} @@ -289,6 +290,7 @@ export default function DBTracePanel({ : childSourceData?.kind === SourceKind.Log ? childSourceData : null; + const traceSourceData = parentSourceData?.kind === SourceKind.Trace ? parentSourceData @@ -335,6 +337,7 @@ export default function DBTracePanel({ '', }, }); + useEffect(() => { if ( parentSourceData && @@ -363,10 +366,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 +382,14 @@ export default function DBTracePanel({ return traceSourceData; }, [selectedSpan, logSourceData, traceSourceData]); + // 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 (
)}
- {selectedSpan != null && ( )} - {traceSourceData != null && selectedSpan != null && selectedSpanSource != null && ( @@ -531,6 +539,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..3a19452d3c 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -173,21 +173,23 @@ 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 ?? '' + : '', + SpanKind: + source.kind === SourceKind.Trace ? source.spanKindExpression ?? '' : '', + SpanLinks: + source.kind === SourceKind.Trace + ? source.spanLinksValueExpression ?? '' : '', }; @@ -216,6 +218,10 @@ function getConfig( }, ...(alias.ServiceName ? [ + { + valueExpression: alias.TraceId, + alias: 'TraceId', + }, { valueExpression: alias.ServiceName, alias: 'ServiceName', @@ -281,6 +287,25 @@ function getConfig( }, ] : []), + ...(alias.SpanKind + ? [ + { + valueExpression: alias.SpanKind, + alias: 'SpanKind', + }, + ] + : []), + ...(alias.SpanLinks + ? [ + { + // matches ROW_DATA_ALIASES.SPAN_LINKS ('__hdx_span_links') + // used by useRowData, so getReverseSpanLinks can read the + // same field name regardless of which hook produced the row. + valueExpression: alias.SpanLinks, + alias: '__hdx_span_links', + }, + ] + : []), ], ); } else if (source.kind === SourceKind.Log) { @@ -424,7 +449,7 @@ export function useEventsAroundFocus({ }; } -function useFilteredEventsAroundFocus( +export function useFilteredEventsAroundFocus( args: Parameters[0], ) { const filtered = useEventsAroundFocus(args); @@ -572,6 +597,7 @@ export function DBTraceWaterfallChartContainer({ initialRowHighlightHint, emptyState, controlsExtra, + onTraceRowsChange, }: { traceTableSource: TTraceSource; logTableSource: TLogSource | null; @@ -592,6 +618,7 @@ export function DBTraceWaterfallChartContainer({ emptyState?: ReactNode; /** Extra controls rendered in the waterfall controls bar (e.g. the correlated logs source selector). */ controlsExtra?: ReactNode; + onTraceRowsChange?: (rows: Record[] | undefined) => void; }) { const formatTime = useFormatTime(); @@ -625,11 +652,11 @@ export function DBTraceWaterfallChartContainer({ traceWhereLanguage: traceWhereLanguage === 'sql' || traceWhereLanguage === 'lucene' ? traceWhereLanguage - : (getStoredLanguage() ?? 'lucene'), + : getStoredLanguage() ?? 'lucene', logWhereLanguage: logWhereLanguage === 'sql' || logWhereLanguage === 'lucene' ? logWhereLanguage - : (getStoredLanguage() ?? 'lucene'), + : getStoredLanguage() ?? 'lucene', }, }); @@ -675,6 +702,10 @@ export function DBTraceWaterfallChartContainer({ hiddenRowExpressionLanguage: traceFilterLanguage, enabled: true, }); + useEffect(() => { + onTraceRowsChange?.(traceRowsData); + }, [traceRowsData, onTraceRowsChange]); + const { rows: logRowsData, isFetching: logIsFetching, @@ -1093,7 +1124,7 @@ export function DBTraceWaterfallChartContainer({ type === SourceKind.Log ? getChartColorSuccess() : serviceName - ? (serviceColorMap.get(serviceName) ?? '#6A7077') + ? serviceColorMap.get(serviceName) ?? '#6A7077' : '#6A7077'; return { @@ -1515,9 +1546,9 @@ export function DBTraceWaterfallChartContainer({ flattenedNodes.length > 0 ? (
All items are hidden by filters
) : ( - (emptyState ?? ( + emptyState ?? (
No matching spans or logs found
- )) + ) ) ) : ( [] | 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.Body ?? ''), + '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); + }); +});