diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx index bccdc8c58c50..36c32457d2c6 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx @@ -140,18 +140,17 @@ export function NativeContent({ address: frame.instructionAddr, }); - try { - const relativeAddress = ( - parseAddress(frame.instructionAddr) - - parseAddress(correspondingImage.image_addr) - ).toString(16); - - return maxLengthUntilThisPoint > relativeAddress.length - ? maxLengthUntilThisPoint - : relativeAddress.length; - } catch { + if (!frame.instructionAddr || !correspondingImage?.image_addr) { return maxLengthUntilThisPoint; } + + const relativeAddress = ( + parseAddress(frame.instructionAddr) - parseAddress(correspondingImage.image_addr) + ).toString(16); + + return maxLengthUntilThisPoint > relativeAddress.length + ? maxLengthUntilThisPoint + : relativeAddress.length; }, 0 ); diff --git a/static/app/components/events/interfaces/nativeFrame.tsx b/static/app/components/events/interfaces/nativeFrame.tsx index fe91dcde2bd5..2856476ebb2b 100644 --- a/static/app/components/events/interfaces/nativeFrame.tsx +++ b/static/app/components/events/interfaces/nativeFrame.tsx @@ -34,7 +34,7 @@ import {IconFileBroken} from 'sentry/icons/iconFileBroken'; import {IconRefresh} from 'sentry/icons/iconRefresh'; import {IconWarning} from 'sentry/icons/iconWarning'; import {t, tn} from 'sentry/locale'; -import type {ImageWithCombinedStatus} from 'sentry/types/debugImage'; +import type {Image} from 'sentry/types/debugImage'; import type {Event, Frame} from 'sentry/types/event'; import type {SentryAppSchemaStacktraceLink} from 'sentry/types/integrations'; import type {PlatformKey} from 'sentry/types/platform'; @@ -56,7 +56,7 @@ type Props = { frame: Frame; frameMeta: Record; hiddenFrameCount: number | undefined; - image: ImageWithCombinedStatus; + image: Image | null; isFirstInAppFrame: boolean; /** * Is the stack trace being previewed in a hovercard? diff --git a/static/app/components/events/interfaces/utils.tsx b/static/app/components/events/interfaces/utils.tsx index d9a88503a088..83da614e4688 100644 --- a/static/app/components/events/interfaces/utils.tsx +++ b/static/app/components/events/interfaces/utils.tsx @@ -29,7 +29,11 @@ interface HiddenFrameIndicesProps { toggleFrameMap: Record; } -export function findImageForAddress({event, addrMode, address}: ImageForAddressProps) { +export function findImageForAddress({ + event, + addrMode, + address, +}: ImageForAddressProps): Image | null { const images = event.entries.find(entry => entry.type === 'debugmeta')?.data?.images; if (!images || !address) { @@ -45,7 +49,7 @@ export function findImageForAddress({event, addrMode, address}: ImageForAddressP return addrMode === `rel:${idx}`; }); - return image; + return image ?? null; } export function isRepeatedFrame(frame: Frame, nextFrame?: Frame) { diff --git a/static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx b/static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx index d4495737b8e9..8974ef23a329 100644 --- a/static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx +++ b/static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx @@ -1,11 +1,37 @@ import {EventFixture} from 'sentry-fixture/event'; +import {EventStacktraceFrameFixture} from 'sentry-fixture/eventStacktraceFrame'; +import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import type {EventError, ExceptionType, ExceptionValue, Frame} from 'sentry/types/event'; import {EntryType} from 'sentry/types/event'; - -import {StackTracePreview} from './stackTracePreview'; +import type {StacktraceType} from 'sentry/types/stacktrace'; + +import {StackTracePreview, StackTracePreviewContent} from './stackTracePreview'; + +function makeNativePreview() { + const stacktrace: StacktraceType = { + frames: [ + EventStacktraceFrameFixture({ + filename: 'ViewController.m', + function: 'ViewController.causeCrash', + inApp: true, + instructionAddr: '0x100001000', + package: '/build/CrashyApp.app/CrashyApp', + platform: 'cocoa', + }), + ], + framesOmitted: null, + hasSystemFrames: false, + registers: null, + }; + + return { + event: EventFixture({platform: 'cocoa'}), + stacktrace, + }; +} beforeEach(() => { MockApiClient.clearMockResponses(); @@ -106,4 +132,47 @@ describe('StackTracePreview', () => { // Hide the platform icon for stack trace previews expect(screen.queryAllByRole('img')).toHaveLength(0); }); + + it('renders legacy native stack trace previews without the feature flag', () => { + const {event, stacktrace} = makeNativePreview(); + + render(); + + expect(screen.getByTestId('stack-trace-frame')).toBeInTheDocument(); + expect( + screen.queryByTestId('native-stack-trace-frame-title') + ).not.toBeInTheDocument(); + }); + + it('renders new native stack trace previews with the feature flag', () => { + const {event, stacktrace} = makeNativePreview(); + + render(, { + organization: OrganizationFixture({ + features: ['issue-details-native-stack-trace'], + }), + }); + + expect(screen.getByTestId('native-stack-trace-frame-title')).toBeInTheDocument(); + expect(screen.queryByTestId('stack-trace-frame')).not.toBeInTheDocument(); + }); + + it('shows every system-only native frame in the new preview', () => { + const {event, stacktrace} = makeNativePreview(); + stacktrace.frames = ['system_start', 'system_wait', 'system_end'].map(functionName => + EventStacktraceFrameFixture({ + function: functionName, + inApp: false, + platform: 'cocoa', + }) + ); + + render(, { + organization: OrganizationFixture({features: ['issue-details-native-stack-trace']}), + }); + + expect(screen.getByText('system_start')).toBeInTheDocument(); + expect(screen.getByText('system_wait')).toBeInTheDocument(); + expect(screen.getByText('system_end')).toBeInTheDocument(); + }); }); diff --git a/static/app/components/groupPreviewTooltip/stackTracePreview.tsx b/static/app/components/groupPreviewTooltip/stackTracePreview.tsx index 9da4120dd15a..a817af80e87a 100644 --- a/static/app/components/groupPreviewTooltip/stackTracePreview.tsx +++ b/static/app/components/groupPreviewTooltip/stackTracePreview.tsx @@ -13,12 +13,14 @@ import { } from 'sentry/components/groupPreviewTooltip/utils'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IssueStackTracePreview} from 'sentry/components/stackTrace/issueStackTrace/issueStackTracePreview'; +import {NativeStackTracePreview} from 'sentry/components/stackTrace/native/nativeStackTracePreview'; import {t} from 'sentry/locale'; import type {Event} from 'sentry/types/event'; import {EntryType} from 'sentry/types/event'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils/defined'; import {isNativePlatform} from 'sentry/utils/platform'; +import {useOrganization} from 'sentry/utils/useOrganization'; export function getStacktrace(event: Event): StacktraceType | null { const exceptionsWithStacktrace = @@ -60,6 +62,7 @@ export function StackTracePreviewContent({ stacktrace: StacktraceType; groupingCurrentLevel?: number; }) { + const organization = useOrganization(); const includeSystemFrames = useMemo(() => { return stacktrace?.frames?.every(frame => !frame.inApp) ?? false; }, [stacktrace]); @@ -80,6 +83,17 @@ export function StackTracePreviewContent({ | Partial>; if (isNativePlatform(platform)) { + if (organization.features.includes('issue-details-native-stack-trace')) { + return ( + + ); + } + return ; } diff --git a/static/app/components/stackTrace/frame/actions/chevron.tsx b/static/app/components/stackTrace/frame/actions/chevron.tsx index 7bbfaf268f9d..01308991ae06 100644 --- a/static/app/components/stackTrace/frame/actions/chevron.tsx +++ b/static/app/components/stackTrace/frame/actions/chevron.tsx @@ -1,25 +1,42 @@ import styled from '@emotion/styled'; +import {Button} from '@sentry/scraps/button'; + import { useStackTraceContext, useStackTraceFrameContext, } from 'sentry/components/stackTrace/stackTraceContext'; import {IconChevron} from 'sentry/icons'; +import {t} from 'sentry/locale'; const CHEVRON_SLOT_SIZE = 24; export function ChevronAction() { const {hasAnyExpandableFrames} = useStackTraceContext(); - const {isExpandable, isExpanded} = useStackTraceFrameContext(); + const {frameContextId, isExpandable, isExpanded, toggleExpansion} = + useStackTraceFrameContext(); if (!hasAnyExpandableFrames) { return null; } return ( - + {isExpandable ? ( - + + ) : ( + address + ); + + if (!tooltip) { + return cell; + } + + return {cell}; +} diff --git a/static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx b/static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx new file mode 100644 index 000000000000..6539d603cdd1 --- /dev/null +++ b/static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx @@ -0,0 +1,276 @@ +import {useState} from 'react'; +import styled from '@emotion/styled'; + +import {InfoText} from '@sentry/scraps/info'; +import {Container, Flex, Grid} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {getLeadHint, trimPackage} from 'sentry/components/events/interfaces/frame/utils'; +import {AnnotatedText} from 'sentry/components/events/meta/annotatedText'; +import {useNativeDisplayOptionsContext} from 'sentry/components/stackTrace/native/nativeDisplayOptionsContext'; +import {useNativeStackTraceContext} from 'sentry/components/stackTrace/native/nativeStackTraceContext'; +import { + useStackTraceContext, + useStackTraceFrameContext, + useStackTraceViewState, +} from 'sentry/components/stackTrace/stackTraceContext'; +import type {StackTraceMeta} from 'sentry/components/stackTrace/types'; +import {t} from 'sentry/locale'; +import {defined} from 'sentry/utils/defined'; + +import {isDartAsyncSuspension} from './actions/getSymbolicatorStatus'; +import {NativeDefaultActions} from './actions/nativeDefaultActions'; +import {SymbolicatorStatusIcon} from './actions/symbolicatorStatusIcon'; +import {NativeFrameAddress} from './nativeFrameAddress'; + +type FrameMeta = NonNullable[number]; + +interface NativeFrameHeaderProps { + /** Custom trailing actions; falls back to NativeDefaultActions. */ + actions?: React.ReactNode | ((props: {isHovering: boolean}) => React.ReactNode); +} + +function getFunctionLabel({ + frame, + frameMeta, + verboseFunctionNames, +}: { + frame: ReturnType['frame']; + frameMeta: FrameMeta | undefined; + verboseFunctionNames: boolean; +}) { + const functionNameHiddenDetails = + defined(frame.rawFunction) && + defined(frame.function) && + frame.function !== frame.rawFunction; + + if (verboseFunctionNames && functionNameHiddenDetails && frame.rawFunction) { + return { + value: frame.rawFunction, + meta: frameMeta?.rawFunction?.[''], + }; + } + + if (frame.function) { + return { + value: frame.function, + meta: frameMeta?.function?.[''], + }; + } + + return null; +} + +export function NativeFrameHeader({actions}: NativeFrameHeaderProps) { + const { + event, + frame, + frameIndex, + isExpandable, + isExpanded, + isSubFrame, + nextFrame, + toggleExpansion, + } = useStackTraceFrameContext(); + const {meta} = useStackTraceContext(); + const {view} = useStackTraceViewState(); + const {absoluteFilePaths, verboseFunctionNames} = useNativeDisplayOptionsContext(); + const {hasAnyStatusIcons} = useNativeStackTraceContext(); + const [isHovering, setIsHovering] = useState(false); + + const isDartAsync = isDartAsyncSuspension(frame); + const frameMeta = meta?.frames?.[frameIndex]; + const functionLabel = getFunctionLabel({frame, frameMeta, verboseFunctionNames}); + const packageLabel = frame.package ? trimPackage(frame.package) : null; + const leadsToApp = !frame.inApp && (nextFrame?.inApp || !nextFrame); + const showLeadHint = view === 'app' && !isExpanded && leadsToApp; + + const resolvedActions = + typeof actions === 'function' + ? actions({isHovering}) + : (actions ?? ); + + return ( + + { + const selectedText = window.getSelection()?.toString(); + if (isExpandable && !selectedText) { + toggleExpansion(); + } + }} + onMouseEnter={() => setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + {hasAnyStatusIcons ? ( + + + + ) : null} + + + {showLeadHint ? ( + + + {getLeadHint({event, hasNextFrame: defined(nextFrame)})} + + + ) : null} + + + {packageLabel ?? + (isDartAsync ? ( + t('Dart async') + ) : ( + + {t('')} + + ))} + + + + + + + + + + {functionLabel ? ( + + + + ) : isDartAsync ? ( + t('Dart') + ) : ( + {`<${t('unknown')}>`} + )} + {frame.filename ? ( + + {'('} + {absoluteFilePaths ? (frame.absPath ?? frame.filename) : frame.filename} + {frame.lineNo ? `:${frame.lineNo}` : ''} + {')'} + + ) : null} + + + + {resolvedActions} + + + + ); +} + +const HeaderGrid = styled(Grid)<{ + isExpandable: boolean; + isInAppFrame: boolean; + isSubFrame: boolean; +}>` + min-height: 32px; + cursor: ${p => (p.isExpandable ? 'pointer' : 'default')}; + background: ${p => + !p.isInAppFrame && p.isSubFrame + ? p.theme.colors.surface200 + : p.theme.tokens.background.secondary}; + font-size: ${p => p.theme.font.size.sm}; + color: ${p => + p.isInAppFrame ? p.theme.tokens.content.primary : p.theme.tokens.content.secondary}; + font-style: ${p => (p.isInAppFrame ? 'normal' : 'italic')}; + text-align: left; + + &:hover { + background: ${p => p.theme.tokens.background.tertiary}; + } +`; + +const FunctionName = styled(AnnotatedText)` + min-width: 0; + flex: 0 1 auto; + word-break: break-all; +`; diff --git a/static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx b/static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx new file mode 100644 index 000000000000..6f58ea4a3029 --- /dev/null +++ b/static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx @@ -0,0 +1,699 @@ +import { + render, + screen, + userEvent, + waitFor, + within, +} from 'sentry-test/reactTestingLibrary'; + +import { + DebugMetaSearchProvider, + useDebugMetaSearch, +} from 'sentry/components/events/interfaces/debugMeta/debugMetaSearchContext'; +import {SymbolicatorStatus} from 'sentry/components/events/interfaces/types'; +import {NativeStackTraceViewStateProvider} from 'sentry/components/stackTrace/native/nativeDisplayOptionsContext'; +import {NATIVE_DISPLAY_OPTION} from 'sentry/components/stackTrace/native/nativeDisplayOptionsPersistence'; +import {NativeStackTraceFrames} from 'sentry/components/stackTrace/native/nativeStackTraceFrames'; +import {NativeStackTraceProvider} from 'sentry/components/stackTrace/native/nativeStackTraceProvider'; +import type {StackTraceMeta, StackTraceView} from 'sentry/components/stackTrace/types'; +import {ImageStatus} from 'sentry/types/debugImage'; +import {EntryType, EventOrGroupType, type Event, type Frame} from 'sentry/types/event'; +import type {StacktraceType} from 'sentry/types/stacktrace'; +import {localStorageWrapper} from 'sentry/utils/localStorage'; +import {IssueDetailsContext, SectionKey} from 'sentry/views/issueDetails/context'; +import {getFoldSectionKey} from 'sentry/views/issueDetails/foldSection'; + +function makeFrame(overrides: Partial): Frame { + return { + absPath: null, + colNo: null, + lineNo: null, + context: [], + filename: 'CrashyAppDelegate.m', + function: '-[CrashyAppDelegate applicationDidFinishLaunching:]', + inApp: true, + instructionAddr: '0x100012000', + module: null, + package: '/build/CrashyApp.app/CrashyApp', + platform: 'cocoa', + rawFunction: null, + symbol: null, + symbolAddr: '0x100000000', + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + trust: 'cfi', + vars: {}, + ...overrides, + }; +} + +function makeEvent(_stacktrace: StacktraceType, images: any[] = []): Event { + return { + id: '1', + message: 'EXC_BAD_ACCESS', + title: 'EXC_BAD_ACCESS', + metadata: {}, + entries: images.length ? [{type: EntryType.DEBUGMETA, data: {images} as any}] : [], + projectID: '1', + groupID: '1', + eventID: '12345678901234567890123456789012', + dateCreated: '2019-05-21T18:01:48.762Z', + dateReceived: '2019-05-21T18:01:48.762Z', + tags: [], + errors: [], + crashFile: null, + size: 0, + dist: null, + fingerprints: [], + culprit: '', + user: null, + location: '', + type: EventOrGroupType.ERROR, + occurrence: null, + resolvedWith: [], + contexts: {}, + platform: 'cocoa', + } as Event; +} + +function makeImage(overrides: any = {}) { + return { + type: 'macho', + image_addr: '0x100000000', + image_size: 0x100000, + code_id: 'aaaa', + code_file: '/build/CrashyApp.app/CrashyApp', + debug_id: '11111111-1111-1111-1111-111111111111', + debug_file: 'CrashyApp.dSYM', + arch: 'arm64', + debug_status: ImageStatus.FOUND, + unwind_status: ImageStatus.FOUND, + ...overrides, + }; +} + +function renderFrames( + stacktrace: StacktraceType, + event: Event, + { + defaultView = 'app', + defaultIsNewestFirst = true, + groupingCurrentLevel, + meta, + storageKey, + }: { + defaultIsNewestFirst?: boolean; + defaultView?: StackTraceView; + groupingCurrentLevel?: number; + meta?: StackTraceMeta; + storageKey?: string; + } = {} +) { + return render( + + + + + + ); +} + +function DebugMetaSearchProbe() { + const {searchTerm} = useDebugMetaSearch(); + return
{searchTerm}
; +} + +function renderFramesWithDebugMeta(stacktrace: StacktraceType, event: Event) { + return render( + + + + + + + + +
+ + + ); +} + +describe('NativeFrameRow', () => { + it('renders a relative offset address when a debug image matches', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [makeFrame({instructionAddr: '0x100012abc'})], + }; + renderFrames(stacktrace, makeEvent(stacktrace, [makeImage()])); + + expect(screen.getByText('+0x12abc')).toBeInTheDocument(); + }); + + it('renders the absolute address when no debug image is found', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [makeFrame({instructionAddr: '0xdeadbeef'})], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + expect(screen.getByText('0xdeadbeef')).toBeInTheDocument(); + }); + + it('shows a symbolication error icon when debug files are missing', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + symbolicatorStatus: SymbolicatorStatus.MISSING, + instructionAddr: '0xdeadbeef', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + expect(screen.getByTestId('symbolication-error-icon')).toBeInTheDocument(); + }); + + it("shows an error icon when the resolved image's debug files are missing", () => { + // Image is found for the frame's address, but its debug_status is MISSING. + // The image-level status must win over the frame's symbolicatorStatus. + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + instructionAddr: '0x100012abc', + }), + ], + }; + renderFrames( + stacktrace, + makeEvent(stacktrace, [ + makeImage({ + debug_status: ImageStatus.MISSING, + unwind_status: ImageStatus.MISSING, + }), + ]) + ); + + expect(screen.getByTestId('symbolication-error-icon')).toBeInTheDocument(); + }); + + it("does not show a status icon when the resolved image's debug files are found", () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + instructionAddr: '0x100012abc', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace, [makeImage()])); + + expect(screen.queryByTestId('symbolication-error-icon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('symbolication-warning-icon')).not.toBeInTheDocument(); + }); + + it('renders the function name and trimmed package', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + function: 'main', + package: '/usr/lib/libSystem.B.dylib', + instructionAddr: '0x100012abc', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace, [makeImage()])); + + expect(screen.getByText('main')).toBeInTheDocument(); + expect(screen.getByText('libSystem.B')).toBeInTheDocument(); + }); + + it.each([null, 'CrashyAppDelegate.m'])( + 'shows the filename tooltip when absPath is %s', + async absPath => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [makeFrame({absPath})], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + await userEvent.hover(screen.getByText('(CrashyAppDelegate.m)')); + expect(await screen.findByText('CrashyAppDelegate.m')).toBeInTheDocument(); + } + ); + + it('renders redaction metadata on native frame function names', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + function: 'secret_function', + instructionAddr: '0x100012abc', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace, [makeImage()]), { + meta: { + frames: [ + { + function: { + '': { + chunks: [ + { + type: 'redaction', + text: '', + rule_id: 'project:0', + remark: 's', + }, + {type: 'text', text: ''}, + ], + }, + }, + }, + ], + }, + }); + + expect(screen.getByText('')).toBeInTheDocument(); + expect(screen.queryByText('secret_function')).not.toBeInTheDocument(); + }); + + it('renders grouping markers in default native frame actions', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + function: 'grouping_frame', + inApp: false, + minGroupingLevel: 0, + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace), {groupingCurrentLevel: 0}); + + expect( + screen.getByLabelText('This frame is repeated in every event of this issue') + ).toBeInTheDocument(); + }); + + it('only renders verbose raw functions when they differ from the function name', () => { + const storageKey = 'native-frame-row-verbose-functions'; + localStorageWrapper.setItem( + storageKey, + JSON.stringify([NATIVE_DISPLAY_OPTION.VERBOSE_FUNCTION_NAMES]) + ); + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + function: 'demangled_symbol', + inApp: true, + rawFunction: '_mangled_symbol', + }), + makeFrame({ + function: null, + inApp: true, + rawFunction: 'raw_only_symbol', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace), { + storageKey, + }); + + expect(screen.getByText('_mangled_symbol')).toBeInTheDocument(); + expect(screen.queryByText('demangled_symbol')).not.toBeInTheDocument(); + expect(screen.queryByText('raw_only_symbol')).not.toBeInTheDocument(); + }); + + it('drops the status column when no frame has a status icon', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + instructionAddr: '0x100012abc', + }), + makeFrame({ + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + instructionAddr: '0x100013000', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace, [makeImage()])); + + // None of the frames have an error/warning, so the status column shouldn't + // be reserved on any row. + expect(screen.queryByTestId('symbolication-error-icon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('symbolication-warning-icon')).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('native-stack-trace-status-cell')).toHaveLength(0); + }); + + it('reserves the status column on every row when any frame has an icon', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + // Cleanly symbolicated. + makeFrame({ + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + instructionAddr: '0x100012abc', + }), + // Will trigger an error icon (no image, MISSING status). + makeFrame({ + symbolicatorStatus: SymbolicatorStatus.MISSING, + instructionAddr: '0xdeadbeef', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace, [makeImage()])); + + expect(screen.getByTestId('symbolication-error-icon')).toBeInTheDocument(); + // One status cell reserved per frame, even the cleanly symbolicated row, + // so addresses/packages stay column-aligned. + expect(screen.getAllByTestId('native-stack-trace-status-cell')).toHaveLength(2); + }); + + it('renders Dart async suspension labels for Dart async frames', () => { + // Dart sentinels for async frames: filename or absPath = "" + // and no real package/function payload. The native renderer should + // substitute "Dart async" / "Dart" instead of the generic . + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + filename: '', + absPath: '', + function: null, + package: null, + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + instructionAddr: '0xdeadbeef', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace), {defaultView: 'full'}); + + expect(screen.getByText('Dart')).toBeInTheDocument(); + expect(screen.getByText('Dart async')).toBeInTheDocument(); + // Dart frames are treated as symbolicated, no error icon. + expect(screen.queryByTestId('symbolication-error-icon')).not.toBeInTheDocument(); + }); + + it('hides Dart async suspension frames in app-only view', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: true, + registers: null, + frames: [ + makeFrame({ + filename: '', + absPath: '', + function: null, + inApp: false, + package: null, + instructionAddr: '0xdeadbeef', + }), + makeFrame({function: 'app_main', inApp: true}), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + expect(screen.getByText('app_main')).toBeInTheDocument(); + expect(screen.queryByText('Dart async')).not.toBeInTheDocument(); + expect(screen.queryByText('Dart')).not.toBeInTheDocument(); + }); + + it('shows a warning icon when symbolicatorStatus is MISSING_SYMBOL', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + symbolicatorStatus: SymbolicatorStatus.MISSING_SYMBOL, + instructionAddr: '0xdeadbeef', + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + expect(screen.getByTestId('symbolication-warning-icon')).toBeInTheDocument(); + expect(screen.queryByTestId('symbolication-error-icon')).not.toBeInTheDocument(); + }); + + it('reveals hidden system frames when the toggle is clicked', async () => { + // Default view is "app", which collapses runs of non-app frames into a + // "Show N more frames" toggle on the last visible non-app row. + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: true, + registers: null, + frames: [ + makeFrame({function: 'app_main', inApp: true}), + makeFrame({function: 'hidden_one', inApp: false}), + makeFrame({function: 'hidden_two', inApp: false}), + makeFrame({function: 'hidden_three', inApp: false}), + // Last system frame stays visible (anchor for the toggle). + makeFrame({function: 'visible_tail', inApp: false}), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + expect(screen.getByText('app_main')).toBeInTheDocument(); + expect(screen.getByText('visible_tail')).toBeInTheDocument(); + expect(screen.queryByText('hidden_one')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Show 3 more frames')); + + expect(screen.getByText('hidden_one')).toBeInTheDocument(); + expect(screen.getByText('hidden_two')).toBeInTheDocument(); + expect(screen.getByText('hidden_three')).toBeInTheDocument(); + expect( + screen + .getByText('hidden_one') + .closest('[data-test-id="native-stack-trace-frame-title"]') + ).toHaveAttribute('data-sub-frame', 'true'); + }); + + it('shows native lead hints only in app-only view', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: true, + registers: null, + frames: [ + makeFrame({function: 'system_entry', inApp: false}), + makeFrame({function: 'app_main', inApp: true}), + ], + }; + const event = makeEvent(stacktrace); + const {unmount} = renderFrames(stacktrace, event); + + expect(screen.getByText('Called from')).toBeInTheDocument(); + + unmount(); + renderFrames(stacktrace, event, {defaultView: 'full'}); + + expect(screen.queryByText('Called from')).not.toBeInTheDocument(); + }); + + it('expands and filters images loaded when a frame address is clicked', async () => { + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const collapseStorageKey = getFoldSectionKey(SectionKey.DEBUGMETA); + localStorageWrapper.setItem(collapseStorageKey, JSON.stringify(true)); + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [ + makeFrame({ + addrMode: 'rel:0', + instructionAddr: '0x100012abc', + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + }), + ], + }; + + renderFramesWithDebugMeta(stacktrace, makeEvent(stacktrace, [makeImage()])); + + const addressButton = screen.getByRole('button', { + name: 'Go to images loaded for address +0x12abc', + }); + addressButton.focus(); + await userEvent.keyboard('{Enter}'); + + expect(screen.getByTestId('debug-meta-search-term')).toHaveTextContent( + '11111111-1111-1111-1111-111111111111!0x100012abc' + ); + expect(scrollIntoView).toHaveBeenCalledWith({block: 'start', behavior: 'smooth'}); + await waitFor(() => { + expect(localStorageWrapper.getItem(collapseStorageKey)).toBe('false'); + }); + }); + + it('auto-expands the last in-app frame', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: true, + registers: null, + frames: [ + makeFrame({function: 'app_first', inApp: true, context: [[1, 'first source']]}), + makeFrame({ + function: 'sys_middle', + inApp: false, + context: [[2, 'middle source']], + }), + makeFrame({function: 'app_last', inApp: true, context: [[3, 'last source']]}), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + // Default newest-first reverses the display order, so app_last (the + // last in-app frame in the original array, and the auto-expanded one) + // shows up first. + const titles = screen.getAllByTestId('native-stack-trace-frame-title'); + expect( + within(titles[0]!).getByRole('button', {name: 'Collapse frame details'}) + ).toHaveAttribute('aria-expanded', 'true'); + expect( + within(titles[1]!).getByRole('button', {name: 'Expand frame details'}) + ).toHaveAttribute('aria-expanded', 'false'); + expect( + within(titles[2]!).getByRole('button', {name: 'Expand frame details'}) + ).toHaveAttribute('aria-expanded', 'false'); + }); + + it('auto-expands the first in-app frame when oldest frames are shown first', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: true, + registers: null, + frames: [ + makeFrame({function: 'app_first', inApp: true, context: [[1, 'first source']]}), + makeFrame({ + function: 'sys_middle', + inApp: false, + context: [[2, 'middle source']], + }), + makeFrame({function: 'app_last', inApp: true, context: [[3, 'last source']]}), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace), {defaultIsNewestFirst: false}); + + const titles = screen.getAllByTestId('native-stack-trace-frame-title'); + expect(titles[0]).toHaveTextContent('app_first'); + expect( + within(titles[0]!).getByRole('button', {name: 'Collapse frame details'}) + ).toHaveAttribute('aria-expanded', 'true'); + expect(titles[2]).toHaveTextContent('app_last'); + expect( + within(titles[2]!).getByRole('button', {name: 'Expand frame details'}) + ).toHaveAttribute('aria-expanded', 'false'); + }); + + it('allows a single empty native frame to expand to the empty details message', async () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: {}, + frames: [ + makeFrame({ + context: [], + filename: null, + function: null, + inApp: false, + instructionAddr: null, + package: null, + rawFunction: null, + vars: null, + }), + ], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + const title = screen.getByTestId('native-stack-trace-frame-title'); + const expandButton = within(title).getByRole('button', { + name: 'Expand frame details', + }); + expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + expect( + screen.queryByText('No additional details are available for this frame.') + ).not.toBeInTheDocument(); + + expandButton.focus(); + await userEvent.keyboard('{Enter}'); + + expect( + within(title).getByRole('button', {name: 'Collapse frame details'}) + ).toHaveAttribute('aria-expanded', 'true'); + expect( + screen.getByText('No additional details are available for this frame.') + ).toBeInTheDocument(); + }); + + it('renders an in-app tag for in-app frames', () => { + const stacktrace: StacktraceType = { + framesOmitted: null, + hasSystemFrames: false, + registers: null, + frames: [makeFrame({inApp: true})], + }; + renderFrames(stacktrace, makeEvent(stacktrace)); + + expect(screen.getByText('In App')).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/actions.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/actions.tsx new file mode 100644 index 000000000000..f0915413706f --- /dev/null +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/actions.tsx @@ -0,0 +1,113 @@ +import {Flex} from '@sentry/scraps/layout'; + +import {CopyAsDropdown} from 'sentry/components/copyAsDropdown'; +import {displayRawContent} from 'sentry/components/events/interfaces/crashContent/stackTrace/rawContent'; +import {DisplayOptions} from 'sentry/components/stackTrace/displayOptions'; +import {getOrderedExceptions} from 'sentry/components/stackTrace/issueStackTrace/utils'; +import {NativeDisplayOptionsMenu} from 'sentry/components/stackTrace/native/nativeDisplayOptions'; +import {getNativeFrameCapabilities} from 'sentry/components/stackTrace/native/nativeFrameAnalysis'; +import {RawDownloadAction} from 'sentry/components/stackTrace/native/rawDownloadAction'; +import {useStackTraceViewState} from 'sentry/components/stackTrace/stackTraceContext'; +import {isNativePlatform} from 'sentry/utils/platform'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {useIssueThreadStackTraceContext} from './context'; + +export function IssueThreadStackTraceActions() { + const organization = useOrganization(); + const {activeThreadModel, event, projectSlug} = useIssueThreadStackTraceContext(); + const { + activeException, + activeThread, + exception, + minifiedStacktrace, + platform, + stacktrace, + } = activeThreadModel; + const {isMinified, isNewestFirst, view} = useStackTraceViewState(); + const isNativeStackTrace = isNativePlatform(platform); + const displayedStacktraces = exception?.values.length + ? exception.values.map(value => + isMinified ? (value.rawStacktrace ?? value.stacktrace) : value.stacktrace + ) + : [isMinified ? (minifiedStacktrace ?? stacktrace) : stacktrace]; + const frames = displayedStacktraces.flatMap(trace => trace?.frames ?? []); + const displayOptions = displayedStacktraces.some(Boolean) ? ( + isNativeStackTrace ? ( + + ) : ( + + ) + ) : null; + + const copyItems = CopyAsDropdown.makeDefaultCopyAsOptions({ + text: () => { + const threadInfo = activeThread?.name ? `Thread: ${activeThread.name}\n` : ''; + + if (exception?.values?.length) { + const exceptions = getOrderedExceptions(exception.values, isNewestFirst, view); + const activeExceptionIndex = activeException + ? exception.values.indexOf(activeException) + : -1; + + return ( + threadInfo + + exceptions + .map(exc => { + const isActiveException = exc.exceptionIndex === activeExceptionIndex; + let stacktraceData = exc.stacktrace; + + if (isMinified) { + stacktraceData = isActiveException + ? (minifiedStacktrace ?? exc.rawStacktrace ?? exc.stacktrace) + : (exc.rawStacktrace ?? exc.stacktrace); + } else if (isActiveException) { + stacktraceData = stacktrace ?? exc.stacktrace; + } + + return displayRawContent({ + data: stacktraceData, + platform: stacktraceData?.frames?.[0]?.platform ?? platform, + exception: exc, + isMinified, + }); + }) + .join('\n\n') + ); + } + + const stacktraceData = isMinified ? (minifiedStacktrace ?? stacktrace) : stacktrace; + + if (!stacktraceData) { + return ''; + } + + return ( + threadInfo + + displayRawContent({ + data: stacktraceData, + platform: stacktraceData.frames?.[0]?.platform ?? platform, + isMinified, + }) + ); + }, + json: undefined, + markdown: undefined, + }); + + return ( + + {isNativeStackTrace ? ( + + ) : null} + {displayOptions} + + + ); +} diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadModel.ts b/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadModel.ts new file mode 100644 index 000000000000..f90b7a05b080 --- /dev/null +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadModel.ts @@ -0,0 +1,160 @@ +import {getThreadException} from 'sentry/components/events/interfaces/threads/threadSelector/getThreadException'; +import { + inferPlatform, + isStacktraceNewestFirst, +} from 'sentry/components/events/interfaces/utils'; +import type {StackTraceMeta, StackTraceView} from 'sentry/components/stackTrace/types'; +import type {Event, ExceptionValue, Thread} from 'sentry/types/event'; +import {EntryType} from 'sentry/types/event'; +import type {StacktraceType} from 'sentry/types/stacktrace'; + +function getEntryIndex(event: Event, type: EntryType) { + return event.entries.findIndex(entry => entry.type === type); +} + +function getExceptionStacktraceMeta({ + activeException, + event, +}: { + activeException: ExceptionValue; + event: Event; +}): StackTraceMeta | undefined { + const entryIndex = getEntryIndex(event, EntryType.EXCEPTION); + const exceptionEntry = event.entries[entryIndex]; + const exceptionValues = + exceptionEntry?.type === EntryType.EXCEPTION + ? (exceptionEntry.data.values ?? []) + : []; + let exceptionIndex = exceptionValues.indexOf(activeException); + + if (exceptionIndex === -1 && activeException.threadId !== null) { + exceptionIndex = exceptionValues.findIndex( + value => value.threadId === activeException.threadId + ); + } + if (exceptionIndex === -1 && exceptionValues.length === 1) { + exceptionIndex = 0; + } + + return event._meta?.entries?.[entryIndex]?.data?.values?.[exceptionIndex]?.stacktrace; +} + +function getThreadStacktraceMeta({ + activeThread, + event, +}: { + activeThread: Thread | undefined; + event: Event; +}): StackTraceMeta | undefined { + const entryIndex = getEntryIndex(event, EntryType.THREADS); + const threadsEntry = event.entries[entryIndex]; + const threadIndex = + threadsEntry?.type === EntryType.THREADS + ? (threadsEntry.data.values ?? []).findIndex( + thread => thread.id === activeThread?.id + ) + : -1; + + return event._meta?.entries?.[entryIndex]?.data?.values?.[threadIndex]?.stacktrace; +} + +function getActiveStacktraceMeta({ + activeException, + activeThread, + event, +}: { + activeException: ExceptionValue | undefined; + activeThread: Thread | undefined; + event: Event; +}): StackTraceMeta | undefined { + if (activeException) { + return getExceptionStacktraceMeta({activeException, event}); + } + + return getThreadStacktraceMeta({activeThread, event}); +} + +function getActiveExceptionValue({ + activeThread, + exceptionValues, +}: { + activeThread: Thread | undefined; + exceptionValues: ExceptionValue[]; +}): ExceptionValue | undefined { + return ( + exceptionValues.find(value => value.threadId === activeThread?.id) ?? + exceptionValues[0] + ); +} + +function getActiveStacktrace({ + activeException, + activeThread, +}: { + activeException: ExceptionValue | undefined; + activeThread: Thread | undefined; +}): { + minifiedStacktrace: StacktraceType | undefined; + stacktrace: StacktraceType | undefined; +} { + return { + stacktrace: activeException?.stacktrace ?? activeThread?.stacktrace ?? undefined, + minifiedStacktrace: + activeException?.rawStacktrace ?? activeThread?.rawStacktrace ?? undefined, + }; +} + +function getDefaultView({ + activeThread, + exception, +}: { + activeThread: Thread | undefined; + exception: ReturnType; +}): StackTraceView { + if (exception) { + return exception.values.some(value => !!value.stacktrace?.hasSystemFrames) + ? 'app' + : 'full'; + } + + return activeThread?.stacktrace?.hasSystemFrames ? 'app' : 'full'; +} + +export function getActiveThreadStackTraceModel({ + activeThread, + event, +}: { + activeThread: Thread | undefined; + event: Event; +}) { + const exception = getThreadException(event, activeThread); + const activeException = getActiveExceptionValue({ + activeThread, + exceptionValues: exception?.values ?? [], + }); + const {minifiedStacktrace, stacktrace} = getActiveStacktrace({ + activeException, + activeThread, + }); + const platform = inferPlatform(event, activeThread); + const hasMinifiedStacktrace = + !!activeThread?.rawStacktrace || + !!exception?.values.some(value => !!value.rawStacktrace); + + return { + activeException, + activeThread, + defaultIsNewestFirst: isStacktraceNewestFirst(), + defaultView: getDefaultView({activeThread, exception}), + exception, + hasMinifiedStacktrace, + minifiedStacktrace, + platform, + stacktrace, + stacktraceMeta: getActiveStacktraceMeta({activeException, activeThread, event}), + }; +} + +export type ActiveThreadStackTraceModel = ReturnType< + typeof getActiveThreadStackTraceModel +>; diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadStackTrace.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadStackTrace.tsx new file mode 100644 index 000000000000..3a2e3d16cd01 --- /dev/null +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadStackTrace.tsx @@ -0,0 +1,128 @@ +import {Container, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {ErrorBoundary} from 'sentry/components/errorBoundary'; +import {StacktraceBanners} from 'sentry/components/events/interfaces/crashContent/exception/banners/stacktraceBanners'; +import {SuspectCommits} from 'sentry/components/events/suspectCommits'; +import { + ExceptionDescription, + ExceptionHeader, +} from 'sentry/components/stackTrace/exceptionHeader'; +import { + IssueExceptionStackTrace, + IssueStackTraceFrameList, +} from 'sentry/components/stackTrace/issueStackTrace/exceptionStackTrace'; +import {supportsAppleCrashReport} from 'sentry/components/stackTrace/native/appleCrashReport'; +import {NativeAppleCrashReportContent} from 'sentry/components/stackTrace/native/nativeAppleCrashReportContent'; +import {useStackTraceViewState} from 'sentry/components/stackTrace/stackTraceContext'; +import {t} from 'sentry/locale'; +import type {ExceptionValue} from 'sentry/types/event'; +import {isNativePlatform} from 'sentry/utils/platform'; + +import {useIssueThreadStackTraceContext} from './context'; + +export function ActiveThreadStackTrace() { + const { + activeThreadModel, + event, + groupingCurrentLevel, + hasScmSourceContext, + projectSlug, + } = useIssueThreadStackTraceContext(); + const { + activeException, + activeThread, + exception, + stacktrace, + minifiedStacktrace, + stacktraceMeta, + } = activeThreadModel; + const {view} = useStackTraceViewState(); + const isNativeStackTrace = isNativePlatform(activeThreadModel.platform); + const showAppleCrashReport = + view === 'raw' && + !!exception && + !!stacktrace && + isNativeStackTrace && + supportsAppleCrashReport(event.platform); + + if (exception?.values.length && !showAppleCrashReport) { + return ( + + ); + } + + if (!stacktrace) { + return {t('No stack trace available')}; + } + + return ( + + + {activeException?.stacktrace ? ( + + + + ) : null} + {showAppleCrashReport ? ( + + ) : ( + + )} + + ); +} + +export function IssueThreadStackTraceSuspectCommits() { + const {event, group, projectSlug} = useIssueThreadStackTraceContext(); + + if (!group) { + return null; + } + + return ( + + + + ); +} + +function ExceptionDetails({exception}: {exception: ExceptionValue | undefined}) { + if (!exception) { + return null; + } + + const hasExceptionInfo = Boolean(exception.type || exception.value); + if (!hasExceptionInfo) { + return null; + } + + return ( + + + + + + + ); +} diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/context.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/context.tsx new file mode 100644 index 000000000000..22c355aea41c --- /dev/null +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/context.tsx @@ -0,0 +1,165 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type {ReactNode} from 'react'; + +import {findBestThread} from 'sentry/components/events/interfaces/threads/threadSelector/findBestThread'; +import {NativeStackTraceViewStateProvider} from 'sentry/components/stackTrace/native/nativeDisplayOptionsContext'; +import type {Event, Thread} from 'sentry/types/event'; +import type {Group} from 'sentry/types/group'; +import type {Project} from 'sentry/types/project'; +import {defined} from 'sentry/utils/defined'; +import {useDetailedProject} from 'sentry/utils/project/useDetailedProject'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {setActiveThreadId as setCopyIssueDetailsActiveThreadId} from 'sentry/views/issueDetails/hooks/useCopyIssueDetails'; + +import { + getActiveThreadStackTraceModel, + type ActiveThreadStackTraceModel, +} from './activeThreadModel'; + +interface IssueThreadStackTraceContextValue { + activeThreadModel: ActiveThreadStackTraceModel; + changeThread: (direction: 'previous' | 'next') => void; + event: Event; + group: Group | undefined; + groupingCurrentLevel: Group['metadata']['current_level']; + hasScmSourceContext: boolean; + projectSlug: Project['slug']; + setActiveThread: (thread: Thread | undefined) => void; + threads: Thread[]; +} + +interface IssueThreadStackTraceProvidersProps { + children: ReactNode; + event: Event; + group: Group | undefined; + groupingCurrentLevel: Group['metadata']['current_level']; + projectSlug: Project['slug']; + threads: Thread[]; +} + +const IssueThreadStackTraceContext = + createContext(null); + +export function useIssueThreadStackTraceContext() { + const context = useContext(IssueThreadStackTraceContext); + if (!context) { + throw new Error( + 'useIssueThreadStackTraceContext must be used within IssueThreadStackTrace' + ); + } + return context; +} + +export function useActiveThread() { + return useIssueThreadStackTraceContext().activeThreadModel.activeThread; +} + +export function IssueThreadStackTraceProviders({ + children, + event, + group, + groupingCurrentLevel, + projectSlug, + threads, +}: IssueThreadStackTraceProvidersProps) { + const organization = useOrganization(); + const storageKey = `issue-details-stracktrace-display-${organization.slug}-${projectSlug}`; + const {data: detailedProject} = useDetailedProject( + {orgSlug: organization.slug, projectSlug}, + {enabled: defined(projectSlug)} + ); + const hasScmSourceContext = !!detailedProject?.scmSourceContextEnabled; + const [selectedThreadId, setSelectedThreadId] = useState( + () => findBestThread(threads)?.id + ); + const activeThread = useMemo( + () => + threads.find(thread => thread.id === selectedThreadId) ?? findBestThread(threads), + [selectedThreadId, threads] + ); + const activeThreadModel = useMemo( + () => getActiveThreadStackTraceModel({activeThread, event}), + [activeThread, event] + ); + const setActiveThread = useCallback((thread: Thread | undefined) => { + setSelectedThreadId(thread?.id); + }, []); + + const changeThread = useCallback( + (direction: 'previous' | 'next') => { + setSelectedThreadId(currentId => { + if (!threads.length) { + return; + } + + const selectedThread = + threads.find(thread => thread.id === currentId) ?? findBestThread(threads); + const currentIndex = threads.findIndex( + thread => thread.id === selectedThread?.id + ); + let nextIndex = + direction === 'previous' + ? (currentIndex === -1 ? 0 : currentIndex) - 1 + : (currentIndex === -1 ? 0 : currentIndex) + 1; + if (nextIndex < 0) { + nextIndex = threads.length - 1; + } else if (nextIndex >= threads.length) { + nextIndex = 0; + } + + return threads[nextIndex]?.id; + }); + }, + [threads] + ); + + useEffect(() => { + setCopyIssueDetailsActiveThreadId(activeThreadModel.activeThread?.id); + }, [activeThreadModel.activeThread?.id]); + + const contextValue = useMemo( + () => ({ + activeThreadModel, + changeThread, + event, + group, + groupingCurrentLevel, + hasScmSourceContext, + projectSlug, + setActiveThread, + threads, + }), + [ + activeThreadModel, + changeThread, + event, + group, + groupingCurrentLevel, + hasScmSourceContext, + projectSlug, + setActiveThread, + threads, + ] + ); + + return ( + + + {children} + + + ); +} diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx new file mode 100644 index 000000000000..526b395de5fe --- /dev/null +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx @@ -0,0 +1,858 @@ +import {EventStacktraceFrameFixture} from 'sentry-fixture/eventStacktraceFrame'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {DetailedProjectFixture} from 'sentry-fixture/project'; + +import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; + +import {IssueThreadStackTrace} from 'sentry/components/stackTrace/native/issueThreadStackTrace'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {Event, Thread} from 'sentry/types/event'; +import {EntryType, EventOrGroupType} from 'sentry/types/event'; +import type {PlatformKey} from 'sentry/types/platform'; +import type {StacktraceType} from 'sentry/types/stacktrace'; +import {localStorageWrapper} from 'sentry/utils/localStorage'; + +const organization = OrganizationFixture({slug: 'org-slug'}); +const project = DetailedProjectFixture({slug: 'project-slug'}); +const storageKey = `issue-details-stracktrace-display-${organization.slug}-${project.slug}`; + +function makeStacktrace(functionName: string): StacktraceType { + return { + framesOmitted: null, + hasSystemFrames: true, + registers: null, + frames: [ + EventStacktraceFrameFixture({ + function: 'system_start', + inApp: false, + instructionAddr: '0x100000100', + package: '/usr/lib/system/libsystem.dylib', + platform: 'cocoa', + }), + EventStacktraceFrameFixture({ + function: functionName, + inApp: true, + instructionAddr: '0x100001000', + package: '/build/CrashyApp.app/CrashyApp', + platform: 'cocoa', + rawFunction: `${functionName}(Any) -> ()`, + }), + ], + }; +} + +function makeJavascriptStacktrace(functionName: string): StacktraceType { + return { + framesOmitted: null, + hasSystemFrames: true, + registers: null, + frames: [ + EventStacktraceFrameFixture({ + absPath: 'app/screens/Home.tsx', + colNo: 18, + filename: 'app/screens/Home.tsx', + function: functionName, + inApp: true, + instructionAddr: null, + lineNo: 42, + module: 'app.screens.Home', + package: null, + platform: 'javascript', + }), + ], + }; +} + +function makeThread(overrides: Partial): Thread { + return { + crashed: false, + current: false, + id: 1, + name: 'main', + rawStacktrace: null, + stacktrace: makeStacktrace('ViewController.causeCrash'), + ...overrides, + }; +} + +function makeEvent(threads: Thread[], platform: PlatformKey = 'cocoa'): Event { + return { + id: 'event-id', + message: 'EXC_BAD_ACCESS', + title: 'EXC_BAD_ACCESS', + metadata: {}, + entries: [ + { + type: EntryType.EXCEPTION, + data: { + excOmitted: null, + hasSystemFrames: true, + values: [ + { + mechanism: null, + module: null, + rawStacktrace: null, + stacktrace: threads[0]!.stacktrace, + threadId: threads[0]!.id, + type: 'EXC_BAD_ACCESS', + value: 'Attempted to dereference null pointer', + }, + ], + }, + }, + { + type: EntryType.THREADS, + data: {values: threads}, + }, + ], + projectID: project.id, + groupID: '1', + eventID: 'event-id', + dateCreated: '2019-05-21T18:01:48.762Z', + dateReceived: '2019-05-21T18:01:48.762Z', + tags: [], + errors: [], + crashFile: null, + size: 0, + dist: null, + fingerprints: [], + culprit: '', + user: null, + location: '', + type: EventOrGroupType.ERROR, + occurrence: null, + resolvedWith: [], + contexts: {}, + platform, + } as Event; +} + +function renderThreadStackTrace(event: Event) { + const threadsEntry = event.entries.find(entry => entry.type === EntryType.THREADS)!; + + return render( + , + {organization} + ); +} + +describe('IssueThreadStackTrace', () => { + beforeEach(() => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + body: project, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/stacktrace-link/`, + body: {config: null, sourceUrl: null, integrations: []}, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/prompts-activity/`, + body: {dismissed_ts: undefined, snoozed_ts: undefined}, + }); + ProjectsStore.loadInitialData([project]); + localStorageWrapper.removeItem(storageKey); + Object.assign(navigator, { + clipboard: {writeText: jest.fn().mockResolvedValue(undefined)}, + }); + }); + + it('restores raw and minified choices made on a JavaScript thread in a native event', async () => { + const event = makeEvent([ + makeThread({crashed: true, id: 7}), + makeThread({ + id: 8, + name: 'js-bundle', + stacktrace: makeJavascriptStacktrace('Home.onMount'), + rawStacktrace: makeJavascriptStacktrace('a'), + }), + ]); + const {unmount} = renderThreadStackTrace(event); + + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + expect(await screen.findByText('Home.onMount')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + await userEvent.click(screen.getByRole('option', {name: 'Minified'})); + await userEvent.keyboard('{Escape}'); + expect(await screen.findByText('a')).toBeInTheDocument(); + expect(screen.queryByText('Home.onMount')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + await userEvent.click(screen.getByRole('option', {name: 'Raw Stack Trace'})); + await userEvent.keyboard('{Escape}'); + expect( + screen.getByText('at a (app/screens/Home.tsx:42:18)', {selector: 'pre'}) + ).toBeInTheDocument(); + + unmount(); + // The native thread initially selected after remount uses its Apple report. + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/events/${event.id}/apple-crash-report`, + body: 'Native crash report', + }); + renderThreadStackTrace(event); + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + + expect( + await screen.findByText('at a (app/screens/Home.tsx:42:18)', {selector: 'pre'}) + ).toBeInTheDocument(); + expect(screen.queryByText(/Home.onMount/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + expect(screen.getByRole('option', {name: 'Raw Stack Trace'})).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('option', {name: 'Minified'})).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('renders thread controls and metadata from context', async () => { + const event = makeEvent([ + makeThread({ + crashed: true, + heldLocks: { + '0x0d3a2f0a': { + address: '0x0d3a2f0a', + class_name: 'Object', + package_name: 'java.lang', + thread_id: 11, + type: 8, + }, + }, + id: 7, + state: 'BLOCKED', + }), + makeThread({ + id: 8, + name: 'worker', + stacktrace: makeStacktrace('Worker.run'), + state: 'TIMED_WAITING', + }), + ]); + + renderThreadStackTrace(event); + + const threadSelector = await screen.findByTestId('thread-selector'); + expect(threadSelector).toHaveTextContent('Thread #7'); + expect(within(threadSelector).getByText('main')).toBeInTheDocument(); + expect(screen.getByText('Threads')).toBeInTheDocument(); + expect(screen.getByText('Thread State')).toBeInTheDocument(); + expect(screen.getByText('Thread Tags')).toBeInTheDocument(); + expect(screen.getByText('yes')).toBeInTheDocument(); + expect(screen.getAllByText('Blocked')).toHaveLength(2); + expect( + screen.getAllByText('waiting to lock <0x0d3a2f0a> held by thread 11') + ).toHaveLength(2); + + expect(screen.getByRole('heading', {name: 'EXC_BAD_ACCESS'})).toBeInTheDocument(); + expect(screen.getByText('Attempted to dereference null pointer')).toBeInTheDocument(); + expect(screen.getByText('ViewController.causeCrash')).toBeInTheDocument(); + }); + + it('renders git provider banner for exception-backed native threads', async () => { + const event = makeEvent([makeThread({crashed: true, id: 7})]); + + renderThreadStackTrace(event); + + expect(await screen.findByText('Connect with Git Providers')).toBeInTheDocument(); + }); + + it('does not render git provider banner for thread-only native stack traces', async () => { + const event = makeEvent([makeThread({crashed: true, id: 7})]); + event.entries = event.entries.filter(entry => entry.type !== EntryType.EXCEPTION); + + renderThreadStackTrace(event); + + expect(await screen.findByText('ViewController.causeCrash')).toBeInTheDocument(); + expect(screen.queryByText('Connect with Git Providers')).not.toBeInTheDocument(); + }); + + it('changes the active thread without prop plumbing', async () => { + const event = makeEvent([ + makeThread({crashed: true, id: 7}), + makeThread({ + id: 8, + name: 'worker', + stacktrace: makeStacktrace('Worker.run'), + }), + ]); + + renderThreadStackTrace(event); + + const threadSelector = await screen.findByTestId('thread-selector'); + expect(threadSelector).toHaveTextContent('Thread #7'); + expect(screen.getByText('ViewController.causeCrash')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + + expect(await screen.findByText('Worker.run')).toBeInTheDocument(); + expect(screen.getByTestId('thread-selector')).toHaveTextContent('Thread #8'); + expect(screen.queryByText('ViewController.causeCrash')).not.toBeInTheDocument(); + }); + + it('keeps thread navigation focused across trace types and missing stacks', async () => { + const event = makeEvent([ + makeThread({crashed: true, id: 7}), + makeThread({id: 8, name: 'worker', stacktrace: makeStacktrace('Worker.run')}), + makeThread({ + id: 9, + name: 'js-bundle', + stacktrace: makeJavascriptStacktrace('Home.onMount'), + }), + makeThread({id: 10, name: 'empty', stacktrace: null}), + ]); + + renderThreadStackTrace(event); + + const nextThread = await screen.findByRole('button', {name: 'Next Thread'}); + for (const frame of [ + 'Worker.run', + 'Home.onMount', + 'No stack trace available', + 'ViewController.causeCrash', + ]) { + await userEvent.click(nextThread); + + expect(await screen.findByText(frame)).toBeInTheDocument(); + expect(nextThread).toHaveFocus(); + } + }); + + it.each([true, false])( + 'resets frame state when changing threads (exception-backed: %s)', + async hasException => { + const firstStacktrace = makeStacktrace('FirstThread.run'); + firstStacktrace.frames![1] = EventStacktraceFrameFixture({ + ...firstStacktrace.frames![1], + context: [[42, 'first thread source']], + lineNo: 42, + }); + const secondStacktrace = makeStacktrace('SecondThread.run'); + secondStacktrace.frames![1] = EventStacktraceFrameFixture({ + ...secondStacktrace.frames![1], + context: [[84, 'second thread source']], + lineNo: 84, + }); + const event = makeEvent([ + makeThread({crashed: true, id: 7, stacktrace: firstStacktrace}), + makeThread({id: 8, name: 'worker', stacktrace: secondStacktrace}), + ]); + if (!hasException) { + event.entries = event.entries.filter(entry => entry.type !== EntryType.EXCEPTION); + } + + renderThreadStackTrace(event); + + const firstHeader = ( + await screen.findByText('FirstThread.run') + ).closest('[data-test-id="native-stack-trace-frame-title"]')!; + await userEvent.click( + within(firstHeader).getByRole('button', {name: 'Collapse frame details'}) + ); + expect( + within(firstHeader).getByRole('button', {name: 'Expand frame details'}) + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + + const secondHeader = ( + await screen.findByText('SecondThread.run') + ).closest('[data-test-id="native-stack-trace-frame-title"]')!; + expect( + within(secondHeader).getByRole('button', {name: 'Collapse frame details'}) + ).toHaveAttribute('aria-expanded', 'true'); + } + ); + + it('renders non-native active threads with the generic stack trace rows', async () => { + const event = makeEvent([ + makeThread({crashed: true, id: 7}), + makeThread({ + id: 8, + name: 'js-bundle', + stacktrace: makeJavascriptStacktrace('Home.onMount'), + }), + ]); + + renderThreadStackTrace(event); + + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + + expect(await screen.findByText('Home.onMount')).toBeInTheDocument(); + expect(screen.getByTestId('thread-selector')).toHaveTextContent('Thread #8'); + expect(screen.getAllByTestId('core-stacktrace-frame-title')).toHaveLength(1); + expect(screen.queryAllByTestId('native-stack-trace-frame-title')).toHaveLength(0); + }); + + it('keeps grouping frames visible in most relevant view', async () => { + const event = makeEvent([ + makeThread({ + crashed: true, + id: 7, + stacktrace: { + framesOmitted: null, + hasSystemFrames: true, + registers: null, + frames: [ + EventStacktraceFrameFixture({ + function: 'grouping_frame', + inApp: false, + instructionAddr: '0x100000100', + minGroupingLevel: 0, + package: '/usr/lib/system/libsystem.dylib', + platform: 'cocoa', + }), + EventStacktraceFrameFixture({ + function: 'lead_frame', + inApp: false, + instructionAddr: '0x100000200', + package: '/usr/lib/system/libsystem.dylib', + platform: 'cocoa', + }), + EventStacktraceFrameFixture({ + function: 'app_frame', + inApp: true, + instructionAddr: '0x100001000', + package: '/build/CrashyApp.app/CrashyApp', + platform: 'cocoa', + }), + ], + }, + }), + ]); + + renderThreadStackTrace(event); + + expect(await screen.findByText('grouping_frame')).toBeInTheDocument(); + expect( + screen.getByLabelText('This frame is repeated in every event of this issue') + ).toBeInTheDocument(); + }); + + it('passes native frame header metadata for active thread stack traces', async () => { + const event = makeEvent([makeThread({crashed: true, id: 7})]); + event._meta = { + entries: { + 0: { + data: { + values: { + 0: { + stacktrace: { + frames: { + 1: { + function: { + '': { + chunks: [ + { + type: 'redaction', + text: '', + rule_id: 'project:0', + remark: 's', + }, + {type: 'text', text: ''}, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } as Event['_meta']; + + renderThreadStackTrace(event); + + expect(await screen.findByText('')).toBeInTheDocument(); + expect(screen.queryByText('ViewController.causeCrash')).not.toBeInTheDocument(); + }); + + it('renders chained exceptions for exception-backed native threads', async () => { + const event = makeEvent([makeThread({crashed: true, id: 7})]); + const exceptionEntry = event.entries.find( + entry => entry.type === EntryType.EXCEPTION + ); + if (exceptionEntry?.type !== EntryType.EXCEPTION) { + throw new Error('Expected exception entry'); + } + exceptionEntry.data.values = [ + { + mechanism: null, + module: null, + rawStacktrace: null, + stacktrace: makeStacktrace('Outer.frame'), + threadId: 7, + type: 'OuterError', + value: 'outer failure', + }, + { + mechanism: null, + module: null, + rawStacktrace: null, + stacktrace: makeStacktrace('Inner.frame'), + threadId: 7, + type: 'InnerError', + value: 'inner failure', + }, + ]; + + renderThreadStackTrace(event); + + expect( + await screen.findByText('There are 2 chained exceptions in this event.') + ).toBeInTheDocument(); + expect(screen.getByText('OuterError')).toBeInTheDocument(); + expect(screen.getByText('InnerError')).toBeInTheDocument(); + expect(screen.getByText('outer failure')).toBeInTheDocument(); + expect(screen.getByText('inner failure')).toBeInTheDocument(); + }); + + it('renders available chained exception stack traces when the active exception has no stack trace', async () => { + const event = makeEvent([makeThread({crashed: true, id: 7, stacktrace: null})]); + const exceptionEntry = event.entries.find( + entry => entry.type === EntryType.EXCEPTION + ); + if (exceptionEntry?.type !== EntryType.EXCEPTION) { + throw new Error('Expected exception entry'); + } + exceptionEntry.data.values = [ + { + mechanism: null, + module: null, + rawStacktrace: null, + stacktrace: null, + threadId: 7, + type: 'OuterError', + value: 'outer failure without stacktrace', + }, + { + mechanism: null, + module: null, + rawStacktrace: null, + stacktrace: makeStacktrace('Inner.frame'), + threadId: 7, + type: 'InnerError', + value: 'inner failure', + }, + ]; + + renderThreadStackTrace(event); + + expect(await screen.findByText('InnerError')).toBeInTheDocument(); + expect(screen.getByText('inner failure')).toBeInTheDocument(); + expect(screen.getByText('Inner.frame')).toBeInTheDocument(); + expect(screen.queryByText('No stack trace available')).not.toBeInTheDocument(); + }); + + it('copies the active native thread using raw stack trace formatting', async () => { + const event = makeEvent([ + makeThread({ + crashed: true, + id: 7, + rawStacktrace: makeStacktrace('raw_crash_symbol'), + }), + ]); + + renderThreadStackTrace(event); + + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + await userEvent.click(await screen.findByRole('option', {name: 'Unsymbolicated'})); + + 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('Thread: main\n'); + expect(copiedText).toContain('CrashyApp'); + expect(copiedText).toContain('0x100001000'); + expect(copiedText).toContain('raw_crash_symbol'); + expect(copiedText).toContain('libsystem'); + expect(copiedText).not.toContain('ViewController.causeCrash'); + expect(copiedText).not.toContain('undefined'); + expect(copiedText.indexOf('raw_crash_symbol')).toBeLessThan( + copiedText.indexOf('system_start') + ); + }); + + it('copies the active exception stack trace for exception-backed native threads', async () => { + const event = makeEvent([ + makeThread({ + crashed: true, + id: 7, + stacktrace: makeStacktrace('Thread.onlyFrame'), + }), + ]); + const exceptionEntry = event.entries.find( + entry => entry.type === EntryType.EXCEPTION + ); + if (exceptionEntry?.type !== EntryType.EXCEPTION) { + throw new Error('Expected exception entry'); + } + exceptionEntry.data.values![0]!.stacktrace = makeStacktrace('Exception.visibleFrame'); + + renderThreadStackTrace(event); + + expect(await screen.findByText('Exception.visibleFrame')).toBeInTheDocument(); + + 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('Exception.visibleFrame'); + expect(copiedText).toContain('EXC_BAD_ACCESS: Attempted to dereference null pointer'); + expect(copiedText).not.toContain('Thread.onlyFrame'); + }); + + it('applies native frame detail display options to exception-backed native frames', async () => { + const event = makeEvent([ + makeThread({ + crashed: true, + id: 7, + stacktrace: makeStacktrace('Thread.onlyFrame'), + }), + ]); + const exceptionEntry = event.entries.find( + entry => entry.type === EntryType.EXCEPTION + ); + if (exceptionEntry?.type !== EntryType.EXCEPTION) { + throw new Error('Expected exception entry'); + } + exceptionEntry.data.values![0]!.stacktrace = makeStacktrace('Exception.visibleFrame'); + + renderThreadStackTrace(event); + + expect(await screen.findByText('Exception.visibleFrame')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + await userEvent.click( + await screen.findByRole('option', {name: 'Verbose Function Names'}) + ); + + expect( + await screen.findByText('Exception.visibleFrame(Any) -> ()') + ).toBeInTheDocument(); + }); + + it('matches old raw thread logic for exception and non-exception threads', async () => { + localStorageWrapper.setItem(storageKey, JSON.stringify(['raw-stack-trace'])); + const event = makeEvent([ + makeThread({crashed: true, id: 7}), + makeThread({id: 8, name: 'worker', stacktrace: makeStacktrace('Worker.run')}), + ]); + event.eventID = 'event-uuid-not-used-for-this-endpoint'; + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/events/${event.id}/apple-crash-report`, + match: [MockApiClient.matchQuery({minified: 'false', thread_id: '7'})], + body: `OS Version: iOS 15.5 (21A559) +Report Version: 104 + +Application Specific Information: +happyCustomer (Code: 1) + +Thread 7 Crashed: +0 CrashyApp 0x100001000 ViewController.causeCrash + +Thread 7 crashed with ARM Thread State (64-bit): + x0: 0x0000000000000000 + +Binary Images: +0x100000000 - 0x10000ffff CrashyApp arm64 + +EOF`, + }); + + renderThreadStackTrace(event); + + // Matches old ExceptionContent raw behavior: exception-backed native threads + // render the full Apple crash report. + expect(await screen.findByText(/OS Version:/)).toBeInTheDocument(); + expect(screen.getByText(/Application Specific Information:/)).toBeInTheDocument(); + expect(await screen.findByText(/Thread 7 Crashed:/)).toBeInTheDocument(); + expect(screen.getByText(/ViewController\.causeCrash/)).toBeInTheDocument(); + expect(screen.getByText(/Binary Images:/)).toBeInTheDocument(); + expect( + screen.getByText(/Thread 7 crashed with ARM Thread State/) + ).toBeInTheDocument(); + expect(screen.getByTestId('raw-stack-trace').tagName).toBe('PRE'); + expect(await screen.findByRole('button', {name: 'Download'})).toHaveAttribute( + 'href', + '/projects/org-slug/project-slug/events/event-id/apple-crash-report?minified=false&thread_id=7&download=1' + ); + + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + + // Matches old StackTraceContent raw behavior: native threads without a + // matching exception render formatted stacktrace frames, not a .crash report. + expect(await screen.findByText(/Worker\.run/)).toBeInTheDocument(); + expect(screen.queryByText(/Thread 8/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Download'})).toHaveAttribute( + 'href', + '/projects/org-slug/project-slug/events/event-id/apple-crash-report?minified=false&thread_id=8&download=1' + ); + }); + + it('falls back to formatted raw frames when apple crash reports are unsupported', async () => { + localStorageWrapper.setItem(storageKey, JSON.stringify(['raw-stack-trace'])); + const event = makeEvent([makeThread({crashed: true, id: 7})], 'c'); + + renderThreadStackTrace(event); + + expect(await screen.findByText(/ViewController\.causeCrash/)).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Download'})).not.toBeInTheDocument(); + }); + + it('renders stacktrace source links from issue frame actions', async () => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/stacktrace-link/`, + body: { + config: {provider: {key: 'github', name: 'GitHub'}}, + sourceUrl: 'https://github.com/getsentry/sentry/blob/main/raven/base.py', + integrations: [], + }, + }); + const event = makeEvent([makeThread({crashed: true, id: 7})]); + + renderThreadStackTrace(event); + + expect( + await screen.findByRole('button', {name: 'Open this line in GitHub'}) + ).toHaveAttribute( + 'href', + 'https://github.com/getsentry/sentry/blob/main/raven/base.py#L303' + ); + }); + + it('keeps chained exceptions in raw view when Apple crash reports are unavailable', async () => { + localStorageWrapper.setItem(storageKey, JSON.stringify(['raw-stack-trace'])); + const event = makeEvent([makeThread({crashed: true, id: 7})], 'c'); + const entry = event.entries.find(value => value.type === EntryType.EXCEPTION)!; + const exception = entry.data.values![0]!; + entry.data.values = [ + {...exception, type: 'OuterError', stacktrace: makeStacktrace('Outer.frame')}, + {...exception, type: 'InnerError', stacktrace: makeStacktrace('Inner.frame')}, + ]; + + renderThreadStackTrace(event); + + expect( + await screen.findByText( + /OuterError[\s\S]*Outer.frame[\s\S]*InnerError[\s\S]*Inner.frame/ + ) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Download'})).not.toBeInTheDocument(); + }); + + it.each([false, true])( + 'uses inner exception display capabilities when the outer stack is missing: %s', + async missingOuterStack => { + const event = makeEvent([makeThread({crashed: true, id: 7, stacktrace: null})]); + const entry = event.entries.find(value => value.type === EntryType.EXCEPTION)!; + const exception = entry.data.values![0]!; + const outer = makeStacktrace('Outer.frame'); + outer.frames = outer.frames!.map(frame => ({ + ...frame, + rawFunction: null, + instructionAddr: null, + filename: null, + absPath: null, + })); + const inner = makeStacktrace('Inner.frame'); + inner.frames![1]!.filename = 'inner.m'; + inner.frames![1]!.absPath = '/src/inner.m'; + entry.data.values = [ + {...exception, type: 'OuterError', stacktrace: missingOuterStack ? null : outer}, + {...exception, type: 'InnerError', stacktrace: inner}, + ]; + + renderThreadStackTrace(event); + + await screen.findByText('Inner.frame'); + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + for (const name of [ + 'Absolute Addresses', + 'Absolute File Paths', + 'Verbose Function Names', + ]) { + expect(screen.getByRole('option', {name})).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + } + await userEvent.click(screen.getByRole('option', {name: 'Verbose Function Names'})); + expect(await screen.findByText('Inner.frame(Any) -> ()')).toBeInTheDocument(); + } + ); + + it('applies the unsymbolicated preference only to threads that have raw frames', async () => { + localStorageWrapper.setItem(storageKey, JSON.stringify(['minified'])); + const event = makeEvent([ + makeThread({crashed: true, id: 7}), + makeThread({ + id: 8, + name: 'worker', + stacktrace: makeStacktrace('Worker.run'), + rawStacktrace: makeStacktrace('Worker.raw'), + }), + ]); + + renderThreadStackTrace(event); + + expect(await screen.findByText('ViewController.causeCrash')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + expect(await screen.findByText('Worker.raw')).toBeInTheDocument(); + expect(screen.queryByText('Worker.run')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Previous Thread'})); + expect(await screen.findByText('ViewController.causeCrash')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + expect(await screen.findByText('Worker.raw')).toBeInTheDocument(); + }); + + it('shows system-only threads in full without losing the requested view or order', async () => { + const worker = makeStacktrace('Worker.run'); + worker.hasSystemFrames = false; + worker.frames = worker.frames!.map(frame => ({...frame, inApp: false})); + const event = makeEvent([ + makeThread({crashed: true, id: 7}), + makeThread({id: 8, name: 'worker', stacktrace: worker}), + ]); + + renderThreadStackTrace(event); + + await screen.findByText('ViewController.causeCrash'); + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + expect(await screen.findByText('Worker.run')).toBeInTheDocument(); + expect(screen.getByText('system_start')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + await userEvent.click(screen.getByRole('option', {name: 'Oldest First'})); + await userEvent.keyboard('{Escape}'); + await userEvent.click(screen.getByRole('button', {name: 'Previous Thread'})); + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + expect(screen.getByRole('option', {name: 'Most Relevant'})).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('option', {name: 'Oldest First'})).toHaveAttribute( + 'aria-selected', + 'true' + ); + await userEvent.click(screen.getByRole('option', {name: 'Full Stack Trace'})); + await userEvent.keyboard('{Escape}'); + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + await userEvent.click(screen.getByRole('button', {name: 'Previous Thread'})); + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + expect(screen.getByRole('option', {name: 'Full Stack Trace'})).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); +}); diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx new file mode 100644 index 000000000000..9d7f0d5d3d9c --- /dev/null +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx @@ -0,0 +1,68 @@ +import {useMemo} from 'react'; + +import {Stack} from '@sentry/scraps/layout'; + +import {t, tn} from 'sentry/locale'; +import type {EntryThreads, Event} from 'sentry/types/event'; +import type {Group} from 'sentry/types/group'; +import type {Project} from 'sentry/types/project'; +import {SectionKey} from 'sentry/views/issueDetails/context'; +import {FoldSection} from 'sentry/views/issueDetails/foldSection'; + +import {IssueThreadStackTraceActions} from './actions'; +import { + ActiveThreadStackTrace, + IssueThreadStackTraceSuspectCommits, +} from './activeThreadStackTrace'; +import {IssueThreadStackTraceProviders} from './context'; +import {ThreadSummary} from './threadSummary'; + +type Props = { + data: EntryThreads['data']; + event: Event; + group: Group | undefined; + groupingCurrentLevel: Group['metadata']['current_level']; + projectSlug: Project['slug']; +}; + +export function IssueThreadStackTrace({ + data, + event, + projectSlug, + groupingCurrentLevel, + group, +}: Props) { + const threads = useMemo( + () => (data.values ?? []).toSorted((a, b) => Number(b.crashed) - Number(a.crashed)), + [data.values] + ); + const hasMoreThanOneThread = threads.length > 1; + + return ( + + } + disableCollapsePersistence={hasMoreThanOneThread} + > + + + + + + + + ); +} diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/threadSummary.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/threadSummary.tsx new file mode 100644 index 000000000000..bd620e9c1ff7 --- /dev/null +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/threadSummary.tsx @@ -0,0 +1,195 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; + +import {Button, ButtonBar} from '@sentry/scraps/button'; +import {Container, Flex} from '@sentry/scraps/layout'; + +import {ThreadSelector} from 'sentry/components/events/interfaces/threads/threadSelector'; +import {getLockReason} from 'sentry/components/events/interfaces/threads/threadSelector/lockReason'; +import { + getMappedThreadState, + getThreadStateHelpText, + ThreadStates, +} from 'sentry/components/events/interfaces/threads/threadSelector/threadStates'; +import {Pill} from 'sentry/components/pill'; +import {Pills} from 'sentry/components/pills'; +import {QuestionTooltip} from 'sentry/components/questionTooltip'; +import {TextOverflow} from 'sentry/components/textOverflow'; +import { + IconChevron, + IconClock, + IconInfo, + IconLock, + IconPlay, + IconTimer, +} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {defined} from 'sentry/utils/defined'; + +import {useActiveThread, useIssueThreadStackTraceContext} from './context'; + +function ThreadStateIcon({state}: {state: ThreadStates | undefined}) { + if (state === null || state === undefined) { + return null; + } + + switch (state) { + case ThreadStates.BLOCKED: + return ; + case ThreadStates.TIMED_WAITING: + return ; + case ThreadStates.WAITING: + return ; + case ThreadStates.RUNNABLE: + return ; + default: + return ; + } +} + +export function ThreadSummary() { + const {threads} = useIssueThreadStackTraceContext(); + + if (threads.length <= 1) { + return null; + } + + return ( + + + + {t('Threads')} + + + + + + + ); +} + +function ThreadControls() { + const activeThread = useActiveThread(); + const {activeThreadModel, changeThread, event, setActiveThread, threads} = + useIssueThreadStackTraceContext(); + + if (!activeThread) { + return null; + } + + return ( + + +