feat(native): add Sentry native crash reporting with privacy-conscious defaults

Adds @sentry/react-native for native crash reporting (segfaults, OOM,
ANRs, watchdog kills) that PostHog's JS-level tracking can't capture.

Privacy measures:
- sendDefaultPii: false, no tracing/replay/profiling/screenshots
- beforeSend strips user/request data from JS errors
- beforeBreadcrumb drops navigation/http breadcrumbs
- Screenshot, ViewHierarchy, UserInteraction integrations filtered out

Crash reporting has its own toggle in Settings, independent from the
existing analytics toggle. Enabled by default, takes effect on restart
since native crash handlers must be installed at init time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 09:38:53 -04:00
co-authored by Claude Opus 4.6
parent 4fc6b2f8d1
commit 647784d8fc
16 changed files with 543 additions and 313 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
EXPO_PUBLIC_POSTHOG_KEY=
EXPO_PUBLIC_POSTHOG_HOST=https://public-api.sofa.watch/ingest
EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
EXPO_PUBLIC_SENTRY_DSN=
+7
View File
@@ -128,6 +128,13 @@
}
]
}
],
[
"@sentry/react-native/expo",
{
"organization": "jarvis-0a",
"project": "sofa-native"
}
]
],
"experiments": {
+7 -4
View File
@@ -1,4 +1,5 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withSentryConfig } = require("@sentry/react-native/metro");
const { withUniwindConfig } = require("uniwind/metro");
const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config");
@@ -26,7 +27,9 @@ config.resolver = {
},
};
module.exports = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
cssEntryFile: "./src/global.css",
dtsFile: "./uniwind-types.d.ts",
});
module.exports = withSentryConfig(
withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
cssEntryFile: "./src/global.css",
dtsFile: "./uniwind-types.d.ts",
}),
);
+1
View File
@@ -32,6 +32,7 @@
"@react-native-menu/menu": "2.0.0",
"@react-navigation/elements": "2.9.11",
"@react-navigation/native": "7.1.34",
"@sentry/react-native": "7.11.0",
"@shopify/flash-list": "2.0.2",
"@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
@@ -3,6 +3,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
import {
IconArrowUpRight,
IconBrandGithub,
IconBug,
IconCamera,
IconChartBar,
IconCloud,
@@ -53,6 +54,7 @@ import { setPersistedLocale } from "@/lib/i18n";
import { orpc } from "@/lib/orpc";
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { isCrashReportingEnabled, setCrashReportingEnabled } from "@/lib/sentry";
import { authClient, getServerUrl, requestServerChange } from "@/lib/server";
import { toast } from "@/lib/toast";
import { activateLocale, isLocaleRTL, type SupportedLocale } from "@sofa/i18n";
@@ -83,6 +85,7 @@ export default function SettingsScreen() {
const [languageModalOpen, setLanguageModalOpen] = useState(false);
const languageLabel = LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
const [crashReportingEnabled, setCrashReportingToggle] = useState(isCrashReportingEnabled);
const isAdmin = session?.user?.role === "admin";
const serverUrl = getServerUrl();
@@ -404,6 +407,21 @@ export default function SettingsScreen() {
/>
}
/>
<SettingsRow
label={t`Crash reporting`}
icon={IconBug}
right={
<Switch
value={crashReportingEnabled}
accessibilityLabel={t`Crash reporting`}
onValueChange={(enabled) => {
setCrashReportingToggle(enabled);
setCrashReportingEnabled(enabled);
toast.info(t`Takes effect after restarting the app`);
}}
/>
}
/>
</SettingsSection>
</Animated.View>
+7 -3
View File
@@ -25,13 +25,15 @@ import { useWidgetRefresh } from "@/hooks/use-widget-refresh";
import { initLocale } from "@/lib/i18n";
import { initAnalytics, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { getScopeKey, initialize, onStorageScopeChange, queryPersister } from "@/lib/server";
import { initSentry, Sentry } from "@/lib/sentry";
import { getScopeKey, initSession, onStorageScopeChange, queryPersister } from "@/lib/server";
import { sofaTheme } from "@/lib/theme";
import { i18n } from "@sofa/i18n";
SplashScreen.preventAutoHideAsync();
enableFreeze(true);
initialize();
initSession();
initSentry();
const localeReady = initLocale();
const changePasswordOptions =
@@ -187,7 +189,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
export default function RootLayout() {
function RootLayout() {
const inner = (
<I18nProvider i18n={i18n}>
<QueryProvider>
@@ -210,3 +212,5 @@ export default function RootLayout() {
</PostHogProvider>
);
}
export default Sentry.wrap(RootLayout);
+2
View File
@@ -1,4 +1,5 @@
import { msg } from "@lingui/core/macro";
import * as Sentry from "@sentry/react-native";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { posthog } from "@/lib/posthog";
@@ -31,6 +32,7 @@ export const queryClient = new QueryClient({
toast.error(i18n._(msg`Something went wrong\u2026`));
posthog?.captureException(error, { source: "react-query" });
Sentry.captureException(error, { tags: { source: "react-query" } });
},
}),
});
+98
View File
@@ -0,0 +1,98 @@
import * as Sentry from "@sentry/react-native";
import { globalStorage } from "@/lib/mmkv";
const CRASH_REPORTING_ENABLED_KEY = "sofa_crash_reporting_enabled";
const CRASH_REPORTING_EXPLICIT_KEY = "sofa_crash_reporting_explicit";
const sentryDsn = process.env.EXPO_PUBLIC_SENTRY_DSN ?? "";
/** Whether the user has explicitly set a crash reporting preference. */
export function hasExplicitCrashPreference(): boolean {
return globalStorage.getBoolean(CRASH_REPORTING_EXPLICIT_KEY) === true;
}
/** Current crash reporting enabled state (explicit preference or default=true). */
export function isCrashReportingEnabled(): boolean {
return globalStorage.getBoolean(CRASH_REPORTING_ENABLED_KEY) ?? true;
}
/**
* Called by the settings toggle — marks the preference as explicit.
* Takes effect on next app restart (Sentry native handlers are installed at init).
*/
export function setCrashReportingEnabled(enabled: boolean): void {
globalStorage.set(CRASH_REPORTING_ENABLED_KEY, enabled);
globalStorage.set(CRASH_REPORTING_EXPLICIT_KEY, true);
}
/**
* Initialize Sentry. Must be called at module scope in the root layout
* (before any React renders) so native crash handlers are installed early.
*
* The `enabled` flag is read synchronously from MMKV. Toggling the
* setting in Settings takes effect on the next app restart.
*/
export function initSentry(): void {
if (!sentryDsn) return;
Sentry.init({
dsn: sentryDsn,
enabled: isCrashReportingEnabled(),
// --- Privacy ---
sendDefaultPii: false,
// --- Crashes only: disable performance/session features ---
tracesSampleRate: 0,
profilesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
attachScreenshot: false,
attachViewHierarchy: false,
beforeBreadcrumb(breadcrumb) {
// Keep only console errors and sentry-internal breadcrumbs.
// Drop navigation, http, UI tap, etc. to avoid leaking screen params.
if (breadcrumb.category === "console" && breadcrumb.level === "error") {
return breadcrumb;
}
if (breadcrumb.category === "sentry") {
return breadcrumb;
}
return null;
},
// PII scrubbing for JS errors (does NOT run for native crashes, but
// native crashes only contain stack traces + device info, no PII).
beforeSend(event) {
delete event.user;
delete event.request;
if (event.breadcrumbs) {
for (const crumb of event.breadcrumbs) {
if (crumb.data) {
delete crumb.data.params;
delete crumb.data.query;
}
}
}
return event;
},
integrations(integrations) {
return integrations.filter((i) => {
if (i.name === "Screenshot") return false;
if (i.name === "ViewHierarchy") return false;
if (i.name === "UserInteraction") return false;
return true;
});
},
environment: __DEV__ ? "development" : "production",
});
}
export { Sentry };
+1 -1
View File
@@ -386,7 +386,7 @@ let _cachedSessionSeeded = false;
* Seed the Better Auth session atom from SecureStore before React renders.
* Call at module scope in the root layout. Idempotent.
*/
export function initialize(): void {
export function initSession(): void {
const cached = getCachedSession();
if (cached) {
_cachedSessionSeeded = true;
+2 -2
View File
@@ -29,7 +29,7 @@
"@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-form": "catalog:",
"@tanstack/react-hotkeys": "0.5.1",
"@tanstack/react-hotkeys": "0.6.0",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.3",
"@tanstack/zod-adapter": "1.166.9",
@@ -37,7 +37,7 @@
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"jotai": "2.18.1",
"jotai": "2.19.0",
"motion": "12.38.0",
"react": "catalog:",
"react-dom": "catalog:",