From cfa9769a061cd77dda249aa0b37317ff4a560320 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 13 Aug 2026 14:02:35 +0200 Subject: [PATCH] 2.0.2: evento de fallo de compra + analytics off en UI tests 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 Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt --- MealMood/MealMoodApp.swift | 1 + MealMood/Services/AnalyticsService.swift | 37 +++++++++++++++++++++ MealMood/Services/StoreManager.swift | 9 +++++ MealMoodUITests/OnboardingFlowUITests.swift | 5 +++ 4 files changed, 52 insertions(+) diff --git a/MealMood/MealMoodApp.swift b/MealMood/MealMoodApp.swift index 3912143..52918ea 100644 --- a/MealMood/MealMoodApp.swift +++ b/MealMood/MealMoodApp.swift @@ -81,6 +81,7 @@ struct MealMoodApp: App { init() { FirebaseApp.configure() + AnalyticsService.applyCollectionPolicy() #if canImport(GoogleMobileAds) GADMobileAds.sharedInstance().start(completionHandler: nil) #endif diff --git a/MealMood/Services/AnalyticsService.swift b/MealMood/Services/AnalyticsService.swift index 11d04f2..ab15533 100644 --- a/MealMood/Services/AnalyticsService.swift +++ b/MealMood/Services/AnalyticsService.swift @@ -17,6 +17,7 @@ enum AnalyticsEvent { // Premium static let premiumUpgradeTapped = "premium_upgrade_tapped" static let premiumPurchased = "premium_purchased" + static let premiumPurchaseFailed = "premium_purchase_failed" static let premiumRestored = "premium_restored" // Planner @@ -51,11 +52,33 @@ enum AnalyticsEvent { } 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 @@ -99,6 +122,20 @@ enum AnalyticsService { 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) } diff --git a/MealMood/Services/StoreManager.swift b/MealMood/Services/StoreManager.swift index 644c268..7c70a6f 100644 --- a/MealMood/Services/StoreManager.swift +++ b/MealMood/Services/StoreManager.swift @@ -85,16 +85,25 @@ final class StoreManager: ObservableObject { AnalyticsService.logPremiumPurchased(environment: Self.environmentName(for: transaction)) return .success } + AnalyticsService.logPremiumPurchaseFailed(reason: "unverified", productId: product.id) return .failed case .userCancelled: + AnalyticsService.logPremiumPurchaseFailed(reason: "cancelled", productId: product.id) return .cancelled case .pending: + AnalyticsService.logPremiumPurchaseFailed(reason: "pending", productId: product.id) return .pending @unknown default: + AnalyticsService.logPremiumPurchaseFailed(reason: "unknown", productId: product.id) return .failed } } catch { CrashlyticsService.record(error, context: "purchase") + AnalyticsService.logPremiumPurchaseFailed( + reason: "error", + productId: product.id, + detail: error.localizedDescription + ) print("Purchase failed: \(error)") return .failed } diff --git a/MealMoodUITests/OnboardingFlowUITests.swift b/MealMoodUITests/OnboardingFlowUITests.swift index b52c988..4d70251 100644 --- a/MealMoodUITests/OnboardingFlowUITests.swift +++ b/MealMoodUITests/OnboardingFlowUITests.swift @@ -16,6 +16,11 @@ final class OnboardingFlowUITests: XCTestCase { app = XCUIApplication() // Pin the language so the run doesn't depend on the simulator's locale. app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + // Keep synthetic runs out of the production GA4 property. Literal on + // purpose: a UI test bundle doesn't link the app target, so it can't + // reference AnalyticsService.launchArgumentDisablingAnalytics — keep the + // two in sync by hand. + app.launchArguments.append("UITEST_DISABLE_ANALYTICS") } func testOnboardingReachesWeekReadyAndPaywallBeforeTheHome() {