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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
This commit is contained in:
alexandrev-tibco
2026-08-13 14:02:35 +02:00
parent 95a1e3a133
commit cfa9769a06
4 changed files with 52 additions and 0 deletions
+1
View File
@@ -81,6 +81,7 @@ struct MealMoodApp: App {
init() {
FirebaseApp.configure()
AnalyticsService.applyCollectionPolicy()
#if canImport(GoogleMobileAds)
GADMobileAds.sharedInstance().start(completionHandler: nil)
#endif
+37
View File
@@ -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)
}
+9
View File
@@ -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
}
@@ -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() {