Files
Sauci/apps/mobile/src/lib/analytics.ts
T

313 lines
12 KiB
TypeScript

import { Platform } from "react-native";
import Constants from "expo-constants";
// Import default export to ensure namespace registration runs
import analytics, {
getAnalytics,
logEvent as firebaseLogEvent,
setUserId as firebaseSetUserId,
setUserProperty,
setAnalyticsCollectionEnabled,
resetAnalyticsData,
FirebaseAnalyticsTypes,
} from "@react-native-firebase/analytics";
// Force the analytics module to be retained (prevents tree-shaking)
const _analyticsModule = analytics;
let isInitialized = false;
let analyticsInstance: FirebaseAnalyticsTypes.Module | null = null;
let analyticsUnavailable = false;
let analyticsUnavailableLogged = 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 isAnalyticsSupported(): boolean {
if (Platform.OS === "web") return false;
if (Constants.appOwnership === "expo") return false;
return true;
}
function logAnalyticsUnavailableOnce(reason: string) {
if (analyticsUnavailableLogged) return;
analyticsUnavailableLogged = true;
if (__DEV__) console.log("[Analytics] " + reason);
}
function getAnalyticsInstance(): FirebaseAnalyticsTypes.Module | null {
if (analyticsUnavailable) {
return null;
}
if (!isAnalyticsSupported()) {
analyticsUnavailable = true;
logAnalyticsUnavailableOnce("Skipping Firebase Analytics on Expo Go or web.");
return null;
}
if (analyticsInstance) {
return analyticsInstance;
}
try {
analyticsInstance = getAnalytics();
return analyticsInstance;
} catch (error) {
const message = getErrorMessage(error);
if (message.includes("module could not be found")) {
analyticsUnavailable = true;
logAnalyticsUnavailableOnce(
"Firebase Analytics native module missing. Rebuild with a dev client or native app."
);
return null;
}
console.warn("[Analytics] Failed to get analytics instance:", error);
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 {
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 {
if (__DEV__) console.log("[Analytics] Event:", name, params);
const analytics = getAnalyticsInstance();
if (analytics) {
firebaseLogEvent(analytics, name, params);
}
} catch (error) {
console.warn("[Analytics] Failed to log event:", error);
}
}
// 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) {
firebaseSetUserId(analytics, userId);
}
} catch (error) {
console.warn("[Analytics] Failed to set user ID:", error);
}
}
// Clear user ID on logout
export function clearUserId() {
try {
if (__DEV__) console.log("[Analytics] Cleared user ID");
const analytics = getAnalyticsInstance();
if (analytics) {
firebaseSetUserId(analytics, null);
resetAnalyticsData(analytics);
}
} catch (error) {
console.warn("[Analytics] Failed to clear user ID:", error);
}
}
// 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) {
// Firebase Analytics requires setting user properties one at a time
for (const [key, value] of Object.entries(properties)) {
setUserProperty(analytics, key, value);
}
}
} catch (error) {
console.warn("[Analytics] Failed to set user properties:", error);
}
}
// Log screen view
export function logScreenView(screenName: string, screenClass?: string) {
try {
if (__DEV__) console.log("[Analytics] Screen view:", screenName);
const analytics = getAnalyticsInstance();
if (analytics) {
firebaseLogEvent(analytics, "screen_view", {
screen_name: screenName,
screen_class: screenClass ?? screenName,
});
}
} 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
// No manual flush needed, but we keep the function for API compatibility
if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)");
}
function truncateParam(value: string, maxLength = 100) {
if (value.length <= maxLength) return value;
return value.slice(0, maxLength - 1) + "…";
}
function safeParam(value: unknown, maxLength = 100): string {
if (typeof value === "string") return truncateParam(value, maxLength);
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value instanceof Error) return truncateParam(value.message, maxLength);
return value == null ? "" : truncateParam(String(value), maxLength);
}
// 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"),
questionShared: (destination: "instagram" | "messages" | "more") =>
logEvent("question_shared", { destination }),
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
pendingQuestionsPromptViewed: (count: number) =>
logEvent("pending_questions_prompt_viewed", { question_count: count }),
pendingQuestionsPromptTapped: (count: number) =>
logEvent("pending_questions_prompt_tapped", { question_count: count }),
// 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) =>
logEvent("paywall_closed", { source, reason }),
purchaseInitiated: (packageType: string) =>
logEvent("purchase_initiated", { package_type: packageType }),
purchaseCancelled: (packageType: string, errorCode?: unknown) =>
logEvent("purchase_cancelled", {
package_type: packageType,
error_code: safeParam(errorCode) || "unknown",
}),
purchaseFailed: (packageType: string, errorCode?: unknown, errorMessage?: unknown) =>
logEvent("purchase_failed", {
package_type: packageType,
error_code: safeParam(errorCode) || "unknown",
error_message: safeParam(errorMessage),
}),
purchaseCompleted: (packageType: string) =>
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"),
avatarSkipped: () => logEvent("avatar_skipped"),
avatarPromptShown: (source: string) => logEvent("avatar_prompt_shown", { source }),
avatarPromptAccepted: (source: string) => logEvent("avatar_prompt_accepted", { source }),
avatarPromptDismissed: (source: string) => logEvent("avatar_prompt_dismissed", { source }),
// 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"),
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
logEvent("invite_link_opened", { source }),
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
invitePromptViewed: (surface: "swipe_banner" | "pairing_waiting" | "swipe_no_partner", sealedBucket: string) => logEvent("invite_prompt_viewed", { surface, sealed_bucket: sealedBucket }),
inviteShareOpened: (surface: "pairing_waiting", channel: "system" | "sms" | "whatsapp" | "copy_link" | "copy_code") => logEvent("invite_share_opened", { surface, channel }),
pairingJoinAttempted: (codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_attempted", { code_source: codeSource }),
pairingJoinFailed: (reason: "invalid_shape" | "code_not_found" | "couple_full" | "already_paired" | "network" | "unknown", codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_failed", { reason, code_source: codeSource }),
pairingCompleted: (role: "inviter" | "joiner", codeSource: "link" | "stash" | "clipboard" | "manual", hadSealed: boolean) => logEvent("pairing_completed", { role, code_source: codeSource, had_sealed: hadSealed }),
pairingUnlockViewed: (role: "inviter" | "joiner", unlockedBucket: string) => logEvent("pairing_unlock_viewed", { role, unlocked_bucket: unlockedBucket }),
pairingUnlockCtaTapped: (role: "inviter" | "joiner", cta: "view_matches" | "start_session" | "dismiss") => logEvent("pairing_unlock_cta_tapped", { role, cta }),
pairingCancelPrompted: (surface: "waiting" | "conflict") => logEvent("pairing_cancel_prompted", { surface }),
clipboardInviteOfferShown: () => logEvent("clipboard_invite_offer_shown"),
clipboardInviteOfferResolved: (outcome: "accepted" | "dismissed") => logEvent("clipboard_invite_offer_resolved", { outcome }),
inviteConflictShown: (state: "already_paired" | "waiting_own_code") => logEvent("invite_conflict_shown", { state }),
inviteConflictResolved: (state: "already_paired" | "waiting_own_code", choice: "cancel_and_join" | "keep_own" | "acknowledged") => logEvent("invite_conflict_resolved", { state, choice }),
// Quiz events
quizStarted: () => logEvent("quiz_started"),
quizCompleted: (scorePercent: number) => logEvent("quiz_completed", { score_percent: scorePercent }),
quizShared: () => logEvent("quiz_shared"),
quizPartnerNudged: () => logEvent("quiz_partner_nudged"),
// 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 }),
};