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 aa80daad3e50b141e01036d2f6c5ff4ab1267fd5 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 6/6] 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}