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