diff --git a/static/app/components/events/breadcrumbs/breadcrumbItemContent.spec.tsx b/static/app/components/events/breadcrumbs/breadcrumbItemContent.spec.tsx index ece6884a1bdd..58b2198522c4 100644 --- a/static/app/components/events/breadcrumbs/breadcrumbItemContent.spec.tsx +++ b/static/app/components/events/breadcrumbs/breadcrumbItemContent.spec.tsx @@ -143,6 +143,49 @@ describe('BreadcrumbItemContent', () => { ).toBeInTheDocument(); }); + it('renders colored text without escape codes for ANSI messages', () => { + const breadcrumb: BreadcrumbTypeDefault = { + type: BreadcrumbType.DEBUG, + level: BreadcrumbLevelType.INFO, + message: '\x1B[31mfailed\x1B[0m to connect', + }; + const {container} = render(); + expect(container).toHaveTextContent(/^failed to connect$/); + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + }); + + it('renders colored text without escape codes for ANSI exception values', () => { + const breadcrumb: BreadcrumbTypeDefault = { + type: BreadcrumbType.ERROR, + level: BreadcrumbLevelType.ERROR, + data: {type: 'ConnectionError', value: '\x1B[31mfailed\x1B[0m to connect'}, + }; + const {container} = render(); + expect(container).toHaveTextContent(/^ConnectionError: failed to connect$/); + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + }); + + it('renders annotated text for ANSI messages with meta', () => { + const breadcrumb: BreadcrumbTypeDefault = { + type: BreadcrumbType.DEBUG, + level: BreadcrumbLevelType.INFO, + message: '\x1B[31mfailed\x1B[0m to connect to [Filtered]', + }; + const meta = { + message: { + '': { + chunks: [ + {type: 'text', text: '\x1B[31mfailed\x1B[0m to connect to '}, + {type: 'redaction', text: '[Filtered]', rule_id: '@password', remark: 's'}, + ], + }, + }, + }; + render(); + expect(screen.getByText('[Filtered]')).toBeInTheDocument(); + expect(screen.queryByText('failed')).not.toBeInTheDocument(); + }); + it('applies item limits with fullyExpanded', () => { const longMessage = 'longMessage'.repeat(100); const breadcrumb: BreadcrumbTypeDefault = { diff --git a/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx b/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx index b8b4193626a1..02412fb6009f 100644 --- a/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx +++ b/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx @@ -2,6 +2,7 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; +import {AnsiText} from 'sentry/components/ansiText'; import {AnnotatedText} from 'sentry/components/events/meta/annotatedText'; import {StructuredData} from 'sentry/components/structuredEventData'; import {Timeline} from 'sentry/components/timeline'; @@ -13,6 +14,7 @@ import { type BreadcrumbTypeNavigation, type RawCrumb, } from 'sentry/types/breadcrumbs'; +import {hasAnsi} from 'sentry/utils/ansiEscapeCodes'; import {defined} from 'sentry/utils/defined'; import {isValidUrl} from 'sentry/utils/string/isValidUrl'; import {usePrismTokens} from 'sentry/utils/usePrismTokens'; @@ -44,7 +46,15 @@ export function BreadcrumbItemContent({ const defaultMessage = defined(bc.message) ? ( - + {!meta?.message && hasAnsi(bc.message) ? ( + {bc.message} + ) : ( + + )} ) : null; @@ -206,7 +216,8 @@ function ExceptionCrumbContent({ {type ? type : null} - {type && hasValue ? `: ${formattedValue}` : hasValue ? formattedValue : null} + {type && hasValue ? ': ' : null} + {hasValue ? {formattedValue} : null} {children} {Object.keys(otherData).length > 0 ? ( diff --git a/static/app/components/events/breadcrumbs/copyBreadcrumbs.spec.tsx b/static/app/components/events/breadcrumbs/copyBreadcrumbs.spec.tsx index b459c88a2bdf..6fb853a01725 100644 --- a/static/app/components/events/breadcrumbs/copyBreadcrumbs.spec.tsx +++ b/static/app/components/events/breadcrumbs/copyBreadcrumbs.spec.tsx @@ -54,6 +54,11 @@ Message: Second request Data: {"url":"/api/test","status_code":200}" `); }); + it('strips ANSI codes when the message contains them', () => { + const crumb = createMockCrumb({message: '\x1B[31mGET\x1B[0m request'}); + const result = formatBreadcrumbsAsText([crumb]); + expect(result).toContain('Message: GET request\n'); + }); }); describe('formatBreadcrumbsAsMarkdown', () => { it('formats breadcrumbs as a markdown table with header', () => { @@ -74,4 +79,9 @@ describe('formatBreadcrumbsAsMarkdown', () => { | 2024-01-15T10:30:45.123Z | http | http | info | value\\|with\\|pipes | {"key":"val\\|ue"} |" `); }); + it('strips ANSI codes when the message contains them', () => { + const crumb = createMockCrumb({message: '\x1B[31mGET\x1B[0m request'}); + const result = formatBreadcrumbsAsMarkdown([crumb]); + expect(result).toContain('| info | GET request |'); + }); }); diff --git a/static/app/components/events/breadcrumbs/copyBreadcrumbs.tsx b/static/app/components/events/breadcrumbs/copyBreadcrumbs.tsx index ea1f59d0a088..ddc29000b522 100644 --- a/static/app/components/events/breadcrumbs/copyBreadcrumbs.tsx +++ b/static/app/components/events/breadcrumbs/copyBreadcrumbs.tsx @@ -1,6 +1,7 @@ import {CopyAsDropdown} from 'sentry/components/copyAsDropdown'; import type {EnhancedCrumb} from 'sentry/components/events/breadcrumbs/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {useOrganization} from 'sentry/utils/useOrganization'; function escapeMarkdownCell(value: string): string { @@ -20,7 +21,7 @@ export function formatBreadcrumbsAsMarkdown(crumbs: EnhancedCrumb[]): string { const timestamp = bc.timestamp ? new Date(bc.timestamp).toISOString() : ''; const type = bc.type; const category = bc.category ?? ''; - const message = escapeMarkdownCell(bc.message ?? ''); + const message = escapeMarkdownCell(stripAnsi(bc.message ?? '')); const data = bc.data ? escapeMarkdownCell(JSON.stringify(bc.data)) : ''; return `| ${timestamp} | ${type} | ${category} | ${bc.level} | ${message} | ${data} |`; @@ -45,7 +46,7 @@ export function formatBreadcrumbsAsText(crumbs: EnhancedCrumb[]): string { lines.push(`Level: ${crumb.breadcrumb.level}`); if (crumb.breadcrumb.message) { - lines.push(`Message: ${crumb.breadcrumb.message}`); + lines.push(`Message: ${stripAnsi(crumb.breadcrumb.message)}`); } if (crumb.breadcrumb.data) { lines.push(`Data: ${JSON.stringify(crumb.breadcrumb.data)}`); diff --git a/static/app/components/events/eventMessage.spec.tsx b/static/app/components/events/eventMessage.spec.tsx index 3ecfbd938fec..1a98ea84638f 100644 --- a/static/app/components/events/eventMessage.spec.tsx +++ b/static/app/components/events/eventMessage.spec.tsx @@ -33,4 +33,15 @@ describe('EventMessage', () => { ); expect(screen.getByText('Unhandled')).toBeInTheDocument(); }); + + it('renders colored text without escape codes when given an ANSI message', () => { + const message = '\x1B[31mfailed\x1B[0m to connect'; + + render(); + + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('failed').parentElement).toHaveTextContent( + /^failed to connect$/ + ); + }); }); diff --git a/static/app/components/events/eventMessage.tsx b/static/app/components/events/eventMessage.tsx index c18c4ad7a579..5bacf6c80180 100644 --- a/static/app/components/events/eventMessage.tsx +++ b/static/app/components/events/eventMessage.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; +import {AnsiText} from 'sentry/components/ansiText'; import {ErrorLevel} from 'sentry/components/events/errorLevel'; import {UnhandledTag} from 'sentry/components/group/inboxBadges/unhandledTag'; import {t} from 'sentry/locale'; @@ -24,7 +25,9 @@ export function EventMessage({ }: Props) { const showEventLevel = level && eventTypeHasLogLevel(type); const renderedMessage = message ? ( - {message} + + {typeof message === 'string' ? {message} : message} + ) : ( ({t('No error message')}) ); diff --git a/static/app/components/events/interfaces/breadcrumbs/index.spec.tsx b/static/app/components/events/interfaces/breadcrumbs/index.spec.tsx new file mode 100644 index 000000000000..a7a93be6581b --- /dev/null +++ b/static/app/components/events/interfaces/breadcrumbs/index.spec.tsx @@ -0,0 +1,28 @@ +import {applyBreadcrumbSearch} from 'sentry/components/events/interfaces/breadcrumbs'; +import type {BreadcrumbWithMeta} from 'sentry/components/events/interfaces/breadcrumbs/types'; +import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs'; + +function createCrumb(id: number, message: string): BreadcrumbWithMeta { + return { + breadcrumb: { + id, + description: 'Debug', + variant: 'muted', + type: BreadcrumbType.DEBUG, + level: BreadcrumbLevelType.INFO, + message, + }, + meta: {}, + }; +} + +describe('applyBreadcrumbSearch', () => { + it('matches across escape codes when a message contains ANSI', () => { + const ansiCrumb = createCrumb(0, '\x1B[31mfailed\x1B[0m to connect'); + const otherCrumb = createCrumb(1, 'connected'); + + const result = applyBreadcrumbSearch([ansiCrumb, otherCrumb], 'failed to'); + + expect(result).toEqual([ansiCrumb]); + }); +}); diff --git a/static/app/components/events/interfaces/breadcrumbs/index.tsx b/static/app/components/events/interfaces/breadcrumbs/index.tsx index ce6370ba4d31..43053ebd99eb 100644 --- a/static/app/components/events/interfaces/breadcrumbs/index.tsx +++ b/static/app/components/events/interfaces/breadcrumbs/index.tsx @@ -2,6 +2,7 @@ import pick from 'lodash/pick'; import type {EnhancedCrumb} from 'sentry/components/events/breadcrumbs/utils'; import {t} from 'sentry/locale'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {defined} from 'sentry/utils/defined'; import type {BreadcrumbWithMeta} from './types'; @@ -40,7 +41,8 @@ export function applyBreadcrumbSearch( pick(breadcrumb, ['type', 'category', 'message', 'level', 'timestamp', 'data']) ).some(key => { // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - const info = breadcrumb[key]; + const value = breadcrumb[key]; + const info = typeof value === 'string' ? stripAnsi(value) : value; if (!defined(info) || !String(info).trim()) { return false; diff --git a/static/app/components/events/interfaces/crashContent/exception/content.spec.tsx b/static/app/components/events/interfaces/crashContent/exception/content.spec.tsx index fd306ae61ec3..3c6c774f7917 100644 --- a/static/app/components/events/interfaces/crashContent/exception/content.spec.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/content.spec.tsx @@ -309,6 +309,42 @@ describe('Exception Content', () => { expect(screen.queryByRole('button', {name: 'View Section'})).not.toBeInTheDocument(); }); + it('renders colored text without escape codes when given an ANSI exception value', () => { + const event = EventFixture({ + projectID: project.id, + entries: [ + { + type: EntryType.EXCEPTION, + data: { + values: [ + { + type: 'ValueError', + value: '\x1B[31mfailed\x1B[0m to connect', + stacktrace: {frames: [EventStacktraceFrameFixture()]}, + }, + ], + }, + }, + ], + }); + + render( + + ); + + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('failed').closest('pre')).toHaveTextContent( + /^failed to connect$/ + ); + }); + describe('exception groups', () => { const event = EventFixture({ entries: [EventEntryExceptionGroupFixture()], diff --git a/static/app/components/events/interfaces/crashContent/exception/content.tsx b/static/app/components/events/interfaces/crashContent/exception/content.tsx index 5e5fe89e1309..0fb7d533176b 100644 --- a/static/app/components/events/interfaces/crashContent/exception/content.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/content.tsx @@ -5,6 +5,7 @@ import {Button} from '@sentry/scraps/button'; import {Container} from '@sentry/scraps/layout'; import {Tooltip} from '@sentry/scraps/tooltip'; +import {AnsiText} from 'sentry/components/ansiText'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {StacktraceBanners} from 'sentry/components/events/interfaces/crashContent/exception/banners/stacktraceBanners'; import { @@ -178,9 +179,11 @@ function InnerContent({ const exceptionValue = type === StackType.ORIGINAL ? exception.value : exception.rawValue || exception.value; - const renderedExceptionValue = exceptionValue - ? renderLinksInText({exceptionText: exceptionValue}) - : null; + const renderedExceptionValue = exceptionValue ? ( + renderLinksInText({exceptionText: text})}> + {exceptionValue} + + ) : null; const platform = getStacktracePlatform(event, exception.stacktrace); // The banners should appear on the top exception only diff --git a/static/app/components/events/interfaces/crashContent/exception/rawContent.tsx b/static/app/components/events/interfaces/crashContent/exception/rawContent.tsx index d3386dcee674..f4b2204060d1 100644 --- a/static/app/components/events/interfaces/crashContent/exception/rawContent.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/rawContent.tsx @@ -8,6 +8,7 @@ import {Placeholder} from 'sentry/components/placeholder'; import type {Event, ExceptionType} from 'sentry/types/event'; import type {PlatformKey} from 'sentry/types/platform'; import type {Project} from 'sentry/types/project'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -94,7 +95,7 @@ export function RawContent({ }) ) : (
- {exceptionType}: {exceptionValue} + {exceptionType}: {exceptionValue && stripAnsi(exceptionValue)}
); return ( diff --git a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx index d513a39faef1..e46bec80a9bf 100644 --- a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx @@ -76,6 +76,21 @@ describe('ExceptionGroupContext', () => { ).toBeInTheDocument(); }); + it('renders colored text without escape codes when given ANSI exception values', () => { + const allExceptions = defaultProps.allExceptions.map(exception => + exception.type === 'TypeError' + ? {...exception, value: '\x1B[31mnested\x1B[0m failure'} + : exception + ); + + render(); + + expect( + screen.getByRole('button', {name: 'TypeError: nested failure'}) + ).toBeInTheDocument(); + expect(screen.getByText('nested').style.color).toContain('color-mix(in srgb,'); + }); + it('does not render for sub-exception', () => { const {container} = render( diff --git a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx index fbbaf4bad3ee..30f991300b7d 100644 --- a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; +import {AnsiText} from 'sentry/components/ansiText'; import {t} from 'sentry/locale'; import type {ExceptionValue} from 'sentry/types/event'; import type {StackTraceMechanism} from 'sentry/types/stacktrace'; @@ -38,10 +39,20 @@ type ExceptionTreeItemProps = { function getExceptionName(exception: ExceptionValue) { if (exception.type) { - return exception.value ? `${exception.type}: ${exception.value}` : exception.type; + return exception.value ? ( + + {exception.type}: {exception.value} + + ) : ( + exception.type + ); } - return exception.value ?? t('Exception'); + return defined(exception.value) ? ( + {exception.value} + ) : ( + t('Exception') + ); } function ExceptionLink({exception, link, onExceptionClick}: ExceptionLinkProps) { diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.spec.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.spec.tsx index 30c706fa8e77..a0c35d4200b9 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.spec.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.spec.tsx @@ -166,6 +166,26 @@ describe('RawStacktraceContent', () => { }) ).toBe('IllegalStateException: Oops!'); }); + + it('strips ANSI codes when the exception value contains them', () => { + const exception = ExceptionValueFixture({ + type: 'ValueError', + value: '\x1B[31mfailed\x1B[0m to connect', + }); + + expect( + displayRawContent({ + data: { + hasSystemFrames: false, + framesOmitted: null, + registers: {}, + frames: [], + }, + platform: 'javascript', + exception, + }) + ).toBe('ValueError: failed to connect'); + }); }); describe('render()', () => { diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.tsx index 481ce0198280..22305d62275c 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.tsx @@ -1,6 +1,7 @@ import {trimPackage} from 'sentry/components/events/interfaces/frame/utils'; import type {ExceptionValue, Frame} from 'sentry/types/event'; import type {StacktraceType} from 'sentry/types/stacktrace'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {defined} from 'sentry/utils/defined'; function getJavaScriptFrame( @@ -386,7 +387,7 @@ export function displayRawContent({ ); if (exception) { - frames.push(getExceptionSummary(exception, platform, isMinified)); + frames.push(stripAnsi(getExceptionSummary(exception, platform, isMinified))); } // For the raw stacktrace view on the issue details page, ignore newestFirst and order frames based on default platform behavior diff --git a/static/app/components/events/interfaces/message.spec.tsx b/static/app/components/events/interfaces/message.spec.tsx index 277db1c261eb..ddc550fdfbbf 100644 --- a/static/app/components/events/interfaces/message.spec.tsx +++ b/static/app/components/events/interfaces/message.spec.tsx @@ -59,4 +59,19 @@ describe('Message entry', () => { '/settings/org-slug/security-and-privacy/' ); }); + + it('renders colored text and links without escape codes when given an ANSI message', () => { + const formatted = '\x1B[31mfailed\x1B[0m to fetch https://example.com/status'; + const event = EventFixture({ + entries: [{type: 'message', data: {formatted}}], + }); + + render(); + + expect(screen.getByText('failed').closest('pre')).toHaveTextContent( + /^failed to fetch https:\/\/example\.com\/status$/ + ); + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('https://example.com/status').tagName).toBe('A'); + }); }); diff --git a/static/app/components/events/interfaces/message.tsx b/static/app/components/events/interfaces/message.tsx index 735bea611040..8713593f7137 100644 --- a/static/app/components/events/interfaces/message.tsx +++ b/static/app/components/events/interfaces/message.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; +import {AnsiText} from 'sentry/components/ansiText'; import {renderLinksInText} from 'sentry/components/events/interfaces/crashContent/exception/utils'; import {AnnotatedText} from 'sentry/components/events/meta/annotatedText'; import {StructuredData} from 'sentry/components/structuredEventData'; @@ -59,9 +60,11 @@ function renderParams(params: Props['data']['params'], meta: any) { export function Message({data, event}: Props) { const entryIndex = event.entries.findIndex(entry => entry.type === EntryType.MESSAGE); const meta = event?._meta?.entries?.[entryIndex] ?? {}; - const messageData = data.formatted - ? renderLinksInText({exceptionText: data.formatted}) - : null; + const messageData = data.formatted ? ( + renderLinksInText({exceptionText: text})}> + {data.formatted} + + ) : null; return ( diff --git a/static/app/components/groupTitle.spec.tsx b/static/app/components/groupTitle.spec.tsx index 8ddc8cf37c04..017a32f88a1e 100644 --- a/static/app/components/groupTitle.spec.tsx +++ b/static/app/components/groupTitle.spec.tsx @@ -56,6 +56,21 @@ describe('GroupTitle', () => { expect(screen.getByText('metadata title')).toBeInTheDocument(); }); + it('renders colored text without escape codes when given an ANSI title', () => { + const type = '\x1B[31mValueError\x1B[0m raised'; + + render( + + ); + + expect(screen.getByText('ValueError').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('ValueError').parentElement).toHaveTextContent( + /^ValueError raised$/ + ); + }); + it('does not render stack trace when issueCategory is performance', () => { render( - {titleLabel} + + <AnsiText>{titleLabel}</AnsiText> + ) : ( - titleLabel + {titleLabel} )} ); diff --git a/static/app/components/stackTrace/exceptionGroup.spec.tsx b/static/app/components/stackTrace/exceptionGroup.spec.tsx index d6abc9b8d9f3..aa7196cbcd9e 100644 --- a/static/app/components/stackTrace/exceptionGroup.spec.tsx +++ b/static/app/components/stackTrace/exceptionGroup.spec.tsx @@ -137,4 +137,34 @@ describe('exceptionGroup', () => { expect(screen.getByTestId('exc-3')).toBeInTheDocument(); expect(screen.getByTestId('exc-4')).toBeInTheDocument(); }); + + it('renders colored text without escape codes when given ANSI exception values', () => { + const values = makeValues().map(exception => { + if (exception.type === 'ValueError') { + return {...exception, value: '\x1B[31mbad\x1B[0m value'}; + } + if (exception.mechanism?.exception_id === 0) { + return {...exception, value: '\x1B[32mroot\x1B[0m group'}; + } + return exception; + }); + + render( + + ); + + expect( + screen.getByRole('button', {name: 'ValueError: bad value'}) + ).toBeInTheDocument(); + expect(screen.getByText('bad').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('root').closest('div')).toHaveTextContent( + /^ExceptionGroup: root group$/ + ); + expect(screen.getByText('root').style.color).toContain('color-mix(in srgb,'); + }); }); diff --git a/static/app/components/stackTrace/exceptionGroup.tsx b/static/app/components/stackTrace/exceptionGroup.tsx index aa80b52d0c6b..7b8439abd016 100644 --- a/static/app/components/stackTrace/exceptionGroup.tsx +++ b/static/app/components/stackTrace/exceptionGroup.tsx @@ -5,6 +5,7 @@ import {Button} from '@sentry/scraps/button'; import {Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; +import {AnsiText} from 'sentry/components/ansiText'; import {t, tn} from 'sentry/locale'; import type {ExceptionValue} from 'sentry/types/event'; import {defined} from 'sentry/utils/defined'; @@ -59,9 +60,19 @@ export function useHiddenExceptions(values: ExceptionValue[]) { function getExceptionName(exception: ExceptionValue) { if (exception.type) { - return exception.value ? `${exception.type}: ${exception.value}` : exception.type; + return exception.value ? ( + + {exception.type}: {exception.value} + + ) : ( + exception.type + ); } - return exception.value ?? t('Exception'); + return defined(exception.value) ? ( + {exception.value} + ) : ( + t('Exception') + ); } interface ToggleRelatedExceptionsButtonProps { diff --git a/static/app/components/stackTrace/exceptionHeader.spec.tsx b/static/app/components/stackTrace/exceptionHeader.spec.tsx new file mode 100644 index 000000000000..43bbfd00b83b --- /dev/null +++ b/static/app/components/stackTrace/exceptionHeader.spec.tsx @@ -0,0 +1,17 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {ExceptionDescription} from 'sentry/components/stackTrace/exceptionHeader'; + +describe('ExceptionDescription', () => { + it('renders colored text and links without escape codes when given an ANSI value', () => { + const value = '\x1B[31mfailed\x1B[0m to fetch https://example.com/status'; + + const {container} = render(); + + expect(container).toHaveTextContent( + /^failed to fetch https:\/\/example\.com\/status$/ + ); + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('https://example.com/status').tagName).toBe('A'); + }); +}); diff --git a/static/app/components/stackTrace/exceptionHeader.tsx b/static/app/components/stackTrace/exceptionHeader.tsx index b342ddb112be..600badf4b1c6 100644 --- a/static/app/components/stackTrace/exceptionHeader.tsx +++ b/static/app/components/stackTrace/exceptionHeader.tsx @@ -4,6 +4,7 @@ import {Stack} from '@sentry/scraps/layout'; import {Heading} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; +import {AnsiText} from 'sentry/components/ansiText'; import {Mechanism} from 'sentry/components/events/interfaces/crashContent/exception/mechanism'; import {renderLinksInText} from 'sentry/components/events/interfaces/crashContent/exception/utils'; import {AnnotatedText} from 'sentry/components/events/meta/annotatedText'; @@ -54,7 +55,11 @@ export function ExceptionDescription({ ) : value ? ( - {renderLinksInText({exceptionText: value})} + + renderLinksInText({exceptionText: text})}> + {value} + + ) : null} {mechanism && } diff --git a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx index ccd17952dc97..82e193c26a2b 100644 --- a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx +++ b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx @@ -526,6 +526,33 @@ describe('IssueStackTrace', () => { `); }); + it('copies stack trace text without ANSI codes when the exception value contains them', async () => { + const {event, stacktrace} = makeCopyTestData(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Copy as'})); + await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Text'})); + + const copiedText = jest.mocked(navigator.clipboard.writeText).mock.calls[0]![0]; + expect(copiedText).toContain('ValueError: list index out of range'); + }); + it('copies stack trace text including exception type and value for chained exceptions', async () => { const {event, stacktrace} = makeCopyTestData(); diff --git a/static/app/utils/getStacktraceBody.spec.tsx b/static/app/utils/getStacktraceBody.spec.tsx index f0f0789852b3..804ad00c1865 100644 --- a/static/app/utils/getStacktraceBody.spec.tsx +++ b/static/app/utils/getStacktraceBody.spec.tsx @@ -27,6 +27,17 @@ Error: an error occurred`, expect(result).toEqual(['Something is broken']); }); + it('strips ANSI codes when the message contains them', () => { + const result = getStacktraceBody({ + event: EventFixture({ + entries: [ + {type: 'message', data: {formatted: '\x1B[31mSomething\x1B[0m is broken'}}, + ], + }), + }); + expect(result).toEqual(['Something is broken']); + }); + it('formats with a thread', () => { const result = getStacktraceBody({event: eventThreads}); expect(result).toEqual([ diff --git a/static/app/utils/getStacktraceBody.tsx b/static/app/utils/getStacktraceBody.tsx index ccdcd72794b4..ac185f17ed55 100644 --- a/static/app/utils/getStacktraceBody.tsx +++ b/static/app/utils/getStacktraceBody.tsx @@ -1,5 +1,6 @@ import {displayRawContent as rawStacktraceContent} from 'sentry/components/events/interfaces/crashContent/stackTrace/rawContent'; import type {Event} from 'sentry/types/event'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; type GetStacktraceBodyArgs = { /** @@ -51,7 +52,7 @@ export function getStacktraceBody({ if (!msg) { return []; } - return msg?.data?.formatted && [msg.data.formatted]; + return msg?.data?.formatted && [stripAnsi(msg.data.formatted)]; } } diff --git a/static/app/views/issueDetails/actions/index.tsx b/static/app/views/issueDetails/actions/index.tsx index 9024595ae706..e4584ff04388 100644 --- a/static/app/views/issueDetails/actions/index.tsx +++ b/static/app/views/issueDetails/actions/index.tsx @@ -41,6 +41,7 @@ import type {Group, GroupStatusResolution, MarkReviewed} from 'sentry/types/grou import {GroupStatus, GroupSubstatus} from 'sentry/types/group'; import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {getUtcDateString} from 'sentry/utils/dates'; import {displayReprocessEventAction} from 'sentry/utils/displayReprocessEventAction'; import {getAnalyticsDataForGroup, getMessage, getTitle} from 'sentry/utils/events'; @@ -204,9 +205,8 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp const config = useMemo(() => getConfigForIssueType(group, project), [group, project]); const issueCommandLabel = useMemo(() => { - const {title: rawIssueTitle} = getTitle(group); - const title = rawIssueTitle ?? ''; - const message = getMessage(group); + const title = stripAnsi(getTitle(group).title ?? ''); + const message = stripAnsi(getMessage(group) ?? ''); return message && message !== title ? `${title}: ${message}` : title; }, [group]); diff --git a/static/app/views/issueDetails/groupDetails.spec.tsx b/static/app/views/issueDetails/groupDetails.spec.tsx index 47f10cec44e5..3e03966f8d56 100644 --- a/static/app/views/issueDetails/groupDetails.spec.tsx +++ b/static/app/views/issueDetails/groupDetails.spec.tsx @@ -15,6 +15,7 @@ import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingL import {setWindowLocation} from 'sentry-test/utils'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ConfigStore} from 'sentry/stores/configStore'; import {GroupStore} from 'sentry/stores/groupStore'; import {OrganizationStore} from 'sentry/stores/organizationStore'; @@ -553,4 +554,33 @@ describe('groupDetails', () => { // Verify that the hasSeen request was NOT made expect(hasSeenMock).not.toHaveBeenCalled(); }); + + it('sets the document title without escape codes when given ANSI metadata', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${defaultInit.organization.slug}/issues/${group.id}/`, + body: { + ...group, + metadata: {type: '\x1B[31mRequestError\x1B[0m', value: '\x1B[33mfailed\x1B[0m'}, + }, + }); + + setWindowLocation(`http://localhost/?project=${group.project.id}`); + render( + + + + + , + { + organization: defaultInit.organization, + initialRouterConfig, + } + ); + + await waitFor(() => + expect(document.title).toBe( + `RequestError: failed — ${defaultInit.organization.slug} — ${group.project.slug}` + ) + ); + }); }); diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx index 371bb82d7e97..0bda09bfe946 100644 --- a/static/app/views/issueDetails/groupDetails.tsx +++ b/static/app/views/issueDetails/groupDetails.tsx @@ -26,6 +26,7 @@ import type {Group} from 'sentry/types/group'; import {GroupStatus} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {getUtcDateString} from 'sentry/utils/dates'; import {defined} from 'sentry/utils/defined'; import { @@ -838,8 +839,8 @@ function GroupDetails() { return defaultTitle; } - const {title} = getTitle(group); - const message = getMessage(group); + const title = stripAnsi(getTitle(group).title ?? ''); + const message = stripAnsi(getMessage(group) ?? ''); const eventDetails = `${organization.slug} — ${group.project.slug}`; diff --git a/static/app/views/issueDetails/header/header.spec.tsx b/static/app/views/issueDetails/header/header.spec.tsx index 80a4c83ca7ba..223e16f73b00 100644 --- a/static/app/views/issueDetails/header/header.spec.tsx +++ b/static/app/views/issueDetails/header/header.spec.tsx @@ -119,6 +119,39 @@ describe('GroupHeader', () => { expect(screen.getByText(group.shortId)).toBeInTheDocument(); }); + it('renders colored title and message without escape codes when given ANSI metadata', async () => { + const ansiGroup = GroupFixture({ + ...group, + metadata: { + type: '\x1B[31mRequestError\x1B[0m', + value: '\x1B[33mGET\x1B[0m /api failed', + }, + }); + + render( + + + , + { + organization, + } + ); + + expect(await screen.findByRole('link', {name: 'View events'})).toBeInTheDocument(); + expect(screen.getByText('RequestError').style.color).toContain( + 'color-mix(in srgb,' + ); + expect(screen.getByText('GET').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('GET').parentElement).toHaveTextContent( + /^GET \/api failed$/ + ); + }); + it('displays share icon if issue has been shared', async () => { render( MAX_PICKABLE_DAYS); const maxRetentionDays = useGetMaxRetentionDays(); // oxlint-disable-line react/hooks -- Hook comes from the override registry, which is populated before React renders. const userCountPeriod = maxRetentionDays ? `(${maxRetentionDays}d)` : '(30d)'; - const {title: primaryTitle} = getTitle(group); + const {title: primaryTitle = ''} = getTitle(group); const secondaryTitle = getMessage(group); const isComplete = group.status === 'resolved' || group.status === 'ignored'; const groupReprocessingStatus = getGroupReprocessingStatus(group); @@ -114,8 +116,15 @@ export function GroupHeader({event, group, project}: GroupHeaderProps) { - <Tooltip title={primaryTitle} skipWrapper showOnlyOnOverflow delay={1000}> - <PrimaryTitle>{primaryTitle}</PrimaryTitle> + <Tooltip + title={stripAnsi(primaryTitle)} + skipWrapper + showOnlyOnOverflow + delay={1000} + > + <PrimaryTitle> + <AnsiText>{primaryTitle}</AnsiText> + </PrimaryTitle> </Tooltip> {isAIDetectedIssue && <FeatureBadge type="new" />} diff --git a/static/app/views/issueDetails/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/hooks/useCopyIssueDetails.spec.tsx index acbb7774d1b8..2dac6ad9a487 100644 --- a/static/app/views/issueDetails/hooks/useCopyIssueDetails.spec.tsx +++ b/static/app/views/issueDetails/hooks/useCopyIssueDetails.spec.tsx @@ -223,6 +223,27 @@ describe('useCopyIssueDetails', () => { expect(result).not.toContain('## Message'); }); + it('strips ANSI codes from the title and message when they contain them', () => { + const result = issueAndEventToMarkdown({ + group: GroupFixture({title: '\x1B[31mTypeError\x1B[0m: connection failed'}), + event: EventFixture({...event, message: '\x1B[33mretrying\x1B[0m in 5s'}), + organization, + }); + + expect(result).toContain('# TypeError: connection failed\n'); + expect(result).toContain('## Message\n\nretrying in 5s\n'); + }); + + it('omits the message when it is part of the title after stripping ANSI codes', () => { + const result = issueAndEventToMarkdown({ + group: GroupFixture({title: 'TypeError: \x1B[31mconnection failed\x1B[0m'}), + event: EventFixture({...event, message: '\x1B[31mconnection failed\x1B[0m'}), + organization, + }); + + expect(result).not.toContain('## Message'); + }); + it('omits the message when it is empty', () => { const result = issueAndEventToMarkdown({ group: GroupFixture({title: 'TypeError'}), @@ -298,6 +319,28 @@ describe('useCopyIssueDetails', () => { expect(result).not.toContain('**Handled:**'); }); + it('strips ANSI codes from exception values when they contain them', () => { + const eventWithException = EventFixture({ + ...event, + entries: [ + { + type: EntryType.EXCEPTION, + data: { + values: [{type: 'ValueError', value: '\x1B[31mfailed\x1B[0m to connect'}], + }, + }, + ], + }); + + const result = issueAndEventToMarkdown({ + group, + event: eventWithException, + organization, + }); + + expect(result).toContain('**Value:** failed to connect\n'); + }); + it('marks an unhandled exception', () => { const eventWithUnhandled = EventFixture({ ...event, @@ -562,6 +605,35 @@ describe('useCopyIssueDetails', () => { expect(result).toContain(' User clicked submit'); }); + it('strips ANSI codes from breadcrumb messages when they contain them', () => { + const eventWithBreadcrumbs = EventFixture({ + ...event, + entries: [ + { + type: EntryType.BREADCRUMBS, + data: { + values: [ + { + type: 'default', + category: 'console', + level: 'info', + message: '\x1B[32mserver started\x1B[0m', + }, + ], + }, + }, + ], + }); + + const result = issueAndEventToMarkdown({ + group, + event: eventWithBreadcrumbs, + organization, + }); + + expect(result).toContain('- **default** `console` [info]\n server started\n'); + }); + it('truncates a single breadcrumb to the per-crumb character limit', () => { const longMessage = 'x'.repeat(600); const eventWithLongBreadcrumb = EventFixture({ diff --git a/static/app/views/issueDetails/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/hooks/useCopyIssueDetails.tsx index 4e0674c68e93..7cd5f0fbb278 100644 --- a/static/app/views/issueDetails/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/hooks/useCopyIssueDetails.tsx @@ -20,6 +20,7 @@ import type {Group} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {getFormat, getUserTimezone} from 'sentry/utils/dates'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -99,7 +100,7 @@ function formatBreadcrumbsToMarkdown(crumbs: RawCrumb[]): string { const entries: string[] = []; crumbs.slice(-MAX_BREADCRUMBS).forEach(crumb => { - const message = crumb.message ?? ''; + const message = stripAnsi(crumb.message ?? ''); // Drop empty values, matching Seer's `{k: v for k, v in data if v}`. const data = crumb.data @@ -212,7 +213,7 @@ function formatEventToMarkdown(event: Event, activeThreadId: number | undefined) markdownText += `**Handled:** ${handled ? 'Yes' : 'No'}\n`; } if (exception.value) { - markdownText += `**Value:** ${exception.value}\n\n`; + markdownText += `**Value:** ${stripAnsi(exception.value)}\n\n`; } // Add stacktrace if available @@ -285,7 +286,8 @@ export const issueAndEventToMarkdown = ({ } // Format the basic issue information - let markdownText = `# ${group.title}\n\n`; + const title = stripAnsi(group.title); + let markdownText = `# ${title}\n\n`; markdownText += `**Issue ID:** ${group.id}\n`; if (group.shortId) { @@ -313,8 +315,8 @@ export const issueAndEventToMarkdown = ({ // Mirror Seer: include the event message only when it adds something beyond // the title, since for most errors the title already is the message. - const message = event?.message?.trim(); - if (message && !group.title.includes(message)) { + const message = stripAnsi(event?.message ?? '').trim(); + if (message && !title.includes(message)) { markdownText += `\n## Message\n\n${message}\n`; } diff --git a/static/app/views/issueList/pages/inbox/index.tsx b/static/app/views/issueList/pages/inbox/index.tsx index a5b940605138..cc62657be15e 100644 --- a/static/app/views/issueList/pages/inbox/index.tsx +++ b/static/app/views/issueList/pages/inbox/index.tsx @@ -23,6 +23,7 @@ import {SegmentedControl} from '@sentry/scraps/segmentedControl'; import {StatusIndicator} from '@sentry/scraps/statusIndicator'; import {Heading, Text} from '@sentry/scraps/text'; +import {AnsiText} from 'sentry/components/ansiText'; import {NotFound} from 'sentry/components/errors/notFound'; import {EventMessage} from 'sentry/components/events/eventMessage'; import { @@ -705,7 +706,7 @@ function InboxIssueCard({ }) { const location = useLocation(); const organization = useOrganization(); - const {title} = getTitle(group); + const {title = ''} = getTitle(group); const message = getMessage(group); const prefetchHoverProps = useInboxPreviewPrefetch(group); const suggestedAssignees = useIssueSuggestedAssignees(group); @@ -751,7 +752,7 @@ function InboxIssueCard({ - {title} + {title} {showPullRequests ? ( diff --git a/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx b/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx index 43eae45cbab7..b67538d35969 100644 --- a/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx +++ b/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx @@ -7,6 +7,7 @@ import {Heading} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import {AnalyticsArea} from 'sentry/components/analyticsArea'; +import {AnsiText} from 'sentry/components/ansiText'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {EventMessage} from 'sentry/components/events/eventMessage'; import { @@ -20,6 +21,7 @@ import {IconOpen} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {getAnalyticsDataForGroup, getMessage, getTitle} from 'sentry/utils/events'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -154,7 +156,7 @@ function IssuePreviewContent() { const {group, project} = useGroupData(); const previewSeer = useIssuePreviewSeer(); const linkedPullRequests = useLinkedPullRequests({group}); - const {title: primaryTitle} = getTitle(group); + const {title: primaryTitle = ''} = getTitle(group); const secondaryTitle = getMessage(group); const disableActions = [ ReprocessingStatus.REPROCESSING, @@ -187,7 +189,12 @@ function IssuePreviewContent() { - + - {primaryTitle} + {primaryTitle}