feat(mobile): replace Sentry with Firebase Crashlytics

Migrate error tracking from Sentry to Firebase Crashlytics.
Bump version to 1.0.4, add Discord notification triggers.
This commit is contained in:
jjbrunton committed 2026-01-27 22:08:43 +00:00
1 parent 620369a7ce
commit 14b0199625
16 files changed
+649 -525

No files matched your search

+4 -5
View File
@@ -80,8 +80,6 @@ def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInRelea
*/
def jscFlavor = 'org.webkit:android-jsc:+'
apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle")
android {
ndkVersion rootProject.ext.ndkVersion
@@ -93,8 +91,8 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 37
versionName "1.0.3"
versionCode 42
versionName "1.0.4"
}
signingConfigs {
debug {
@@ -177,4 +175,5 @@ dependencies {
}
}
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
+1
View File
@@ -15,6 +15,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.6'
classpath 'com.google.gms:google-services:4.4.1'
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
+5 -11
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Sauci",
"slug": "sauci",
"version": "1.0.3",
"version": "1.0.4",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "app.sauci",
@@ -16,10 +16,11 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.sauci.app",
"buildNumber": "33",
"buildNumber": "35",
"usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": {
"GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED": false,
"UIBackgroundModes": [
"remote-notification"
],
@@ -177,7 +178,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 37,
"versionCode": 42,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
@@ -227,14 +228,7 @@
"faceIDPermission": "Sauci uses Face ID to unlock the app and keep your conversations private."
}
],
[
"@sentry/react-native/expo",
{
"organization": "molten-prints",
"project": "sauci"
}
],
"@sentry/react-native",
"@react-native-firebase/crashlytics",
"expo-font",
"expo-asset",
[
+4 -4
View File
@@ -1,9 +1,9 @@
// Polyfills must be imported FIRST before any other React Native imports
import "../src/polyfills/web";
// Initialize Sentry early to capture all errors
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
initSentry();
// Initialize Crashlytics early to capture all errors
import { initCrashlytics, setUserContext, clearUserContext } from "../src/lib/crashlytics";
initCrashlytics();
// Analytics imports (initialization deferred until app mounts)
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
@@ -212,7 +212,7 @@ export default function RootLayout() {
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
console.log('[Auth] Calling fetchUser...');
fetchUser();
// Set Sentry user context for error tracking
// Set Crashlytics user context for error tracking
if (session?.user) {
setUserContext(session.user.id, session.user.email);
// Set Analytics user ID
-1
View File
@@ -19,7 +19,6 @@
"env": {
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
"EXPO_PUBLIC_SENTRY_DSN": "https://03a91f8a1b1ecdce2d5f38a4c23ca9d5@o4510613894070272.ingest.de.sentry.io/4510613896560720",
"EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_lfPyYonFQtVytftPAfXIrGsMNFu",
"EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY": "goog_zoHtaRswsgvxtvYWIhZyvrPBGgh",
"EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID": "Sauci Pro",
+4 -1
View File
@@ -1,6 +1,9 @@
{
"react-native": {
"analytics_auto_collection_enabled": true,
"google_analytics_adid_collection_enabled": false
"google_analytics_adid_collection_enabled": false,
"crashlytics_auto_collection_enabled": true,
"crashlytics_debug_enabled": false,
"crashlytics_ndk_enabled": true
}
}
+4 -2
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.3</string>
<string>1.0.4</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -47,7 +47,9 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>33</string>
<string>35</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
+1 -1
View File
@@ -24,11 +24,11 @@
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-firebase/analytics": "^23.8.2",
"@react-native-firebase/app": "^23.8.2",
"@react-native-firebase/crashlytics": "^23.8.4",
"@react-native-google-signin/google-signin": "^16.1.1",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@sentry/react-native": "~6.10.0",
"@supabase/supabase-js": "^2.39.0",
"@tanstack/react-query": "^5.17.0",
"babel-preset-expo": "~12.0.0",
+118 -118
View File
@@ -72,35 +72,35 @@ function getAnalyticsInstance(): FirebaseAnalyticsTypes.Module | null {
return null;
}
}
export async function initAnalytics() {
if (isInitialized) {
return;
}
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
return;
}
try {
export async function initAnalytics() {
if (isInitialized) {
return;
}
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
return;
}
try {
const analytics = getAnalyticsInstance();
if (analytics) {
await setAnalyticsCollectionEnabled(analytics, true);
isInitialized = true;
console.log("[Analytics] Firebase Analytics initialized");
}
} catch (error) {
console.warn("[Analytics] Failed to initialize Firebase Analytics:", error);
}
}
// Log a custom event
export function logEvent(
name: string,
params?: Record<string, string | number | boolean>
) {
try {
} catch (error) {
console.warn("[Analytics] Failed to initialize Firebase Analytics:", error);
}
}
// Log a custom event
export function logEvent(
name: string,
params?: Record<string, string | number | boolean>
) {
try {
if (__DEV__) console.log("[Analytics] Event:", name, params);
const analytics = getAnalyticsInstance();
if (analytics) {
@@ -109,11 +109,11 @@ export function logEvent(
} catch (error) {
console.warn("[Analytics] Failed to log event:", error);
}
}
// Set user ID for tracking
export function setUserId(userId: string) {
try {
}
// Set user ID for tracking
export function setUserId(userId: string) {
try {
if (__DEV__) console.log("[Analytics] Set user ID:", userId);
const analytics = getAnalyticsInstance();
if (analytics) {
@@ -122,11 +122,11 @@ export function setUserId(userId: string) {
} catch (error) {
console.warn("[Analytics] Failed to set user ID:", error);
}
}
// Clear user ID on logout
export function clearUserId() {
try {
}
// Clear user ID on logout
export function clearUserId() {
try {
if (__DEV__) console.log("[Analytics] Cleared user ID");
const analytics = getAnalyticsInstance();
if (analytics) {
@@ -136,13 +136,13 @@ export function clearUserId() {
} catch (error) {
console.warn("[Analytics] Failed to clear user ID:", error);
}
}
// Set user properties
export function setUserProperties(
properties: Record<string, string | null>
) {
try {
}
// Set user properties
export function setUserProperties(
properties: Record<string, string | null>
) {
try {
if (__DEV__) console.log("[Analytics] Set user properties:", properties);
const analytics = getAnalyticsInstance();
if (analytics) {
@@ -154,11 +154,11 @@ export function setUserProperties(
} catch (error) {
console.warn("[Analytics] Failed to set user properties:", error);
}
}
// Log screen view
export function logScreenView(screenName: string, screenClass?: string) {
try {
}
// Log screen view
export function logScreenView(screenName: string, screenClass?: string) {
try {
if (__DEV__) console.log("[Analytics] Screen view:", screenName);
const analytics = getAnalyticsInstance();
if (analytics) {
@@ -166,12 +166,12 @@ export function logScreenView(screenName: string, screenClass?: string) {
screen_name: screenName,
screen_class: screenClass ?? screenName,
});
}
} catch (error) {
console.warn("[Analytics] Failed to log screen view:", error);
}
}
}
} catch (error) {
console.warn("[Analytics] Failed to log screen view:", error);
}
}
// Flush events (useful before app backgrounding)
export function flush() {
// Firebase Analytics batches and sends events automatically
@@ -193,35 +193,35 @@ function safeParam(value: unknown, maxLength = 100): string {
// Pre-defined event helpers for common actions
export const Events = {
// Auth events
signUp: (method: string) => logEvent("sign_up", { method }),
signIn: (method: string) => logEvent("login", { method }),
signOut: () => logEvent("sign_out"),
// Onboarding events
onboardingStart: () => logEvent("onboarding_start"),
onboardingStageComplete: (stage: string) =>
logEvent("onboarding_stage_complete", { stage }),
onboardingComplete: () => logEvent("onboarding_complete"),
// Pairing events
coupleCreated: () => logEvent("couple_created"),
coupleJoined: () => logEvent("couple_joined"),
codeShared: () => logEvent("invite_code_shared"),
pairingCancelled: () => logEvent("pairing_cancelled"),
// Swipe/question events
questionAnswered: (answer: string, packId?: string) =>
logEvent("question_answered", { answer, pack_id: packId ?? "unknown" }),
questionSkipped: () => logEvent("question_skipped"),
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
// Chat events
matchViewed: () => logEvent("match_viewed"),
messageSent: () => logEvent("message_sent"),
mediaUploaded: () => logEvent("chat_media_uploaded"),
// Auth events
signUp: (method: string) => logEvent("sign_up", { method }),
signIn: (method: string) => logEvent("login", { method }),
signOut: () => logEvent("sign_out"),
// Onboarding events
onboardingStart: () => logEvent("onboarding_start"),
onboardingStageComplete: (stage: string) =>
logEvent("onboarding_stage_complete", { stage }),
onboardingComplete: () => logEvent("onboarding_complete"),
// Pairing events
coupleCreated: () => logEvent("couple_created"),
coupleJoined: () => logEvent("couple_joined"),
codeShared: () => logEvent("invite_code_shared"),
pairingCancelled: () => logEvent("pairing_cancelled"),
// Swipe/question events
questionAnswered: (answer: string, packId?: string) =>
logEvent("question_answered", { answer, pack_id: packId ?? "unknown" }),
questionSkipped: () => logEvent("question_skipped"),
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
// Chat events
matchViewed: () => logEvent("match_viewed"),
messageSent: () => logEvent("message_sent"),
mediaUploaded: () => logEvent("chat_media_uploaded"),
// Subscription events
paywallShown: (source: string) => logEvent("paywall_shown", { source }),
paywallClosed: (source: string, reason: string) =>
@@ -240,42 +240,42 @@ export const Events = {
error_message: safeParam(errorMessage),
}),
purchaseCompleted: (packageType: string) =>
logEvent("purchase", { package_type: packageType }),
logEvent("purchase_completed", { package_type: packageType }),
purchaseRestored: () => logEvent("purchase_restored"),
// Pack events
packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }),
packDisabled: (packId: string) => logEvent("pack_disabled", { pack_id: packId }),
// App lifecycle events
appOpened: (type: "cold" | "warm") => logEvent("app_opened", { type }),
// Profile events
profileUpdated: (fields: string[]) => logEvent("profile_updated", { fields: fields.join(",") }),
avatarUploaded: () => logEvent("avatar_uploaded"),
// Notification events
notificationPermissionGranted: () => logEvent("notification_permission_granted"),
notificationPermissionDenied: () => logEvent("notification_permission_denied"),
// Relationship events
relationshipEnded: () => logEvent("relationship_ended"),
accountDeleted: () => logEvent("account_deleted"),
// Milestone events
firstMatch: () => logEvent("first_match"),
milestoneMatch: (count: number) => logEvent("milestone_match", { match_count: count }),
// Invite events
inviteCodeCopied: () => logEvent("invite_code_copied"),
// Tutorial events
tutorialStarted: (tutorial: "swipe" | "matches") =>
logEvent("tutorial_started", { tutorial }),
tutorialStepViewed: (tutorial: "swipe" | "matches", step: number, stepName: string) =>
logEvent("tutorial_step_viewed", { tutorial, step, step_name: stepName }),
tutorialCompleted: (tutorial: "swipe" | "matches") =>
logEvent("tutorial_completed", { tutorial }),
tutorialSkipped: (tutorial: "swipe" | "matches", atStep: number) =>
logEvent("tutorial_skipped", { tutorial, at_step: atStep }),
};
// Pack events
packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }),
packDisabled: (packId: string) => logEvent("pack_disabled", { pack_id: packId }),
// App lifecycle events
appOpened: (type: "cold" | "warm") => logEvent("app_opened", { type }),
// Profile events
profileUpdated: (fields: string[]) => logEvent("profile_updated", { fields: fields.join(",") }),
avatarUploaded: () => logEvent("avatar_uploaded"),
// Notification events
notificationPermissionGranted: () => logEvent("notification_permission_granted"),
notificationPermissionDenied: () => logEvent("notification_permission_denied"),
// Relationship events
relationshipEnded: () => logEvent("relationship_ended"),
accountDeleted: () => logEvent("account_deleted"),
// Milestone events
firstMatch: () => logEvent("first_match"),
milestoneMatch: (count: number) => logEvent("milestone_match", { match_count: count }),
// Invite events
inviteCodeCopied: () => logEvent("invite_code_copied"),
// Tutorial events
tutorialStarted: (tutorial: "swipe" | "matches") =>
logEvent("tutorial_started", { tutorial }),
tutorialStepViewed: (tutorial: "swipe" | "matches", step: number, stepName: string) =>
logEvent("tutorial_step_viewed", { tutorial, step, step_name: stepName }),
tutorialCompleted: (tutorial: "swipe" | "matches") =>
logEvent("tutorial_completed", { tutorial }),
tutorialSkipped: (tutorial: "swipe" | "matches", atStep: number) =>
logEvent("tutorial_skipped", { tutorial, at_step: atStep }),
};
+128
View File
@@ -0,0 +1,128 @@
import { Platform } from "react-native";
import Constants from "expo-constants";
import crashlytics from "@react-native-firebase/crashlytics";
let isInitialized = false;
let crashlyticsUnavailable = false;
let crashlyticsUnavailableLogged = false;
function getErrorMessage(error: unknown): string {
if (!error) return "";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
if (typeof (error as { message?: unknown }).message === "string") {
return (error as { message?: string }).message || "";
}
return "";
}
function isCrashlyticsSupported(): boolean {
if (Platform.OS === "web") return false;
if (Constants.appOwnership === "expo") return false;
return true;
}
function logCrashlyticsUnavailableOnce(reason: string) {
if (crashlyticsUnavailableLogged) return;
crashlyticsUnavailableLogged = true;
if (__DEV__) console.log("[Crashlytics] " + reason);
}
function getCrashlyticsInstance(): ReturnType<typeof crashlytics> | null {
if (crashlyticsUnavailable) {
return null;
}
if (!isCrashlyticsSupported()) {
crashlyticsUnavailable = true;
logCrashlyticsUnavailableOnce("Skipping Firebase Crashlytics on Expo Go or web.");
return null;
}
try {
return crashlytics();
} catch (error) {
const message = getErrorMessage(error);
if (message.includes("module could not be found")) {
crashlyticsUnavailable = true;
logCrashlyticsUnavailableOnce(
"Firebase Crashlytics native module missing. Rebuild with a dev client or native app."
);
return null;
}
console.warn("[Crashlytics] Failed to get crashlytics instance:", error);
return null;
}
}
export function initCrashlytics() {
if (isInitialized) return;
try {
const instance = getCrashlyticsInstance();
if (instance) {
instance.setCrashlyticsCollectionEnabled(true);
isInitialized = true;
console.log("[Crashlytics] Firebase Crashlytics initialized");
}
} catch (error) {
console.warn("[Crashlytics] Failed to initialize:", error);
}
}
export function captureError(error: Error, context?: Record<string, unknown>) {
try {
const instance = getCrashlyticsInstance();
if (!instance) return;
if (context) {
for (const [key, value] of Object.entries(context)) {
instance.setAttribute(key, String(value ?? ""));
}
}
instance.recordError(error);
} catch (e) {
console.warn("[Crashlytics] Failed to capture error:", e);
}
}
export function setUserContext(userId: string, email?: string) {
try {
const instance = getCrashlyticsInstance();
if (!instance) return;
instance.setUserId(userId);
if (email) {
instance.setAttribute("email", email);
}
} catch (error) {
console.warn("[Crashlytics] Failed to set user context:", error);
}
}
export function clearUserContext() {
try {
const instance = getCrashlyticsInstance();
if (!instance) return;
instance.setUserId("");
instance.setAttribute("email", "");
} catch (error) {
console.warn("[Crashlytics] Failed to clear user context:", error);
}
}
export function addBreadcrumb(
message: string,
category: string,
data?: Record<string, unknown>
) {
try {
const instance = getCrashlyticsInstance();
if (!instance) return;
instance.log(`[${category}] ${message}${data ? " " + JSON.stringify(data) : ""}`);
} catch (error) {
console.warn("[Crashlytics] Failed to add breadcrumb:", error);
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import Constants from "expo-constants";
import { Platform } from "react-native";
import { router } from "expo-router";
import { supabase } from "./supabase";
import { captureError } from "./sentry";
import { captureError } from "./crashlytics";
import { Events } from "./analytics";
import { useMatchStore } from "../store/matchStore";
-72
View File
@@ -1,72 +0,0 @@
import * as Sentry from "@sentry/react-native";
import Constants from "expo-constants";
const SENTRY_DSN = process.env.EXPO_PUBLIC_SENTRY_DSN;
export function initSentry() {
if (!SENTRY_DSN) {
console.warn("Sentry DSN not configured - error tracking disabled");
return;
}
Sentry.init({
dsn: SENTRY_DSN,
debug: __DEV__,
enabled: !__DEV__, // Only enable in production
environment: __DEV__ ? "development" : "production",
release: Constants.expoConfig?.version ?? "1.0.0",
dist: Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode?.toString(),
// Performance monitoring
tracesSampleRate: 0.2, // 20% of transactions for performance
// Session tracking
enableAutoSessionTracking: true,
sessionTrackingIntervalMillis: 30000,
// Capture unhandled promise rejections
enableNative: true,
// Filter out noisy errors
beforeSend(event) {
// Filter out network errors that are expected (e.g., user offline)
if (event.exception?.values?.[0]?.value?.includes("Network request failed")) {
return null;
}
return event;
},
});
}
// Helper to capture errors with context
export function captureError(error: Error, context?: Record<string, unknown>) {
if (context) {
Sentry.setContext("additional", context);
}
Sentry.captureException(error);
}
// Set user context when they log in
export function setUserContext(userId: string, email?: string) {
Sentry.setUser({
id: userId,
email,
});
}
// Clear user context on logout
export function clearUserContext() {
Sentry.setUser(null);
}
// Add breadcrumb for debugging
export function addBreadcrumb(message: string, category: string, data?: Record<string, unknown>) {
Sentry.addBreadcrumb({
message,
category,
data,
level: "info",
});
}
export { Sentry };
+1 -1
View File
@@ -58,7 +58,7 @@ if (!isTestEnv && (!supabaseUrl || !supabaseAnonKey)) {
if (!supabaseAnonKey) missing.push('EXPO_PUBLIC_SUPABASE_ANON_KEY');
const errorMsg = `Missing required environment variables: ${missing.join(', ')}. App cannot start.`;
console.error('[Supabase]', errorMsg);
// In production, this will be caught by Sentry before crashing
// In production, this will be caught by Crashlytics before crashing
throw new Error(errorMsg);
}