From 2a5a075e6d46ceb2c94b48ea86b28f264da35ebe Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 9 Sep 2026 18:17:06 -0700 Subject: [PATCH] feat(issues): Use new stack traces on shared issues Reuse the exception and thread containers for public events. Keep project requests, private actions, downloads, and saved display preferences out of shared views. Co-Authored-By: Codex --- .../issueStackTrace/exceptionStackTrace.tsx | 8 +- .../sharedIssueStackTrace.spec.tsx | 69 ++++- .../issueStackTrace/sharedIssueStackTrace.tsx | 274 ++++-------------- .../issueThreadStackTrace/actions.tsx | 5 +- .../activeThreadModel.ts | 0 .../activeThreadStackTrace.tsx | 16 +- .../issueThreadStackTrace/context.tsx | 17 +- .../issueThreadStackTrace/index.spec.tsx | 41 ++- .../issueThreadStackTrace/index.tsx | 3 + .../issueThreadStackTrace/threadSummary.tsx | 0 .../groupEventDetailsContent.tsx | 2 +- .../sharedEventContent.spec.tsx | 74 ++++- .../sharedGroupDetails/sharedEventContent.tsx | 5 +- 13 files changed, 282 insertions(+), 232 deletions(-) rename static/app/components/stackTrace/{native => }/issueThreadStackTrace/actions.tsx (96%) rename static/app/components/stackTrace/{native => }/issueThreadStackTrace/activeThreadModel.ts (100%) rename static/app/components/stackTrace/{native => }/issueThreadStackTrace/activeThreadStackTrace.tsx (89%) rename static/app/components/stackTrace/{native => }/issueThreadStackTrace/context.tsx (91%) rename static/app/components/stackTrace/{native => }/issueThreadStackTrace/index.spec.tsx (94%) rename static/app/components/stackTrace/{native => }/issueThreadStackTrace/index.tsx (96%) rename static/app/components/stackTrace/{native => }/issueThreadStackTrace/threadSummary.tsx (100%) diff --git a/static/app/components/stackTrace/issueStackTrace/exceptionStackTrace.tsx b/static/app/components/stackTrace/issueStackTrace/exceptionStackTrace.tsx index 217f63fdd3f9..5f0ec5607dad 100644 --- a/static/app/components/stackTrace/issueStackTrace/exceptionStackTrace.tsx +++ b/static/app/components/stackTrace/issueStackTrace/exceptionStackTrace.tsx @@ -55,6 +55,7 @@ interface IssueExceptionStackTraceProps { groupingCurrentLevel?: Group['metadata']['current_level']; hasScmSourceContext?: boolean; isStandalone?: boolean; + showBanners?: boolean; } export function IssueExceptionStackTrace({ @@ -63,6 +64,7 @@ export function IssueExceptionStackTrace({ groupingCurrentLevel, hasScmSourceContext = false, isStandalone = false, + showBanners = true, values, }: IssueExceptionStackTraceProps) { const {isMinified, isNewestFirst, view} = useStackTraceViewState(); @@ -115,7 +117,7 @@ export function IssueExceptionStackTrace({ /> ) : null} - {exception.stacktrace && ( + {showBanners && exception.stacktrace && ( @@ -186,7 +188,9 @@ export function IssueExceptionStackTrace({ newestFirst={isNewestFirst} onExceptionClick={expandException} /> - {exception.stacktrace && index === firstVisibleExceptionIndex ? ( + {showBanners && + exception.stacktrace && + index === firstVisibleExceptionIndex ? ( diff --git a/static/app/components/stackTrace/issueStackTrace/sharedIssueStackTrace.spec.tsx b/static/app/components/stackTrace/issueStackTrace/sharedIssueStackTrace.spec.tsx index 3c1f9861b315..0e30e313029b 100644 --- a/static/app/components/stackTrace/issueStackTrace/sharedIssueStackTrace.spec.tsx +++ b/static/app/components/stackTrace/issueStackTrace/sharedIssueStackTrace.spec.tsx @@ -1,7 +1,9 @@ import {EventFixture} from 'sentry-fixture/event'; +import {EventEntryChainedExceptionFixture} from 'sentry-fixture/eventEntryChainedException'; import {EventEntryStacktraceFixture} from 'sentry-fixture/eventEntryStacktrace'; +import {FrameFixture} from 'sentry-fixture/frame'; -import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {SharedIssueStackTrace} from 'sentry/components/stackTrace/issueStackTrace/sharedIssueStackTrace'; @@ -9,6 +11,55 @@ describe('SharedIssueStackTrace', () => { const entry = EventEntryStacktraceFixture(); const event = EventFixture({entries: [entry]}); + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('keeps shared native display choices local and formats raw frames without requests', async () => { + const storageKey = 'issue-details-stracktrace-display-org-slug-project-slug'; + const savedOptions = JSON.stringify(['raw-stack-trace']); + localStorage.setItem(storageKey, savedOptions); + const stacktrace = { + ...entry.data, + frames: [ + FrameFixture({ + platform: 'cocoa', + function: 'causeCrash', + rawFunction: null, + module: null, + }), + ], + }; + const nativeEvent = EventFixture({ + platform: 'cocoa', + entries: [{type: 'stacktrace', data: stacktrace}], + }); + const request = jest.spyOn(MockApiClient.prototype, 'request'); + const {unmount} = render( + + ); + + expect(await screen.findByText('causeCrash')).toBeInTheDocument(); + expect(screen.queryByText(/causeCrash/, {selector: 'pre'})).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(/causeCrash/, {selector: 'pre'})).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Display options'})); + await userEvent.click(screen.getByRole('option', {name: 'Full Stack Trace'})); + await userEvent.keyboard('{Escape}'); + expect(screen.queryByText(/causeCrash/, {selector: 'pre'})).not.toBeInTheDocument(); + expect(localStorage.getItem(storageKey)).toBe(savedOptions); + + unmount(); + render(); + expect(await screen.findByText('causeCrash')).toBeInTheDocument(); + expect(screen.queryByText(/causeCrash/, {selector: 'pre'})).not.toBeInTheDocument(); + expect(request).not.toHaveBeenCalled(); + }); + it('renders a single exception', async () => { render( { expect(screen.getAllByTestId('core-stacktrace-frame-row').length).toBeGreaterThan(0); }); + it('renders chained exceptions with expandable frame lists', async () => { + const chained = EventEntryChainedExceptionFixture(); + render( + + ); + expect( + await screen.findByText(/chained exceptions in this event/) + ).toBeInTheDocument(); + expect(screen.getByText('func4')).not.toBeVisible(); + await userEvent.click(screen.getByRole('button', {name: /ValueError/})); + expect(screen.getByText('func4')).toBeVisible(); + }); + it('renders a standalone stacktrace', async () => { render(); diff --git a/static/app/components/stackTrace/issueStackTrace/sharedIssueStackTrace.tsx b/static/app/components/stackTrace/issueStackTrace/sharedIssueStackTrace.tsx index d30aff7ba9b1..e5bc3b1ece36 100644 --- a/static/app/components/stackTrace/issueStackTrace/sharedIssueStackTrace.tsx +++ b/static/app/components/stackTrace/issueStackTrace/sharedIssueStackTrace.tsx @@ -1,44 +1,22 @@ -import {Fragment, useMemo} from 'react'; - -import {Disclosure} from '@sentry/scraps/disclosure'; -import {Flex, Stack} from '@sentry/scraps/layout'; -import {Separator} from '@sentry/scraps/separator'; -import {Text} from '@sentry/scraps/text'; +import {Flex} from '@sentry/scraps/layout'; import {CopyAsDropdown} from 'sentry/components/copyAsDropdown'; -import {Panel} from 'sentry/components/panels/panel'; +import {getStacktracePlatform} from 'sentry/components/events/interfaces/utils'; import {DisplayOptions} from 'sentry/components/stackTrace/displayOptions'; -import { - RelatedExceptionsTree, - ToggleRelatedExceptionsButton, - useHiddenExceptions, -} from 'sentry/components/stackTrace/exceptionGroup'; -import { - ExceptionDescription, - ExceptionHeader, -} from 'sentry/components/stackTrace/exceptionHeader'; -import {FrameContent} from 'sentry/components/stackTrace/frame/frameContent'; -import {RawStackTraceText} from 'sentry/components/stackTrace/rawStackTrace'; -import { - StackTraceViewStateProvider, - useStackTraceViewState, -} from 'sentry/components/stackTrace/stackTraceContext'; -import {StackTraceFrames} from 'sentry/components/stackTrace/stackTraceFrames'; -import {StackTraceProvider} from 'sentry/components/stackTrace/stackTraceProvider'; -import {tn} from 'sentry/locale'; +import {IssueExceptionStackTrace} from 'sentry/components/stackTrace/issueStackTrace/exceptionStackTrace'; +import {NativeDisplayOptionsMenu} from 'sentry/components/stackTrace/native/nativeDisplayOptions'; +import {NativeStackTraceViewStateProvider} from 'sentry/components/stackTrace/native/nativeDisplayOptionsContext'; +import {getNativeFrameCapabilities} from 'sentry/components/stackTrace/native/nativeFrameAnalysis'; +import {useStackTraceViewState} from 'sentry/components/stackTrace/stackTraceContext'; +import {StackTraceFrameList} from 'sentry/components/stackTrace/stackTraceFrameList'; import type {Event, ExceptionValue} 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 {SectionKey} from 'sentry/views/issueDetails/context'; import {FoldSection} from 'sentry/views/issueDetails/foldSection'; -import { - formatExceptionsAsText, - getExceptionEntryMeta, - getOrderedExceptions, - resolveExceptionFields, -} from './utils'; +import {formatExceptionsAsText, getOrderedExceptions} from './utils'; interface SharedIssueStackTraceBaseProps { event: Event; @@ -58,22 +36,7 @@ type SharedIssueStackTraceProps = | SharedExceptionStackTraceProps | SharedStandaloneStackTraceProps; -/** - * Stack trace component for the shared issue page. - * - * Renders the full exception experience (headers, chaining, display options, - * raw view, copy-as) without making any authenticated API requests. - * - * The shared issue page is viewed by unauthenticated users, so this component - * intentionally avoids the following from {@link IssueStackTrace}: - * - {@link IssueFrameActions}: calls stacktrace-link and source-map-debug APIs - * - {@link IssueStackTraceFrameContext}: fetches SCM source context when expanded (authenticated) - * - {@link StacktraceBanners}: depends on authenticated project context - * - {@link SuspectCommits}: requires group and project data - * - * Uses {@link DefaultFrameActions} and {@link FrameContent} instead, which - * render entirely from local event data. - */ +/** Public issue renderer: uses only event data, without authenticated actions or requests. */ export function SharedIssueStackTrace(props: SharedIssueStackTraceProps) { const {event} = props; const eventHasThreads = event.entries?.some(entry => entry.type === EntryType.THREADS); @@ -107,16 +70,22 @@ export function SharedIssueStackTrace(props: SharedIssueStackTraceProps) { !isStandalone && values.some(v => v.rawStacktrace !== null); return ( - value.stacktrace)?.stacktrace + )} hasMinifiedStacktrace={hasMinifiedStacktrace} + defaultView={ + values.some(value => value.stacktrace?.hasSystemFrames) ? 'app' : 'full' + } > - + ); } @@ -129,172 +98,57 @@ function SharedIssueStackTraceContent({ isStandalone: boolean; values: ExceptionValue[]; }) { - const {isMinified, isNewestFirst, view} = useStackTraceViewState(); - const {hiddenExceptions, toggleRelatedExceptions, expandException} = - useHiddenExceptions(values); - - const {rawEntryMeta, exceptionValuesMeta} = getExceptionEntryMeta(event, isStandalone); - - const exceptions = useMemo( - () => getOrderedExceptions(values, isNewestFirst, view), - [values, isNewestFirst, view] - ); - - const firstVisibleExceptionIndex = exceptions.findIndex( - exc => - exc.mechanism?.parent_id === undefined || !hiddenExceptions[exc.mechanism.parent_id] - ); - - if (exceptions.length === 0) { + const {isMinified, isNewestFirst, view, platform} = useStackTraceViewState(); + const exceptions = getOrderedExceptions(values, isNewestFirst, view); + if (!exceptions.length) { return null; } - - const copyItems = CopyAsDropdown.makeDefaultCopyAsOptions({ - text: () => - formatExceptionsAsText({ - exceptions, - platform: event.platform, - isMinified, - isStandalone, - }), - json: undefined, - markdown: undefined, - }); - - const sectionKey = isStandalone ? SectionKey.STACKTRACE : SectionKey.EXCEPTION; - - const sectionActions = ( - - - - + const frames = values.flatMap( + value => + (isMinified ? (value.rawStacktrace ?? value.stacktrace) : value.stacktrace) + ?.frames ?? [] ); - - if (view === 'raw') { - return ( - - - - {formatExceptionsAsText({ - exceptions, - platform: event.platform, - isMinified, - isStandalone, - })} - - - - ); - } - - if (exceptions.length === 1) { - const exc = exceptions[0]!; - const {type, module, value} = resolveExceptionFields(exc, isMinified); - const hasExceptionInfo = Boolean(type || value); - const excMeta = exceptionValuesMeta?.[exc.exceptionIndex]; - - return ( - - - {hasExceptionInfo && ( - -
- -
- -
- )} - - - -
-
+ const isNative = + isNativePlatform(platform) || + values.some(value => + isNativePlatform(getStacktracePlatform(event, value.stacktrace)) ); - } return ( - - - - {tn( - 'There is %s chained exception in this event.', - 'There are %s chained exceptions in this event.', - exceptions.length + + {isNative ? ( + + ) : ( + )} - - - {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: excType, - module: excModule, - value: excValue, - } = resolveExceptionFields(exc, isMinified); - - return ( - - - } - > - - - - - - - - - - - - - ); - })} - + + formatExceptionsAsText({ + exceptions, + platform: event.platform, + isMinified, + isStandalone, + }), + json: undefined, + markdown: undefined, + })} + /> + + } + > + ); } diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/actions.tsx b/static/app/components/stackTrace/issueThreadStackTrace/actions.tsx similarity index 96% rename from static/app/components/stackTrace/native/issueThreadStackTrace/actions.tsx rename to static/app/components/stackTrace/issueThreadStackTrace/actions.tsx index f0915413706f..a90593135439 100644 --- a/static/app/components/stackTrace/native/issueThreadStackTrace/actions.tsx +++ b/static/app/components/stackTrace/issueThreadStackTrace/actions.tsx @@ -15,7 +15,8 @@ import {useIssueThreadStackTraceContext} from './context'; export function IssueThreadStackTraceActions() { const organization = useOrganization(); - const {activeThreadModel, event, projectSlug} = useIssueThreadStackTraceContext(); + const {activeThreadModel, event, projectSlug, isShared} = + useIssueThreadStackTraceContext(); const { activeException, activeThread, @@ -97,7 +98,7 @@ export function IssueThreadStackTraceActions() { return ( - {isNativeStackTrace ? ( + {isNativeStackTrace && !isShared ? ( ); } @@ -66,7 +72,7 @@ export function ActiveThreadStackTrace() { return ( - {activeException?.stacktrace ? ( + {!isShared && activeException?.stacktrace ? ( @@ -79,7 +85,7 @@ export function ActiveThreadStackTrace() { threadId={activeThread?.id} /> ) : ( - void; threads: Thread[]; @@ -40,6 +41,7 @@ interface IssueThreadStackTraceProvidersProps { event: Event; group: Group | undefined; groupingCurrentLevel: Group['metadata']['current_level']; + isShared: boolean; projectSlug: Project['slug']; threads: Thread[]; } @@ -63,6 +65,7 @@ export function useActiveThread() { export function IssueThreadStackTraceProviders({ children, + isShared, event, group, groupingCurrentLevel, @@ -73,9 +76,9 @@ export function IssueThreadStackTraceProviders({ const storageKey = `issue-details-stracktrace-display-${organization.slug}-${projectSlug}`; const {data: detailedProject} = useDetailedProject( {orgSlug: organization.slug, projectSlug}, - {enabled: defined(projectSlug)} + {enabled: !isShared && defined(projectSlug)} ); - const hasScmSourceContext = !!detailedProject?.scmSourceContextEnabled; + const hasScmSourceContext = !isShared && !!detailedProject?.scmSourceContextEnabled; const [selectedThreadId, setSelectedThreadId] = useState( () => findBestThread(threads)?.id ); @@ -121,12 +124,15 @@ export function IssueThreadStackTraceProviders({ ); useEffect(() => { - setCopyIssueDetailsActiveThreadId(activeThreadModel.activeThread?.id); - }, [activeThreadModel.activeThread?.id]); + if (!isShared) { + setCopyIssueDetailsActiveThreadId(activeThreadModel.activeThread?.id); + } + }, [activeThreadModel.activeThread?.id, isShared]); const contextValue = useMemo( () => ({ activeThreadModel, + isShared, changeThread, event, group, @@ -138,6 +144,7 @@ export function IssueThreadStackTraceProviders({ }), [ activeThreadModel, + isShared, changeThread, event, group, @@ -156,7 +163,7 @@ export function IssueThreadStackTraceProviders({ hasMinifiedStacktrace={activeThreadModel.hasMinifiedStacktrace} defaultView={activeThreadModel.defaultView} defaultIsNewestFirst={activeThreadModel.defaultIsNewestFirst} - storageKey={storageKey} + storageKey={isShared ? undefined : storageKey} > {children} diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx b/static/app/components/stackTrace/issueThreadStackTrace/index.spec.tsx similarity index 94% rename from static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx rename to static/app/components/stackTrace/issueThreadStackTrace/index.spec.tsx index 526b395de5fe..6e9813740f76 100644 --- a/static/app/components/stackTrace/native/issueThreadStackTrace/index.spec.tsx +++ b/static/app/components/stackTrace/issueThreadStackTrace/index.spec.tsx @@ -1,13 +1,15 @@ import {EventStacktraceFrameFixture} from 'sentry-fixture/eventStacktraceFrame'; +import {GroupFixture} from 'sentry-fixture/group'; 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 {IssueThreadStackTrace} from 'sentry/components/stackTrace/issueThreadStackTrace'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Event, Thread} from 'sentry/types/event'; import {EntryType, EventOrGroupType} from 'sentry/types/event'; +import type {Group} from 'sentry/types/group'; import type {PlatformKey} from 'sentry/types/platform'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {localStorageWrapper} from 'sentry/utils/localStorage'; @@ -127,7 +129,10 @@ function makeEvent(threads: Thread[], platform: PlatformKey = 'cocoa'): Event { } as Event; } -function renderThreadStackTrace(event: Event) { +function renderThreadStackTrace( + event: Event, + options: {group?: Group; isShared?: boolean} = {} +) { const threadsEntry = event.entries.find(entry => entry.type === EntryType.THREADS)!; return render( @@ -136,7 +141,8 @@ function renderThreadStackTrace(event: Event) { event={event} projectSlug={project.slug} groupingCurrentLevel={0} - group={undefined} + group={options.group} + isShared={options.isShared} />, {organization} ); @@ -164,6 +170,10 @@ describe('IssueThreadStackTrace', () => { }); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('restores raw and minified choices made on a JavaScript thread in a native event', async () => { const event = makeEvent([ makeThread({crashed: true, id: 7}), @@ -214,6 +224,31 @@ describe('IssueThreadStackTrace', () => { ); }); + it('keeps shared exception and thread views free of authenticated requests and downloads', async () => { + MockApiClient.clearMockResponses(); + const request = jest.spyOn(MockApiClient.prototype, 'request'); + const event = makeEvent([ + makeThread({crashed: true, id: 7}), + makeThread({id: 8, name: 'worker', stacktrace: makeStacktrace('Worker.run')}), + ]); + renderThreadStackTrace(event, {isShared: true, group: GroupFixture()}); + + expect(await screen.findByText('ViewController.causeCrash')).toBeInTheDocument(); + expect(screen.queryByText('Connect with Git Providers')).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(/ViewController.causeCrash/, {selector: 'pre'}) + ).toHaveTextContent('EXC_BAD_ACCESS'); + expect(screen.queryByRole('button', {name: 'Download'})).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Next Thread'})); + expect(await screen.findByText(/Worker.run/, {selector: 'pre'})).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Download'})).not.toBeInTheDocument(); + expect(request).not.toHaveBeenCalled(); + }); + it('renders thread controls and metadata from context', async () => { const event = makeEvent([ makeThread({ diff --git a/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx b/static/app/components/stackTrace/issueThreadStackTrace/index.tsx similarity index 96% rename from static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx rename to static/app/components/stackTrace/issueThreadStackTrace/index.tsx index 9d7f0d5d3d9c..fb6ef0df6f68 100644 --- a/static/app/components/stackTrace/native/issueThreadStackTrace/index.tsx +++ b/static/app/components/stackTrace/issueThreadStackTrace/index.tsx @@ -23,6 +23,7 @@ type Props = { group: Group | undefined; groupingCurrentLevel: Group['metadata']['current_level']; projectSlug: Project['slug']; + isShared?: boolean; }; export function IssueThreadStackTrace({ @@ -31,6 +32,7 @@ export function IssueThreadStackTrace({ projectSlug, groupingCurrentLevel, group, + isShared = false, }: Props) { const threads = useMemo( () => (data.values ?? []).toSorted((a, b) => Number(b.crashed) - Number(a.crashed)), @@ -41,6 +43,7 @@ export function IssueThreadStackTrace({ return ( { const project = ProjectFixture(); const group = GroupFixture(); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('routes native thread entries through the public renderer with limited organization context', async () => { + const stacktrace = { + ...EventEntryStacktraceFixture().data, + frames: [ + FrameFixture({ + platform: 'cocoa', + function: 'causeCrash', + rawFunction: null, + module: null, + }), + ], + }; + const request = jest.spyOn(MockApiClient.prototype, 'request'); + render( + + + + ); + + expect(await screen.findByTestId('native-stack-trace-frame-title')).toHaveTextContent( + 'causeCrash' + ); + expect(screen.getAllByRole('heading', {name: 'EXC_BAD_ACCESS'})).toHaveLength(1); + expect(request).not.toHaveBeenCalled(); + }); + it('renders event entries', () => { render( ; case EntryType.THREADS: return ( -