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..8637081adff3 100644
--- a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx
+++ b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx
@@ -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();
+
+ 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..d0ac44cc7a55 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,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] ?? '');
@@ -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(
);
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.
*