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 3c8e8b80988911a48f4a6ae006b929bcb9f57543 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 6/6] 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;