Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reverse-span-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

feat: show spans referencing the current span in the trace span detail panel ("Referenced By" section)
75 changes: 64 additions & 11 deletions packages/app/src/components/DBRowOverviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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<Record<string, any>>;
}

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 } =
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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;
}
}
Comment on lines +216 to +227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Visibility accepts invalid span links

When a trace row has the selected SpanId but lacks a string TraceId or an Attributes value, hasReverseSpanLinks displays the Referenced By accordion even though getValidSpanLinks rejects that entry, causing the expanded panel to report that no spans reference the selected span.

Suggested change
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;
}
}
const validLinks = getValidSpanLinks(row.__hdx_span_links);
if (validLinks.some(link => link.SpanId === currentSpanId)) {
return true;
}

Knowledge Base Used: App Components and Charts

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

}
return false;
}, [allTraceRows, firstRow?.SpanId]);

const mainContentColumn = getEventBody(source);
const mainContent = isString(firstRow?.['__hdx_body'])
? firstRow['__hdx_body']
Expand Down Expand Up @@ -226,6 +260,7 @@ export function RowOverviewPanel({
'exception',
'spanEvents',
'spanLinks',
'reverseSpanLinks',
'network',
'resourceAttributes',
'eventAttributes',
Expand Down Expand Up @@ -342,6 +377,24 @@ export function RowOverviewPanel({
</Accordion.Panel>
</Accordion.Item>
)}
{hasReverseSpanLinks && (
<Accordion.Item value="reverseSpanLinks">
<Accordion.Control>
<Text size="sm" ps="md">
Referenced By
</Text>
</Accordion.Control>
<Accordion.Panel>
<Box px="md">
<SpansReverseLinksSubpanel
rows={allTraceRows}
currentSpanId={firstRow?.SpanId as string | undefined}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
onOpenTrace={onOpenLinkedTrace}
/>
</Box>
Comment on lines 377 to +394

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 User-facing changeset is missing

This adds the user-visible Referenced By panel to the published application package without a corresponding .changeset/ entry, so the release metadata omits this behavior change.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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

</Accordion.Panel>
</Accordion.Item>
)}

{Object.keys(resourceAttributes).length > 0 && (
<Accordion.Item value="resourceAttributes">
Expand Down
68 changes: 68 additions & 0 deletions packages/app/src/components/SpansReverseLinksSubpanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useMemo } from 'react';

import {
getValidSpanLinks,
SpanLinkData,
SpanLinksSubpanel,
} from './SpanLinksSubpanel';

export function getReverseSpanLinks(
rows: Record<string, unknown>[] | 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<string, unknown>[] | 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<string, unknown>[] | null;
currentSpanId?: string;
onOpenTrace?: (link: SpanLinkData) => void;
}) => {
const reverseLinks = useMemo(
() => getReverseSpanLinks(rows, currentSpanId),
[rows, currentSpanId],
);
if (reverseLinks.length === 0) {
return (
<div className="p-3 text-muted fs-7" data-testid="reverse-links-empty">
No spans reference this span
</div>
);
}
return (
<SpanLinksSubpanel
spanLinks={reverseLinks as unknown as Record<string, unknown>[]}
onOpenTrace={onOpenTrace}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading