From 0aa68fd288265a2b5370defe4a69a607736310e0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:29:00 +0000 Subject: [PATCH 1/2] feat(seerExplorer): Surface residual Explore links and span deep-links Keep multi-project telemetry bus links visible under call rows, add a get_span_details link rule, and prefer that lib row over its trace child. Co-Authored-By: Dhrumil Parekh --- static/app/views/seerExplorer/callRecords.tsx | 38 ++++++++- .../components/chat/callRecords.spec.tsx | 26 ++++++ .../components/chat/toolUse.spec.tsx | 47 +++++++++++ .../seerExplorer/components/chat/toolUse.tsx | 21 +++-- static/app/views/seerExplorer/links.spec.tsx | 83 ++++++++++++++++++- static/app/views/seerExplorer/links.tsx | 69 +++++++++++++-- 6 files changed, 266 insertions(+), 18 deletions(-) diff --git a/static/app/views/seerExplorer/callRecords.tsx b/static/app/views/seerExplorer/callRecords.tsx index edd654ec3443..152319705bd2 100644 --- a/static/app/views/seerExplorer/callRecords.tsx +++ b/static/app/views/seerExplorer/callRecords.tsx @@ -99,6 +99,15 @@ 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. * @@ -106,7 +115,7 @@ function withEllipsis( * 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( @@ -114,5 +123,30 @@ export function visibleCallRecords(records: CallRecord[]): CallRecord[] { 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)); + }); } diff --git a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx index 0f06f3f38958..326eed632d5d 100644 --- a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx +++ b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx @@ -168,6 +168,32 @@ 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(); + + 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. diff --git a/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx b/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx index 293d956ebd53..70ad326c7aa0 100644 --- a/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx +++ b/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx @@ -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(); + + 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. diff --git a/static/app/views/seerExplorer/components/chat/toolUse.tsx b/static/app/views/seerExplorer/components/chat/toolUse.tsx index ba03241c252e..dee79c0a99fb 100644 --- a/static/app/views/seerExplorer/components/chat/toolUse.tsx +++ b/static/app/views/seerExplorer/components/chat/toolUse.tsx @@ -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(); 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. @@ -383,6 +392,8 @@ 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] ?? ''); @@ -439,14 +450,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( ); diff --git a/static/app/views/seerExplorer/links.spec.tsx b/static/app/views/seerExplorer/links.spec.tsx index 9b8c0ac29ba3..3264ac091c50 100644 --- a/static/app/views/seerExplorer/links.spec.tsx +++ b/static/app/views/seerExplorer/links.spec.tsx @@ -47,6 +47,11 @@ const LINK_RULE_EXAMPLES: Record = { 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', @@ -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', () => { @@ -410,8 +468,9 @@ 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, @@ -419,6 +478,24 @@ describe('subjectFromCallRecord', () => { 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(); }); }); diff --git a/static/app/views/seerExplorer/links.tsx b/static/app/views/seerExplorer/links.tsx index 973c08d9c52f..d6af0c00b10f 100644 --- a/static/app/views/seerExplorer/links.tsx +++ b/static/app/views/seerExplorer/links.tsx @@ -205,19 +205,41 @@ export const LINK_RULES: LinkRule[] = [ } const query: Record = {}; - if (span_id) { - query.node = `span-${span_id}`; + // A concrete span deep-links into the waterfall rather than opening a separate page. + const spanId = asUrlSegment(span_id); + if (spanId) { + query.node = `span-${spanId}`; } if (timestamp) { query.timestamp = String(timestamp); } return { - label: title ?? t('View trace'), + label: title ?? (spanId ? t('View span') : t('View trace')), url: {pathname: `/explore/traces/trace/${traceId}/`, query}, }; }, }, + // Lib helper: no route of its own, only scalar args. Kept as its own rule so a span lookup does + // not depend on the less-specific trace child underneath it. + { + id: 'get_span_details', + resolve: ({params, title}) => { + const traceId = asUrlSegment(params.trace_id); + const spanId = asUrlSegment(params.span_id); + if (!traceId || !spanId) { + return null; + } + + return { + label: title ?? t('View span'), + url: { + pathname: `/explore/traces/trace/${traceId}/`, + query: {node: `span-${spanId}`}, + }, + }; + }, + }, { id: 'get_replay_details', match: ({path}) => /\{replay_id\}\/?$/.test(path ?? ''), @@ -346,13 +368,24 @@ export const LINK_RULES: LinkRule[] = [ resolve: ({kind, params, title}, {projects}) => { // The one name that arrives on both channels, and only one of them can be re-run. The call // record reports a search that already happened and carries no query; the link seer emits - // alongside it carries the query, so that is the one with somewhere to point. + // alongside it carries the query (and multi-project `project_slugs`), so that is the one with + // somewhere to point. if (kind !== 'link') { return null; } const url = searchUrl(params, projects); - return url ? {label: title ?? t('View results'), url} : null; + if (!url) { + return null; + } + + // Prefer seer's title when present; otherwise name the dataset so the residual nav link is + // not a generic "View results" under a row that already says which dataset ran. + const datasetLabel = telemetryDatasetLabel(params.dataset); + return { + label: title ?? (datasetLabel ? t('View %s', datasetLabel) : t('View results')), + url, + }; }, }, ]; @@ -399,9 +432,10 @@ export function subjectFromCallRecord(record: CallRecord): LinkSubject { return { kind: record.kind === 'lib' ? 'lib' : 'api', - // Only path params. A lib call's own arguments are not route params, and reading them here - // would let a rule keyed on a route build a link for a row that never made that request. - params: record.path_params ?? {}, + // API rows use path params. Lib rows use their scalar args — name-matched rules (e.g. + // `get_span_details`) are the only ones that see them, since route `match` predicates key on + // `path`, which a lib call does not have. + params: record.kind === 'lib' ? (record.params ?? {}) : (record.path_params ?? {}), name: record.kind === 'lib' ? record.name : undefined, method: record.method, path: record.path, @@ -457,6 +491,25 @@ function getStringArray(value: unknown): string[] { return typeof value === 'string' ? [value] : []; } +/** Dataset noun for residual telemetry links, or undefined when unknown. */ +function telemetryDatasetLabel(dataset: unknown): string | undefined { + switch (dataset) { + case 'spans': + return 'spans'; + case 'errors': + return 'errors'; + case 'logs': + return 'logs'; + case 'metrics': + case 'tracemetrics': + return 'metrics'; + case 'issues': + return 'issues'; + default: + return undefined; + } +} + /** * The search a `telemetry_live_search` link stands for, as a URL onto the matching Explore page. * From c14612126681a338d6d29db931040410df2117e4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:33:11 +0000 Subject: [PATCH 2/2] style(seerExplorer): Apply oxfmt to residual-link changes --- .../views/seerExplorer/components/chat/callRecords.spec.tsx | 4 +++- static/app/views/seerExplorer/components/chat/toolUse.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx index 326eed632d5d..8637081adff3 100644 --- a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx +++ b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx @@ -191,7 +191,9 @@ describe('call record rendering', () => { render(); expect(screen.getByText('Retrieving span abc in trace def')).toBeInTheDocument(); - expect(screen.queryByText('Retrieving waterfall for trace def')).not.toBeInTheDocument(); + expect( + screen.queryByText('Retrieving waterfall for trace def') + ).not.toBeInTheDocument(); }); it('keeps a lib row that made no api calls of its own', () => { diff --git a/static/app/views/seerExplorer/components/chat/toolUse.tsx b/static/app/views/seerExplorer/components/chat/toolUse.tsx index dee79c0a99fb..d0ac44cc7a55 100644 --- a/static/app/views/seerExplorer/components/chat/toolUse.tsx +++ b/static/app/views/seerExplorer/components/chat/toolUse.tsx @@ -392,7 +392,9 @@ 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 residualNavItems = navItems.filter( + item => !claimedLinkKinds.has(item.kind) + ); const isCodeMode = CODE_MODE_TOOLS.has(toolCall.function); const toolString = isCodeMode ? '' : (toolsUsed[idx] ?? '');