diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx index ae714e0afd6d..8d2f0aa2ac0b 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx @@ -791,6 +791,35 @@ describe('LogsInfiniteTable', () => { ); }); + it('renders colored text without escape codes when an injected error row has an ANSI title', async () => { + const traceError: TraceTree.TraceError = { + event_id: 'abc123def456', + issue: 'JAVASCRIPT-1', + issue_id: 42, + level: 'error', + message: 'Boom happened', + project_id: Number(project.id), + project_slug: project.slug, + span: 'span1', + title: 'TypeError: \x1B[31mBoom\x1B[0m happened', + timestamp: new Date('2100-01-01T00:00:00Z').getTime() / 1000, + }; + + renderWithProviders( + + ); + + const colored = await screen.findByText('Boom'); + + expect(colored.style.color).toContain('color-mix(in srgb,'); + expect(screen.getByTestId('log-table-cell-error')).toHaveTextContent( + /^TypeError: Boom happened$/ + ); + }); + it('renders injected error rows without the empty state when the logs query is empty', async () => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ diff --git a/static/app/views/explore/logs/tables/logsTableRow.tsx b/static/app/views/explore/logs/tables/logsTableRow.tsx index c416aebdc6ed..addca1e755c2 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.tsx @@ -9,6 +9,7 @@ import {Button, LinkButton} from '@sentry/scraps/button'; import type {MenuItemProps} from '@sentry/scraps/dropdownMenu'; import {Flex} from '@sentry/scraps/layout'; +import {AnsiText} from 'sentry/components/ansiText'; import {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -618,7 +619,7 @@ export const LogRowContent = memo(function LogRowContentImpl({ {isErrorRow ? ( - {String(dataRow[OurLogKnownFieldKey.MESSAGE] ?? '')} + {String(dataRow[OurLogKnownFieldKey.MESSAGE] ?? '')} ) : ( fields?.map((field, index) => { diff --git a/static/app/views/performance/traceDetails/traceHeader/title.spec.tsx b/static/app/views/performance/traceDetails/traceHeader/title.spec.tsx new file mode 100644 index 000000000000..89bc6ebe9f87 --- /dev/null +++ b/static/app/views/performance/traceDetails/traceHeader/title.spec.tsx @@ -0,0 +1,64 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; +import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; +import type {TraceRootEventQueryResults} from 'sentry/views/performance/traceDetails/traceApi/useTraceRootEvent'; +import {Title} from 'sentry/views/performance/traceDetails/traceHeader/title'; +import {ErrorNode} from 'sentry/views/performance/traceDetails/traceModels/traceTreeNode/errorNode'; +import {makeTraceError} from 'sentry/views/performance/traceDetails/traceModels/traceTreeTestUtils'; + +const rootEventResults = { + data: undefined, + isLoading: false, + status: 'pending', +} as TraceRootEventQueryResults; + +function makeLog(message: string) { + return { + [OurLogKnownFieldKey.MESSAGE]: message, + [OurLogKnownFieldKey.SEVERITY]: 'info', + } as OurLogsResponseItem; +} + +describe('Title', () => { + it('renders colored text without escape codes when the representative log message has ANSI codes', () => { + render( + + ); + + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('failed').parentElement).toHaveTextContent( + /^failed to connect$/ + ); + }); + + it('renders colored text without escape codes when the representative error title has ANSI codes', () => { + const node = new ErrorNode( + null, + makeTraceError({title: '\x1B[31mfailed\x1B[0m to connect'}), + {organization: OrganizationFixture()} + ); + + render( + <Title + isLoading={false} + representativeEvent={{event: node, dataset: null}} + rootEventResults={rootEventResults} + /> + ); + + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('failed').parentElement).toHaveTextContent( + /^failed to connect$/ + ); + }); +}); diff --git a/static/app/views/performance/traceDetails/traceHeader/title.tsx b/static/app/views/performance/traceDetails/traceHeader/title.tsx index 9bf428eb1a4d..73f355d1462e 100644 --- a/static/app/views/performance/traceDetails/traceHeader/title.tsx +++ b/static/app/views/performance/traceDetails/traceHeader/title.tsx @@ -5,6 +5,7 @@ import {LinkButton} from '@sentry/scraps/button'; import {Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; +import {AnsiText} from 'sentry/components/ansiText'; import {IconPlay} from 'sentry/icons'; import {t} from 'sentry/locale'; import {ReplayContextKey} from 'sentry/types/event'; @@ -103,12 +104,12 @@ export function Title({isLoading, representativeEvent, rootEventResults}: TitleP return ( <Stack align="start" width="100%"> <Text size="xl" bold ellipsis> - {traceTitle.title} + <AnsiText>{traceTitle.title}</AnsiText> </Text> {traceTitle.subtitle && ( <Flex align="center" gap="sm" width="100%"> <Text size="md" ellipsis variant="muted"> - {traceTitle.subtitle} + <AnsiText>{traceTitle.subtitle}</AnsiText> </Text> <ContextBadges rootEventResults={rootEventResults} /> </Flex> diff --git a/static/app/views/performance/traceDetails/traceModels/traceTreeNode/errorNode.spec.tsx b/static/app/views/performance/traceDetails/traceModels/traceTreeNode/errorNode.spec.tsx index 0a1cdb116a1a..f2639f2346ba 100644 --- a/static/app/views/performance/traceDetails/traceModels/traceTreeNode/errorNode.spec.tsx +++ b/static/app/views/performance/traceDetails/traceModels/traceTreeNode/errorNode.spec.tsx @@ -274,6 +274,18 @@ describe('ErrorNode', () => { expect(node.drawerTabsTitle).toBe('Test Error'); }); + it('should strip ANSI escape codes from drawerTabsTitle', () => { + const extra = createMockExtra(); + const value = makeTraceError({ + title: '\x1B[31mTest\x1B[0m Error', + level: 'error', + }); + + const node = new ErrorNode(null, value, extra); + + expect(node.drawerTabsTitle).toBe('Test Error'); + }); + it('should return fallback drawerTabsTitle without description', () => { const extra = createMockExtra(); const value = makeTraceError({ diff --git a/static/app/views/performance/traceDetails/traceModels/traceTreeNode/errorNode.tsx b/static/app/views/performance/traceDetails/traceModels/traceTreeNode/errorNode.tsx index 67f5fcaae628..eb7aa658c409 100644 --- a/static/app/views/performance/traceDetails/traceModels/traceTreeNode/errorNode.tsx +++ b/static/app/views/performance/traceDetails/traceModels/traceTreeNode/errorNode.tsx @@ -1,6 +1,7 @@ import type {Theme} from '@emotion/react'; import {t} from 'sentry/locale'; +import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; import {ErrorNodeDetails} from 'sentry/views/performance/traceDetails/traceDrawer/details/error'; import type {TraceTreeNodeDetailsProps} from 'sentry/views/performance/traceDetails/traceDrawer/tabs/traceTreeNodeDetails'; import {isTraceError} from 'sentry/views/performance/traceDetails/traceGuards'; @@ -60,7 +61,7 @@ export class ErrorNode extends BaseNode<TraceTree.TraceErrorIssue> { } get drawerTabsTitle(): string { - return this.description || t('Error'); + return (this.description && stripAnsi(this.description)) || t('Error'); } analyticsName(): string { diff --git a/static/app/views/performance/traceDetails/traceRow/traceErrorRow.spec.tsx b/static/app/views/performance/traceDetails/traceRow/traceErrorRow.spec.tsx new file mode 100644 index 000000000000..a056725680b7 --- /dev/null +++ b/static/app/views/performance/traceDetails/traceRow/traceErrorRow.spec.tsx @@ -0,0 +1,61 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ThemeFixture} from 'sentry-fixture/theme'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {ErrorNode} from 'sentry/views/performance/traceDetails/traceModels/traceTreeNode/errorNode'; +import {makeTraceError} from 'sentry/views/performance/traceDetails/traceModels/traceTreeTestUtils'; +import {TraceScheduler} from 'sentry/views/performance/traceDetails/traceRenderers/traceScheduler'; +import {TraceView} from 'sentry/views/performance/traceDetails/traceRenderers/traceView'; +import {VirtualizedViewManager} from 'sentry/views/performance/traceDetails/traceRenderers/virtualizedViewManager'; +import {TraceErrorRow} from 'sentry/views/performance/traceDetails/traceRow/traceErrorRow'; + +describe('TraceErrorRow', () => { + it('renders colored text without escape codes when the error title has ANSI codes', () => { + const theme = ThemeFixture(); + const node = new ErrorNode( + null, + makeTraceError({title: '\x1B[31mfailed\x1B[0m to connect', timestamp: 1}), + {organization: OrganizationFixture()} + ); + const manager = new VirtualizedViewManager( + {list: {width: 0.5}, span_list: {width: 0.5}}, + new TraceScheduler(), + new TraceView(), + theme + ); + + render( + <TraceErrorRow + index={0} + listColumnClassName="" + listColumnStyle={{}} + manager={manager} + node={node} + onExpand={jest.fn()} + onExpandDoubleClick={jest.fn()} + onRowClick={jest.fn()} + onRowDoubleClick={jest.fn()} + onRowKeyDown={jest.fn()} + onSpanArrowClick={jest.fn()} + previouslyFocusedNodeRef={{current: null}} + projects={{}} + registerListColumnRef={jest.fn()} + registerSpanArrowRef={jest.fn()} + registerSpanColumnRef={jest.fn()} + rowSearchClassName="" + spanColumnClassName="" + style={{}} + tabIndex={-1} + theme={theme} + trace_id={undefined} + virtualized_index={0} + /> + ); + + expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('failed').closest('.TraceDescription')).toHaveTextContent( + /^failed to connect$/ + ); + }); +}); diff --git a/static/app/views/performance/traceDetails/traceRow/traceErrorRow.tsx b/static/app/views/performance/traceDetails/traceRow/traceErrorRow.tsx index 1bea9b2207c4..f404e7bc7e48 100644 --- a/static/app/views/performance/traceDetails/traceRow/traceErrorRow.tsx +++ b/static/app/views/performance/traceDetails/traceRow/traceErrorRow.tsx @@ -1,5 +1,6 @@ import {PlatformIcon} from 'platformicons'; +import {AnsiText} from 'sentry/components/ansiText'; import {t} from 'sentry/locale'; import type {Level} from 'sentry/types/event'; import {TraceIcons} from 'sentry/views/performance/traceDetails/traceIcons'; @@ -66,8 +67,10 @@ export function TraceErrorRow(props: TraceRowProps<ErrorNode>) { {ERROR_LEVEL_LABELS[props.node.value.level ?? 'error']} </span> <strong className="TraceEmDash"> — </strong> - {/* oxlint-disable-next-line react/refs */} - <span className="TraceDescription">{description}</span> + <span className="TraceDescription"> + {/* oxlint-disable-next-line react/refs */} + <AnsiText>{description ?? ''}</AnsiText> + </span> </div> </div> {/* oxlint-disable-next-line react/refs -- This is a React element, not a ref. */}