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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 65de5e75f623d56141dd615290ac4f70bd97c37e 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 6/6] 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 {