From f344f95639b296f56609c0d3b8bb33f81e645c92 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Sat, 25 Apr 2026 09:52:11 -0700 Subject: [PATCH 01/35] feat(issues): Add native stack trace component New native flavor of the stack trace component at `static/app/components/stackTrace/native/`. Built on top of the existing `StackTraceProvider` so it shares frame/row state, view state, and the expanded body (`FrameContent` already handles source / vars / registers / assembly). `NativeStackTraceProvider` resolves the debug image and pads relative addresses once for the whole stacktrace, then exposes them through `NativeStackTraceContext`. `NativeFrameHeader` lays out the row in the classic native columns: status icon, package, address, function + filename, actions. The status column is dropped entirely when no frame in the stack would render an icon, so cleanly symbolicated stacks don't reserve dead space on the left. Includes: - symbolicator status icons (image debug status wins over the per-frame symbolicatorStatus when an image is found) - absolute / relative instruction addresses with shared column padding - inline frame and stack-scanning tooltips on the address - "Go to images loaded" debug-meta navigation - raw view download button for native (Apple Crash Report endpoint) - dart async suspension labels - lead hint ("crashed in non-app") on its own line so toggling expansion doesn't change row height Followups for parity: real `StacktraceLink` integration, `OpenInContextLine` for sentry-app stacktrace-link components, the `isUsedForGrouping` refresh icon, native display options (absolute-addresses / verbose-function-names / absolute-file-paths) wired into `StackTraceViewState`, and `AnnotatedText` PII annotations on the function name. Stories cover the multi-thread thread picker (one js thread for variety, rendered with the generic stack trace), missing debug file, inline frame, stack scanning, absolute addresses, long package names, omitted frames, and dart async. Co-Authored-By: Claude Opus 4.7 --- .../frame/actions/getSymbolicatorStatus.ts | 57 ++ .../frame/actions/goToImagesLoadedAction.tsx | 37 + .../frame/actions/nativeDefaultActions.tsx | 49 ++ .../frame/actions/symbolicatorStatusIcon.tsx | 48 ++ .../native/frame/nativeFrameAddress.tsx | 99 +++ .../native/frame/nativeFrameHeader.tsx | 288 +++++++ .../native/frame/nativeFrameRow.spec.tsx | 284 +++++++ .../native/nativeStackTrace.stories.tsx | 703 ++++++++++++++++++ .../native/nativeStackTraceContext.tsx | 42 ++ .../native/nativeStackTraceFrames.tsx | 117 +++ .../native/nativeStackTraceProvider.tsx | 85 +++ .../stackTrace/native/rawDownloadAction.tsx | 52 ++ 12 files changed, 1861 insertions(+) create mode 100644 static/app/components/stackTrace/native/frame/actions/getSymbolicatorStatus.ts create mode 100644 static/app/components/stackTrace/native/frame/actions/goToImagesLoadedAction.tsx create mode 100644 static/app/components/stackTrace/native/frame/actions/nativeDefaultActions.tsx create mode 100644 static/app/components/stackTrace/native/frame/actions/symbolicatorStatusIcon.tsx create mode 100644 static/app/components/stackTrace/native/frame/nativeFrameAddress.tsx create mode 100644 static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx create mode 100644 static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx create mode 100644 static/app/components/stackTrace/native/nativeStackTrace.stories.tsx create mode 100644 static/app/components/stackTrace/native/nativeStackTraceContext.tsx create mode 100644 static/app/components/stackTrace/native/nativeStackTraceFrames.tsx create mode 100644 static/app/components/stackTrace/native/nativeStackTraceProvider.tsx create mode 100644 static/app/components/stackTrace/native/rawDownloadAction.tsx diff --git a/static/app/components/stackTrace/native/frame/actions/getSymbolicatorStatus.ts b/static/app/components/stackTrace/native/frame/actions/getSymbolicatorStatus.ts new file mode 100644 index 000000000000..1c3f2213818b --- /dev/null +++ b/static/app/components/stackTrace/native/frame/actions/getSymbolicatorStatus.ts @@ -0,0 +1,57 @@ +import {combineStatus} from 'sentry/components/events/interfaces/debugMeta/utils'; +import {SymbolicatorStatus} from 'sentry/components/events/interfaces/types'; +import type {ImageWithCombinedStatus} from 'sentry/types/debugImage'; +import type {Frame} from 'sentry/types/event'; + +export type SymbolicatorIconStatus = 'success' | 'error' | 'warning' | undefined; + +export function isDartAsyncSuspension(frame: Frame): boolean { + return ( + frame.filename === '' || + frame.absPath === '' + ); +} + +/** + * Resolves the symbolicator status for a single frame. The image's combined + * status (debug + unwind) wins over the frame's `symbolicatorStatus` when an + * image is found — the image is the source of truth for the binary's debug + * data. Returns the icon variant to display, or `undefined` for "no icon". + */ +export function getSymbolicatorStatus( + frame: Frame, + image: ImageWithCombinedStatus | null +): SymbolicatorIconStatus { + if (isDartAsyncSuspension(frame)) { + return 'success'; + } + + if (!image) { + switch (frame.symbolicatorStatus) { + case SymbolicatorStatus.SYMBOLICATED: + return 'success'; + case SymbolicatorStatus.MISSING: + case SymbolicatorStatus.MALFORMED: + return 'error'; + case SymbolicatorStatus.UNKNOWN_IMAGE: + return frame.instructionAddr === '0x0' ? 'success' : 'error'; + case SymbolicatorStatus.MISSING_SYMBOL: + default: + return 'warning'; + } + } + + const combinedStatus = combineStatus(image.debug_status, image.unwind_status); + switch (combinedStatus) { + case 'unused': + return undefined; + case 'found': + return 'success'; + default: + return 'error'; + } +} + +export function hasStatusIcon(status: SymbolicatorIconStatus): boolean { + return status === 'error' || status === 'warning'; +} diff --git a/static/app/components/stackTrace/native/frame/actions/goToImagesLoadedAction.tsx b/static/app/components/stackTrace/native/frame/actions/goToImagesLoadedAction.tsx new file mode 100644 index 000000000000..88cac82ae564 --- /dev/null +++ b/static/app/components/stackTrace/native/frame/actions/goToImagesLoadedAction.tsx @@ -0,0 +1,37 @@ +import {SymbolicatorStatus} from 'sentry/components/events/interfaces/types'; +import {useNativeStackTraceContext} from 'sentry/components/stackTrace/native/nativeStackTraceContext'; +import {useStackTraceFrameContext} from 'sentry/components/stackTrace/stackTraceContext'; +import {DebugMetaStore} from 'sentry/stores/debugMetaStore'; +import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; + +export function useGoToImagesLoaded() { + const {frame, frameIndex} = useStackTraceFrameContext(); + const {imageByFrameIndex, isHoverPreviewed} = useNativeStackTraceContext(); + const image = imageByFrameIndex.get(frameIndex) ?? null; + + const isClickable = + !!frame.symbolicatorStatus && + frame.symbolicatorStatus !== SymbolicatorStatus.UNKNOWN_IMAGE && + !isHoverPreviewed; + + return { + isClickable, + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + if (!frame.instructionAddr) { + return; + } + + const useDebugId = !!frame.addrMode && frame.addrMode !== 'abs' && image; + const searchTerm = useDebugId + ? `${image.debug_id}!${frame.instructionAddr}` + : frame.instructionAddr; + + DebugMetaStore.updateFilter(searchTerm); + + document + .getElementById(SectionKey.DEBUGMETA) + ?.scrollIntoView({block: 'start', behavior: 'smooth'}); + }, + }; +} diff --git a/static/app/components/stackTrace/native/frame/actions/nativeDefaultActions.tsx b/static/app/components/stackTrace/native/frame/actions/nativeDefaultActions.tsx new file mode 100644 index 000000000000..69910bd69c62 --- /dev/null +++ b/static/app/components/stackTrace/native/frame/actions/nativeDefaultActions.tsx @@ -0,0 +1,49 @@ +import {Fragment} from 'react'; + +import {Tag} from '@sentry/scraps/badge'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {ChevronAction} from 'sentry/components/stackTrace/frame/actions/chevron'; +import {HiddenFramesToggleAction} from 'sentry/components/stackTrace/frame/actions/hiddenFramesToggle'; +import { + useStackTraceContext, + useStackTraceFrameContext, +} from 'sentry/components/stackTrace/stackTraceContext'; +import {IconRefresh} from 'sentry/icons'; +import {t, tn} from 'sentry/locale'; + +/** + * Default trailing actions for native frame rows. Mirrors the generic + * DefaultFrameActions but tailored for native — symbolicator status and the + * Go-to-images-loaded link are rendered inline in the header itself. + */ +export function NativeDefaultActions() { + const {hasAnyExpandableFrames} = useStackTraceContext(); + const {frame, hiddenFrameCount, timesRepeated} = useStackTraceFrameContext(); + + return ( + + {hiddenFrameCount ? : null} + {timesRepeated > 0 ? ( + + } + variant="muted" + aria-label={tn( + 'Frame repeated %s time', + 'Frame repeated %s times', + timesRepeated + )} + > + {timesRepeated} + + + ) : null} + {frame.inApp ? {t('In App')} : null} + {hasAnyExpandableFrames ? : null} + + ); +} diff --git a/static/app/components/stackTrace/native/frame/actions/symbolicatorStatusIcon.tsx b/static/app/components/stackTrace/native/frame/actions/symbolicatorStatusIcon.tsx new file mode 100644 index 000000000000..94c839fad0c0 --- /dev/null +++ b/static/app/components/stackTrace/native/frame/actions/symbolicatorStatusIcon.tsx @@ -0,0 +1,48 @@ +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {useNativeStackTraceContext} from 'sentry/components/stackTrace/native/nativeStackTraceContext'; +import {useStackTraceFrameContext} from 'sentry/components/stackTrace/stackTraceContext'; +import {IconFileBroken} from 'sentry/icons/iconFileBroken'; +import {IconWarning} from 'sentry/icons/iconWarning'; +import {t} from 'sentry/locale'; + +import {getSymbolicatorStatus} from './getSymbolicatorStatus'; + +export function SymbolicatorStatusIcon() { + const {frame, frameIndex} = useStackTraceFrameContext(); + const {imageByFrameIndex} = useNativeStackTraceContext(); + const image = imageByFrameIndex.get(frameIndex) ?? null; + const status = getSymbolicatorStatus(frame, image); + + if (status === 'error') { + return ( + + + + ); + } + + if (status === 'warning') { + return ( + + + + ); + } + + return null; +} diff --git a/static/app/components/stackTrace/native/frame/nativeFrameAddress.tsx b/static/app/components/stackTrace/native/frame/nativeFrameAddress.tsx new file mode 100644 index 000000000000..1789a7762a65 --- /dev/null +++ b/static/app/components/stackTrace/native/frame/nativeFrameAddress.tsx @@ -0,0 +1,99 @@ +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {formatAddress, parseAddress} from 'sentry/components/events/interfaces/utils'; +import {useNativeStackTraceContext} from 'sentry/components/stackTrace/native/nativeStackTraceContext'; +import { + useStackTraceContext, + useStackTraceFrameContext, +} from 'sentry/components/stackTrace/stackTraceContext'; +import {t} from 'sentry/locale'; +import type {Frame} from 'sentry/types/event'; + +import {useGoToImagesLoaded} from './actions/goToImagesLoadedAction'; + +interface NativeFrameAddressProps { + /** When true, render absolute instruction address instead of relative offset. */ + absolute?: boolean; +} + +function isInlineFrame(frame: Frame, prevFrame: Frame | undefined, platform: string) { + if (!prevFrame) { + return false; + } + const framePlatform = frame.platform ?? platform; + const prevPlatform = prevFrame.platform ?? platform; + return ( + framePlatform === prevPlatform && + !!frame.instructionAddr && + frame.instructionAddr === prevFrame.instructionAddr + ); +} + +function getAddressTooltip({ + inlineFrame, + foundByStackScanning, +}: { + foundByStackScanning: boolean; + inlineFrame: boolean; +}) { + if (inlineFrame && foundByStackScanning) { + return t('Inline frame, found by stack scanning'); + } + if (inlineFrame) { + return t('Inline frame'); + } + if (foundByStackScanning) { + return t('Found by stack scanning'); + } + return undefined; +} + +export function NativeFrameAddress({absolute = false}: NativeFrameAddressProps) { + const {frame, frameIndex, platform} = useStackTraceFrameContext(); + const {frames} = useStackTraceContext(); + const {imageByFrameIndex, maxLengthOfRelativeAddress} = useNativeStackTraceContext(); + const {isClickable, onClick} = useGoToImagesLoaded(); + + const image = imageByFrameIndex.get(frameIndex) ?? null; + const prevFrame = frames[frameIndex - 1]; + + const inlineFrame = isInlineFrame(frame, prevFrame, platform); + const foundByStackScanning = frame.trust === 'scan' || frame.trust === 'cfi-scan'; + + const startingAddress = image?.image_addr ?? null; + const relative = startingAddress + ? `+${formatAddress( + parseAddress(frame.instructionAddr) - parseAddress(startingAddress), + maxLengthOfRelativeAddress + )}` + : ''; + + const display = !relative || absolute ? (frame.instructionAddr ?? '') : relative; + const tooltip = getAddressTooltip({inlineFrame, foundByStackScanning}); + + const cell = ( + { + e.preventDefault(); + onClick(e); + } + : undefined + } + > + {display} + + ); + + 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..f164bc8b6d9b --- /dev/null +++ b/static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx @@ -0,0 +1,288 @@ +import {useState} from 'react'; +import styled from '@emotion/styled'; + +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {getLeadHint, trimPackage} from 'sentry/components/events/interfaces/frame/utils'; +import {useNativeStackTraceContext} from 'sentry/components/stackTrace/native/nativeStackTraceContext'; +import {useStackTraceFrameContext} from 'sentry/components/stackTrace/stackTraceContext'; +import {t} from 'sentry/locale'; +import {defined} from 'sentry/utils'; + +import {isDartAsyncSuspension} from './actions/getSymbolicatorStatus'; +import {NativeDefaultActions} from './actions/nativeDefaultActions'; +import {SymbolicatorStatusIcon} from './actions/symbolicatorStatusIcon'; +import {NativeFrameAddress} from './nativeFrameAddress'; + +interface NativeFrameHeaderProps { + /** Render absolute instruction address instead of relative offset. */ + absoluteAddresses?: boolean; + /** Show the absolute file path instead of the basename. */ + absoluteFilePaths?: boolean; + /** Custom trailing actions; falls back to NativeDefaultActions. */ + actions?: React.ReactNode | ((props: {isHovering: boolean}) => React.ReactNode); + /** Use rawFunction in place of the demangled function name. */ + fullFunctionName?: boolean; +} + +function getFunctionLabel({ + frame, + fullFunctionName, +}: { + frame: ReturnType['frame']; + fullFunctionName: boolean; +}) { + if (fullFunctionName && frame.rawFunction) { + return frame.rawFunction; + } + return frame.function ?? frame.rawFunction ?? null; +} + +export function NativeFrameHeader({ + absoluteAddresses = false, + absoluteFilePaths = false, + actions, + fullFunctionName = false, +}: NativeFrameHeaderProps) { + const { + event, + frame, + frameContextId, + isExpandable, + isExpanded, + nextFrame, + toggleExpansion, + } = useStackTraceFrameContext(); + const {hasAnyStatusIcons} = useNativeStackTraceContext(); + const [isHovering, setIsHovering] = useState(false); + + const isDartAsync = isDartAsyncSuspension(frame); + const functionLabel = getFunctionLabel({frame, fullFunctionName}); + const packageLabel = frame.package ? trimPackage(frame.package) : null; + const leadsToApp = !frame.inApp && (nextFrame?.inApp || !nextFrame); + + 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} + + + {leadsToApp ? ( + + + {getLeadHint({event, hasNextFrame: defined(nextFrame)})} + + + ) : null} + + + {packageLabel ?? + (isDartAsync ? ( + t('Dart async') + ) : ( + + {t('')} + + ))} + + + + + + + + + + {functionLabel ? ( + + {functionLabel} + + ) : isDartAsync ? ( + t('Dart') + ) : ( + {`<${t('unknown')}>`} + )} + {frame.filename ? ( + + + + {'('} + {absoluteFilePaths ? (frame.absPath ?? frame.filename) : frame.filename} + {frame.lineNo ? `:${frame.lineNo}` : ''} + {')'} + + + + ) : null} + + + {resolvedActions} + + ); +} + +const HeaderGrid = styled('div')<{ + hasStatusColumn: boolean; + isExpandable: boolean; + isInApp: boolean; +}>` + display: grid; + grid-template-columns: ${p => + p.hasStatusColumn + ? '16px minmax(100px, 140px) minmax(64px, 88px) minmax(0, 1fr) auto' + : 'minmax(100px, 140px) minmax(64px, 88px) minmax(0, 1fr) auto'}; + align-items: center; + gap: ${p => p.theme.space.sm}; + padding: ${p => p.theme.space.sm} ${p => p.theme.space.md}; + min-height: 36px; + cursor: ${p => (p.isExpandable ? 'pointer' : 'default')}; + background: ${p => p.theme.tokens.background.secondary}; + font-size: ${p => p.theme.font.size.sm}; + font-style: ${p => (p.isInApp ? 'normal' : 'italic')}; + color: ${p => + p.isInApp ? p.theme.tokens.content.primary : p.theme.tokens.content.secondary}; + text-align: left; + + &:hover { + background: ${p => p.theme.tokens.background.tertiary}; + } + + @media (max-width: ${p => p.theme.breakpoints.sm}) { + grid-template-columns: ${p => + p.hasStatusColumn ? '16px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'}; + /* stylelint-disable-next-line named-grid-areas-no-invalid */ + grid-template-areas: ${p => + p.hasStatusColumn + ? "'status package actions' 'status address actions' 'status function function'" + : "'package actions' 'address actions' 'function function'"}; + row-gap: ${p => p.theme.space['2xs']}; + } +`; + +const StatusCell = styled('div')` + display: flex; + align-items: center; + justify-content: center; + + @media (max-width: ${p => p.theme.breakpoints.sm}) { + grid-area: status; + } +`; + +const PackageCell = styled('div')` + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-width: 0; + overflow: hidden; + line-height: 1.4; + + @media (max-width: ${p => p.theme.breakpoints.sm}) { + grid-area: package; + } +`; + +const LeadHint = styled('span')` + display: block; + max-width: 100%; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const PackageLabel = styled('span')` + display: block; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const AddressCell = styled('div')` + display: flex; + align-items: center; + min-width: 0; + line-height: 1.4; + + @media (max-width: ${p => p.theme.breakpoints.sm}) { + grid-area: address; + } +`; + +const FunctionCell = styled('div')` + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: ${p => p.theme.space.xs}; + row-gap: ${p => p.theme.space['2xs']}; + min-width: 0; + word-break: break-all; + + @media (max-width: ${p => p.theme.breakpoints.sm}) { + grid-area: function; + } +`; + +const FunctionName = styled('span')` + min-width: 0; + flex: 0 1 auto; + word-break: break-all; +`; + +const FileName = styled('span')` + border-bottom: 1px dashed ${p => p.theme.tokens.border.primary}; +`; + +const ActionsCell = styled('div')` + display: flex; + align-items: center; + gap: ${p => p.theme.space.xs}; + margin-left: auto; + + @media (max-width: ${p => p.theme.breakpoints.sm}) { + grid-area: actions; + } +`; 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..ebbbe3d44022 --- /dev/null +++ b/static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx @@ -0,0 +1,284 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {SymbolicatorStatus} from 'sentry/components/events/interfaces/types'; +import {NativeStackTraceFrames} from 'sentry/components/stackTrace/native/nativeStackTraceFrames'; +import {NativeStackTraceProvider} from 'sentry/components/stackTrace/native/nativeStackTraceProvider'; +import {StackTraceViewStateProvider} from 'sentry/components/stackTrace/stackTraceContext'; +import {ImageStatus} from 'sentry/types/debugImage'; +import {EntryType, EventOrGroupType, type Event, type Frame} from 'sentry/types/event'; +import type {StacktraceType} from 'sentry/types/stacktrace'; + +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) { + 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('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)); + + 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('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/nativeStackTrace.stories.tsx b/static/app/components/stackTrace/native/nativeStackTrace.stories.tsx new file mode 100644 index 000000000000..ef1206ac8ab8 --- /dev/null +++ b/static/app/components/stackTrace/native/nativeStackTrace.stories.tsx @@ -0,0 +1,703 @@ +import {Fragment, useMemo, useState} from 'react'; +import styled from '@emotion/styled'; + +import {Button, ButtonBar} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {CopyAsDropdown} from 'sentry/components/copyAsDropdown'; +import {ThreadSelector} from 'sentry/components/events/interfaces/threads/threadSelector'; +import {SymbolicatorStatus} from 'sentry/components/events/interfaces/types'; +import {DisplayOptions} from 'sentry/components/stackTrace/displayOptions'; +import { + ExceptionDescription, + ExceptionHeader, +} from 'sentry/components/stackTrace/exceptionHeader'; +import {FrameContent} from 'sentry/components/stackTrace/frame/frameContent'; +import { + StackTraceViewStateProvider, + useStackTraceFrameContext, +} from 'sentry/components/stackTrace/stackTraceContext'; +import {StackTraceFrames} from 'sentry/components/stackTrace/stackTraceFrames'; +import {StackTraceProvider} from 'sentry/components/stackTrace/stackTraceProvider'; +import {IconChevron, IconCopy, IconGithub} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import * as Storybook from 'sentry/stories'; +import {ImageStatus} from 'sentry/types/debugImage'; +import { + EntryType, + EventOrGroupType, + type Event, + type Frame, + type Thread, +} from 'sentry/types/event'; +import type {Organization} from 'sentry/types/organization'; +import type {StacktraceType} from 'sentry/types/stacktrace'; +import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; +import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection'; + +import {NativeDefaultActions} from './frame/actions/nativeDefaultActions'; +import {NativeStackTraceFrames} from './nativeStackTraceFrames'; +import {NativeStackTraceProvider} from './nativeStackTraceProvider'; +import {RawDownloadAction} from './rawDownloadAction'; + +type StacktraceWithFrames = StacktraceType & { + frames: NonNullable; +}; + +function makeFrame(overrides: Partial): Frame { + return { + absPath: '/build/CrashyApp.app/Frameworks/MyLib.framework/MyLib', + colNo: null, + lineNo: null, + context: [], + filename: 'MyLib.m', + function: '-[MyLibFoo barWithBaz:]', + inApp: true, + instructionAddr: '0x10001a000', + module: null, + package: '/build/CrashyApp.app/Frameworks/MyLib.framework/MyLib.dylib', + platform: 'cocoa', + rawFunction: null, + symbol: null, + symbolAddr: '0x100000000', + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + trust: 'cfi', + vars: {}, + ...overrides, + }; +} + +function makeImage(addr: string, overrides: Partial = {}) { + return { + type: 'macho', + image_addr: addr, + image_size: 0x100000, + image_vmaddr: '0x0', + code_id: 'aaaaaaaaaaaa', + code_file: '/build/CrashyApp.app/Frameworks/MyLib.framework/MyLib', + debug_id: '11111111-1111-1111-1111-111111111111', + debug_file: 'MyLib.dSYM', + arch: 'arm64', + debug_status: ImageStatus.FOUND, + unwind_status: ImageStatus.FOUND, + ...overrides, + }; +} + +function makeEvent(_stacktrace: StacktraceWithFrames, 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 makeBasicData(imageOverrides: Partial = {}) { + const image = makeImage('0x100000000', imageOverrides); + const frames: Frame[] = [ + makeFrame({ + function: 'main', + filename: 'main.m', + lineNo: 21, + instructionAddr: '0x100002000', + package: '/build/CrashyApp.app/CrashyApp', + inApp: true, + }), + makeFrame({ + function: '-[CrashyAppDelegate applicationDidFinishLaunching:]', + filename: 'CrashyAppDelegate.m', + lineNo: 47, + instructionAddr: '0x100012abc', + inApp: true, + }), + makeFrame({ + function: '-[MyLibFoo barWithBaz:]', + instructionAddr: '0x10001a000', + inApp: false, + }), + makeFrame({ + function: 'objc_msgSend', + package: '/usr/lib/libobjc.A.dylib', + instructionAddr: '0x10005f3c4', + symbolicatorStatus: SymbolicatorStatus.SYMBOLICATED, + inApp: false, + }), + ]; + + const stacktrace: StacktraceWithFrames = { + framesOmitted: null, + hasSystemFrames: true, + registers: {rax: '0x0000000000000001', rip: '0x000000010001a000'}, + frames, + }; + + return {event: makeEvent(stacktrace, [image]), stacktrace}; +} + +function StoryProvider({ + children, + event, + stacktrace, +}: { + children: React.ReactNode; + event: Event; + stacktrace: StacktraceType; +}) { + return ( + + + {children} + + + ); +} + +function NativeStoryFrameActions({isHovering}: {isHovering: boolean}) { + const {isExpanded} = useStackTraceFrameContext(); + const showHoverActions = isExpanded || isHovering; + + return ( + + + + + ) : ( + address ); if (!tooltip) { @@ -131,16 +141,3 @@ export function NativeFrameAddress() { return {cell}; } - -const AddressText = styled('span')<{isClickable: boolean}>` - display: inline-block; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - font-family: ${p => p.theme.font.family.mono}; - font-size: ${p => p.theme.font.size.xs}; - font-style: inherit; - white-space: nowrap; - color: ${p => - p.isClickable ? p.theme.tokens.interactive.link.accent.rest : 'inherit'}; -`; diff --git a/static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx b/static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx index 75f4427bdf97..1de3cc46e2ef 100644 --- a/static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx +++ b/static/app/components/stackTrace/native/frame/nativeFrameHeader.tsx @@ -6,6 +6,7 @@ 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, @@ -65,7 +66,6 @@ export function NativeFrameHeader({actions}: NativeFrameHeaderProps) { const { event, frame, - frameContextId, frameIndex, isExpandable, isExpanded, @@ -75,8 +75,8 @@ export function NativeFrameHeader({actions}: NativeFrameHeaderProps) { } = useStackTraceFrameContext(); const {meta} = useStackTraceContext(); const {view} = useStackTraceViewState(); - const {absoluteFilePaths, hasAnyStatusIcons, verboseFunctionNames} = - useNativeStackTraceContext(); + const {absoluteFilePaths, verboseFunctionNames} = useNativeDisplayOptionsContext(); + const {hasAnyStatusIcons} = useNativeStackTraceContext(); const [isHovering, setIsHovering] = useState(false); const isDartAsync = isDartAsyncSuspension(frame); @@ -100,8 +100,6 @@ export function NativeFrameHeader({actions}: NativeFrameHeaderProps) { isInAppFrame={frame.inApp} isSubFrame={isSubFrame} hasStatusColumn={hasAnyStatusIcons} - aria-expanded={isExpandable ? isExpanded : undefined} - aria-controls={isExpandable ? frameContextId : undefined} onClick={() => { const selectedText = window.getSelection()?.toString(); if (isExpandable && !selectedText) { diff --git a/static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx b/static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx index db8a84c41525..93d50743eb15 100644 --- a/static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx +++ b/static/app/components/stackTrace/native/frame/nativeFrameRow.spec.tsx @@ -1,14 +1,20 @@ -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +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 {StackTraceViewStateProvider} from 'sentry/components/stackTrace/stackTraceContext'; 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'; @@ -91,33 +97,33 @@ function renderFrames( { defaultView = 'app', defaultIsNewestFirst = true, - displayOptionsStorageKey, groupingCurrentLevel, meta, + storageKey, }: { defaultIsNewestFirst?: boolean; defaultView?: StackTraceView; - displayOptionsStorageKey?: string; groupingCurrentLevel?: number; meta?: StackTraceMeta; + storageKey?: string; } = {} ) { return render( - - + ); } @@ -144,11 +150,11 @@ function renderFramesWithDebugMeta(stacktrace: StacktraceType, event: Event) { }} > - + - +
@@ -344,7 +350,7 @@ describe('NativeFrameRow', () => { ], }; renderFrames(stacktrace, makeEvent(stacktrace), { - displayOptionsStorageKey: storageKey, + storageKey, }); expect(screen.getByText('_mangled_symbol')).toBeInTheDocument(); @@ -547,7 +553,11 @@ describe('NativeFrameRow', () => { renderFramesWithDebugMeta(stacktrace, makeEvent(stacktrace, [makeImage()])); - await userEvent.click(screen.getByText('+0x12abc')); + 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' @@ -579,9 +589,15 @@ describe('NativeFrameRow', () => { // 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(titles[0]).toHaveAttribute('aria-expanded', 'true'); - expect(titles[1]).toHaveAttribute('aria-expanded', 'false'); - expect(titles[2]).toHaveAttribute('aria-expanded', 'false'); + 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', () => { @@ -603,9 +619,13 @@ describe('NativeFrameRow', () => { const titles = screen.getAllByTestId('native-stack-trace-frame-title'); expect(titles[0]).toHaveTextContent('app_first'); - expect(titles[0]).toHaveAttribute('aria-expanded', 'true'); + expect( + within(titles[0]!).getByRole('button', {name: 'Collapse frame details'}) + ).toHaveAttribute('aria-expanded', 'true'); expect(titles[2]).toHaveTextContent('app_last'); - expect(titles[2]).toHaveAttribute('aria-expanded', 'false'); + 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 () => { @@ -629,14 +649,20 @@ describe('NativeFrameRow', () => { renderFrames(stacktrace, makeEvent(stacktrace)); const title = screen.getByTestId('native-stack-trace-frame-title'); - expect(title).toHaveAttribute('aria-expanded', 'false'); + 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(); - await userEvent.click(title); + expandButton.focus(); + await userEvent.keyboard('{Enter}'); - expect(title).toHaveAttribute('aria-expanded', 'true'); + 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(); diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadStackTrace.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadStackTrace.tsx index ac2f407d52e5..68d8cbd94848 100644 --- a/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadStackTrace.tsx +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/activeThreadStackTrace.tsx @@ -1,46 +1,30 @@ -import {Disclosure} from '@sentry/scraps/disclosure'; -import {Container, Flex} from '@sentry/scraps/layout'; -import {Separator} from '@sentry/scraps/separator'; +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 {getStacktracePlatform} from 'sentry/components/events/interfaces/utils'; import {SuspectCommits} from 'sentry/components/events/suspectCommits'; -import { - RelatedExceptionsTree, - ToggleRelatedExceptionsButton, - useHiddenExceptions, -} from 'sentry/components/stackTrace/exceptionGroup'; import { ExceptionDescription, ExceptionHeader, } from 'sentry/components/stackTrace/exceptionHeader'; +import { + IssueExceptionStackTrace, + IssueStackTraceFrameList, + type IssueStackTraceFrameListProps, +} from 'sentry/components/stackTrace/issueStackTrace/exceptionStackTrace'; import {IssueFrameActions} from 'sentry/components/stackTrace/issueStackTrace/issueFrameActions'; import {IssueStackTraceFrameContext} from 'sentry/components/stackTrace/issueStackTrace/issueStackTraceFrameContext'; -import { - getExceptionEntryMeta, - formatExceptionsAsText, - getOrderedExceptions, - resolveExceptionFields, -} from 'sentry/components/stackTrace/issueStackTrace/utils'; import {supportsAppleCrashReport} from 'sentry/components/stackTrace/native/appleCrashReport'; import {NativeIssueFrameActions} from 'sentry/components/stackTrace/native/frame/actions/nativeIssueActions'; import {NativeAppleCrashReportContent} from 'sentry/components/stackTrace/native/nativeAppleCrashReportContent'; -import {useInheritedNativeDisplayOptions} from 'sentry/components/stackTrace/native/nativeStackTraceContext'; import {NativeStackTraceFrames} from 'sentry/components/stackTrace/native/nativeStackTraceFrames'; import {NativeStackTraceProvider} from 'sentry/components/stackTrace/native/nativeStackTraceProvider'; -import {RawStackTraceText} from 'sentry/components/stackTrace/rawStackTrace'; -import {createStackTraceRowPolicy} from 'sentry/components/stackTrace/rowPolicy'; import {useStackTraceViewState} from 'sentry/components/stackTrace/stackTraceContext'; import {StackTraceFrames} from 'sentry/components/stackTrace/stackTraceFrames'; -import {StackTraceProvider} from 'sentry/components/stackTrace/stackTraceProvider'; -import type {StackTraceMeta} from 'sentry/components/stackTrace/types'; -import {t, tn} from 'sentry/locale'; -import type {Event, ExceptionValue} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; -import type {StacktraceType} from 'sentry/types/stacktrace'; -import {defined} from 'sentry/utils/defined'; +import {t} from 'sentry/locale'; +import type {ExceptionValue} from 'sentry/types/event'; import {isNativePlatform} from 'sentry/utils/platform'; import {useIssueThreadStackTraceContext} from './context'; @@ -61,11 +45,12 @@ export function ActiveThreadStackTrace() { if (shouldRenderExceptionStackTraces) { return ( - ); } @@ -75,7 +60,7 @@ export function ActiveThreadStackTrace() { } return ( - + {activeException?.stacktrace ? ( @@ -102,209 +87,20 @@ export function ActiveThreadStackTrace() { frameContextComponent={IssueStackTraceFrameContext} /> )} - + ); } -function ExceptionStackTrace({ - event, - groupingCurrentLevel, - hasScmSourceContext, - values, -}: { - event: Event; - groupingCurrentLevel: Group['metadata']['current_level']; - hasScmSourceContext: boolean; - values: ExceptionValue[]; -}) { - const {isMinified, isNewestFirst, view} = useStackTraceViewState(); - const {hiddenExceptions, toggleRelatedExceptions, expandException} = - useHiddenExceptions(values); - const {exceptionValuesMeta} = getExceptionEntryMeta(event, false); - const exceptions = getOrderedExceptions(values, isNewestFirst, view); - const firstVisibleExceptionIndex = exceptions.findIndex( - exc => - exc.mechanism?.parent_id === undefined || !hiddenExceptions[exc.mechanism.parent_id] - ); - - if (exceptions.length === 0) { - return {t('No stack trace available')}; - } - - if (view === 'raw') { - return ( - - - {formatExceptionsAsText({ - exceptions, - platform: event.platform, - isMinified, - isStandalone: false, - })} - - - ); - } - - if (exceptions.length === 1) { - const exc = exceptions[0]!; - const excMeta = exceptionValuesMeta?.[exc.exceptionIndex]; - - return ( - - ); - } - - return ( - - - {tn( - 'There is %s chained exception in this event.', - 'There are %s chained exceptions in this event.', - exceptions.length - )} - - - {exceptions.map((exc, idx) => { - if ( - exc.mechanism?.parent_id !== undefined && - hiddenExceptions[exc.mechanism.parent_id] - ) { - return null; - } - - const exceptionId = exc.mechanism?.exception_id; - const {type, module, value} = resolveExceptionFields(exc, isMinified); - - return ( - - - } - > - - - - - - - {idx === firstVisibleExceptionIndex ? ( - - - - ) : null} - - - - - ); - })} - - ); -} - -function SingleExceptionStackTrace({ - event, - exception, - exceptionMeta, - groupingCurrentLevel, - hasScmSourceContext, - stackTraceMeta, -}: { - event: Event; - exception: ExceptionValue & {exceptionIndex: number; stacktrace: StacktraceType}; - groupingCurrentLevel: Group['metadata']['current_level']; - hasScmSourceContext: boolean; - exceptionMeta?: Record; - stackTraceMeta?: StackTraceMeta; -}) { - const {isMinified} = useStackTraceViewState(); - const {type, module, value} = resolveExceptionFields(exception, isMinified); - const hasExceptionInfo = Boolean(type || value); - - return ( - - {hasExceptionInfo ? ( - - - - - - - ) : null} - - - - - - ); -} - -function StackTraceFrameList({ - event, - exceptionIndex, - groupingCurrentLevel, - hasScmSourceContext, - meta, - minifiedStacktrace, - stacktrace, -}: { - event: Event; - groupingCurrentLevel: Group['metadata']['current_level']; - hasScmSourceContext: boolean; - stacktrace: StacktraceType; - exceptionIndex?: number; - meta?: StackTraceMeta; - minifiedStacktrace?: StacktraceType; -}) { - const inheritedDisplayOptions = useInheritedNativeDisplayOptions(); +function NativeIssueStackTraceFrameList(props: IssueStackTraceFrameListProps) { + const { + event, + exceptionIndex, + groupingCurrentLevel, + hasScmSourceContext, + meta, + minifiedStacktrace, + stacktrace, + } = props; const platform = getStacktracePlatform(event, stacktrace); if (isNativePlatform(platform)) { @@ -316,7 +112,6 @@ function StackTraceFrameList({ groupingCurrentLevel={groupingCurrentLevel} hasScmSourceContext={hasScmSourceContext} exceptionIndex={exceptionIndex} - inheritedDisplayOptions={inheritedDisplayOptions} meta={meta} platform={platform} > @@ -328,23 +123,7 @@ function StackTraceFrameList({ ); } - return ( - - - - ); + return ; } export function IssueThreadStackTraceSuspectCommits() { @@ -372,11 +151,11 @@ function ExceptionDetails({exception}: {exception: ExceptionValue | undefined}) } return ( - + - + ); } diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/context.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/context.tsx index adadc2a41f1f..61f424012771 100644 --- a/static/app/components/stackTrace/native/issueThreadStackTrace/context.tsx +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/context.tsx @@ -9,13 +9,9 @@ import { import type {ReactNode} from 'react'; import {findBestThread} from 'sentry/components/events/interfaces/threads/threadSelector/findBestThread'; -import { - getNativeDisplayOptionDefaults, - useNativeDisplayOptionsStorage, -} from 'sentry/components/stackTrace/native/nativeDisplayOptionsPersistence'; +import {NativeStackTraceViewStateProvider} from 'sentry/components/stackTrace/native/nativeDisplayOptionsContext'; import {NativeStackTraceProvider} from 'sentry/components/stackTrace/native/nativeStackTraceProvider'; import {createStackTraceRowPolicy} from 'sentry/components/stackTrace/rowPolicy'; -import {StackTraceViewStateProvider} from 'sentry/components/stackTrace/stackTraceContext'; import {StackTraceProvider} from 'sentry/components/stackTrace/stackTraceProvider'; import type {Event, Thread} from 'sentry/types/event'; import type {Group} from 'sentry/types/group'; @@ -101,12 +97,6 @@ export function IssueThreadStackTraceProviders({ () => getActiveThreadStackTraceModel({activeThread, event}), [activeThread, event] ); - const [persistedOptions] = useNativeDisplayOptionsStorage(storageKey); - const {defaultIsMinified, defaultView} = getNativeDisplayOptionDefaults({ - defaultView: activeThreadModel.defaultView, - hasMinifiedStacktrace: activeThreadModel.hasMinifiedStacktrace, - persistedOptions, - }); const rowPolicy = useMemo( () => createStackTraceRowPolicy({groupingCurrentLevel}), [groupingCurrentLevel] @@ -182,16 +172,17 @@ export function IssueThreadStackTraceProviders({ return ( - {activeThreadModel.stacktrace ? ( activeThreadUsesNativeStackTrace ? ( {children} ) : ( + ); } diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx index 139007d9b519..6532f2a2c025 100644 --- a/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx @@ -248,6 +248,46 @@ describe('IssueThreadStackTrace', () => { expect(screen.queryByText('ViewController.causeCrash')).not.toBeInTheDocument(); }); + it('resets stack-local frame state when changing threads', async () => { + 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}), + ]); + + 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}), diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx b/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx index a14f9bb05acf..974ebd68c8c1 100644 --- a/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx +++ b/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx @@ -1,6 +1,6 @@ import {useMemo} from 'react'; -import {Flex} from '@sentry/scraps/layout'; +import {Stack} from '@sentry/scraps/layout'; import {t, tn} from 'sentry/locale'; import type {EntryThreads, Event} from 'sentry/types/event'; @@ -58,11 +58,11 @@ export function IssueThreadStackTrace({ actions={} disableCollapsePersistence={hasMoreThanOneThread} > - + - + ); diff --git a/static/app/components/stackTrace/native/nativeDisplayOptions.tsx b/static/app/components/stackTrace/native/nativeDisplayOptions.tsx index b679881ffbc7..1983a6563f35 100644 --- a/static/app/components/stackTrace/native/nativeDisplayOptions.tsx +++ b/static/app/components/stackTrace/native/nativeDisplayOptions.tsx @@ -5,6 +5,7 @@ import {useStackTraceViewState} from 'sentry/components/stackTrace/stackTraceCon import {IconSettings} from 'sentry/icons'; import {t} from 'sentry/locale'; +import {useNativeDisplayOptionsContext} from './nativeDisplayOptionsContext'; import {NATIVE_DISPLAY_OPTION} from './nativeDisplayOptionsPersistence'; import {useNativeStackTraceContext} from './nativeStackTraceContext'; @@ -24,28 +25,17 @@ const SORT_OPTION_VALUES = ['newest', 'oldest'] as const; * benefit from them. */ export function NativeDisplayOptions() { - const { - view, - setView, - hasMinifiedStacktrace, - isMinified, - setIsMinified, - isNewestFirst, - setIsNewestFirst, - platform, - } = useStackTraceViewState(); + const {view, hasMinifiedStacktrace, isMinified, isNewestFirst, platform} = + useStackTraceViewState(); const { absoluteAddresses, absoluteFilePaths, - hasAbsoluteAddresses, - hasAbsoluteFilePaths, - hasVerboseFunctionNames, - persistDisplayOptions, - setAbsoluteAddresses, - setAbsoluteFilePaths, - setVerboseFunctionNames, + prefersMinified, + updateDisplayOptions, verboseFunctionNames, - } = useNativeStackTraceContext(); + } = useNativeDisplayOptionsContext(); + const {hasAbsoluteAddresses, hasAbsoluteFilePaths, hasVerboseFunctionNames} = + useNativeStackTraceContext(); const isJavaScriptPlatform = platform?.startsWith('javascript') || platform?.startsWith('node'); @@ -94,37 +84,36 @@ export function NativeDisplayOptions() { ? ('full' as const) : ('app' as const); - setView(nextView); - // Mutually exclusive sort selection. const newSortVals = vals.filter(v => SORT_OPTION_VALUES.includes(v as (typeof SORT_OPTION_VALUES)[number]) ); const newSortVal = newSortVals.find(v => v !== currentSortVal) ?? newSortVals[0] ?? currentSortVal; - setIsNewestFirst(newSortVal === 'newest'); - - const nextIsMinified = vals.includes(NATIVE_DISPLAY_OPTION.MINIFIED); + const nextPrefersMinified = hasMinifiedStacktrace + ? vals.includes(NATIVE_DISPLAY_OPTION.MINIFIED) + : prefersMinified; let nextAbsoluteAddresses = absoluteAddresses; let nextAbsoluteFilePaths = absoluteFilePaths; let nextVerboseFunctionNames = verboseFunctionNames; - setIsMinified(nextIsMinified); if (!isRawView && nextView !== 'raw') { - nextAbsoluteAddresses = vals.includes(NATIVE_DISPLAY_OPTION.ABSOLUTE_ADDRESSES); - nextAbsoluteFilePaths = vals.includes(NATIVE_DISPLAY_OPTION.ABSOLUTE_FILE_PATHS); - nextVerboseFunctionNames = vals.includes( - NATIVE_DISPLAY_OPTION.VERBOSE_FUNCTION_NAMES - ); - setAbsoluteAddresses(nextAbsoluteAddresses); - setAbsoluteFilePaths(nextAbsoluteFilePaths); - setVerboseFunctionNames(nextVerboseFunctionNames); + nextAbsoluteAddresses = hasAbsoluteAddresses + ? vals.includes(NATIVE_DISPLAY_OPTION.ABSOLUTE_ADDRESSES) + : absoluteAddresses; + nextAbsoluteFilePaths = hasAbsoluteFilePaths + ? vals.includes(NATIVE_DISPLAY_OPTION.ABSOLUTE_FILE_PATHS) + : absoluteFilePaths; + nextVerboseFunctionNames = hasVerboseFunctionNames + ? vals.includes(NATIVE_DISPLAY_OPTION.VERBOSE_FUNCTION_NAMES) + : verboseFunctionNames; } - persistDisplayOptions({ + updateDisplayOptions({ absoluteAddresses: nextAbsoluteAddresses, absoluteFilePaths: nextAbsoluteFilePaths, - isMinified: nextIsMinified, + isNewestFirst: newSortVal === 'newest', + prefersMinified: nextPrefersMinified, verboseFunctionNames: nextVerboseFunctionNames, view: nextView, }); diff --git a/static/app/components/stackTrace/native/nativeDisplayOptionsContext.tsx b/static/app/components/stackTrace/native/nativeDisplayOptionsContext.tsx new file mode 100644 index 000000000000..cd1d90785fe2 --- /dev/null +++ b/static/app/components/stackTrace/native/nativeDisplayOptionsContext.tsx @@ -0,0 +1,199 @@ +import {createContext, useCallback, useContext, useMemo, useState} from 'react'; + +import { + StackTraceViewStateProvider, + useStackTraceViewState, +} from 'sentry/components/stackTrace/stackTraceContext'; +import type { + StackTraceView, + StackTraceViewStateProviderProps, +} from 'sentry/components/stackTrace/types'; + +import { + getNativeDisplayOptionDefaults, + getNativeDisplayOptions, + NATIVE_DISPLAY_OPTION, + useNativeDisplayOptionsStorage, +} from './nativeDisplayOptionsPersistence'; + +interface NativeDisplayOptionsState { + absoluteAddresses: boolean; + absoluteFilePaths: boolean; + isNewestFirst: boolean; + prefersMinified: boolean; + verboseFunctionNames: boolean; + view: StackTraceView; +} + +interface NativeDisplayOptionsContextValue { + absoluteAddresses: boolean; + absoluteFilePaths: boolean; + prefersMinified: boolean; + updateDisplayOptions: (options: NativeDisplayOptionsState) => void; + verboseFunctionNames: boolean; +} + +interface NativeStackTraceViewStateProviderProps extends StackTraceViewStateProviderProps { + storageKey?: string; +} + +type PersistedOptions = ReturnType; +type SetPersistedOptions = React.Dispatch>; + +const NativeDisplayOptionsContext = + createContext(null); + +export function NativeStackTraceViewStateProvider({ + storageKey, + ...props +}: NativeStackTraceViewStateProviderProps) { + if (storageKey) { + return ( + + ); + } + + return ; +} + +function PersistedNativeStackTraceViewStateProvider({ + storageKey, + ...props +}: Omit & {storageKey: string}) { + const [persistedOptions, setPersistedOptions] = + useNativeDisplayOptionsStorage(storageKey); + + return ( + + ); +} + +function LocalNativeStackTraceViewStateProvider({ + defaultIsMinified = false, + defaultView = 'app', + ...props +}: Omit) { + const [persistedOptions, setPersistedOptions] = useState(() => + getNativeDisplayOptions({ + absoluteAddresses: false, + absoluteFilePaths: false, + isMinified: defaultIsMinified, + verboseFunctionNames: false, + view: defaultView, + }) + ); + + return ( + + ); +} + +function NativeStackTraceViewStateRoot({ + children, + defaultIsMinified = false, + defaultView = 'app', + hasMinifiedStacktrace = false, + persistedOptions, + setPersistedOptions, + ...viewStateProps +}: Omit & { + persistedOptions: PersistedOptions; + setPersistedOptions: SetPersistedOptions; +}) { + const defaults = getNativeDisplayOptionDefaults({ + defaultIsMinified, + defaultView, + hasMinifiedStacktrace, + persistedOptions, + }); + + return ( + + + {children} + + + ); +} + +function NativeDisplayOptionsProvider({ + children, + persistedOptions, + setPersistedOptions, +}: { + children: React.ReactNode; + persistedOptions: PersistedOptions; + setPersistedOptions: SetPersistedOptions; +}) { + const {hasMinifiedStacktrace, isMinified, setIsMinified, setIsNewestFirst, setView} = + useStackTraceViewState(); + const updateDisplayOptions = useCallback( + (options: NativeDisplayOptionsState) => { + setView(options.view); + setIsNewestFirst(options.isNewestFirst); + setIsMinified(hasMinifiedStacktrace && options.prefersMinified); + setPersistedOptions( + getNativeDisplayOptions({ + absoluteAddresses: options.absoluteAddresses, + absoluteFilePaths: options.absoluteFilePaths, + isMinified: options.prefersMinified, + verboseFunctionNames: options.verboseFunctionNames, + view: options.view, + }) + ); + }, + [hasMinifiedStacktrace, setIsMinified, setIsNewestFirst, setPersistedOptions, setView] + ); + const value = useMemo( + () => ({ + absoluteAddresses: persistedOptions.includes( + NATIVE_DISPLAY_OPTION.ABSOLUTE_ADDRESSES + ), + absoluteFilePaths: persistedOptions.includes( + NATIVE_DISPLAY_OPTION.ABSOLUTE_FILE_PATHS + ), + prefersMinified: + isMinified || persistedOptions.includes(NATIVE_DISPLAY_OPTION.MINIFIED), + updateDisplayOptions, + verboseFunctionNames: persistedOptions.includes( + NATIVE_DISPLAY_OPTION.VERBOSE_FUNCTION_NAMES + ), + }), + [isMinified, persistedOptions, updateDisplayOptions] + ); + + return ( + + {children} + + ); +} + +export function useNativeDisplayOptionsContext() { + const context = useContext(NativeDisplayOptionsContext); + if (!context) { + throw new Error( + 'useNativeDisplayOptionsContext must be used within NativeStackTraceViewStateProvider' + ); + } + return context; +} diff --git a/static/app/components/stackTrace/native/nativeDisplayOptionsPersistence.ts b/static/app/components/stackTrace/native/nativeDisplayOptionsPersistence.ts index d7e9b420271f..142ffaf90a04 100644 --- a/static/app/components/stackTrace/native/nativeDisplayOptionsPersistence.ts +++ b/static/app/components/stackTrace/native/nativeDisplayOptionsPersistence.ts @@ -12,22 +12,19 @@ export const NATIVE_DISPLAY_OPTION = { type NativePersistedDisplayOption = (typeof NATIVE_DISPLAY_OPTION)[keyof typeof NATIVE_DISPLAY_OPTION]; -const NO_PERSIST_KEY = '__no_persist_native_stacktrace_display__'; - -export function useNativeDisplayOptionsStorage(storageKey: string | undefined) { - return useLocalStorageState( - storageKey ?? NO_PERSIST_KEY, - [] - ); +export function useNativeDisplayOptionsStorage(storageKey: string) { + return useLocalStorageState(storageKey, []); } export function getNativeDisplayOptionDefaults({ + defaultIsMinified = false, defaultView = 'app', hasMinifiedStacktrace, persistedOptions, }: { hasMinifiedStacktrace: boolean; persistedOptions: NativePersistedDisplayOption[]; + defaultIsMinified?: boolean; defaultView?: StackTraceView; }) { return { @@ -38,7 +35,8 @@ export function getNativeDisplayOptionDefaults({ NATIVE_DISPLAY_OPTION.ABSOLUTE_FILE_PATHS ), defaultIsMinified: - hasMinifiedStacktrace && persistedOptions.includes(NATIVE_DISPLAY_OPTION.MINIFIED), + hasMinifiedStacktrace && + (defaultIsMinified || persistedOptions.includes(NATIVE_DISPLAY_OPTION.MINIFIED)), defaultVerboseFunctionNames: persistedOptions.includes( NATIVE_DISPLAY_OPTION.VERBOSE_FUNCTION_NAMES ), diff --git a/static/app/components/stackTrace/native/nativeStackTrace.stories.tsx b/static/app/components/stackTrace/native/nativeStackTrace.stories.tsx index 3b074867b4f1..e42cf30ae745 100644 --- a/static/app/components/stackTrace/native/nativeStackTrace.stories.tsx +++ b/static/app/components/stackTrace/native/nativeStackTrace.stories.tsx @@ -2,7 +2,7 @@ import {Fragment, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {Button, ButtonBar} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -14,10 +14,7 @@ import { ExceptionHeader, } from 'sentry/components/stackTrace/exceptionHeader'; import {FrameContent} from 'sentry/components/stackTrace/frame/frameContent'; -import { - StackTraceViewStateProvider, - useStackTraceFrameContext, -} from 'sentry/components/stackTrace/stackTraceContext'; +import {useStackTraceFrameContext} from 'sentry/components/stackTrace/stackTraceContext'; import {StackTraceFrames} from 'sentry/components/stackTrace/stackTraceFrames'; import {StackTraceProvider} from 'sentry/components/stackTrace/stackTraceProvider'; import {IconChevron, IconCopy, IconGithub} from 'sentry/icons'; @@ -38,10 +35,7 @@ import {FoldSection} from 'sentry/views/issueDetails/foldSection'; import {NativeDefaultActions} from './frame/actions/nativeDefaultActions'; import {NativeDisplayOptions} from './nativeDisplayOptions'; -import { - getNativeDisplayOptionDefaults, - useNativeDisplayOptionsStorage, -} from './nativeDisplayOptionsPersistence'; +import {NativeStackTraceViewStateProvider} from './nativeDisplayOptionsContext'; import {NativeStackTraceFrames} from './nativeStackTraceFrames'; import {NativeStackTraceProvider} from './nativeStackTraceProvider'; import {RawDownloadAction} from './rawDownloadAction'; @@ -173,26 +167,15 @@ function StoryProvider({ event: Event; stacktrace: StacktraceType; }) { - const [persistedOptions] = useNativeDisplayOptionsStorage(DISPLAY_OPTIONS_STORAGE_KEY); - const {defaultIsMinified, defaultView} = getNativeDisplayOptionDefaults({ - hasMinifiedStacktrace: false, - persistedOptions, - }); - return ( - - + {children} - + ); } @@ -613,11 +596,6 @@ function ActiveThreadFrames({event, thread}: {event: Event; thread: NamedThread} function NativeIssueStackTraceStory() { const {event, threads} = useMemo(() => makeMultiThreadData(), []); const [activeThread, setActiveThread] = useState(threads[0]!); - const [persistedOptions] = useNativeDisplayOptionsStorage(DISPLAY_OPTIONS_STORAGE_KEY); - const {defaultIsMinified, defaultView} = getNativeDisplayOptionDefaults({ - hasMinifiedStacktrace: false, - persistedOptions, - }); const handleChange = (direction: 'previous' | 'next') => { const currentIndex = threads.findIndex(thread => thread.id === activeThread.id); @@ -668,30 +646,25 @@ function NativeIssueStackTraceStory() { return ( // Re-key on the active thread so view state (app/full/raw) resets per thread // and the provider sees the correct default platform. - - + - - + + - +