Skip to content
38 changes: 36 additions & 2 deletions static/app/views/seerExplorer/callRecords.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,20 +99,54 @@ function withEllipsis(
return truncated ? `${text}\n…` : text;
}

/**
* Lib helpers whose own row is a better destination than the HTTP children underneath.
*
* Most composite libs are dropped when they fan out: the child API rows say more.
* `get_span_details` is the exception — its only HTTP call is the trace endpoint, which can only
* link to the trace, while the lib's own args name the span the user asked about.
*/
const PREFER_LIB_OVER_CHILDREN = new Set(['get_span_details']);

/**
* The records worth rendering, in the order they ran.
*
* A lib call that fanned out into api calls is dropped: it is a heading for rows that each say
* more than it does, and keeping it means a parent with no expander sitting above indented
* children. A lib call with no api children is kept — the Explorer-backed helpers (`code_search`,
* `bash`, `ask_user_question`) never touch the transport, so their own row is the only trace they
* leave.
* leave. Helpers in `PREFER_LIB_OVER_CHILDREN` keep their own row and suppress children instead.
*/
export function visibleCallRecords(records: CallRecord[]): CallRecord[] {
const hasChildren = new Set(
records.flatMap(record =>
record.parent === null || record.parent === undefined ? [] : [record.parent]
)
);
return records.filter(record => record.kind !== 'lib' || !hasChildren.has(record.id));

const hideChildrenOf = new Set(
records
.filter(
record =>
record.kind === 'lib' &&
record.name &&
PREFER_LIB_OVER_CHILDREN.has(record.name) &&
hasChildren.has(record.id)
)
.map(record => record.id)
);

return records.filter(record => {
if (
record.parent !== null &&
record.parent !== undefined &&
hideChildrenOf.has(record.parent)
) {
return false;
}
if (record.kind !== 'lib' || !hasChildren.has(record.id)) {
return true;
}
return Boolean(record.name && PREFER_LIB_OVER_CHILDREN.has(record.name));
});

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.

Hidden child hides span lookup failures

Medium Severity

Preferring the get_span_details lib row now drops its HTTP child, but the parent still uses callRecordStatus, which treats a settled lib record with no error or status as success. A 4xx/5xx on the trace request therefore renders as a successful, clickable span row instead of a failed call.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47aee09. Configure here.

}
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,34 @@ describe('call record rendering', () => {
expect(screen.queryByText('Retrieving details')).not.toBeInTheDocument();
});

it('keeps get_span_details over its less-specific trace child', () => {
// The only HTTP call under get_span_details is the trace endpoint. The lib row carries span_id
// and is the better destination, so the child is suppressed rather than the parent.
const block = codeModeBlock([
{
id: 1,
parent: null,
kind: 'lib',
name: 'get_span_details',
title: 'Retrieving span abc in trace def',
params: {trace_id: 'def', span_id: 'abc'},
},
apiRecord({
id: 2,
parent: 1,
path: '/api/0/organizations/{organization_id_or_slug}/trace/{trace_id}/',
path_params: {organization_id_or_slug: 'acme', trace_id: 'def'},
title: 'Retrieving waterfall for trace def',
}),
]);
render(<BlockComponent block={block} blockIndex={0} />);

expect(screen.getByText('Retrieving span abc in trace def')).toBeInTheDocument();
expect(
screen.queryByText('Retrieving waterfall for trace def')
).not.toBeInTheDocument();
});

it('keeps a lib row that made no api calls of its own', () => {
// code_search never touches the transport, so its row is the only trace it leaves. Seer titles
// it, as it does every call — nothing in the frontend renames a row.
Expand Down
47 changes: 47 additions & 0 deletions static/app/views/seerExplorer/components/chat/toolUse.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,53 @@ describe('ToolUseBlock', () => {
);
});

it('keeps a multi-project Explore bus link under a telemetry call row', () => {
// The call row itself has no destination (search rows decline without the translated query).
// The bus link carries project_slugs + query and must still render as residual nav rather than
// being suppressed because a call row exists.
const block = createBlock({
message: {
role: 'tool_use',
content: null,
tool_calls: [{id: 'call-1', function: 'sentry_api_execute', args: '{}'}],
},
tool_results: [
{
tool_call_id: 'call-1',
tool_call_function: 'sentry_api_execute',
content: 'ran',
structuredContent: {
calls: [
{
id: 1,
kind: 'lib',
name: 'telemetry_live_search',
title: 'Querying spans',
params: {dataset: 'spans', question: 'top pageloads'},
},
],
links: [
{
kind: 'telemetry_live_search',
params: {
dataset: 'spans',
query: 'transaction.op:pageload',
project_slugs: ['javascript', 'docs'],
stats_period: '24h',
},
},
],
},
},
],
});

render(<BlockComponent block={block} blockIndex={0} blocks={[block]} />);

expect(screen.getByText('Querying spans')).toBeInTheDocument();
expect(screen.getByRole('link', {name: /View spans/})).toBeInTheDocument();
});

it('does not double-render a classic link present in both channels', () => {
// A classic tool populates both the positional tool_links (row link) and structuredContent.links
// during migration; the bus entry that duplicates the row link is deduped, so it renders once.
Expand Down
23 changes: 18 additions & 5 deletions static/app/views/seerExplorer/components/chat/toolUse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,12 +362,21 @@ function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps) {
// has still finished, and reading "settled" as "reported something" would leave any row
// built from the live mirror spinning.
const callsAreSettled = toolCall.id ? settledCallIds.has(toolCall.id) : false;
// Bus destinations already claimed by a call row (same rule id). A Code Mode execute often
// emits both a call record and a coarser bus link for the same entity; without this, the
// residual nav path would repeat "View issue" under a row that already navigates there.
// Destinations only the bus carries (translated Explore queries, multi-project searches)
// stay residual.
const claimedLinkKinds = new Set<string>();
const callRows = visibleCallRecords(finishedCalls.length ? finishedCalls : live)
.map(record => {
const link = resolveLink(subjectFromCallRecord(record), {
organization,
projects,
});
if (link) {
claimedLinkKinds.add(link.id);
}
return {
record,
// A rule that matched names the row; seer's own title stands for every other call.
Expand All @@ -383,6 +392,10 @@ function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps) {
// narrows `label` for the render below, which is why it is not a plain Boolean check.
.filter((row): row is typeof row & {label: string} => Boolean(row.label));

const residualNavItems = navItems.filter(
item => !claimedLinkKinds.has(item.kind)
);

const isCodeMode = CODE_MODE_TOOLS.has(toolCall.function);
const toolString = isCodeMode ? '' : (toolsUsed[idx] ?? '');

Expand Down Expand Up @@ -439,14 +452,14 @@ function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps) {
// Trailing per-tool-call surfaces. These belong to the call as a whole rather than to any
// one row, so they follow its rows rather than sitting inside one.
//
// The links bus is skipped when call rows are present: those already name and link what
// the execute did, so it would repeat them at coarser granularity — the tool rather than
// the call.
if (navItems.length > 0 && callRows.length === 0) {
// Residual bus links only — same-kind destinations already on a call row were filtered out
// above. This is what keeps multi-project Explore links visible under a telemetry call row
// (the row itself has no path params / no destination; only the bus carries the query).
if (residualNavItems.length > 0) {
rows.push(
<NavLinks
key={`${key}-links`}
navItems={navItems}
navItems={residualNavItems}
onNavLinkClick={trackLinkClick}
/>
);
Expand Down
83 changes: 80 additions & 3 deletions static/app/views/seerExplorer/links.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ const LINK_RULE_EXAMPLES: Record<string, LinkSubject> = {
path: '/api/0/organizations/{organization_id_or_slug}/trace/{trace_id}/',
params: {trace_id: 'trace1'},
},
get_span_details: {
kind: 'lib',
name: 'get_span_details',
params: {trace_id: 'trace1', span_id: 'span1'},
},
get_replay_details: {
kind: 'api',
method: 'GET',
Expand Down Expand Up @@ -387,9 +392,62 @@ describe('search links', () => {
id: 1,
kind: 'lib',
name: 'telemetry_live_search',
params: {dataset: 'spans', question: 'top pageloads'},
});
expect(resolveLink(subject, ctx)).toBeNull();
});

it('builds one Explore link with every project_slug selected', () => {
const result = resolveLink(
subjectFromToolLink({
kind: 'telemetry_live_search',
params: {
dataset: 'spans',
query: 'transaction.op:pageload',
project_slugs: ['javascript', 'python'],
stats_period: '24h',
},
}),
ctx
);

expect(result).toEqual(
expect.objectContaining({
id: 'telemetry_live_search',
label: 'View spans',
url: expect.objectContaining({
pathname: '/organizations/org-slug/traces/',
query: expect.objectContaining({
query: 'transaction.op:pageload',
project: ['2', '3'],
statsPeriod: '24h',
}),
}),
})
);
});

it('links a span lib call into the trace waterfall node', () => {
const result = resolveLink(
subjectFromCallRecord({
id: 1,
kind: 'lib',
name: 'get_span_details',
params: {trace_id: 'trace1', span_id: 'span1'},
title: 'Retrieving span span1 in trace trace1',
}),
ctx
);

expect(result).toEqual({
id: 'get_span_details',
label: 'Retrieving span span1 in trace trace1',
url: {
pathname: '/organizations/org-slug/explore/traces/trace/trace1/',
query: {node: 'span-span1'},
},
});
});
});

describe('subjectFromCallRecord', () => {
Expand All @@ -410,15 +468,34 @@ describe('subjectFromCallRecord', () => {
);
});

it('does not pass a lib call’s own arguments off as route params', () => {
// Otherwise a lib call carrying `issue_id` would link through a route rule it never requested.
it('keeps lib scalar args for name-matched rules, not for route matchers', () => {
// Route match predicates key on `path`, which lib calls lack — so passing scalar args is safe
// for name-matched helpers like get_span_details, and cannot fire a path-matched issue rule.
expect(
subjectFromCallRecord({
id: 1,
kind: 'lib',
name: 'code_search',
params: {issue_id: '54'},
})
).toEqual(expect.objectContaining({kind: 'lib', name: 'code_search', params: {}}));
).toEqual(
expect.objectContaining({
kind: 'lib',
name: 'code_search',
params: {issue_id: '54'},
path: undefined,
})
);
expect(
resolveLink(
subjectFromCallRecord({
id: 1,
kind: 'lib',
name: 'code_search',
params: {issue_id: '54'},
}),
ctx
)
).toBeNull();
});
});
Loading
Loading