Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -331,6 +345,7 @@ function getTraceUrl(orgSlug: string, traceId: string, spanId: string) {

interface ConversationAggregates {
errorCount: number;
errorIssueIds: string[];
erroredToolNames: Set<string>;
llmCalls: number;
toolCalls: number;
Expand All @@ -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;
Comment thread
constantinius marked this conversation as resolved.
errorIssueIds: string[];
organization: Organization;
selection: ReturnType<typeof usePageFilters>['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);
}
Expand All @@ -351,11 +408,18 @@ function calculateAggregates(nodes: AITraceSpanNode[]): ConversationAggregates {
let totalCost = 0;
const toolNameSet = new Set<string>();
const erroredToolNameSet = new Set<string>();
const errorIssueIdSet = new Set<string>();

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;
Expand All @@ -380,6 +444,7 @@ function calculateAggregates(nodes: AITraceSpanNode[]): ConversationAggregates {
llmCalls,
toolCalls,
errorCount,
errorIssueIds: Array.from(errorIssueIdSet),
erroredToolNames: erroredToolNameSet,
totalTokens,
totalCost,
Expand Down Expand Up @@ -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 (
Expand All @@ -449,9 +526,9 @@ export function ConversationAggregatesBar({
<AggregateItem
label={t('Errors')}
value={<Count value={aggregates.errorCount} />}
to={aggregates.errorCount > 0 ? errorsUrl : undefined}
to={errorsUrl}
isLoading={isLoading}
onClick={aggregates.errorCount > 0 ? onErrorsLinkClick : undefined}
onClick={errorsUrl ? onErrorsLinkClick : undefined}
/>
<AggregateItem
label={t('Tokens')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,61 @@ describe('ConversationDetailPage summary errors', () => {
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/');
});
});
Loading