What Flowo Measures: A Typed Event Catalogue Without Content
By Maksym Bardakh Ivko · Chief Executive Officer
In short
Flowo's analytics is a closed catalogue of 68 named events, and every property of every event is typed in a manifest kept in the app's repository. Of the 189 declared properties, 150 are integers, enumerations or booleans, and no property may hold a task title or a note. BBMM Technologies wrote the catalogue to diagnose the planner one event at a time, and a script in CI fails the build when the manifest and the code disagree.
What the catalogue contains
The Flowo event catalogue is a JSON manifest that lives in the app's repository next to the code. It names 68 events and gives each one a schema. We treat the analytics workspace as a projection of this file.
A property can have one of six types: boolean, enum, integer, pseudonymous_id, string or string_list. The current manifest declares 67 integers, 59 enumerations, 24 booleans, 22 pseudonymous identifiers, 16 strings and one string list.
The consequence is that most of what we can learn is a number or one label from a fixed set. The runtime allowlist that enforces this on the device is described in our article on telemetry consent.
Events written for diagnosis
We designed the catalogue so that a single event can be read alone and still say what went wrong. The design note for the taxonomy sets three rules. One event with several properties is preferred over several events. A reason is worth more than a counter. Anything that can be derived from other events does not become an event.
The planner events show the rules at work. planner_unplaced fires when the engine cannot fit something into the schedule, and it carries three properties.
- kind: sleep, meal, rest, habit or task.
- reason: no_capacity, no_availability, chain_conflict, horizon or pinned_block.
- deficit_minutes: how much time did not fit.
schedule_block_moved fires when a person drags a block, and it carries the kind of block, the signed number of minutes, whether the move crossed a day, and how many other blocks were pushed.
A large shift on blocks of kind sleep tells us the engine places sleep at an hour the person does not keep. We learn that without reading any task.
Shape in place of content
Where a feature handles text, the event describes the shape of the text and leaves the text on the phone. task_created records whether a deadline exists, whether a description exists, a priority from 0 to 4, an estimated duration and a recurrence bucket of daily, weekly, monthly, yearly or other.
The event for a chat message carries the length of the message as a number, a flag for an attachment and a coarse intent label. The chat screen hands the tracker those three values and no part of the text.
ai_api_request measures our own backend calls: status code, request and response bytes, duration and retry attempt. Its endpoint property is normalised to a static route name. A value with a query string is rejected, and so is a path segment that contains four or more digits, because such a segment could be an identifier.
storage_recovery reports that a local store could not be read and was recreated. It carries a fixed store identifier and a failure class, and no key, value or count of anything the person wrote.
Context attached to every event
Every event leaves the app with the same nine context fields: schema version, app session id, device environment, build mode, install source, deployment environment, release channel, app version and app build. The session id is a random UUID created when the app process starts. A flow such as onboarding can add a second random UUID that links its steps.
The device environment comes from one boolean. The detector asks the platform whether the device is physical and reports physical_device, ios_simulator, android_emulator or unknown. No model name or machine name leaves that class. A failed check is reported as unknown, so a simulator is never counted as a real phone by mistake.
Install source is read from the installer package and maps to app_store, play_store, testflight, simulator, sideload, developer, web or unknown. We added it because a TestFlight build is a release build with production settings, and the deployment environment alone cannot tell it from a store install.
The manifest defines the production population with four conditions together: the deployment environment and the release channel are both production, the build mode is release, and the device is physical.
Country without an IP address
Our analytics configuration turns off IP address collection, which leaves the provider's country field empty for every install. We accepted that and added two coarse user properties. store_country is the App Store or Google Play storefront, and region is the region of the device locale.
Both values must be two-letter ISO 3166-1 codes. StoreKit reports the storefront as a three-letter code, so the app converts it with a table bundled in the source. When the storefront is unknown, store_country falls back to the device region.
User properties have their own allowlist of 15 keys, and each key has a validator. The locale must be one of the seven languages the app ships in. Days since install must be an integer from 0 to 36500. A campaign token may hold up to 64 characters from lowercase letters, digits, dots, underscores and hyphens. A key outside the list is rejected.
What the validator checks
A Python script, validate_analytics_governance.py, runs in CI and compares the manifest with the Dart source.
- The event names in the manifest must equal the event constants in the code, in both directions.
- Every event needs a schema, every property name must be lower snake case, and every type must be one of the six.
- There must be exactly three environments, development, staging and production, each with a distinct API key secret.
- Every listed website must use HTTPS and have cross-domain identity disabled.
- Funnels, charts, dashboards and hypotheses may reference only events, properties and cohorts that exist.
- An export destination whose field description mentions user ids, device ids or replay payloads without excluding them fails the check.
The manifest currently defines 7 funnels, 28 charts, 10 cohorts and 5 dashboards. The dashboards are marked as specifications that wait for data at schema version 2.
Measurement state kept on the phone
A few events must fire once per install, and the memory for that stays local. feature_first_use covers eight features, and each is remembered as one boolean in the app's preferences. The marker for a closed habit period is stored under the SHA-256 hash of the period key.
These markers are never uploaded. The erase flow removes them, resets the analytics identity and replaces the session id with a new UUID.
The same sanitised event goes to both providers, Amplitude and TelemetryDeck. Outside a release build, TelemetryDeck starts in its test mode.
Key takeaways
- The Flowo manifest declares 68 events and 189 properties, of which 150 are integers, enumerations or booleans.
- Planner events carry a reason from a fixed list, such as no_capacity or pinned_block, so one event can be read alone.
- The endpoint property of ai_api_request rejects query strings and any path segment with four or more digits.
- Device environment is derived from one boolean, and a failed check is reported as unknown.
- Country comes from the store storefront and the device locale because IP address collection is turned off.
- validate_analytics_governance.py fails CI when the manifest and the Dart event constants differ in either direction.
Frequently asked questions
- How can a product team measure usage without collecting what people write?
- Flowo records the shape of an action and leaves the text on the phone. A task creation event carries booleans such as whether a deadline exists, a priority number and a recurrence bucket. A chat event carries the character count of the message. No event property is allowed to hold a title or a note.
- What is an analytics governance manifest?
- It is a version-controlled file that lists every event an app may send and the type of every property. In Flowo it is a JSON file in the app's repository with 68 events, and it also defines funnels, charts and cohorts. A CI script compares it with the code and fails the build on any difference.
- How does Flowo know a user's country if it does not collect IP addresses?
- It reads the App Store or Google Play storefront and the region of the device locale. Both are stored as two-letter country codes in user properties. The provider's own country field stays empty because IP collection is turned off in the configuration.
- How does Flowo keep test devices out of production numbers?
- Every event carries the deployment environment, release channel, build mode and a flag for virtual devices, plus an install source such as app_store or testflight. The production population is defined as production environment, production channel, release build and a physical device. A device whose type cannot be detected is reported as unknown.
- Does Flowo send the same events to more than one analytics provider?
- Yes. Each event is checked once and the same sanitised copy is sent to Amplitude and TelemetryDeck. Neither provider is started until analytics consent has been granted.
References
- Flowo
- BBMM Technologies
- ISO 3166-1, codes for the representation of names of countries
- RFC 9562, Universally Unique IDentifiers (UUIDs)
- Amplitude developer documentation, Flutter SDK configuration
- TelemetryDeck documentation, test mode
About the author
Maksym Bardakh Ivko
Chief Executive Officer
Maksym leads product at BBMM Technologies. He designs Flowo’s scheduler and writes most of what ships in it.