diff --git a/package.json b/package.json index f1e81ed9fccf..ed346fbada7c 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,6 @@ "@types/reflux": "0.4.1", "@types/trusted-types": "^2.0.7", "ansi-sequence-parser": "^1.1.3", - "ansi-to-react": "^6.1.6", "base64-arraybuffer": "^1.0.1", "cbor2": "^1.12.0", "classnames": "2.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0634b405d57..6345fcfa8d29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,9 +317,6 @@ importers: ansi-sequence-parser: specifier: ^1.1.3 version: 1.1.3 - ansi-to-react: - specifier: ^6.1.6 - version: 6.1.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) base64-arraybuffer: specifier: ^1.0.1 version: 1.0.2 @@ -4766,9 +4763,6 @@ packages: algoliasearch@4.13.1: resolution: {integrity: sha512-dtHUSE0caWTCE7liE1xaL+19AFf6kWEcyn76uhcitWpntqvicFHXKFoZe5JJcv9whQOTRM6+B8qJz6sFj+rDJA==} - anser@1.4.10: - resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} - ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -4799,12 +4793,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - ansi-to-react@6.1.6: - resolution: {integrity: sha512-+HWn72GKydtupxX9TORBedqOMsJRiKTqaLUKW8txSBZw9iBpzPKLI8KOu4WzwD4R7hSv1zEspobY6LwlWvwZ6Q==} - peerDependencies: - react: ^16.3.2 || ^17.0.0 - react-dom: ^16.3.2 || ^17.0.0 - anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -5775,9 +5763,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-carriage@1.3.0: - resolution: {integrity: sha512-ATWi5MD8QlAGQOeMgI8zTp671BG8aKvAC0M7yenlxU4CRLGO/sKthxVUyjiOFKjHdIo+6dZZUNFgHFeVEaKfGQ==} - escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -13143,8 +13128,6 @@ snapshots: '@algolia/requester-node-http': 4.13.1 '@algolia/transporter': 4.13.1 - anser@1.4.10: {} - ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -13167,13 +13150,6 @@ snapshots: ansi-styles@6.2.3: {} - ansi-to-react@6.1.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - dependencies: - anser: 1.4.10 - escape-carriage: 1.3.0 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -14308,8 +14284,6 @@ snapshots: escalade@3.2.0: {} - escape-carriage@1.3.0: {} - escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} diff --git a/static/app/components/ansiText.spec.tsx b/static/app/components/ansiText.spec.tsx new file mode 100644 index 000000000000..1e63195d4bb8 --- /dev/null +++ b/static/app/components/ansiText.spec.tsx @@ -0,0 +1,122 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {AnsiText} from 'sentry/components/ansiText'; + +function renderSegments(text: string) { + render({text}); + + const wrapper = screen.getByText('FATAL').parentElement; + + return { + segments: Array.from(wrapper?.querySelectorAll('span') ?? [], span => ({ + text: span.textContent, + colored: span.style.color !== '', + })), + wrapper, + }; +} + +describe('AnsiText', () => { + it('renders text without escape codes when given ANSI colors', () => { + const {container} = render({'\x1B[31mfailed\x1B[0m to connect'}); + + expect(container).toHaveTextContent(/^failed to connect$/); + }); + + it('blends ANSI colors into the text and background colors when given colored segments', () => { + render({'\x1B[31;44mcolored'}); + + const segment = screen.getByText('colored'); + expect(segment.style.color).toContain('color-mix(in srgb,'); + expect(segment.style.backgroundColor).toContain('color-mix(in srgb,'); + }); + + it('resolves 256-color and truecolor codes to their RGB values', () => { + render( + {'\x1B[38;5;208mpalette\x1B[0m \x1B[38;2;1;2;3mtruecolor'} + ); + + expect(screen.getByText('palette').style.color).toContain('rgb(255, 135, 0)'); + expect(screen.getByText('truecolor').style.color).toContain('rgb(1, 2, 3)'); + }); + + it('applies every decoration when given combined decoration codes', () => { + render({'\x1B[1;3;4;9mdecorated'}); + + expect(screen.getByText('decorated')).toHaveStyle({ + fontWeight: 'bold', + fontStyle: 'italic', + textDecorationLine: 'underline line-through', + }); + }); + + it('removes non-color escape codes when given cursor control sequences', () => { + const {container} = render({'\x1B[2Kdone\x1B[?25h'}); + + expect(container).toHaveTextContent(/^done$/); + }); + + it('colors only the escaped text when given uncolored spaces around a colored word', () => { + const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0m '); + + expect(segments).toEqual([ + {text: ' ', colored: false}, + {text: 'FATAL', colored: true}, + {text: ' ', colored: false}, + ]); + }); + + it('colors a trailing space when given the space inside the escape codes', () => { + const {segments} = renderSegments(' \x1B[31mFATAL \x1B[0m '); + + expect(segments).toEqual([ + {text: ' ', colored: false}, + {text: 'FATAL ', colored: true}, + {text: ' ', colored: false}, + ]); + }); + + it('colors a leading space when given the space inside the escape codes', () => { + const {segments} = renderSegments(' \x1B[31m FATAL\x1B[0m '); + + expect(segments).toEqual([ + {text: ' ', colored: false}, + {text: ' FATAL', colored: true}, + {text: ' ', colored: false}, + ]); + }); + + it('colors only the escaped word when given uncolored text before it and a space after it', () => { + const {segments} = renderSegments('ABC\x1B[31mFATAL\x1B[0m '); + + expect(segments).toEqual([ + {text: 'ABC', colored: false}, + {text: 'FATAL', colored: true}, + {text: ' ', colored: false}, + ]); + }); + + it('colors only the escaped word when given a space before it and uncolored text after it', () => { + const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0mABC'); + + expect(segments).toEqual([ + {text: ' ', colored: false}, + {text: 'FATAL', colored: true}, + {text: 'ABC', colored: false}, + ]); + }); + + it('collapses whitespace by default when given escape codes', () => { + const {wrapper} = renderSegments(' \x1B[41m FATAL \x1B[0m '); + + expect(wrapper).not.toHaveStyle({whiteSpaceCollapse: 'preserve'}); + }); + + it('preserves whitespace when given escape codes and preserveWhitespace', () => { + render({' \x1B[41m FATAL \x1B[0m '}); + + expect(screen.getByText('FATAL').parentElement).toHaveStyle({ + whiteSpaceCollapse: 'preserve', + }); + }); +}); diff --git a/static/app/components/ansiText.tsx b/static/app/components/ansiText.tsx new file mode 100644 index 000000000000..531e1c1578f4 --- /dev/null +++ b/static/app/components/ansiText.tsx @@ -0,0 +1,92 @@ +import {useMemo, type CSSProperties, type ReactNode} from 'react'; +import {useTheme} from '@emotion/react'; +import { + createColorPalette, + parseAnsiSequences, + type ParseToken, +} from 'ansi-sequence-parser'; + +import {hasAnsi, stripAnsi} from 'sentry/utils/ansiEscapeCodes'; + +const ANSI_BACKGROUND_STRENGTH = '15%'; +const ANSI_TEXT_STRENGTH = '50%'; + +const PRESERVE_WHITESPACE_STYLE: CSSProperties = {whiteSpaceCollapse: 'preserve'}; + +type ColorPalette = ReturnType; + +interface AnsiTextProps { + children: string; + preserveWhitespace?: boolean; + renderText?: (text: string) => ReactNode; +} + +export function AnsiText({ + children, + preserveWhitespace, + renderText = text => text, +}: AnsiTextProps) { + const theme = useTheme(); + const palette = useMemo(() => createColorPalette(theme.tokens.syntax.ansi), [theme]); + const tokens = useMemo( + () => + hasAnsi(children) + ? parseAnsiSequences(children) + .map(token => ({...token, value: stripAnsi(token.value)})) + .filter(token => token.value) + : undefined, + [children] + ); + + if (!tokens) { + return renderText(children); + } + + return ( + + {tokens.map((token, index) => ( + + {renderText(token.value)} + + ))} + + ); +} + +function getTokenStyle( + {background, decorations, foreground}: ParseToken, + palette: ColorPalette +): CSSProperties { + const style: CSSProperties = {}; + + if (foreground) { + style.color = `color-mix(in srgb, ${palette.value(foreground)} ${ANSI_TEXT_STRENGTH}, currentColor)`; + } + + if (background) { + style.backgroundColor = `color-mix(in srgb, ${palette.value(background)} ${ANSI_BACKGROUND_STRENGTH}, transparent)`; + } + + if (decorations.has('bold')) { + style.fontWeight = 'bold'; + } + + if (decorations.has('dim')) { + style.opacity = 0.7; + } + + if (decorations.has('italic')) { + style.fontStyle = 'italic'; + } + + const lines = [ + decorations.has('underline') && 'underline', + decorations.has('strikethrough') && 'line-through', + decorations.has('overline') && 'overline', + ].filter(Boolean); + if (lines.length) { + style.textDecorationLine = lines.join(' '); + } + + return style; +} diff --git a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx index 753f4ff9b9b0..2e187e288d36 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx @@ -58,6 +58,31 @@ describe('LogFileViewer', () => { ); }); + it('renders colored text without escape codes when given ANSI colors', async () => { + fetchMock.route( + attachmentUrl, + fetchMock.Response(new TextEncoder().encode('\x1B[31mfailed\x1B[0m to connect')) + ); + + render(); + + expect((await screen.findByText('failed')).style.color).toContain( + 'color-mix(in srgb,' + ); + expect(screen.getByText('to connect')).toBeInTheDocument(); + }); + + it('renders only the final write when given carriage-return progress updates', async () => { + fetchMock.route( + attachmentUrl, + fetchMock.Response(new TextEncoder().encode('progress 10%\rprogress 100%')) + ); + + render(); + + expect(await screen.findByText('progress 100%')).toBeInTheDocument(); + }); + it('renders an error when the attachment cannot be downloaded', async () => { fetchMock.route(attachmentUrl, '', {status: 404}); diff --git a/static/app/components/events/attachmentViewers/logFileViewer.tsx b/static/app/components/events/attachmentViewers/logFileViewer.tsx index e697b8ee35bc..6cf43cbe008f 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.tsx @@ -1,14 +1,14 @@ -import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {useQuery} from '@tanstack/react-query'; -import Ansi from 'ansi-to-react'; +import {AnsiText} from 'sentry/components/ansiText'; import {PreviewPanelItem} from 'sentry/components/events/attachmentViewers/previewPanelItem'; import type {ViewerProps} from 'sentry/components/events/attachmentViewers/utils'; import {getAttachmentUrl} from 'sentry/components/events/attachmentViewers/utils'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; +import {applyCarriageReturns} from 'sentry/utils/ansiEscapeCodes'; import {resolveHostname} from 'sentry/utils/api/resolveHostname'; import {RequestError} from 'sentry/utils/requestError/requestError'; @@ -39,7 +39,7 @@ export function LogFileViewer(props: ViewerProps) { ); } - return decodeTextAttachment(await response.arrayBuffer()); + return applyCarriageReturns(decodeTextAttachment(await response.arrayBuffer())); }, retry: false, staleTime: Infinity, @@ -56,7 +56,7 @@ export function LogFileViewer(props: ViewerProps) { return data ? ( - {data} + {data} ) : null; @@ -74,44 +74,6 @@ function decodeTextAttachment(buffer: ArrayBuffer): string { return new TextDecoder(encoding).decode(buffer); } -/** - * Maps ANSI color names -> theme.tsx color names - */ -const COLOR_MAP = { - red: 'red', - green: 'green', - blue: 'blue', - yellow: 'yellow', - magenta: 'pink', - cyan: 'blue', -} as const; - -const SentryStyleAnsi = styled(Ansi)` - ${p => - Object.entries(COLOR_MAP).map( - ([ansiColor, themeColor]) => css` - .ansi-${ansiColor}-bg { - background-color: ${p.theme.colors[`${themeColor}500`]}; - } - .ansi-${ansiColor}-fg { - color: ${p.theme.colors[`${themeColor}500`]}; - } - .ansi-bright-${ansiColor}-fg { - color: ${p.theme.colors[`${themeColor}200`]}; - } - ` - )} - - .ansi-black-fg, - .ansi-bright-black-fg { - color: ${p => p.theme.colors.black}; - } - .ansi-white-fg, - .ansi-bright-white-fg { - color: ${p => p.theme.colors.white}; - } -`; - const CodeWrapper = styled('pre')` padding: ${p => p.theme.space.md} ${p => p.theme.space.xl}; width: 100%; 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/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/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/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/ansiEscapeCodes.spec.tsx b/static/app/utils/ansiEscapeCodes.spec.tsx index 79bbad6f5b6f..6b4795b59b3f 100644 --- a/static/app/utils/ansiEscapeCodes.spec.tsx +++ b/static/app/utils/ansiEscapeCodes.spec.tsx @@ -1,4 +1,4 @@ -import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; +import {applyCarriageReturns, stripAnsi} from 'sentry/utils/ansiEscapeCodes'; describe('ansiEscapeCodes', () => { it('removes ANSI color codes', () => { @@ -25,3 +25,13 @@ describe('ansiEscapeCodes', () => { expect(stripAnsi(input)).toBe('Hello World!'); }); }); + +describe('applyCarriageReturns', () => { + it('keeps only the final write when given carriage-return progress updates', () => { + expect(applyCarriageReturns('10%\r50%\r100%\ndone')).toBe('100%\ndone'); + }); + + it('keeps line endings when given CRLF text', () => { + expect(applyCarriageReturns('first\r\nsecond\r\n')).toBe('first\r\nsecond\r\n'); + }); +}); diff --git a/static/app/utils/ansiEscapeCodes.tsx b/static/app/utils/ansiEscapeCodes.tsx index 6484577a8e48..201dc788564d 100644 --- a/static/app/utils/ansiEscapeCodes.tsx +++ b/static/app/utils/ansiEscapeCodes.tsx @@ -12,3 +12,7 @@ export function stripAnsi(input: string): string { export function hasAnsi(input: string): boolean { return input.includes('\x1B'); } + +export function applyCarriageReturns(input: string): string { + return input.replace(/^.*\r(?!$)/gm, ''); +} 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/explore/logs/logsAnsiHighlight.spec.tsx b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx index 1e9fe7f30e07..1acdad51579f 100644 --- a/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx +++ b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx @@ -2,35 +2,11 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {LogsAnsiHighlight} from 'sentry/views/explore/logs/logsAnsiHighlight'; -function renderSegments(text: string) { - render({text}); - - const wrapper = screen.getByText('FATAL').parentElement; - - return { - segments: Array.from(wrapper?.querySelectorAll('span') ?? [], span => ({ - text: span.textContent, - colored: span.style.color !== '', - })), - wrapper, - }; -} - describe('LogsAnsiHighlight', () => { - it('renders text without escape codes when given ANSI colors', () => { - const {container} = render( - {'\x1B[31mfailed\x1B[0m to connect'} - ); + it('highlights search terms when given plain text', () => { + render(failed to connect); - expect(container).toHaveTextContent(/^failed to connect$/); - }); - - it('blends ANSI colors into the text and background colors when given colored segments', () => { - render({'\x1B[31;44mcolored'}); - - const segment = screen.getByText('colored'); - expect(segment.style.color).toContain('color-mix(in srgb,'); - expect(segment.style.backgroundColor).toContain('color-mix(in srgb,'); + expect(screen.getByText('connect').tagName).toBe('SPAN'); }); it('highlights search terms inside colored segments', () => { @@ -43,88 +19,13 @@ describe('LogsAnsiHighlight', () => { expect(screen.getByText('connect').tagName).toBe('SPAN'); }); - it('resolves 256-color and truecolor codes to their RGB values', () => { + it('preserves whitespace when given colored segments', () => { render( - - {'\x1B[38;5;208mpalette\x1B[0m \x1B[38;2;1;2;3mtruecolor'} - + {'\x1B[31m failed\x1B[0m to connect'} ); - expect(screen.getByText('palette').style.color).toContain('rgb(255, 135, 0)'); - expect(screen.getByText('truecolor').style.color).toContain('rgb(1, 2, 3)'); - }); - - it('applies every decoration when given combined decoration codes', () => { - render({'\x1B[1;3;4;9mdecorated'}); - - expect(screen.getByText('decorated')).toHaveStyle({ - fontWeight: 'bold', - fontStyle: 'italic', - textDecorationLine: 'underline line-through', + expect(screen.getByText('failed').parentElement).toHaveStyle({ + whiteSpaceCollapse: 'preserve', }); }); - - it('removes non-color escape codes when given cursor control sequences', () => { - const {container} = render( - {'\x1B[2Kdone\x1B[?25h'} - ); - - expect(container).toHaveTextContent(/^done$/); - }); - - it('colors only the escaped text when given uncolored spaces around a colored word', () => { - const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0m '); - - expect(segments).toEqual([ - {text: ' ', colored: false}, - {text: 'FATAL', colored: true}, - {text: ' ', colored: false}, - ]); - }); - - it('colors a trailing space when given the space inside the escape codes', () => { - const {segments} = renderSegments(' \x1B[31mFATAL \x1B[0m '); - - expect(segments).toEqual([ - {text: ' ', colored: false}, - {text: 'FATAL ', colored: true}, - {text: ' ', colored: false}, - ]); - }); - - it('colors a leading space when given the space inside the escape codes', () => { - const {segments} = renderSegments(' \x1B[31m FATAL\x1B[0m '); - - expect(segments).toEqual([ - {text: ' ', colored: false}, - {text: ' FATAL', colored: true}, - {text: ' ', colored: false}, - ]); - }); - - it('colors only the escaped word when given uncolored text before it and a space after it', () => { - const {segments} = renderSegments('ABC\x1B[31mFATAL\x1B[0m '); - - expect(segments).toEqual([ - {text: 'ABC', colored: false}, - {text: 'FATAL', colored: true}, - {text: ' ', colored: false}, - ]); - }); - - it('colors only the escaped word when given a space before it and uncolored text after it', () => { - const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0mABC'); - - expect(segments).toEqual([ - {text: ' ', colored: false}, - {text: 'FATAL', colored: true}, - {text: 'ABC', colored: false}, - ]); - }); - - it('preserves whitespace when given escape codes', () => { - const {wrapper} = renderSegments(' \x1B[41m FATAL \x1B[0m '); - - expect(wrapper).toHaveStyle({whiteSpaceCollapse: 'preserve'}); - }); }); diff --git a/static/app/views/explore/logs/logsAnsiHighlight.tsx b/static/app/views/explore/logs/logsAnsiHighlight.tsx index 0fe7b790a4b3..144145ddf7af 100644 --- a/static/app/views/explore/logs/logsAnsiHighlight.tsx +++ b/static/app/views/explore/logs/logsAnsiHighlight.tsx @@ -1,21 +1,6 @@ -import {useMemo, type CSSProperties} from 'react'; -import {useTheme} from '@emotion/react'; -import { - createColorPalette, - parseAnsiSequences, - type ParseToken, -} from 'ansi-sequence-parser'; - -import {hasAnsi, stripAnsi} from 'sentry/utils/ansiEscapeCodes'; +import {AnsiText} from 'sentry/components/ansiText'; import {LogsHighlight} from 'sentry/views/explore/logs/styles'; -const ANSI_BACKGROUND_STRENGTH = '15%'; -const ANSI_TEXT_STRENGTH = '50%'; - -const PRESERVE_WHITESPACE_STYLE: CSSProperties = {whiteSpaceCollapse: 'preserve'}; - -type ColorPalette = ReturnType; - interface LogsAnsiHighlightProps { children: string; caseSensitive?: boolean; @@ -27,73 +12,16 @@ export function LogsAnsiHighlight({ children, terms = [], }: LogsAnsiHighlightProps) { - const theme = useTheme(); - const palette = useMemo(() => createColorPalette(theme.tokens.syntax.ansi), [theme]); - const tokens = useMemo( - () => - hasAnsi(children) - ? parseAnsiSequences(children) - .map(token => ({...token, value: stripAnsi(token.value)})) - .filter(token => token.value) - : undefined, - [children] - ); - - if (!tokens) { - return ( - - {children} - - ); - } - return ( - - {tokens.map((token, index) => ( - - - {token.value} - - - ))} - + ( + + {text} + + )} + > + {children} + ); } - -function getTokenStyle( - {background, decorations, foreground}: ParseToken, - palette: ColorPalette -): CSSProperties { - const style: CSSProperties = {}; - - if (foreground) { - style.color = `color-mix(in srgb, ${palette.value(foreground)} ${ANSI_TEXT_STRENGTH}, currentColor)`; - } - - if (background) { - style.backgroundColor = `color-mix(in srgb, ${palette.value(background)} ${ANSI_BACKGROUND_STRENGTH}, transparent)`; - } - - if (decorations.has('bold')) { - style.fontWeight = 'bold'; - } - - if (decorations.has('dim')) { - style.opacity = 0.7; - } - - if (decorations.has('italic')) { - style.fontStyle = 'italic'; - } - - const lines = [ - decorations.has('underline') && 'underline', - decorations.has('strikethrough') && 'line-through', - decorations.has('overline') && 'overline', - ].filter(Boolean); - if (lines.length) { - style.textDecorationLine = lines.join(' '); - } - - return style; -} 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`; }