Skip to content

Commit 50f0b20

Browse files
romtsnclaude
andauthored
feat(issue-details): Add android native tombstones onboarding banner (#112478)
Show an onboarding banner on the issue details page when a native crash was captured via the Android NDK integration with only a signalhandler mechanism. The banner encourages users to enable [tombstone collection](https://docs.sentry.io/platforms/android/configuration/tombstones/) for richer crash reports. Includes: - Tabbed code snippets (AndroidManifest.xml, Kotlin, Java) inline in the banner - Dismiss/snooze support via the prompts-activity API (scoped per project) - Suppressed on sample/demo events - Analytics for CTA click and dismiss/snooze Depends on #112477 (backend prompt registration) — land that first. <img width="1130" height="1065" alt="image" src="https://github.com/user-attachments/assets/cf826134-78df-4856-945c-748719e8cbd5" /> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ddcd0dd commit 50f0b20

File tree

3 files changed

+264
-0
lines changed

3 files changed

+264
-0
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import {useState} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {LinkButton} from '@sentry/scraps/button';
5+
import {CodeBlock} from '@sentry/scraps/code';
6+
import {Flex} from '@sentry/scraps/layout';
7+
import {Heading, Text} from '@sentry/scraps/text';
8+
9+
import {usePrompt} from 'sentry/actionCreators/prompts';
10+
import {DropdownMenu} from 'sentry/components/dropdownMenu';
11+
import {IconClose} from 'sentry/icons';
12+
import {t} from 'sentry/locale';
13+
import type {EntryException, Event, ExceptionValue} from 'sentry/types/event';
14+
import {EntryType} from 'sentry/types/event';
15+
import {trackAnalytics} from 'sentry/utils/analytics';
16+
import {useOrganization} from 'sentry/utils/useOrganization';
17+
18+
const TOMBSTONES_DOCS_URL =
19+
'https://docs.sentry.io/platforms/android/configuration/tombstones/';
20+
21+
type TabConfig = {
22+
code: string;
23+
label: string;
24+
language: string;
25+
value: string;
26+
};
27+
28+
type SdkConfig = {
29+
defaultTab: string;
30+
tabs: TabConfig[];
31+
};
32+
33+
const ANDROID_SDK_CONFIG: SdkConfig = {
34+
defaultTab: 'manifest',
35+
tabs: [
36+
{
37+
label: 'AndroidManifest.xml',
38+
value: 'manifest',
39+
language: 'xml',
40+
code: `<!-- Requires sentry-android 8.35.0+ -->
41+
<application>
42+
<meta-data
43+
android:name="io.sentry.tombstone.enable"
44+
android:value="true" />
45+
</application>`,
46+
},
47+
{
48+
label: 'Kotlin',
49+
value: 'kotlin',
50+
language: 'kotlin',
51+
code: `// Requires sentry-android 8.35.0+
52+
SentryAndroid.init(context) { options ->
53+
options.isTombstoneEnabled = true
54+
}`,
55+
},
56+
{
57+
label: 'Java',
58+
value: 'java',
59+
language: 'java',
60+
code: `// Requires sentry-android 8.35.0+
61+
SentryAndroid.init(context, options -> {
62+
options.setTombstoneEnabled(true);
63+
});`,
64+
},
65+
],
66+
};
67+
68+
const REACT_NATIVE_SDK_CONFIG: SdkConfig = {
69+
defaultTab: 'javascript',
70+
tabs: [
71+
{
72+
label: 'JavaScript',
73+
value: 'javascript',
74+
language: 'javascript',
75+
code: `// Requires @sentry/react-native 8.5.0+
76+
Sentry.init({
77+
enableTombstone: true,
78+
});`,
79+
},
80+
],
81+
};
82+
83+
const FLUTTER_SDK_CONFIG: SdkConfig = {
84+
defaultTab: 'dart',
85+
tabs: [
86+
{
87+
label: 'Dart',
88+
value: 'dart',
89+
language: 'dart',
90+
code: `// Requires sentry_flutter 9.15.0+
91+
await SentryFlutter.init(
92+
(options) {
93+
options.enableTombstone = true;
94+
},
95+
);`,
96+
},
97+
],
98+
};
99+
100+
const SDK_CONFIGS: Record<string, SdkConfig> = {
101+
'sentry.native.android': ANDROID_SDK_CONFIG,
102+
'sentry.native.android.react-native': REACT_NATIVE_SDK_CONFIG,
103+
'sentry.native.android.flutter': FLUTTER_SDK_CONFIG,
104+
};
105+
106+
function hasSignalHandlerMechanism(event: Event): boolean {
107+
const exceptionEntry = event.entries?.find(
108+
(entry): entry is EntryException => entry.type === EntryType.EXCEPTION
109+
);
110+
if (!exceptionEntry) {
111+
return false;
112+
}
113+
return (
114+
exceptionEntry.data.values?.some(
115+
(value: ExceptionValue) => value.mechanism?.type === 'signalhandler'
116+
) ?? false
117+
);
118+
}
119+
120+
function getSdkConfig(event: Event): SdkConfig | undefined {
121+
const sdkName = event.sdk?.name;
122+
if (!sdkName) {
123+
return undefined;
124+
}
125+
return SDK_CONFIGS[sdkName];
126+
}
127+
128+
export function shouldShowTombstonesBanner(event: Event): boolean {
129+
return getSdkConfig(event) !== undefined && hasSignalHandlerMechanism(event);
130+
}
131+
132+
interface Props {
133+
event: Event;
134+
projectId: string;
135+
}
136+
137+
export function AndroidNativeTombstonesBanner({event, projectId}: Props) {
138+
const organization = useOrganization();
139+
const sdkConfig = getSdkConfig(event);
140+
const [codeTab, setCodeTab] = useState(sdkConfig?.defaultTab ?? 'manifest');
141+
142+
const {isLoading, isError, isPromptDismissed, dismissPrompt, snoozePrompt} = usePrompt({
143+
feature: 'issue_android_tombstones_onboarding',
144+
organization,
145+
projectId,
146+
daysToSnooze: 7,
147+
});
148+
149+
if (isLoading || isError || isPromptDismissed || !sdkConfig) {
150+
return null;
151+
}
152+
153+
const activeTab =
154+
sdkConfig.tabs.find(tab => tab.value === codeTab) ?? sdkConfig.tabs[0]!;
155+
156+
return (
157+
<BannerWrapper>
158+
<Flex direction="column" gap="md">
159+
<Heading as="h4">{t('Enable Tombstone Collection')}</Heading>
160+
<Text as="p" style={{maxWidth: 460}}>
161+
{t(
162+
'This native crash was captured via the Android NDK integration only. Enable Tombstone collection in your application to get richer crash reports with more context, including additional thread information, better stack traces and more.'
163+
)}
164+
</Text>
165+
<CodeBlock
166+
tabs={sdkConfig.tabs.map(tab => ({label: tab.label, value: tab.value}))}
167+
selectedTab={codeTab}
168+
onTabClick={setCodeTab}
169+
language={activeTab.language}
170+
>
171+
{activeTab.code}
172+
</CodeBlock>
173+
<LinkButton
174+
style={{alignSelf: 'flex-start'}}
175+
href={TOMBSTONES_DOCS_URL}
176+
external
177+
priority="primary"
178+
size="sm"
179+
analyticsEventName="Clicked Android Tombstones Onboarding CTA"
180+
analyticsEventKey="issue-details.android-tombstones-onboarding-cta-clicked"
181+
analyticsParams={{
182+
organization,
183+
sdk_name: event.sdk?.name ?? '',
184+
}}
185+
>
186+
{t('Learn More')}
187+
</LinkButton>
188+
</Flex>
189+
<CloseDropdownMenu
190+
position="bottom-end"
191+
triggerProps={{
192+
showChevron: false,
193+
priority: 'transparent',
194+
icon: <IconClose variant="muted" />,
195+
}}
196+
size="xs"
197+
items={[
198+
{
199+
key: 'dismiss',
200+
label: t('Dismiss'),
201+
onAction: () => {
202+
dismissPrompt();
203+
trackAnalytics('issue-details.android-tombstones-cta-dismiss', {
204+
organization,
205+
type: 'dismiss',
206+
});
207+
},
208+
},
209+
{
210+
key: 'snooze',
211+
label: t('Snooze'),
212+
onAction: () => {
213+
snoozePrompt();
214+
trackAnalytics('issue-details.android-tombstones-cta-dismiss', {
215+
organization,
216+
type: 'snooze',
217+
});
218+
},
219+
},
220+
]}
221+
/>
222+
</BannerWrapper>
223+
);
224+
}
225+
226+
const BannerWrapper = styled('div')`
227+
position: relative;
228+
border: 1px solid ${p => p.theme.tokens.border.primary};
229+
border-radius: ${p => p.theme.radius.md};
230+
padding: ${p => p.theme.space.xl};
231+
margin: ${p => p.theme.space.md} 0;
232+
background: linear-gradient(
233+
90deg,
234+
color-mix(in srgb, ${p => p.theme.tokens.background.secondary} 0%, transparent) 0%,
235+
${p => p.theme.tokens.background.secondary} 70%,
236+
${p => p.theme.tokens.background.secondary} 100%
237+
);
238+
`;
239+
240+
const CloseDropdownMenu = styled(DropdownMenu)`
241+
position: absolute;
242+
display: block;
243+
top: ${p => p.theme.space.md};
244+
right: ${p => p.theme.space.md};
245+
cursor: pointer;
246+
z-index: 1;
247+
`;

static/app/utils/analytics/issueAnalyticsEvents.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export type IssueEventParameters = {
7272
'integrations.integration_reinstall_clicked': {
7373
provider: string;
7474
};
75+
'issue-details.android-tombstones-cta-dismiss': {type: string};
7576
'issue-details.replay-cta-dismiss': {type: string};
7677
'issue.engaged_view': {
7778
group_id: number;
@@ -400,6 +401,8 @@ export const issueEventMap: Record<IssueEventKey, string | null> = {
400401
'issue_details.event_dropdown_option_selected':
401402
'Issue Details: Event Dropdown Option Selected',
402403
'issue_details.header_view_replay_clicked': 'Issue Details: Header View Replay Clicked',
404+
'issue-details.android-tombstones-cta-dismiss':
405+
'Issue Details Android Tombstones CTA Dismissed',
403406
'issue-details.replay-cta-dismiss': 'Issue Details Replay CTA Dismissed',
404407
'issue_group_details.anr_root_cause_detected': 'Detected ANR Root Cause',
405408
'issue_details.copy_issue_details_as_markdown':

static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ import {EventFeatureFlagSection} from 'sentry/components/events/featureFlags/eve
3030
import {EventGroupingInfoSection} from 'sentry/components/events/groupingInfo/groupingInfoSection';
3131
import {HighlightsDataSection} from 'sentry/components/events/highlights/highlightsDataSection';
3232
import {HighlightsIconSummary} from 'sentry/components/events/highlights/highlightsIconSummary';
33+
import {
34+
AndroidNativeTombstonesBanner,
35+
shouldShowTombstonesBanner,
36+
} from 'sentry/components/events/interfaces/crashContent/exception/androidNativeTombstonesBanner';
3337
import {Csp} from 'sentry/components/events/interfaces/csp';
3438
import {DebugMeta} from 'sentry/components/events/interfaces/debugMeta';
3539
import {Exception} from 'sentry/components/events/interfaces/exception';
@@ -79,6 +83,7 @@ import {InstrumentationFixSection} from 'sentry/views/issueDetails/streamline/in
7983
import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
8084
import {MetricDetectorTriggeredSection} from 'sentry/views/issueDetails/streamline/sidebar/metricDetectorTriggeredSection';
8185
import {SizeAnalysisTriggeredSection} from 'sentry/views/issueDetails/streamline/sidebar/sizeAnalysisTriggeredSection';
86+
import {useIsSampleEvent} from 'sentry/views/issueDetails/utils';
8287
import {DEFAULT_TRACE_VIEW_PREFERENCES} from 'sentry/views/performance/newTraceDetails/traceState/tracePreferences';
8388
import {TraceStateProvider} from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider';
8489

@@ -118,6 +123,7 @@ export function EventDetailsContent({
118123
: null;
119124
const isMetricKitHang = hangProfileData !== null;
120125
const groupingCurrentLevel = group?.metadata?.current_level;
126+
const isSampleError = useIsSampleEvent();
121127

122128
useCopyIssueDetails(group, event);
123129

@@ -184,6 +190,14 @@ export function EventDetailsContent({
184190
display: block !important;
185191
`}
186192
>
193+
{shouldShowTombstonesBanner(event) && !isSampleError && (
194+
<ErrorBoundary mini>
195+
<AndroidNativeTombstonesBanner
196+
event={event}
197+
projectId={group?.project.id ?? event.projectID ?? ''}
198+
/>
199+
</ErrorBoundary>
200+
)}
187201
{defined(eventEntries[EntryType.EXCEPTION]) && (
188202
<EntryErrorBoundary type={EntryType.EXCEPTION}>
189203
{shouldUseNewStackTrace ? (

0 commit comments

Comments
 (0)