Product Analytics
What the app tracks, how events flow from code to GA4 and BigQuery, the dev/prod split, and where to look at the data.
True Count ships first-party product analytics built on Firebase Analytics (GA4). Every meaningful player action — from opening a screen to going bankrupt to buying credits — is recorded as a typed event and lands in Google Analytics within seconds, with the raw event stream exported daily to BigQuery for SQL analysis. The system exists to answer four product questions:
- Retention — which week-one behaviors predict players who come back?
- Activation — where do new players drop off between opening the app and placing a first bet?
- Comeback features — do the daily challenge and count peek actually correlate with returning?
- Path to purchase — what does a player experience between running low on credits and paying?
How It Works
Events travel a strict one-way pipeline:
Game code → AnalyticsServiceProtocol → Firebase SDK → GA4 property → BigQuery (daily)- One protocol, one importer. All emission goes through
AnalyticsServiceProtocol(analyticsService?.log(...)/.set(...)), injected as an optional service and wired once at the composition root. Nothing imports Firebase outsideAnalyticsService.swift— not ViewModels, not other services. - One owner per event. Each event fires exactly once, at the layer that owns the action: the service that delivers a purchase logs the purchase, the ViewModel that settles a round logs the round. This mirrors the rule that came out of the StoreKit double-crediting incident.
- Typed, not stringly. Events are cases of an
AnalyticsEventenum. Adding an event means adding a case; the compiler then forces a GA4 name and a parameter payload, and a table-driven test forces a matching row in the committed contract,analytics/event-dictionary.md. Alogcall with no dictionary entry (or vice versa) fails review. - Buckets, not raw amounts. Behavioral events carry credit amounts as order-of-magnitude buckets (
100-499,2500-9999, ...) so reports aggregate cleanly. Exceptions carry real numbers: thepurchaserevenue event logs the true price and currency, and small fixed rewards (ad_reward_earnedamount, challengereward_creditsandscore) log raw integers. - Append-only naming. Event names and enum values like
table_tierare never renamed — a rename silently splits a metric's history in GA4/BigQuery and cannot be backfilled.
Dev and Prod Are Fully Separate
Analytics follows the app's dev/prod split end to end. Both GA4 properties live under the Monarch Games Analytics account.
| Dev | Prod | |
|---|---|---|
| Reports here | Debug builds (Xcode, TestFlight) | Release builds (App Store) |
| Firebase project | truecount-dev | truecount-prod |
| GA4 property | truecount-dev (545042448) | truecount-prod (545044435) |
| BigQuery export | none (GA4 only) | daily, dataset analytics_545044435 |
| Purpose | DebugView verification, sandbox purchases | real player data |
A build-phase script bundles the matching GoogleService-Info config per build configuration and fails the build if the config's bundle ID does not match the target — a dev build physically cannot report to prod.
What We Track
Twenty app-defined events plus a handful of Firebase auto-collected ones. The committed source of truth (exact parameters, firing rules, edge cases) is analytics/event-dictionary.md in the app repo; this is the summary.
Funnel and Retention
| Event | Fires when |
|---|---|
screen_view | A screen or sheet appears (game, store, stats, settings, change table, daily challenge, purchase history) |
table_selected | The player picks a dealer at the table selector (never on programmatic switches) |
bet_placed | A wager commits and the round begins — once per round, doubles/splits do not re-fire it |
round_completed | A round settles, with outcome (win/loss/push/blackjack/surrender/mixed), hint and count-peek usage, split/double flags, and net result |
table_unlocked | Rank progression unlocks a new dealer table |
daily_challenge_started | The daily challenge is entered |
daily_challenge_completed | The challenge finishes (any score), with score, reward credits, and streak |
count_peek_opened | The player peeks at the running count |
leaderboard_viewed | A leaderboard tab is shown (per board) |
stats_viewed | The statistics sheet opens |
One deliberate exception: during daily-challenge play, bet_placed, round_completed, and table_selected are suppressed — the challenge reports only through daily_challenge_started / daily_challenge_completed, so challenge hands never inflate betting or round metrics.
Economy and Monetization
| Event | Fires when |
|---|---|
credits_earned | Credits land, tagged by source: purchase, rewarded_ad, challenge_reward, or grant (CloudKit support grants, including background delivery) |
bankroll_below_min | The balance first drops below the current table's minimum bet (once per episode, not per hand) |
bankroll_zero | The balance hits zero — this opens a "recovery episode" |
recovery_path_chosen | During an open recovery episode, the player commits to a way back: rewarded ad, purchase, daily challenge, or moving to a cheaper table |
paywall_shown / paywall_dismissed | The Superwall paywall presents/dismisses, with placement, bankroll context, and whether a started purchase was abandoned. The placement value is the Superwall trigger name — it is the only join key to Superwall's campaign dashboard |
purchase_initiated | A pack is tapped in the native store screen (fallback path only — the shipping store is Superwall, where this does not apply) |
purchase | A paid transaction is delivered — the revenue event, with true price, currency, and transaction ID (see below) |
ad_shown | Every ad attempt, with format (interstitial/rewarded) and outcome (shown, no-fill, skipped, interrupted) — failed attempts stay visible |
ad_reward_earned | A rewarded-ad flow grants credits (the Quick Chips paths), including policy grants on no-fill; the challenge-gate ad rewards a retry, not credits, so it never fires this |
User Properties (Cohort Slicing)
payer_type, ads_removed, table_tier_reached, challenge_streak, lifetime_rounds_bucket, hints_enabled — set as GA4 user properties so any report can be sliced by "payers vs non-payers" or "players who reached Lady Lux".
Auto-Collected and Deliberately Absent
Firebase adds first_open, session_start, and user_engagement on its own. Deliberately not present: advertising identifiers (the app links no AdSupport and shows no ATT prompt), and any personal identifiers in parameters.
The Revenue Event: A StoreKit 2 Gotcha
Firebase documents an auto-collected in_app_purchase event as the GA4 revenue source. It does not fire for this app. The automatic measurement watches StoreKit 1's payment queue; True Count is StoreKit 2 end to end (Transaction.updates), which it never sees. This was proven on-device with a real sandbox purchase: credits were delivered and credits_earned fired, while in_app_purchase never arrived.
Revenue is therefore logged by the app itself as a manual purchase event from StoreKitService.deliverContent — real value and currency (GA4's recommended purchase parameters), deduped by transaction ID so crash-replays never double-count, and covering the zero-credit ad-free pack too. GA4 monetization reports populate from this event; treat them as behavioral. Actual proceeds, units, and refunds are read from App Store Connect, the financial source of truth.
Where to Look at the Data
| Surface | What it shows | How |
|---|---|---|
| Firebase DebugView | Live per-event stream with parameters, per device | Firebase console → project → Analytics → DebugView. Device must run a debug-flagged build (-FIRDebugEnabled launch argument) |
| GA4 Realtime | Last-30-minutes aggregate (users, event counts) | analytics.google.com → property → Reports → Realtime |
| GA4 Reports | Standard funnels, retention, monetization | Same property; ~24h lag. Event parameters need custom-dimension registration to appear in the UI |
| BigQuery | Raw, unsampled event rows for SQL | Prod only, dataset analytics_545044435, daily export only (no intraday tables); the dataset first materializes with the first export after live events, so absent-or-empty is normal until then. The app repo's analytics-query skill wraps access behind a SELECT-only guardrail |
| Admin panel | One-click jump to the prod property | "Analytics" link in the sidebar |
One routing rule from the data map: read each question from the system that owns it. Money questions (proceeds, refunds, units) belong to App Store Connect, paywall conversion belongs to the Superwall dashboard, and player behavior belongs to GA4/BigQuery.
For agents, the app repo carries the full knowledge base: analytics/event-dictionary.md (the contract), analytics/data-map.md (every ID and which source answers which question), and analytics/access-guide.md (credentials and query recipes).
Verification Status
The entire taxonomy was verified live on a physical device against the dev property (July 12, 2026): every event family observed in the realtime stream, including a real sandbox purchase, the paywall lifecycle, ad outcomes, threshold/recovery transitions, and the daily challenge cycle. The one path not yet observed live is credits_earned(source: grant), which requires a real CloudKit support grant; its emission is unit-covered at the delivery site.
Operational to-dos once real data flows: mark bet_placed, daily_challenge_completed, and purchase as key events in both GA4 properties, and register frequently-used parameters (like table_tier) as custom dimensions for GA4 UI visibility.
How is this guide?