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

+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);
}