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 ( -