diff --git a/static/app/views/explore/conversations/components/conversationSummary.tsx b/static/app/views/explore/conversations/components/conversationSummary.tsx index 37b7c9af5167..a35759679d88 100644 --- a/static/app/views/explore/conversations/components/conversationSummary.tsx +++ b/static/app/views/explore/conversations/components/conversationSummary.tsx @@ -2,6 +2,7 @@ import type React from 'react'; import {Fragment, useMemo} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; +import * as qs from 'query-string'; import {Tag} from '@sentry/scraps/badge'; import {InfoText} from '@sentry/scraps/info'; @@ -16,6 +17,7 @@ import {Placeholder} from 'sentry/components/placeholder'; import {TimeSince} from 'sentry/components/timeSince'; import {IconFire, IconOpen, IconUser} from 'sentry/icons'; import {t, tn} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; import {escapeDoubleQuotes} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {isUUID} from 'sentry/utils/string/isUUID'; @@ -68,6 +70,24 @@ export function ConversationSummary({ const user = useMemo(() => getConversationUser(nodes), [nodes]); const userDisplayName = user ? getUserDisplayName(user) : null; + const errorsUrl = useMemo( + () => + getConversationErrorsUrl({ + organization, + selection, + conversationId, + errorIssueIds: aggregates.errorIssueIds, + errorCount: aggregates.errorCount, + }), + [ + organization, + selection, + conversationId, + aggregates.errorIssueIds, + aggregates.errorCount, + ] + ); + const displayId = isUUID(conversationId) ? conversationId.slice(0, 8) : conversationId; // Prefer the human-readable title; fall back to the (possibly truncated) id. const headingText = title || displayId; @@ -76,12 +96,6 @@ export function ConversationSummary({ // the full value; a title or non-UUID id only needs it when it overflows. const headingTooltipOnlyOnOverflow = title ? true : !isUUID(conversationId); - const errorsUrl = getExploreUrl({ - organization, - selection, - query: `gen_ai.conversation.id:"${escapeDoubleQuotes(conversationId)}" span.status:[internal_error,error]`, - }); - // Distinct traces the conversation spans, keyed by trace ID with a // representative span ID to deep-link into the trace view. const traces = useMemo(() => { @@ -244,9 +258,9 @@ export function ConversationSummary({ /> ) : undefined } - to={aggregates.errorCount > 0 ? errorsUrl : undefined} + to={errorsUrl} onClick={ - aggregates.errorCount > 0 + errorsUrl ? () => trackAnalytics('conversations.detail.click-errors-link', {organization}) : undefined @@ -331,6 +345,7 @@ function getTraceUrl(orgSlug: string, traceId: string, spanId: string) { interface ConversationAggregates { errorCount: number; + errorIssueIds: string[]; erroredToolNames: Set; llmCalls: number; toolCalls: number; @@ -339,6 +354,48 @@ interface ConversationAggregates { totalTokens: number; } +// Linked error issues live in the issue stream; span-status errors without a +// linked issue fall back to the traces explorer. Undefined when there are none. +function getConversationErrorsUrl({ + organization, + selection, + conversationId, + errorIssueIds, + errorCount, +}: { + conversationId: string; + errorCount: number; + errorIssueIds: string[]; + organization: Organization; + selection: ReturnType['selection']; +}): string | undefined { + if (errorIssueIds.length > 0) { + const {start, end, period: statsPeriod, utc} = selection.datetime; + const query = { + // Trace-linked errors can live in any project, so scope to all of them. + project: '-1', + query: `issue.id:[${errorIssueIds.join(',')}]`, + statsPeriod: statsPeriod ?? undefined, + start: start ? new Date(start).toISOString() : undefined, + end: end ? new Date(end).toISOString() : undefined, + utc: utc ? 'true' : undefined, + }; + return normalizeUrl( + `/organizations/${organization.slug}/issues/?${qs.stringify(query, {skipNull: true})}` + ); + } + + if (errorCount > 0) { + return getExploreUrl({ + organization, + selection, + query: `gen_ai.conversation.id:"${escapeDoubleQuotes(conversationId)}" span.status:[internal_error,error]`, + }); + } + + return undefined; +} + function getGenAiOpType(node: AITraceSpanNode): string | undefined { return getStringAttr(node, SpanFields.GEN_AI_OPERATION_TYPE); } @@ -351,11 +408,18 @@ function calculateAggregates(nodes: AITraceSpanNode[]): ConversationAggregates { let totalCost = 0; const toolNameSet = new Set(); const erroredToolNameSet = new Set(); + const errorIssueIdSet = new Set(); for (const node of nodes) { const opType = getGenAiOpType(node); const nodeHasError = hasError(node); + for (const issue of node.errors) { + if (issue.issue_id) { + errorIssueIdSet.add(String(issue.issue_id)); + } + } + if (getIsAiGenerationSpan(opType)) { llmCalls++; totalTokens += getNumberAttr(node, SpanFields.GEN_AI_USAGE_TOTAL_TOKENS) ?? 0; @@ -380,6 +444,7 @@ function calculateAggregates(nodes: AITraceSpanNode[]): ConversationAggregates { llmCalls, toolCalls, errorCount, + errorIssueIds: Array.from(errorIssueIdSet), erroredToolNames: erroredToolNameSet, totalTokens, totalCost, @@ -432,11 +497,23 @@ export function ConversationAggregatesBar({ const {selection} = usePageFilters(); const aggregates = useMemo(() => calculateAggregates(nodes), [nodes]); - const errorsUrl = getExploreUrl({ - organization, - selection, - query: `gen_ai.conversation.id:"${escapeDoubleQuotes(conversationId)}" span.status:[internal_error,error]`, - }); + const errorsUrl = useMemo( + () => + getConversationErrorsUrl({ + organization, + selection, + conversationId, + errorIssueIds: aggregates.errorIssueIds, + errorCount: aggregates.errorCount, + }), + [ + organization, + selection, + conversationId, + aggregates.errorIssueIds, + aggregates.errorCount, + ] + ); // minHeight matches the tool Tag height so the row stays the same height whether or not tools render return ( @@ -449,9 +526,9 @@ export function ConversationAggregatesBar({ } - to={aggregates.errorCount > 0 ? errorsUrl : undefined} + to={errorsUrl} isLoading={isLoading} - onClick={aggregates.errorCount > 0 ? onErrorsLinkClick : undefined} + onClick={errorsUrl ? onErrorsLinkClick : undefined} /> { expect(await screen.findByText('First answer')).toBeInTheDocument(); expect(screen.queryByTestId('conversation-error-icon')).not.toBeInTheDocument(); }); + + it('links the errors count to the linked error issues in the issue stream', async () => { + mockApis(null, [ + ...CONVERSATION_BODY, + spanFixture({ + span_id: 'span-error', + 'span.name': 'failed turn', + 'precise.start_ts': 3000, + 'precise.finish_ts': 3000.5, + errors: [ + { + event_id: 'event-1', + event_type: 'error', + issue_id: 42, + level: 'error', + project_id: 1, + project_slug: 'test-project', + start_timestamp: 3000, + transaction: 'failed turn', + }, + ], + }), + ]); + renderPage(); + + expect(await screen.findByTestId('conversation-error-icon')).toBeInTheDocument(); + + // Linked issues resolve to the issue stream across all projects. + const errorsLink = screen.getByRole('link', {name: /1/}); + const href = errorsLink.getAttribute('href')!; + expect(href).toContain('/organizations/org-slug/issues/'); + expect(href).toContain(encodeURIComponent('issue.id:[42]')); + expect(href).toContain('project=-1'); + }); + + it('links the errors count to the traces explorer for span-status errors with no linked issue', async () => { + mockApis(null, [ + ...CONVERSATION_BODY, + spanFixture({ + span_id: 'span-error', + 'span.name': 'failed turn', + 'span.status': 'internal_error', + 'precise.start_ts': 3000, + 'precise.finish_ts': 3000.5, + }), + ]); + renderPage(); + + expect(await screen.findByTestId('conversation-error-icon')).toBeInTheDocument(); + + // Span-status-only errors fall back to the traces explorer. + const errorsLink = screen.getByRole('link', {name: /1/}); + const href = errorsLink.getAttribute('href')!; + expect(href).toContain('/traces/'); + expect(href).toContain(encodeURIComponent('span.status:[internal_error,error]')); + expect(href).not.toContain('/issues/'); + }); });