Journal

Why GA4 was replaced with a collector of our own

Two reasons. Firebase caps a project at thirty apps, and the screen-dwell numbers had been wrong for months. One edge collector, one aggregator, two clients. And most of the first data was not people.

2 min#analytics#ios#android#infra
Apps in this entry

Reason one: thirty

Firebase allows thirty apps per project, and a project maps to exactly one GA4 property. Keep making apps and the project has to split; split it and the iOS and Android builds of the same product land in different properties. Seeing those two side by side is the reason the instrumentation exists.

Rather than ask for a quota increase, the measuring moved in-house. The ported Android apps never went into Firebase at all.

Reason two: months of a wrong number

In July the per-screen dwell times looked off. "LogoForm spends 78% of its time on the paywall", "Pro2Camera 69%", "Artoon 58%". Nobody looks at a paywall that long.

The cause was app code, not GA4 configuration. Screen tracking was one call in onAppear with no onDisappear. Every app shows its paywall as a .sheet, and dismissing a sheet does not re-fire the parent's onAppear. Firebase keeps the last screen_view as the current screen and books later engagement against it. Every minute spent in the editor after closing the paywall was credited to the paywall.

The fix restores the previous screen when a modal closes. Everything measured before that was overstated. One number, trusted for months.

What was built

Piece What
Collector Cloudflare Worker + D1, where apps send events
Aggregation and reports A Go package inside the existing SaaS
Clients One iOS package, one Flutter package

Decisions made in the design.

  • app_id is one string per product, not per store listing. iOS and Android must send the same value to be merged.
  • Country is read at the edge from the connection. The device never sends it.
  • IP, advertising id, precise location and personal identifiers cannot arrive: there are no columns for them, and property keys containing those words are blocked.
  • With no collector URL configured, the client switches itself off. Queueing events for an address that does not exist would overflow the queue before launch day, when the events actually matter.
  • Dwell is measured as enter/exit pairs. That mistake is not repeated.

Most of the first data was not people

In the first week of collection, read at face value, the list said "apps still in review have users". Sorted by fingerprint, there were three.

Who Fingerprint
Play pre-launch report robot Many install ids on one build id, country US, every locale en-US, bunched into one day, day-one retention zero, dozens of screen entries per session
Apple reviewer Exactly one session and one user per app, US, three to four minutes, an app version that is not released yet
Me KR, ko_KR, a real device OS version, a session or two

LogoForm over thirty days: iOS was one session, one user, KR. Android was twelve sessions, eight users, all en-US, two build ids, 182 paywall entries. Merged, that read as "13 sessions, 165 paywall views", which is not a row that can happen.

The three are now separated. The first job of the instrumentation turned out to be not counting people but subtracting what is not.