;
+
+interface AnsiTextProps {
+ children: string;
+ preserveWhitespace?: boolean;
+ renderText?: (text: string) => ReactNode;
+}
+
+export function AnsiText({
+ children,
+ preserveWhitespace,
+ renderText = text => text,
+}: AnsiTextProps) {
+ const theme = useTheme();
+ const palette = useMemo(() => createColorPalette(theme.tokens.syntax.ansi), [theme]);
+ const tokens = useMemo(
+ () =>
+ hasAnsi(children)
+ ? parseAnsiSequences(children)
+ .map(token => ({...token, value: stripAnsi(token.value)}))
+ .filter(token => token.value)
+ : undefined,
+ [children]
+ );
+
+ if (!tokens) {
+ return renderText(children);
+ }
+
+ return (
+
+ {tokens.map((token, index) => (
+
+ {renderText(token.value)}
+
+ ))}
+
+ );
+}
+
+function getTokenStyle(
+ {background, decorations, foreground}: ParseToken,
+ palette: ColorPalette
+): CSSProperties {
+ const style: CSSProperties = {};
+
+ if (foreground) {
+ style.color = `color-mix(in srgb, ${palette.value(foreground)} ${ANSI_TEXT_STRENGTH}, currentColor)`;
+ }
+
+ if (background) {
+ style.backgroundColor = `color-mix(in srgb, ${palette.value(background)} ${ANSI_BACKGROUND_STRENGTH}, transparent)`;
+ }
+
+ if (decorations.has('bold')) {
+ style.fontWeight = 'bold';
+ }
+
+ if (decorations.has('dim')) {
+ style.opacity = 0.7;
+ }
+
+ if (decorations.has('italic')) {
+ style.fontStyle = 'italic';
+ }
+
+ const lines = [
+ decorations.has('underline') && 'underline',
+ decorations.has('strikethrough') && 'line-through',
+ decorations.has('overline') && 'overline',
+ ].filter(Boolean);
+ if (lines.length) {
+ style.textDecorationLine = lines.join(' ');
+ }
+
+ return style;
+}
diff --git a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx
index 753f4ff9b9b0..2e187e288d36 100644
--- a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx
+++ b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx
@@ -58,6 +58,31 @@ describe('LogFileViewer', () => {
);
});
+ it('renders colored text without escape codes when given ANSI colors', async () => {
+ fetchMock.route(
+ attachmentUrl,
+ fetchMock.Response(new TextEncoder().encode('\x1B[31mfailed\x1B[0m to connect'))
+ );
+
+ render();
+
+ expect((await screen.findByText('failed')).style.color).toContain(
+ 'color-mix(in srgb,'
+ );
+ expect(screen.getByText('to connect')).toBeInTheDocument();
+ });
+
+ it('renders only the final write when given carriage-return progress updates', async () => {
+ fetchMock.route(
+ attachmentUrl,
+ fetchMock.Response(new TextEncoder().encode('progress 10%\rprogress 100%'))
+ );
+
+ render();
+
+ expect(await screen.findByText('progress 100%')).toBeInTheDocument();
+ });
+
it('renders an error when the attachment cannot be downloaded', async () => {
fetchMock.route(attachmentUrl, '', {status: 404});
diff --git a/static/app/components/events/attachmentViewers/logFileViewer.tsx b/static/app/components/events/attachmentViewers/logFileViewer.tsx
index e697b8ee35bc..6cf43cbe008f 100644
--- a/static/app/components/events/attachmentViewers/logFileViewer.tsx
+++ b/static/app/components/events/attachmentViewers/logFileViewer.tsx
@@ -1,14 +1,14 @@
-import {css} from '@emotion/react';
import styled from '@emotion/styled';
import {useQuery} from '@tanstack/react-query';
-import Ansi from 'ansi-to-react';
+import {AnsiText} from 'sentry/components/ansiText';
import {PreviewPanelItem} from 'sentry/components/events/attachmentViewers/previewPanelItem';
import type {ViewerProps} from 'sentry/components/events/attachmentViewers/utils';
import {getAttachmentUrl} from 'sentry/components/events/attachmentViewers/utils';
import {LoadingError} from 'sentry/components/loadingError';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {t} from 'sentry/locale';
+import {applyCarriageReturns} from 'sentry/utils/ansiEscapeCodes';
import {resolveHostname} from 'sentry/utils/api/resolveHostname';
import {RequestError} from 'sentry/utils/requestError/requestError';
@@ -39,7 +39,7 @@ export function LogFileViewer(props: ViewerProps) {
);
}
- return decodeTextAttachment(await response.arrayBuffer());
+ return applyCarriageReturns(decodeTextAttachment(await response.arrayBuffer()));
},
retry: false,
staleTime: Infinity,
@@ -56,7 +56,7 @@ export function LogFileViewer(props: ViewerProps) {
return data ? (
- {data}
+ {data}
) : null;
@@ -74,44 +74,6 @@ function decodeTextAttachment(buffer: ArrayBuffer): string {
return new TextDecoder(encoding).decode(buffer);
}
-/**
- * Maps ANSI color names -> theme.tsx color names
- */
-const COLOR_MAP = {
- red: 'red',
- green: 'green',
- blue: 'blue',
- yellow: 'yellow',
- magenta: 'pink',
- cyan: 'blue',
-} as const;
-
-const SentryStyleAnsi = styled(Ansi)`
- ${p =>
- Object.entries(COLOR_MAP).map(
- ([ansiColor, themeColor]) => css`
- .ansi-${ansiColor}-bg {
- background-color: ${p.theme.colors[`${themeColor}500`]};
- }
- .ansi-${ansiColor}-fg {
- color: ${p.theme.colors[`${themeColor}500`]};
- }
- .ansi-bright-${ansiColor}-fg {
- color: ${p.theme.colors[`${themeColor}200`]};
- }
- `
- )}
-
- .ansi-black-fg,
- .ansi-bright-black-fg {
- color: ${p => p.theme.colors.black};
- }
- .ansi-white-fg,
- .ansi-bright-white-fg {
- color: ${p => p.theme.colors.white};
- }
-`;
-
const CodeWrapper = styled('pre')`
padding: ${p => p.theme.space.md} ${p => p.theme.space.xl};
width: 100%;
diff --git a/static/app/components/events/breadcrumbs/copyBreadcrumbs.spec.tsx b/static/app/components/events/breadcrumbs/copyBreadcrumbs.spec.tsx
index b459c88a2bdf..6fb853a01725 100644
--- a/static/app/components/events/breadcrumbs/copyBreadcrumbs.spec.tsx
+++ b/static/app/components/events/breadcrumbs/copyBreadcrumbs.spec.tsx
@@ -54,6 +54,11 @@ Message: Second request
Data: {"url":"/api/test","status_code":200}"
`);
});
+ it('strips ANSI codes when the message contains them', () => {
+ const crumb = createMockCrumb({message: '\x1B[31mGET\x1B[0m request'});
+ const result = formatBreadcrumbsAsText([crumb]);
+ expect(result).toContain('Message: GET request\n');
+ });
});
describe('formatBreadcrumbsAsMarkdown', () => {
it('formats breadcrumbs as a markdown table with header', () => {
@@ -74,4 +79,9 @@ describe('formatBreadcrumbsAsMarkdown', () => {
| 2024-01-15T10:30:45.123Z | http | http | info | value\\|with\\|pipes | {"key":"val\\|ue"} |"
`);
});
+ it('strips ANSI codes when the message contains them', () => {
+ const crumb = createMockCrumb({message: '\x1B[31mGET\x1B[0m request'});
+ const result = formatBreadcrumbsAsMarkdown([crumb]);
+ expect(result).toContain('| info | GET request |');
+ });
});
diff --git a/static/app/components/events/breadcrumbs/copyBreadcrumbs.tsx b/static/app/components/events/breadcrumbs/copyBreadcrumbs.tsx
index ea1f59d0a088..ddc29000b522 100644
--- a/static/app/components/events/breadcrumbs/copyBreadcrumbs.tsx
+++ b/static/app/components/events/breadcrumbs/copyBreadcrumbs.tsx
@@ -1,6 +1,7 @@
import {CopyAsDropdown} from 'sentry/components/copyAsDropdown';
import type {EnhancedCrumb} from 'sentry/components/events/breadcrumbs/utils';
import {trackAnalytics} from 'sentry/utils/analytics';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
import {useOrganization} from 'sentry/utils/useOrganization';
function escapeMarkdownCell(value: string): string {
@@ -20,7 +21,7 @@ export function formatBreadcrumbsAsMarkdown(crumbs: EnhancedCrumb[]): string {
const timestamp = bc.timestamp ? new Date(bc.timestamp).toISOString() : '';
const type = bc.type;
const category = bc.category ?? '';
- const message = escapeMarkdownCell(bc.message ?? '');
+ const message = escapeMarkdownCell(stripAnsi(bc.message ?? ''));
const data = bc.data ? escapeMarkdownCell(JSON.stringify(bc.data)) : '';
return `| ${timestamp} | ${type} | ${category} | ${bc.level} | ${message} | ${data} |`;
@@ -45,7 +46,7 @@ export function formatBreadcrumbsAsText(crumbs: EnhancedCrumb[]): string {
lines.push(`Level: ${crumb.breadcrumb.level}`);
if (crumb.breadcrumb.message) {
- lines.push(`Message: ${crumb.breadcrumb.message}`);
+ lines.push(`Message: ${stripAnsi(crumb.breadcrumb.message)}`);
}
if (crumb.breadcrumb.data) {
lines.push(`Data: ${JSON.stringify(crumb.breadcrumb.data)}`);
diff --git a/static/app/components/events/interfaces/crashContent/exception/rawContent.tsx b/static/app/components/events/interfaces/crashContent/exception/rawContent.tsx
index d3386dcee674..f4b2204060d1 100644
--- a/static/app/components/events/interfaces/crashContent/exception/rawContent.tsx
+++ b/static/app/components/events/interfaces/crashContent/exception/rawContent.tsx
@@ -8,6 +8,7 @@ import {Placeholder} from 'sentry/components/placeholder';
import type {Event, ExceptionType} from 'sentry/types/event';
import type {PlatformKey} from 'sentry/types/platform';
import type {Project} from 'sentry/types/project';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
import {apiOptions} from 'sentry/utils/api/apiOptions';
import {useOrganization} from 'sentry/utils/useOrganization';
@@ -94,7 +95,7 @@ export function RawContent({
})
) : (
- {exceptionType}: {exceptionValue}
+ {exceptionType}: {exceptionValue && stripAnsi(exceptionValue)}
);
return (
diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.spec.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.spec.tsx
index 30c706fa8e77..a0c35d4200b9 100644
--- a/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.spec.tsx
+++ b/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.spec.tsx
@@ -166,6 +166,26 @@ describe('RawStacktraceContent', () => {
})
).toBe('IllegalStateException: Oops!');
});
+
+ it('strips ANSI codes when the exception value contains them', () => {
+ const exception = ExceptionValueFixture({
+ type: 'ValueError',
+ value: '\x1B[31mfailed\x1B[0m to connect',
+ });
+
+ expect(
+ displayRawContent({
+ data: {
+ hasSystemFrames: false,
+ framesOmitted: null,
+ registers: {},
+ frames: [],
+ },
+ platform: 'javascript',
+ exception,
+ })
+ ).toBe('ValueError: failed to connect');
+ });
});
describe('render()', () => {
diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.tsx
index 481ce0198280..22305d62275c 100644
--- a/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.tsx
+++ b/static/app/components/events/interfaces/crashContent/stackTrace/rawContent.tsx
@@ -1,6 +1,7 @@
import {trimPackage} from 'sentry/components/events/interfaces/frame/utils';
import type {ExceptionValue, Frame} from 'sentry/types/event';
import type {StacktraceType} from 'sentry/types/stacktrace';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
import {defined} from 'sentry/utils/defined';
function getJavaScriptFrame(
@@ -386,7 +387,7 @@ export function displayRawContent({
);
if (exception) {
- frames.push(getExceptionSummary(exception, platform, isMinified));
+ frames.push(stripAnsi(getExceptionSummary(exception, platform, isMinified)));
}
// For the raw stacktrace view on the issue details page, ignore newestFirst and order frames based on default platform behavior
diff --git a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
index ccd17952dc97..82e193c26a2b 100644
--- a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
+++ b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
@@ -526,6 +526,33 @@ describe('IssueStackTrace', () => {
`);
});
+ it('copies stack trace text without ANSI codes when the exception value contains them', async () => {
+ const {event, stacktrace} = makeCopyTestData();
+
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole('button', {name: 'Copy as'}));
+ await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Text'}));
+
+ const copiedText = jest.mocked(navigator.clipboard.writeText).mock.calls[0]![0];
+ expect(copiedText).toContain('ValueError: list index out of range');
+ });
+
it('copies stack trace text including exception type and value for chained exceptions', async () => {
const {event, stacktrace} = makeCopyTestData();
diff --git a/static/app/utils/ansiEscapeCodes.spec.tsx b/static/app/utils/ansiEscapeCodes.spec.tsx
index 79bbad6f5b6f..6b4795b59b3f 100644
--- a/static/app/utils/ansiEscapeCodes.spec.tsx
+++ b/static/app/utils/ansiEscapeCodes.spec.tsx
@@ -1,4 +1,4 @@
-import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
+import {applyCarriageReturns, stripAnsi} from 'sentry/utils/ansiEscapeCodes';
describe('ansiEscapeCodes', () => {
it('removes ANSI color codes', () => {
@@ -25,3 +25,13 @@ describe('ansiEscapeCodes', () => {
expect(stripAnsi(input)).toBe('Hello World!');
});
});
+
+describe('applyCarriageReturns', () => {
+ it('keeps only the final write when given carriage-return progress updates', () => {
+ expect(applyCarriageReturns('10%\r50%\r100%\ndone')).toBe('100%\ndone');
+ });
+
+ it('keeps line endings when given CRLF text', () => {
+ expect(applyCarriageReturns('first\r\nsecond\r\n')).toBe('first\r\nsecond\r\n');
+ });
+});
diff --git a/static/app/utils/ansiEscapeCodes.tsx b/static/app/utils/ansiEscapeCodes.tsx
index 6484577a8e48..201dc788564d 100644
--- a/static/app/utils/ansiEscapeCodes.tsx
+++ b/static/app/utils/ansiEscapeCodes.tsx
@@ -12,3 +12,7 @@ export function stripAnsi(input: string): string {
export function hasAnsi(input: string): boolean {
return input.includes('\x1B');
}
+
+export function applyCarriageReturns(input: string): string {
+ return input.replace(/^.*\r(?!$)/gm, '');
+}
diff --git a/static/app/utils/getStacktraceBody.spec.tsx b/static/app/utils/getStacktraceBody.spec.tsx
index f0f0789852b3..804ad00c1865 100644
--- a/static/app/utils/getStacktraceBody.spec.tsx
+++ b/static/app/utils/getStacktraceBody.spec.tsx
@@ -27,6 +27,17 @@ Error: an error occurred`,
expect(result).toEqual(['Something is broken']);
});
+ it('strips ANSI codes when the message contains them', () => {
+ const result = getStacktraceBody({
+ event: EventFixture({
+ entries: [
+ {type: 'message', data: {formatted: '\x1B[31mSomething\x1B[0m is broken'}},
+ ],
+ }),
+ });
+ expect(result).toEqual(['Something is broken']);
+ });
+
it('formats with a thread', () => {
const result = getStacktraceBody({event: eventThreads});
expect(result).toEqual([
diff --git a/static/app/utils/getStacktraceBody.tsx b/static/app/utils/getStacktraceBody.tsx
index ccdcd72794b4..ac185f17ed55 100644
--- a/static/app/utils/getStacktraceBody.tsx
+++ b/static/app/utils/getStacktraceBody.tsx
@@ -1,5 +1,6 @@
import {displayRawContent as rawStacktraceContent} from 'sentry/components/events/interfaces/crashContent/stackTrace/rawContent';
import type {Event} from 'sentry/types/event';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
type GetStacktraceBodyArgs = {
/**
@@ -51,7 +52,7 @@ export function getStacktraceBody({
if (!msg) {
return [];
}
- return msg?.data?.formatted && [msg.data.formatted];
+ return msg?.data?.formatted && [stripAnsi(msg.data.formatted)];
}
}
diff --git a/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx
index 1e9fe7f30e07..1acdad51579f 100644
--- a/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx
+++ b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx
@@ -2,35 +2,11 @@ import {render, screen} from 'sentry-test/reactTestingLibrary';
import {LogsAnsiHighlight} from 'sentry/views/explore/logs/logsAnsiHighlight';
-function renderSegments(text: string) {
- render({text});
-
- const wrapper = screen.getByText('FATAL').parentElement;
-
- return {
- segments: Array.from(wrapper?.querySelectorAll('span') ?? [], span => ({
- text: span.textContent,
- colored: span.style.color !== '',
- })),
- wrapper,
- };
-}
-
describe('LogsAnsiHighlight', () => {
- it('renders text without escape codes when given ANSI colors', () => {
- const {container} = render(
- {'\x1B[31mfailed\x1B[0m to connect'}
- );
+ it('highlights search terms when given plain text', () => {
+ render(failed to connect);
- expect(container).toHaveTextContent(/^failed to connect$/);
- });
-
- it('blends ANSI colors into the text and background colors when given colored segments', () => {
- render({'\x1B[31;44mcolored'});
-
- const segment = screen.getByText('colored');
- expect(segment.style.color).toContain('color-mix(in srgb,');
- expect(segment.style.backgroundColor).toContain('color-mix(in srgb,');
+ expect(screen.getByText('connect').tagName).toBe('SPAN');
});
it('highlights search terms inside colored segments', () => {
@@ -43,88 +19,13 @@ describe('LogsAnsiHighlight', () => {
expect(screen.getByText('connect').tagName).toBe('SPAN');
});
- it('resolves 256-color and truecolor codes to their RGB values', () => {
+ it('preserves whitespace when given colored segments', () => {
render(
-
- {'\x1B[38;5;208mpalette\x1B[0m \x1B[38;2;1;2;3mtruecolor'}
-
+ {'\x1B[31m failed\x1B[0m to connect'}
);
- expect(screen.getByText('palette').style.color).toContain('rgb(255, 135, 0)');
- expect(screen.getByText('truecolor').style.color).toContain('rgb(1, 2, 3)');
- });
-
- it('applies every decoration when given combined decoration codes', () => {
- render({'\x1B[1;3;4;9mdecorated'});
-
- expect(screen.getByText('decorated')).toHaveStyle({
- fontWeight: 'bold',
- fontStyle: 'italic',
- textDecorationLine: 'underline line-through',
+ expect(screen.getByText('failed').parentElement).toHaveStyle({
+ whiteSpaceCollapse: 'preserve',
});
});
-
- it('removes non-color escape codes when given cursor control sequences', () => {
- const {container} = render(
- {'\x1B[2Kdone\x1B[?25h'}
- );
-
- expect(container).toHaveTextContent(/^done$/);
- });
-
- it('colors only the escaped text when given uncolored spaces around a colored word', () => {
- const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0m ');
-
- expect(segments).toEqual([
- {text: ' ', colored: false},
- {text: 'FATAL', colored: true},
- {text: ' ', colored: false},
- ]);
- });
-
- it('colors a trailing space when given the space inside the escape codes', () => {
- const {segments} = renderSegments(' \x1B[31mFATAL \x1B[0m ');
-
- expect(segments).toEqual([
- {text: ' ', colored: false},
- {text: 'FATAL ', colored: true},
- {text: ' ', colored: false},
- ]);
- });
-
- it('colors a leading space when given the space inside the escape codes', () => {
- const {segments} = renderSegments(' \x1B[31m FATAL\x1B[0m ');
-
- expect(segments).toEqual([
- {text: ' ', colored: false},
- {text: ' FATAL', colored: true},
- {text: ' ', colored: false},
- ]);
- });
-
- it('colors only the escaped word when given uncolored text before it and a space after it', () => {
- const {segments} = renderSegments('ABC\x1B[31mFATAL\x1B[0m ');
-
- expect(segments).toEqual([
- {text: 'ABC', colored: false},
- {text: 'FATAL', colored: true},
- {text: ' ', colored: false},
- ]);
- });
-
- it('colors only the escaped word when given a space before it and uncolored text after it', () => {
- const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0mABC');
-
- expect(segments).toEqual([
- {text: ' ', colored: false},
- {text: 'FATAL', colored: true},
- {text: 'ABC', colored: false},
- ]);
- });
-
- it('preserves whitespace when given escape codes', () => {
- const {wrapper} = renderSegments(' \x1B[41m FATAL \x1B[0m ');
-
- expect(wrapper).toHaveStyle({whiteSpaceCollapse: 'preserve'});
- });
});
diff --git a/static/app/views/explore/logs/logsAnsiHighlight.tsx b/static/app/views/explore/logs/logsAnsiHighlight.tsx
index 0fe7b790a4b3..144145ddf7af 100644
--- a/static/app/views/explore/logs/logsAnsiHighlight.tsx
+++ b/static/app/views/explore/logs/logsAnsiHighlight.tsx
@@ -1,21 +1,6 @@
-import {useMemo, type CSSProperties} from 'react';
-import {useTheme} from '@emotion/react';
-import {
- createColorPalette,
- parseAnsiSequences,
- type ParseToken,
-} from 'ansi-sequence-parser';
-
-import {hasAnsi, stripAnsi} from 'sentry/utils/ansiEscapeCodes';
+import {AnsiText} from 'sentry/components/ansiText';
import {LogsHighlight} from 'sentry/views/explore/logs/styles';
-const ANSI_BACKGROUND_STRENGTH = '15%';
-const ANSI_TEXT_STRENGTH = '50%';
-
-const PRESERVE_WHITESPACE_STYLE: CSSProperties = {whiteSpaceCollapse: 'preserve'};
-
-type ColorPalette = ReturnType;
-
interface LogsAnsiHighlightProps {
children: string;
caseSensitive?: boolean;
@@ -27,73 +12,16 @@ export function LogsAnsiHighlight({
children,
terms = [],
}: LogsAnsiHighlightProps) {
- const theme = useTheme();
- const palette = useMemo(() => createColorPalette(theme.tokens.syntax.ansi), [theme]);
- const tokens = useMemo(
- () =>
- hasAnsi(children)
- ? parseAnsiSequences(children)
- .map(token => ({...token, value: stripAnsi(token.value)}))
- .filter(token => token.value)
- : undefined,
- [children]
- );
-
- if (!tokens) {
- return (
-
- {children}
-
- );
- }
-
return (
-
- {tokens.map((token, index) => (
-
-
- {token.value}
-
-
- ))}
-
+ (
+
+ {text}
+
+ )}
+ >
+ {children}
+
);
}
-
-function getTokenStyle(
- {background, decorations, foreground}: ParseToken,
- palette: ColorPalette
-): CSSProperties {
- const style: CSSProperties = {};
-
- if (foreground) {
- style.color = `color-mix(in srgb, ${palette.value(foreground)} ${ANSI_TEXT_STRENGTH}, currentColor)`;
- }
-
- if (background) {
- style.backgroundColor = `color-mix(in srgb, ${palette.value(background)} ${ANSI_BACKGROUND_STRENGTH}, transparent)`;
- }
-
- if (decorations.has('bold')) {
- style.fontWeight = 'bold';
- }
-
- if (decorations.has('dim')) {
- style.opacity = 0.7;
- }
-
- if (decorations.has('italic')) {
- style.fontStyle = 'italic';
- }
-
- const lines = [
- decorations.has('underline') && 'underline',
- decorations.has('strikethrough') && 'line-through',
- decorations.has('overline') && 'overline',
- ].filter(Boolean);
- if (lines.length) {
- style.textDecorationLine = lines.join(' ');
- }
-
- return style;
-}
diff --git a/static/app/views/issueDetails/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/hooks/useCopyIssueDetails.spec.tsx
index acbb7774d1b8..2dac6ad9a487 100644
--- a/static/app/views/issueDetails/hooks/useCopyIssueDetails.spec.tsx
+++ b/static/app/views/issueDetails/hooks/useCopyIssueDetails.spec.tsx
@@ -223,6 +223,27 @@ describe('useCopyIssueDetails', () => {
expect(result).not.toContain('## Message');
});
+ it('strips ANSI codes from the title and message when they contain them', () => {
+ const result = issueAndEventToMarkdown({
+ group: GroupFixture({title: '\x1B[31mTypeError\x1B[0m: connection failed'}),
+ event: EventFixture({...event, message: '\x1B[33mretrying\x1B[0m in 5s'}),
+ organization,
+ });
+
+ expect(result).toContain('# TypeError: connection failed\n');
+ expect(result).toContain('## Message\n\nretrying in 5s\n');
+ });
+
+ it('omits the message when it is part of the title after stripping ANSI codes', () => {
+ const result = issueAndEventToMarkdown({
+ group: GroupFixture({title: 'TypeError: \x1B[31mconnection failed\x1B[0m'}),
+ event: EventFixture({...event, message: '\x1B[31mconnection failed\x1B[0m'}),
+ organization,
+ });
+
+ expect(result).not.toContain('## Message');
+ });
+
it('omits the message when it is empty', () => {
const result = issueAndEventToMarkdown({
group: GroupFixture({title: 'TypeError'}),
@@ -298,6 +319,28 @@ describe('useCopyIssueDetails', () => {
expect(result).not.toContain('**Handled:**');
});
+ it('strips ANSI codes from exception values when they contain them', () => {
+ const eventWithException = EventFixture({
+ ...event,
+ entries: [
+ {
+ type: EntryType.EXCEPTION,
+ data: {
+ values: [{type: 'ValueError', value: '\x1B[31mfailed\x1B[0m to connect'}],
+ },
+ },
+ ],
+ });
+
+ const result = issueAndEventToMarkdown({
+ group,
+ event: eventWithException,
+ organization,
+ });
+
+ expect(result).toContain('**Value:** failed to connect\n');
+ });
+
it('marks an unhandled exception', () => {
const eventWithUnhandled = EventFixture({
...event,
@@ -562,6 +605,35 @@ describe('useCopyIssueDetails', () => {
expect(result).toContain(' User clicked submit');
});
+ it('strips ANSI codes from breadcrumb messages when they contain them', () => {
+ const eventWithBreadcrumbs = EventFixture({
+ ...event,
+ entries: [
+ {
+ type: EntryType.BREADCRUMBS,
+ data: {
+ values: [
+ {
+ type: 'default',
+ category: 'console',
+ level: 'info',
+ message: '\x1B[32mserver started\x1B[0m',
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ const result = issueAndEventToMarkdown({
+ group,
+ event: eventWithBreadcrumbs,
+ organization,
+ });
+
+ expect(result).toContain('- **default** `console` [info]\n server started\n');
+ });
+
it('truncates a single breadcrumb to the per-crumb character limit', () => {
const longMessage = 'x'.repeat(600);
const eventWithLongBreadcrumb = EventFixture({
diff --git a/static/app/views/issueDetails/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/hooks/useCopyIssueDetails.tsx
index 4e0674c68e93..7cd5f0fbb278 100644
--- a/static/app/views/issueDetails/hooks/useCopyIssueDetails.tsx
+++ b/static/app/views/issueDetails/hooks/useCopyIssueDetails.tsx
@@ -20,6 +20,7 @@ import type {Group} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import type {StacktraceType} from 'sentry/types/stacktrace';
import {trackAnalytics} from 'sentry/utils/analytics';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
import {getFormat, getUserTimezone} from 'sentry/utils/dates';
import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard';
import {useOrganization} from 'sentry/utils/useOrganization';
@@ -99,7 +100,7 @@ function formatBreadcrumbsToMarkdown(crumbs: RawCrumb[]): string {
const entries: string[] = [];
crumbs.slice(-MAX_BREADCRUMBS).forEach(crumb => {
- const message = crumb.message ?? '';
+ const message = stripAnsi(crumb.message ?? '');
// Drop empty values, matching Seer's `{k: v for k, v in data if v}`.
const data = crumb.data
@@ -212,7 +213,7 @@ function formatEventToMarkdown(event: Event, activeThreadId: number | undefined)
markdownText += `**Handled:** ${handled ? 'Yes' : 'No'}\n`;
}
if (exception.value) {
- markdownText += `**Value:** ${exception.value}\n\n`;
+ markdownText += `**Value:** ${stripAnsi(exception.value)}\n\n`;
}
// Add stacktrace if available
@@ -285,7 +286,8 @@ export const issueAndEventToMarkdown = ({
}
// Format the basic issue information
- let markdownText = `# ${group.title}\n\n`;
+ const title = stripAnsi(group.title);
+ let markdownText = `# ${title}\n\n`;
markdownText += `**Issue ID:** ${group.id}\n`;
if (group.shortId) {
@@ -313,8 +315,8 @@ export const issueAndEventToMarkdown = ({
// Mirror Seer: include the event message only when it adds something beyond
// the title, since for most errors the title already is the message.
- const message = event?.message?.trim();
- if (message && !group.title.includes(message)) {
+ const message = stripAnsi(event?.message ?? '').trim();
+ if (message && !title.includes(message)) {
markdownText += `\n## Message\n\n${message}\n`;
}