From ce3ac45e98cc9817671860a71393ba219c574110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 16:41:18 -0400 Subject: [PATCH 01/10] ref(ui): Replace ansi-to-react with shared AnsiText component Moves the ansi-sequence-parser rendering from logs into a shared component so .log attachment previews use it too, and drops the ansi-to-react dependency. Fixes DE-1651 --- package.json | 1 - pnpm-lock.yaml | 26 ---- static/app/components/ansiText.spec.tsx | 114 +++++++++++++++++ static/app/components/ansiText.tsx | 87 +++++++++++++ .../attachmentViewers/logFileViewer.spec.tsx | 14 +++ .../attachmentViewers/logFileViewer.tsx | 43 +------ .../explore/logs/logsAnsiHighlight.spec.tsx | 115 +----------------- .../views/explore/logs/logsAnsiHighlight.tsx | 93 ++------------ 8 files changed, 230 insertions(+), 263 deletions(-) create mode 100644 static/app/components/ansiText.spec.tsx create mode 100644 static/app/components/ansiText.tsx 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..25c8a759d3d1 --- /dev/null +++ b/static/app/components/ansiText.spec.tsx @@ -0,0 +1,114 @@ +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('preserves whitespace when given escape codes', () => { + const {wrapper} = renderSegments(' \x1B[41m FATAL \x1B[0m '); + + expect(wrapper).toHaveStyle({whiteSpaceCollapse: 'preserve'}); + }); +}); diff --git a/static/app/components/ansiText.tsx b/static/app/components/ansiText.tsx new file mode 100644 index 000000000000..60c5c57f4cb1 --- /dev/null +++ b/static/app/components/ansiText.tsx @@ -0,0 +1,87 @@ +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; + renderText?: (text: string) => ReactNode; +} + +export function AnsiText({children, 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..b224b7b4d13d 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx @@ -58,6 +58,20 @@ 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 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..d8d19cf6755b 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.tsx @@ -1,8 +1,7 @@ -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'; @@ -56,7 +55,7 @@ export function LogFileViewer(props: ViewerProps) { return data ? ( - {data} + {data} ) : null; @@ -74,44 +73,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/views/explore/logs/logsAnsiHighlight.spec.tsx b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx index 1e9fe7f30e07..1736468775a5 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'} - ); - - expect(container).toHaveTextContent(/^failed to connect$/); - }); - - it('blends ANSI colors into the text and background colors when given colored segments', () => { - render({'\x1B[31;44mcolored'}); + it('highlights search terms when given plain text', () => { + render(failed to connect); - 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', () => { @@ -42,89 +18,4 @@ describe('LogsAnsiHighlight', () => { expect(screen.getByText('connect').tagName).toBe('SPAN'); }); - - 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('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..27156de9c6af 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,15 @@ 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; -} From 7f9792d8bf335a10afb1868362d22920df06c444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 17:11:35 -0400 Subject: [PATCH 02/10] fix(ui): Apply carriage returns in .log previews ansi-to-react collapsed carriage-return overwrites before rendering, so progress-style lines showed only their final write. Restore that in LogFileViewer. Refs DE-1651 --- .../events/attachmentViewers/logFileViewer.spec.tsx | 11 +++++++++++ .../events/attachmentViewers/logFileViewer.tsx | 3 ++- static/app/utils/ansiEscapeCodes.spec.tsx | 12 +++++++++++- static/app/utils/ansiEscapeCodes.tsx | 4 ++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx index b224b7b4d13d..2e187e288d36 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx @@ -72,6 +72,17 @@ describe('LogFileViewer', () => { 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 d8d19cf6755b..6cf43cbe008f 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.tsx @@ -8,6 +8,7 @@ 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'; @@ -38,7 +39,7 @@ export function LogFileViewer(props: ViewerProps) { ); } - return decodeTextAttachment(await response.arrayBuffer()); + return applyCarriageReturns(decodeTextAttachment(await response.arrayBuffer())); }, retry: false, staleTime: Infinity, 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, ''); +} From 29bbffd3bc5412c3a9eb746d0fd125d0ec76ae71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 18:34:53 -0400 Subject: [PATCH 03/10] ref(ui): Make AnsiText whitespace preservation opt-in Only log messages want ANSI output's whitespace preserved. Other callers render it in single-line, truncated containers where preserved newlines would break the layout. Refs DE-1651 --- static/app/components/ansiText.spec.tsx | 12 ++++++++++-- static/app/components/ansiText.tsx | 9 +++++++-- .../views/explore/logs/logsAnsiHighlight.spec.tsx | 10 ++++++++++ static/app/views/explore/logs/logsAnsiHighlight.tsx | 1 + 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/static/app/components/ansiText.spec.tsx b/static/app/components/ansiText.spec.tsx index 25c8a759d3d1..1e63195d4bb8 100644 --- a/static/app/components/ansiText.spec.tsx +++ b/static/app/components/ansiText.spec.tsx @@ -106,9 +106,17 @@ describe('AnsiText', () => { ]); }); - it('preserves whitespace when given escape codes', () => { + it('collapses whitespace by default when given escape codes', () => { const {wrapper} = renderSegments(' \x1B[41m FATAL \x1B[0m '); - expect(wrapper).toHaveStyle({whiteSpaceCollapse: 'preserve'}); + 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 index 60c5c57f4cb1..531e1c1578f4 100644 --- a/static/app/components/ansiText.tsx +++ b/static/app/components/ansiText.tsx @@ -17,10 +17,15 @@ type ColorPalette = ReturnType; interface AnsiTextProps { children: string; + preserveWhitespace?: boolean; renderText?: (text: string) => ReactNode; } -export function AnsiText({children, renderText = text => text}: AnsiTextProps) { +export function AnsiText({ + children, + preserveWhitespace, + renderText = text => text, +}: AnsiTextProps) { const theme = useTheme(); const palette = useMemo(() => createColorPalette(theme.tokens.syntax.ansi), [theme]); const tokens = useMemo( @@ -38,7 +43,7 @@ export function AnsiText({children, renderText = text => text}: AnsiTextProps) { } return ( - + {tokens.map((token, index) => ( {renderText(token.value)} diff --git a/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx index 1736468775a5..1acdad51579f 100644 --- a/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx +++ b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx @@ -18,4 +18,14 @@ describe('LogsAnsiHighlight', () => { expect(screen.getByText('connect').tagName).toBe('SPAN'); }); + + it('preserves whitespace when given colored segments', () => { + render( + {'\x1B[31m failed\x1B[0m to connect'} + ); + + expect(screen.getByText('failed').parentElement).toHaveStyle({ + whiteSpaceCollapse: 'preserve', + }); + }); }); diff --git a/static/app/views/explore/logs/logsAnsiHighlight.tsx b/static/app/views/explore/logs/logsAnsiHighlight.tsx index 27156de9c6af..144145ddf7af 100644 --- a/static/app/views/explore/logs/logsAnsiHighlight.tsx +++ b/static/app/views/explore/logs/logsAnsiHighlight.tsx @@ -14,6 +14,7 @@ export function LogsAnsiHighlight({ }: LogsAnsiHighlightProps) { return ( ( {text} From 2250b8665003b98b6395e5e3103fffd8fc667fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 19:44:21 -0400 Subject: [PATCH 04/10] ref(ui): Soften dark theme ANSI green The dark green ANSI color had noticeably more chroma than the other ANSI hues, so move green and bright green one step lighter. Refs DE-1651 --- static/packages/scraps/src/theme/dark.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/packages/scraps/src/theme/dark.tsx b/static/packages/scraps/src/theme/dark.tsx index dab5f457c06d..d8699d0f342a 100644 --- a/static/packages/scraps/src/theme/dark.tsx +++ b/static/packages/scraps/src/theme/dark.tsx @@ -1872,7 +1872,7 @@ const syntax = { ansi: { black: color.neutral.dark.opaque1500, red: color.red.dark.opaque1200, - green: color.green.dark.opaque1300, + green: color.green.dark.opaque1400, yellow: color.yellow.dark.opaque1300, blue: color.blue.dark.opaque1200, magenta: color.pink.dark.opaque1200, @@ -1880,7 +1880,7 @@ const syntax = { white: color.neutral.dark.opaque1500, brightBlack: color.neutral.dark.opaque1200, brightRed: color.red.dark.opaque1300, - brightGreen: color.green.dark.opaque1400, + brightGreen: color.green.dark.opaque1500, brightYellow: color.yellow.dark.opaque1400, brightBlue: color.blue.dark.opaque1300, brightMagenta: color.pink.dark.opaque1300, From fda6aa72620d66bded8c1dace313c963e1bfb259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 20:22:12 -0400 Subject: [PATCH 05/10] Revert "ref(ui): Soften dark theme ANSI green" This reverts commit 2250b8665003b98b6395e5e3103fffd8fc667fb5. --- static/packages/scraps/src/theme/dark.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/packages/scraps/src/theme/dark.tsx b/static/packages/scraps/src/theme/dark.tsx index d8699d0f342a..dab5f457c06d 100644 --- a/static/packages/scraps/src/theme/dark.tsx +++ b/static/packages/scraps/src/theme/dark.tsx @@ -1872,7 +1872,7 @@ const syntax = { ansi: { black: color.neutral.dark.opaque1500, red: color.red.dark.opaque1200, - green: color.green.dark.opaque1400, + green: color.green.dark.opaque1300, yellow: color.yellow.dark.opaque1300, blue: color.blue.dark.opaque1200, magenta: color.pink.dark.opaque1200, @@ -1880,7 +1880,7 @@ const syntax = { white: color.neutral.dark.opaque1500, brightBlack: color.neutral.dark.opaque1200, brightRed: color.red.dark.opaque1300, - brightGreen: color.green.dark.opaque1500, + brightGreen: color.green.dark.opaque1400, brightYellow: color.yellow.dark.opaque1400, brightBlue: color.blue.dark.opaque1300, brightMagenta: color.pink.dark.opaque1300, From c2ec671a426f24689df6688ea5651bce3cf7122a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 17:41:38 -0400 Subject: [PATCH 06/10] feat(issues): Render ANSI colors in exception values and messages Exception values and the Message section on issue details showed raw ANSI escape codes. Render them with the shared AnsiText component, keeping URL linkification for each segment. Fixes LOGS-1016 --- .../crashContent/exception/content.spec.tsx | 36 +++++++++++++++++++ .../crashContent/exception/content.tsx | 9 +++-- .../events/interfaces/message.spec.tsx | 15 ++++++++ .../components/events/interfaces/message.tsx | 9 +++-- .../stackTrace/exceptionHeader.spec.tsx | 17 +++++++++ .../components/stackTrace/exceptionHeader.tsx | 7 +++- 6 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 static/app/components/stackTrace/exceptionHeader.spec.tsx 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/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/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 && } From 59b3756ad699f87b8dc7c5486561cbccaec31224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 17:53:07 -0400 Subject: [PATCH 07/10] feat(issues): Render ANSI colors in issue titles and messages Issue titles and messages showed raw ANSI escape codes in the issue details header, issue stream, and inbox. Render them with the shared AnsiText component and strip the codes from plain-text uses like the document title, tooltips, and the command palette label. Fixes LOGS-1017 --- .../components/events/eventMessage.spec.tsx | 11 +++++++ static/app/components/events/eventMessage.tsx | 5 ++- static/app/components/groupTitle.spec.tsx | 15 +++++++++ static/app/components/groupTitle.tsx | 7 ++-- .../app/views/issueDetails/actions/index.tsx | 6 ++-- .../views/issueDetails/groupDetails.spec.tsx | 30 +++++++++++++++++ .../app/views/issueDetails/groupDetails.tsx | 5 +-- .../views/issueDetails/header/header.spec.tsx | 33 +++++++++++++++++++ .../app/views/issueDetails/header/header.tsx | 15 +++++++-- .../app/views/issueList/pages/inbox/index.tsx | 5 +-- .../pages/inbox/issuePreview/issuePreview.tsx | 13 ++++++-- 11 files changed, 129 insertions(+), 16 deletions(-) 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/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/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/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} From 3450744ef16fe337ad417c86f55a788d06459ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 17:52:17 -0400 Subject: [PATCH 08/10] fix(issues): Strip ANSI codes from copied and exported issue text Raw stack traces, copy-as-text, issue diff and ticket bodies, copied issue markdown, and copied breadcrumbs included raw ANSI escape codes. Strip them so plain-text outputs read cleanly. Fixes LOGS-1018 --- .../breadcrumbs/copyBreadcrumbs.spec.tsx | 10 +++ .../events/breadcrumbs/copyBreadcrumbs.tsx | 5 +- .../crashContent/exception/rawContent.tsx | 3 +- .../stackTrace/rawContent.spec.tsx | 20 ++++++ .../crashContent/stackTrace/rawContent.tsx | 3 +- .../stackTrace/issueStackTrace/index.spec.tsx | 27 +++++++ static/app/utils/getStacktraceBody.spec.tsx | 11 +++ static/app/utils/getStacktraceBody.tsx | 3 +- .../hooks/useCopyIssueDetails.spec.tsx | 72 +++++++++++++++++++ .../hooks/useCopyIssueDetails.tsx | 12 ++-- 10 files changed, 156 insertions(+), 10 deletions(-) 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/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/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`; } From 2d1cb2a868e9c2871bd0441a5796182d213cae8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 17:50:13 -0400 Subject: [PATCH 09/10] feat(issues): Render ANSI colors in breadcrumb messages Breadcrumb messages on issue details showed raw ANSI escape codes. Render them with the shared AnsiText component when they have no meta, and strip escape codes before matching breadcrumb search terms. Fixes LOGS-1019 --- .../breadcrumbItemContent.spec.tsx | 43 +++++++++++++++++++ .../breadcrumbs/breadcrumbItemContent.tsx | 15 ++++++- .../interfaces/breadcrumbs/index.spec.tsx | 28 ++++++++++++ .../events/interfaces/breadcrumbs/index.tsx | 4 +- 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 static/app/components/events/interfaces/breadcrumbs/index.spec.tsx 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/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; From 602680a86dcdd2b3947837e6cce438b93e03bae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 1 Oct 2026 17:48:17 -0400 Subject: [PATCH 10/10] feat(issues): Render ANSI colors in related exception labels The related exceptions tree for exception groups showed raw ANSI escape codes in exception values. Render the value part of each label with the shared AnsiText component. Fixes LOGS-1022 --- .../exception/relatedExceptions.spec.tsx | 15 ++++++++++ .../exception/relatedExceptions.tsx | 15 ++++++++-- .../stackTrace/exceptionGroup.spec.tsx | 30 +++++++++++++++++++ .../components/stackTrace/exceptionGroup.tsx | 15 ++++++++-- 4 files changed, 71 insertions(+), 4 deletions(-) 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/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 {