cfa9769a06
premium_purchase_failed con reason (cancelled/pending/unverified/error) en todas las salidas no exitosas de StoreManager.purchase. Hasta ahora un usuario que se echaba atras y uno al que le fallaba la compra eran indistinguibles: los dos dejaban un premium_upgrade_tapped huerfano. El caso de Aleman (6 taps en 2 dias, cero compras) no se puede diagnosticar sin esto. Los UI tests dejan de mandar eventos a GA4 de produccion: launch argument UITEST_DISABLE_ANALYTICS que hace no-op logEvent y apaga la recoleccion de Firebase, para cortar tambien los automaticos (first_open, session_start, screen_view). Ya habian contaminado los datos: los eventos de pasos 5/6 bajo la version 2.0 eran ejecuciones de test, no usuarios. Detalle que costo encontrar: setAnalyticsCollectionEnabled persiste entre lanzamientos, asi que se escribe siempre (!isDisabled) y no solo al apagar. Si no, un unico test dejaba analytics muerto para siempre en esa instalacion. Verificado por pares de lanzamientos: con flag acaba en disabled, sin flag vuelve a enabled. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
237 lines
8.2 KiB
Swift
237 lines
8.2 KiB
Swift
import FirebaseAnalytics
|
|
|
|
enum AnalyticsEvent {
|
|
// Onboarding
|
|
static let onboardingCompleted = "onboarding_completed"
|
|
|
|
// Dishes
|
|
static let dishAdded = "dish_added"
|
|
static let dishDeleted = "dish_deleted"
|
|
|
|
// Planner
|
|
static let mealAssigned = "meal_assigned"
|
|
static let mealCleared = "meal_cleared"
|
|
static let weekReset = "week_reset"
|
|
static let weekPlanShared = "week_plan_shared"
|
|
|
|
// Premium
|
|
static let premiumUpgradeTapped = "premium_upgrade_tapped"
|
|
static let premiumPurchased = "premium_purchased"
|
|
static let premiumPurchaseFailed = "premium_purchase_failed"
|
|
static let premiumRestored = "premium_restored"
|
|
|
|
// Planner
|
|
static let autoAssignUsed = "auto_assign_used"
|
|
static let weekCopiedPrevious = "week_copied_previous"
|
|
|
|
// Shopping list (2.0)
|
|
static let shoppingListOpened = "shopping_list_opened"
|
|
static let shoppingItemAdded = "shopping_item_added"
|
|
static let ingredientsGenerated = "ingredients_generated"
|
|
|
|
// Onboarding
|
|
static let onboardingStepViewed = "onboarding_step_viewed"
|
|
|
|
// Settings
|
|
static let iCloudSyncToggled = "icloud_sync_toggled"
|
|
static let notificationsToggled = "notifications_toggled"
|
|
static let languageChanged = "language_changed"
|
|
|
|
// Stats
|
|
static let statsViewed = "stats_viewed"
|
|
|
|
// Feedback loop
|
|
static let weekRated = "week_rated"
|
|
|
|
// Conversion funnel (1.1.5)
|
|
static let paywallViewed = "paywall_viewed"
|
|
static let paywallDismissed = "paywall_dismissed"
|
|
static let dishLimitHit = "dish_limit_hit"
|
|
static let weekLimitHit = "week_limit_hit"
|
|
static let freeTrialStarted = "free_trial_started"
|
|
}
|
|
|
|
enum AnalyticsService {
|
|
/// UI tests drive the app against the production Firebase project, so every
|
|
/// run would otherwise land in GA4 as a real user: a full onboarding funnel,
|
|
/// a paywall view, the lot. It already happened — the step 5/6 events
|
|
/// recorded under 2.0 were test runs, not users.
|
|
static let launchArgumentDisablingAnalytics = "UITEST_DISABLE_ANALYTICS"
|
|
|
|
static let isDisabled: Bool =
|
|
ProcessInfo.processInfo.arguments.contains(launchArgumentDisablingAnalytics)
|
|
|
|
/// Silences Firebase's own automatic events (first_open, session_start,
|
|
/// screen_view), which no-oping `logEvent` alone would not catch.
|
|
/// Call once, right after `FirebaseApp.configure()`.
|
|
///
|
|
/// Always sets the flag, never just when disabling: Firebase persists this
|
|
/// across launches, so a single test run would otherwise leave analytics off
|
|
/// for good on that install.
|
|
static func applyCollectionPolicy() {
|
|
Analytics.setAnalyticsCollectionEnabled(!isDisabled)
|
|
}
|
|
|
|
static func logEvent(_ name: String, parameters: [String: Any]? = nil) {
|
|
guard !isDisabled else { return }
|
|
Analytics.logEvent(name, parameters: parameters)
|
|
}
|
|
|
|
static func logScreenView(_ screenName: String) {
|
|
guard !isDisabled else { return }
|
|
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
|
AnalyticsParameterScreenName: screenName,
|
|
AnalyticsParameterScreenClass: screenName
|
|
])
|
|
}
|
|
|
|
// MARK: - Convenience methods
|
|
|
|
static func logDishAdded(tagCount: Int, source: String = "editor") {
|
|
logEvent(AnalyticsEvent.dishAdded, parameters: [
|
|
"tag_count": tagCount,
|
|
"source": source
|
|
])
|
|
}
|
|
|
|
static func logDishDeleted() {
|
|
logEvent(AnalyticsEvent.dishDeleted)
|
|
}
|
|
|
|
static func logMealAssigned() {
|
|
logEvent(AnalyticsEvent.mealAssigned)
|
|
}
|
|
|
|
static func logMealCleared() {
|
|
logEvent(AnalyticsEvent.mealCleared)
|
|
}
|
|
|
|
static func logWeekReset() {
|
|
logEvent(AnalyticsEvent.weekReset)
|
|
}
|
|
|
|
static func logWeekPlanShared(format: String) {
|
|
logEvent(AnalyticsEvent.weekPlanShared, parameters: ["format": format])
|
|
}
|
|
|
|
static func logPremiumUpgradeTapped(source: String) {
|
|
logEvent(AnalyticsEvent.premiumUpgradeTapped, parameters: ["source": source])
|
|
}
|
|
|
|
static func logPremiumPurchased(environment: String = "unknown") {
|
|
logEvent(AnalyticsEvent.premiumPurchased, parameters: ["environment": environment])
|
|
}
|
|
|
|
/// Why a tapped upgrade never became a purchase. Without this a user who
|
|
/// changed their mind and a user whose purchase broke look identical — both
|
|
/// are just a `premium_upgrade_tapped` with nothing after it.
|
|
///
|
|
/// `reason` is one of: `cancelled`, `pending`, `unverified`, `error`.
|
|
static func logPremiumPurchaseFailed(reason: String, productId: String, detail: String? = nil) {
|
|
var parameters: [String: Any] = ["reason": reason, "product_id": productId]
|
|
if let detail {
|
|
// GA4 drops string params over 100 chars.
|
|
parameters["detail"] = String(detail.prefix(100))
|
|
}
|
|
logEvent(AnalyticsEvent.premiumPurchaseFailed, parameters: parameters)
|
|
}
|
|
|
|
static func logPremiumRestored() {
|
|
logEvent(AnalyticsEvent.premiumRestored)
|
|
}
|
|
|
|
static func logOnboardingCompleted(dishCount: Int) {
|
|
logEvent(AnalyticsEvent.onboardingCompleted, parameters: ["dish_count": dishCount])
|
|
}
|
|
|
|
// Human-readable names for each onboarding step, indexed by step number.
|
|
// Keep in sync with the OnboardingView step order.
|
|
private static let onboardingStepNames = [
|
|
"0_welcome", "1_meal_windows", "2_weekends",
|
|
"3_calendar", "4_first_dishes", "5_week_ready", "6_paywall"
|
|
]
|
|
|
|
static func logOnboardingStepViewed(step: Int) {
|
|
// GA4 text custom dimensions do not capture numeric params (they show as
|
|
// "(not set)"). Log the step as a String so the drop-off is queryable.
|
|
let name = step >= 0 && step < onboardingStepNames.count ? onboardingStepNames[step] : "\(step)"
|
|
logEvent(AnalyticsEvent.onboardingStepViewed, parameters: [
|
|
"step": "\(step)",
|
|
"step_name": name
|
|
])
|
|
}
|
|
|
|
static func logAutoAssignUsed(filledSlots: Int, unfilledSlots: Int) {
|
|
logEvent(AnalyticsEvent.autoAssignUsed, parameters: [
|
|
"filled_slots": filledSlots,
|
|
"unfilled_slots": unfilledSlots
|
|
])
|
|
}
|
|
|
|
static func logWeekCopiedPrevious(copiedSlots: Int, source: String) {
|
|
logEvent(AnalyticsEvent.weekCopiedPrevious, parameters: [
|
|
"copied_slots": copiedSlots,
|
|
"source": source
|
|
])
|
|
}
|
|
|
|
static func logShoppingListOpened(itemCount: Int, missingDishes: Int) {
|
|
logEvent(AnalyticsEvent.shoppingListOpened, parameters: [
|
|
"item_count": itemCount,
|
|
"missing_dishes": missingDishes
|
|
])
|
|
}
|
|
|
|
static func logShoppingItemAdded() {
|
|
logEvent(AnalyticsEvent.shoppingItemAdded)
|
|
}
|
|
|
|
static func logIngredientsGenerated(lineCount: Int, source: String) {
|
|
logEvent(AnalyticsEvent.ingredientsGenerated, parameters: [
|
|
"line_count": lineCount,
|
|
"source": source
|
|
])
|
|
}
|
|
|
|
static func logICloudSyncToggled(enabled: Bool) {
|
|
logEvent(AnalyticsEvent.iCloudSyncToggled, parameters: ["enabled": enabled])
|
|
}
|
|
|
|
static func logNotificationsToggled(enabled: Bool) {
|
|
logEvent(AnalyticsEvent.notificationsToggled, parameters: ["enabled": enabled])
|
|
}
|
|
|
|
static func logStatsViewed() {
|
|
logEvent(AnalyticsEvent.statsViewed)
|
|
}
|
|
|
|
static func logWeekRated(rating: Int) {
|
|
let value = rating == 1 ? "liked" : rating == -1 ? "disliked" : "cleared"
|
|
logEvent(AnalyticsEvent.weekRated, parameters: ["rating": value])
|
|
}
|
|
|
|
static func logPaywallViewed(source: String) {
|
|
logEvent(AnalyticsEvent.paywallViewed, parameters: ["source": source])
|
|
}
|
|
|
|
static func logPaywallDismissed(source: String, secondsOnScreen: Int, didConvert: Bool) {
|
|
logEvent(AnalyticsEvent.paywallDismissed, parameters: [
|
|
"source": source,
|
|
"seconds_on_screen": secondsOnScreen,
|
|
"did_convert": didConvert
|
|
])
|
|
}
|
|
|
|
static func logDishLimitHit() {
|
|
logEvent(AnalyticsEvent.dishLimitHit)
|
|
}
|
|
|
|
static func logWeekLimitHit() {
|
|
logEvent(AnalyticsEvent.weekLimitHit)
|
|
}
|
|
|
|
static func logFreeTrialStarted(plan: String) {
|
|
logEvent(AnalyticsEvent.freeTrialStarted, parameters: ["plan": plan])
|
|
}
|
|
}
|