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:
@@ -81,6 +81,7 @@ struct MealMoodApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
FirebaseApp.configure()
|
FirebaseApp.configure()
|
||||||
|
AnalyticsService.applyCollectionPolicy()
|
||||||
#if canImport(GoogleMobileAds)
|
#if canImport(GoogleMobileAds)
|
||||||
GADMobileAds.sharedInstance().start(completionHandler: nil)
|
GADMobileAds.sharedInstance().start(completionHandler: nil)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum AnalyticsEvent {
|
|||||||
// Premium
|
// Premium
|
||||||
static let premiumUpgradeTapped = "premium_upgrade_tapped"
|
static let premiumUpgradeTapped = "premium_upgrade_tapped"
|
||||||
static let premiumPurchased = "premium_purchased"
|
static let premiumPurchased = "premium_purchased"
|
||||||
|
static let premiumPurchaseFailed = "premium_purchase_failed"
|
||||||
static let premiumRestored = "premium_restored"
|
static let premiumRestored = "premium_restored"
|
||||||
|
|
||||||
// Planner
|
// Planner
|
||||||
@@ -51,11 +52,33 @@ enum AnalyticsEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AnalyticsService {
|
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) {
|
static func logEvent(_ name: String, parameters: [String: Any]? = nil) {
|
||||||
|
guard !isDisabled else { return }
|
||||||
Analytics.logEvent(name, parameters: parameters)
|
Analytics.logEvent(name, parameters: parameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func logScreenView(_ screenName: String) {
|
static func logScreenView(_ screenName: String) {
|
||||||
|
guard !isDisabled else { return }
|
||||||
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
||||||
AnalyticsParameterScreenName: screenName,
|
AnalyticsParameterScreenName: screenName,
|
||||||
AnalyticsParameterScreenClass: screenName
|
AnalyticsParameterScreenClass: screenName
|
||||||
@@ -99,6 +122,20 @@ enum AnalyticsService {
|
|||||||
logEvent(AnalyticsEvent.premiumPurchased, parameters: ["environment": environment])
|
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() {
|
static func logPremiumRestored() {
|
||||||
logEvent(AnalyticsEvent.premiumRestored)
|
logEvent(AnalyticsEvent.premiumRestored)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,16 +85,25 @@ final class StoreManager: ObservableObject {
|
|||||||
AnalyticsService.logPremiumPurchased(environment: Self.environmentName(for: transaction))
|
AnalyticsService.logPremiumPurchased(environment: Self.environmentName(for: transaction))
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
AnalyticsService.logPremiumPurchaseFailed(reason: "unverified", productId: product.id)
|
||||||
return .failed
|
return .failed
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
|
AnalyticsService.logPremiumPurchaseFailed(reason: "cancelled", productId: product.id)
|
||||||
return .cancelled
|
return .cancelled
|
||||||
case .pending:
|
case .pending:
|
||||||
|
AnalyticsService.logPremiumPurchaseFailed(reason: "pending", productId: product.id)
|
||||||
return .pending
|
return .pending
|
||||||
@unknown default:
|
@unknown default:
|
||||||
|
AnalyticsService.logPremiumPurchaseFailed(reason: "unknown", productId: product.id)
|
||||||
return .failed
|
return .failed
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
CrashlyticsService.record(error, context: "purchase")
|
CrashlyticsService.record(error, context: "purchase")
|
||||||
|
AnalyticsService.logPremiumPurchaseFailed(
|
||||||
|
reason: "error",
|
||||||
|
productId: product.id,
|
||||||
|
detail: error.localizedDescription
|
||||||
|
)
|
||||||
print("Purchase failed: \(error)")
|
print("Purchase failed: \(error)")
|
||||||
return .failed
|
return .failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ final class OnboardingFlowUITests: XCTestCase {
|
|||||||
app = XCUIApplication()
|
app = XCUIApplication()
|
||||||
// Pin the language so the run doesn't depend on the simulator's locale.
|
// Pin the language so the run doesn't depend on the simulator's locale.
|
||||||
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
|
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() {
|
func testOnboardingReachesWeekReadyAndPaywallBeforeTheHome() {
|
||||||
|
|||||||
Reference in New Issue
Block a user