(
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;
diff --git a/static/app/components/events/interfaces/crashContent/exception/content.spec.tsx b/static/app/components/events/interfaces/crashContent/exception/content.spec.tsx
index fd306ae61ec3..3c6c774f7917 100644
--- a/static/app/components/events/interfaces/crashContent/exception/content.spec.tsx
+++ b/static/app/components/events/interfaces/crashContent/exception/content.spec.tsx
@@ -309,6 +309,42 @@ describe('Exception Content', () => {
expect(screen.queryByRole('button', {name: 'View Section'})).not.toBeInTheDocument();
});
+ it('renders colored text without escape codes when given an ANSI exception value', () => {
+ const event = EventFixture({
+ projectID: project.id,
+ entries: [
+ {
+ type: EntryType.EXCEPTION,
+ data: {
+ values: [
+ {
+ type: 'ValueError',
+ value: '\x1B[31mfailed\x1B[0m to connect',
+ stacktrace: {frames: [EventStacktraceFrameFixture()]},
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ render(
+
+ );
+
+ expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,');
+ expect(screen.getByText('failed').closest('pre')).toHaveTextContent(
+ /^failed to connect$/
+ );
+ });
+
describe('exception groups', () => {
const event = EventFixture({
entries: [EventEntryExceptionGroupFixture()],
diff --git a/static/app/components/events/interfaces/crashContent/exception/content.tsx b/static/app/components/events/interfaces/crashContent/exception/content.tsx
index 5e5fe89e1309..0fb7d533176b 100644
--- a/static/app/components/events/interfaces/crashContent/exception/content.tsx
+++ b/static/app/components/events/interfaces/crashContent/exception/content.tsx
@@ -5,6 +5,7 @@ import {Button} from '@sentry/scraps/button';
import {Container} from '@sentry/scraps/layout';
import {Tooltip} from '@sentry/scraps/tooltip';
+import {AnsiText} from 'sentry/components/ansiText';
import {ErrorBoundary} from 'sentry/components/errorBoundary';
import {StacktraceBanners} from 'sentry/components/events/interfaces/crashContent/exception/banners/stacktraceBanners';
import {
@@ -178,9 +179,11 @@ function InnerContent({
const exceptionValue =
type === StackType.ORIGINAL ? exception.value : exception.rawValue || exception.value;
- const renderedExceptionValue = exceptionValue
- ? renderLinksInText({exceptionText: exceptionValue})
- : null;
+ const renderedExceptionValue = exceptionValue ? (
+ renderLinksInText({exceptionText: text})}>
+ {exceptionValue}
+
+ ) : null;
const platform = getStacktracePlatform(event, exception.stacktrace);
// The banners should appear on the top exception only
diff --git a/static/app/components/events/interfaces/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/exception/relatedExceptions.spec.tsx b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx
index d513a39faef1..e46bec80a9bf 100644
--- a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx
+++ b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx
@@ -76,6 +76,21 @@ describe('ExceptionGroupContext', () => {
).toBeInTheDocument();
});
+ it('renders colored text without escape codes when given ANSI exception values', () => {
+ const allExceptions = defaultProps.allExceptions.map(exception =>
+ exception.type === 'TypeError'
+ ? {...exception, value: '\x1B[31mnested\x1B[0m failure'}
+ : exception
+ );
+
+ render();
+
+ expect(
+ screen.getByRole('button', {name: 'TypeError: nested failure'})
+ ).toBeInTheDocument();
+ expect(screen.getByText('nested').style.color).toContain('color-mix(in srgb,');
+ });
+
it('does not render for sub-exception', () => {
const {container} = render(
diff --git a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx
index fbbaf4bad3ee..30f991300b7d 100644
--- a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx
+++ b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
import {Button} from '@sentry/scraps/button';
+import {AnsiText} from 'sentry/components/ansiText';
import {t} from 'sentry/locale';
import type {ExceptionValue} from 'sentry/types/event';
import type {StackTraceMechanism} from 'sentry/types/stacktrace';
@@ -38,10 +39,20 @@ type ExceptionTreeItemProps = {
function getExceptionName(exception: ExceptionValue) {
if (exception.type) {
- return exception.value ? `${exception.type}: ${exception.value}` : exception.type;
+ return exception.value ? (
+
+ {exception.type}: {exception.value}
+
+ ) : (
+ exception.type
+ );
}
- return exception.value ?? t('Exception');
+ return defined(exception.value) ? (
+ {exception.value}
+ ) : (
+ t('Exception')
+ );
}
function ExceptionLink({exception, link, onExceptionClick}: ExceptionLinkProps) {
diff --git a/static/app/components/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/events/interfaces/message.spec.tsx b/static/app/components/events/interfaces/message.spec.tsx
index 277db1c261eb..ddc550fdfbbf 100644
--- a/static/app/components/events/interfaces/message.spec.tsx
+++ b/static/app/components/events/interfaces/message.spec.tsx
@@ -59,4 +59,19 @@ describe('Message entry', () => {
'/settings/org-slug/security-and-privacy/'
);
});
+
+ it('renders colored text and links without escape codes when given an ANSI message', () => {
+ const formatted = '\x1B[31mfailed\x1B[0m to fetch https://example.com/status';
+ const event = EventFixture({
+ entries: [{type: 'message', data: {formatted}}],
+ });
+
+ render();
+
+ expect(screen.getByText('failed').closest('pre')).toHaveTextContent(
+ /^failed to fetch https:\/\/example\.com\/status$/
+ );
+ expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,');
+ expect(screen.getByText('https://example.com/status').tagName).toBe('A');
+ });
});
diff --git a/static/app/components/events/interfaces/message.tsx b/static/app/components/events/interfaces/message.tsx
index 735bea611040..8713593f7137 100644
--- a/static/app/components/events/interfaces/message.tsx
+++ b/static/app/components/events/interfaces/message.tsx
@@ -1,5 +1,6 @@
import styled from '@emotion/styled';
+import {AnsiText} from 'sentry/components/ansiText';
import {renderLinksInText} from 'sentry/components/events/interfaces/crashContent/exception/utils';
import {AnnotatedText} from 'sentry/components/events/meta/annotatedText';
import {StructuredData} from 'sentry/components/structuredEventData';
@@ -59,9 +60,11 @@ function renderParams(params: Props['data']['params'], meta: any) {
export function Message({data, event}: Props) {
const entryIndex = event.entries.findIndex(entry => entry.type === EntryType.MESSAGE);
const meta = event?._meta?.entries?.[entryIndex] ?? {};
- const messageData = data.formatted
- ? renderLinksInText({exceptionText: data.formatted})
- : null;
+ const messageData = data.formatted ? (
+ renderLinksInText({exceptionText: text})}>
+ {data.formatted}
+
+ ) : null;
return (
diff --git a/static/app/components/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}
+
+ {titleLabel}
+
) : (
- titleLabel
+ {titleLabel}
)}
);
diff --git a/static/app/components/stackTrace/exceptionGroup.spec.tsx b/static/app/components/stackTrace/exceptionGroup.spec.tsx
index d6abc9b8d9f3..aa7196cbcd9e 100644
--- a/static/app/components/stackTrace/exceptionGroup.spec.tsx
+++ b/static/app/components/stackTrace/exceptionGroup.spec.tsx
@@ -137,4 +137,34 @@ describe('exceptionGroup', () => {
expect(screen.getByTestId('exc-3')).toBeInTheDocument();
expect(screen.getByTestId('exc-4')).toBeInTheDocument();
});
+
+ it('renders colored text without escape codes when given ANSI exception values', () => {
+ const values = makeValues().map(exception => {
+ if (exception.type === 'ValueError') {
+ return {...exception, value: '\x1B[31mbad\x1B[0m value'};
+ }
+ if (exception.mechanism?.exception_id === 0) {
+ return {...exception, value: '\x1B[32mroot\x1B[0m group'};
+ }
+ return exception;
+ });
+
+ render(
+
+ );
+
+ expect(
+ screen.getByRole('button', {name: 'ValueError: bad value'})
+ ).toBeInTheDocument();
+ expect(screen.getByText('bad').style.color).toContain('color-mix(in srgb,');
+ expect(screen.getByText('root').closest('div')).toHaveTextContent(
+ /^ExceptionGroup: root group$/
+ );
+ expect(screen.getByText('root').style.color).toContain('color-mix(in srgb,');
+ });
});
diff --git a/static/app/components/stackTrace/exceptionGroup.tsx b/static/app/components/stackTrace/exceptionGroup.tsx
index aa80b52d0c6b..7b8439abd016 100644
--- a/static/app/components/stackTrace/exceptionGroup.tsx
+++ b/static/app/components/stackTrace/exceptionGroup.tsx
@@ -5,6 +5,7 @@ import {Button} from '@sentry/scraps/button';
import {Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
+import {AnsiText} from 'sentry/components/ansiText';
import {t, tn} from 'sentry/locale';
import type {ExceptionValue} from 'sentry/types/event';
import {defined} from 'sentry/utils/defined';
@@ -59,9 +60,19 @@ export function useHiddenExceptions(values: ExceptionValue[]) {
function getExceptionName(exception: ExceptionValue) {
if (exception.type) {
- return exception.value ? `${exception.type}: ${exception.value}` : exception.type;
+ return exception.value ? (
+
+ {exception.type}: {exception.value}
+
+ ) : (
+ exception.type
+ );
}
- return exception.value ?? t('Exception');
+ return defined(exception.value) ? (
+ {exception.value}
+ ) : (
+ t('Exception')
+ );
}
interface ToggleRelatedExceptionsButtonProps {
diff --git a/static/app/components/stackTrace/exceptionHeader.spec.tsx b/static/app/components/stackTrace/exceptionHeader.spec.tsx
new file mode 100644
index 000000000000..43bbfd00b83b
--- /dev/null
+++ b/static/app/components/stackTrace/exceptionHeader.spec.tsx
@@ -0,0 +1,17 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {ExceptionDescription} from 'sentry/components/stackTrace/exceptionHeader';
+
+describe('ExceptionDescription', () => {
+ it('renders colored text and links without escape codes when given an ANSI value', () => {
+ const value = '\x1B[31mfailed\x1B[0m to fetch https://example.com/status';
+
+ const {container} = render();
+
+ expect(container).toHaveTextContent(
+ /^failed to fetch https:\/\/example\.com\/status$/
+ );
+ expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,');
+ expect(screen.getByText('https://example.com/status').tagName).toBe('A');
+ });
+});
diff --git a/static/app/components/stackTrace/exceptionHeader.tsx b/static/app/components/stackTrace/exceptionHeader.tsx
index b342ddb112be..600badf4b1c6 100644
--- a/static/app/components/stackTrace/exceptionHeader.tsx
+++ b/static/app/components/stackTrace/exceptionHeader.tsx
@@ -4,6 +4,7 @@ import {Stack} from '@sentry/scraps/layout';
import {Heading} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';
+import {AnsiText} from 'sentry/components/ansiText';
import {Mechanism} from 'sentry/components/events/interfaces/crashContent/exception/mechanism';
import {renderLinksInText} from 'sentry/components/events/interfaces/crashContent/exception/utils';
import {AnnotatedText} from 'sentry/components/events/meta/annotatedText';
@@ -54,7 +55,11 @@ export function ExceptionDescription({
) : value ? (
- {renderLinksInText({exceptionText: value})}
+
+ renderLinksInText({exceptionText: text})}>
+ {value}
+
+
) : null}
{mechanism && }
diff --git a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
index ccd17952dc97..82e193c26a2b 100644
--- a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
+++ b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
@@ -526,6 +526,33 @@ describe('IssueStackTrace', () => {
`);
});
+ it('copies stack trace text without ANSI codes when the exception value contains them', async () => {
+ const {event, stacktrace} = makeCopyTestData();
+
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole('button', {name: 'Copy as'}));
+ await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Text'}));
+
+ const copiedText = jest.mocked(navigator.clipboard.writeText).mock.calls[0]![0];
+ expect(copiedText).toContain('ValueError: list index out of range');
+ });
+
it('copies stack trace text including exception type and value for chained exceptions', async () => {
const {event, stacktrace} = makeCopyTestData();
diff --git a/static/app/utils/getStacktraceBody.spec.tsx b/static/app/utils/getStacktraceBody.spec.tsx
index f0f0789852b3..804ad00c1865 100644
--- a/static/app/utils/getStacktraceBody.spec.tsx
+++ b/static/app/utils/getStacktraceBody.spec.tsx
@@ -27,6 +27,17 @@ Error: an error occurred`,
expect(result).toEqual(['Something is broken']);
});
+ it('strips ANSI codes when the message contains them', () => {
+ const result = getStacktraceBody({
+ event: EventFixture({
+ entries: [
+ {type: 'message', data: {formatted: '\x1B[31mSomething\x1B[0m is broken'}},
+ ],
+ }),
+ });
+ expect(result).toEqual(['Something is broken']);
+ });
+
it('formats with a thread', () => {
const result = getStacktraceBody({event: eventThreads});
expect(result).toEqual([
diff --git a/static/app/utils/getStacktraceBody.tsx b/static/app/utils/getStacktraceBody.tsx
index ccdcd72794b4..ac185f17ed55 100644
--- a/static/app/utils/getStacktraceBody.tsx
+++ b/static/app/utils/getStacktraceBody.tsx
@@ -1,5 +1,6 @@
import {displayRawContent as rawStacktraceContent} from 'sentry/components/events/interfaces/crashContent/stackTrace/rawContent';
import type {Event} from 'sentry/types/event';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
type GetStacktraceBodyArgs = {
/**
@@ -51,7 +52,7 @@ export function getStacktraceBody({
if (!msg) {
return [];
}
- return msg?.data?.formatted && [msg.data.formatted];
+ return msg?.data?.formatted && [stripAnsi(msg.data.formatted)];
}
}
diff --git a/static/app/views/issueDetails/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) {
-
- {primaryTitle}
+
+
+ {primaryTitle}
+
{isAIDetectedIssue && }
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`;
}
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}