Analytics: what the app measures today, and what to add
Analytics: what the app measures today, and what to add
Section titled “Analytics: what the app measures today, and what to add”This walkthrough has three parts:
- An audit of every analytics signal the app sends today (as of 2026-09-30).
- The gaps that stop us answering two product questions:
- Playback quality: does video start fast, play without stalls, and avoid errors?
- Engagement and retention: what do people watch and finish, and do they come back?
- A learning path that builds analysis skills on this app’s own data, with SQL you can run against the Firebase BigQuery export and the backend database.
1. Where the data goes today
Section titled “1. Where the data goes today” ┌──────────────────────────── the app ─────────────────────────────┐ │ │ screens / buttons ──────┼─► FirebaseAnalytics.logEvent ──────────────► Firebase Analytics ──┼─► (BigQuery export, if enabled) │ │ auth / purchase / play ─┼─► logMarktagEvent (lib/core/utils/marktag_*) ─► Marktag/Markopolo │ │ │ player (every 10 s) ────┼─► buildMatomoMediaPing / Event ─► MediaAnalyticsUsecase ─► Matomo │ │ (lib/data/helpers/matomo_media_params.dart) │ │ │ player (every 60 s) ────┼─► AddToWatchHistory ─► POST /user/watch-history ─► backend DB ◄──┼── you have access │ │ crashes / errors ───────┼─► Crashlytics + Sentry │ └───────────────────────────────────────────────────────────────────┘Of these, you can reach Firebase and the backend DB. Matomo holds the richest
playback data (per-10 s pings). Ask whoever runs the Matomo instance for read access
to its MySQL tables (matomo_log_media, matomo_log_link_visit_action): that is the
fastest win in this whole plan.
Firebase Analytics events
Section titled “Firebase Analytics events”| Event | Fired from | Parameters |
|---|---|---|
screen_view (Home only) |
home_tab_view.dart:46 |
screen_name |
view_content_list |
genre_screen.dart, collection_screen.dart |
route_uri |
view_content |
single_content_screen.dart:363 |
title, id, classname, route_uri |
trailer_play |
single_content_screen.dart:428 |
same as above |
add_to_watchlist |
watchlist_bottom_menu.dart:69 |
same as above |
ad_started / ad_completed / ad_skipped |
video_player_screen.dart:274, byte_ad_page.dart:133 |
title, id, classname, ad_id, surface=bytes on Bytes |
| user id + user properties | user_auth_bloc.dart, user_auth_data_source.dart |
set on login |
Firebase also records session_start, first_open, app_update and similar events
automatically, which makes it good for retention.
Matomo media analytics (player)
Section titled “Matomo media analytics (player)”video_player_screen.dart sends:
- a ping every 10 s while playing (
ma_psposition,ma_lelength,ma_stseconds watched this session,ma_fsfullscreen, player name); - events
play,pauseandfinished, with the position ine_v.
Custom dimensions: dimension1 = user id, dimension2 = subscribed or not,
dimension3 = app version.
Backend watch history
Section titled “Backend watch history”Every 60 s (logged-in users, online, not during an ad) the player POSTs
{content_id, content_type, watched_duration, watched_at}
(lib/data/helpers/watch_history_payload.dart). This powers Continue Watching, and
it is also the best engagement dataset you already have: it is server-side and
never blocked by ad blockers or consent settings.
2. Gaps
Section titled “2. Gaps”Data-quality bugs found during the audit (fix these first)
Section titled “Data-quality bugs found during the audit (fix these first)”| # | Problem | Where | Effect on the data |
|---|---|---|---|
| D1 | The watch-history timer sends widget.content, but after “next episode” / episode picker the screen plays currentContent |
video_player_screen.dart:538 vs :347 |
Episode 2’s progress is saved against episode 1. Continue Watching and every completion metric for series are wrong after an in-player episode switch. |
| D2 | pv_id is the constant "Z8qIUh" |
matomo_media_params.dart |
Every media ping from every user shares one pageview id, so Matomo cannot separate viewing sessions by pageview. |
| D3 | Only Home logs screen_view |
home_tab_view.dart |
Firebase has no screen funnel (Home → content page → player). No FirebaseAnalyticsObserver is attached to the router. |
| D4 | Full content plays are not in Firebase at all (only trailers and ads) | — | You cannot join “played X” with Firebase retention cohorts. |
Playback quality: what we cannot measure yet
Section titled “Playback quality: what we cannot measure yet”| Metric (industry name) | Question | Available? |
|---|---|---|
| Video Start Time (VST) | Tap → first frame, in ms | No. preparing → playing is observable, but nothing records it. |
| Video Start Failure (VSF) | Share of attempts that never reach playing |
No. There is no “attempt” event. |
| Exits Before Video Start (EBVS) | User leaves while still loading | No |
| Rebuffering ratio | Stall time ÷ watch time | No. kVideo exposes buffer (buffered position) but no “stalled” state; stall recovery on fix/player-stability could emit it. |
| Playback failure | Error mid-play, including after KMS recovery | Partly. KPlaybackStatus.error triggers _recoverWithFreshKmsTokens() but nothing is logged. |
| Bitrate / resolution | Quality actually delivered | No. TrackData.bitrate exists in the kVideo pigeon but is not reported. |
Engagement and retention: what we cannot measure yet
Section titled “Engagement and retention: what we cannot measure yet”- Completion rate per title: possible from watch history (
watched_duration ÷ duration), but only once D1 is fixed. - Where people drop off inside a title: possible from Matomo pings; not in Firebase.
- Discovery source: which rail, search, deep link or notification led to a play.
Nothing carries a
sourceparameter today, and that is the biggest engagement gap. - Series binge behaviour (auto-next accepted vs cancelled): not logged.
3. Proposed events
Section titled “3. Proposed events”Keep one naming style (Firebase snake_case), send identical parameters everywhere, and
put all event code behind one helper (e.g. lib/core/utils/analytics.dart) so the
event list stays auditable, the same way logMarktagEvent does for Marktag.
Every play gets a play_session_id (UUID) created when the player opens. That one
id lets you join all of a play’s events, which is what makes QoE metrics computable.
tap play ─► play_attempt ──► (preparing …) ──► play_start {startup_ms} │ │ │ ├─► play_stall {stall_ms} (0..n) │ ├─► play_error {code, fatal} (0..n) │ └─► play_end {watched_s, pos_s, duration_s, reason} └─► play_abandon {waited_ms} (left before first frame = EBVS)| Event | Parameters | Answers |
|---|---|---|
play_attempt |
play_session_id, content_id, content_type, source, is_offline, is_resume |
Denominator for VSF and EBVS; source drives discovery analysis |
play_start |
play_session_id, startup_ms, network_type |
VST distribution by device and network |
play_stall |
play_session_id, stall_ms, position_s |
Rebuffering ratio |
play_error |
play_session_id, error, recovered, position_s |
Failure rate, KMS-recovery success rate |
play_end |
play_session_id, watched_s, position_s, duration_s, reason (exit / finished / next_episode / error) |
Completion, drop-off, binge rate |
play_abandon |
play_session_id, waited_ms |
EBVS |
screen_view (all routes) |
automatic via a router observer | Navigation funnels |
The source values could be: home_rail:<rail_slug>, search, deeplink,
notification, continue_watching, watchlist, auto_next, episode_picker.
Firebase limits to keep in mind: 500 distinct event names, 25 parameters per event, parameter values up to 100 characters, and custom parameters only appear in reports after you register them as custom dimensions in the console (the BigQuery export has everything regardless).
4. Learning path (for someone who already knows some SQL)
Section titled “4. Learning path (for someone who already knows some SQL)”Each stage has one concept, one tool and one exercise on Chorki data.
Stage 0: turn on the raw data (one-off)
Section titled “Stage 0: turn on the raw data (one-off)”- In the Firebase console, go to Project settings → Integrations → BigQuery and enable the daily export for the Android and iOS apps. The free BigQuery sandbox is enough to start. The export only includes data from the day you enable it, so do this first.
- Get a read-only replica or user for the backend DB. Never run analysis on the production primary.
Stage 1: descriptive metrics (week 1)
Section titled “Stage 1: descriptive metrics (week 1)”Concepts: DAU/MAU, stickiness, distributions (why the median beats the mean for watch time).
With the Firebase BigQuery export: daily active users by platform.
SELECT event_date, platform, COUNT(DISTINCT user_pseudo_id) AS dauFROM `PROJECT.analytics_XXXX.events_*`WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY)) AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) AND event_name = 'session_start'GROUP BY 1, 2 ORDER BY 1;Firebase parameters are nested, and this pattern is the one to learn:
SELECT (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'id') AS content_id, COUNT(*) AS viewsFROM `PROJECT.analytics_XXXX.events_*`WHERE event_name = 'view_content'GROUP BY 1 ORDER BY views DESC LIMIT 20;Stage 2: engagement from watch history (week 2)
Section titled “Stage 2: engagement from watch history (week 2)”Concepts: completion rate, fixing a denominator, outliers.
On the backend DB (table and column names are inferred from the app’s payload, so adjust them to the real schema):
-- Latest progress per user per title, then completion by titleWITH last AS ( SELECT user_id, content_id, MAX(watched_duration) AS watched_s FROM watch_history WHERE watched_at >= NOW() - INTERVAL 30 DAY GROUP BY user_id, content_id)SELECT c.title, COUNT(*) AS viewers, AVG(LEAST(l.watched_s / NULLIF(c.duration, 0), 1)) AS avg_completion, SUM(l.watched_s >= 0.9 * c.duration) / COUNT(*) AS finished_shareFROM last l JOIN contents c ON c.id = l.content_idGROUP BY c.titleHAVING viewers >= 50ORDER BY finished_share DESC;Use this to separate titles that attract clicks but lose viewers (high viewers, low
finished_share) from titles that hold them. Remember D1: series numbers stay
unreliable until that bug is fixed.
Stage 3: retention cohorts (week 3)
Section titled “Stage 3: retention cohorts (week 3)”Concepts: cohorts, D1/D7/D30 retention, survivorship bias.
WITH first_seen AS ( SELECT user_pseudo_id, MIN(PARSE_DATE('%Y%m%d', event_date)) AS cohort_day FROM `PROJECT.analytics_XXXX.events_*` WHERE event_name = 'first_open' GROUP BY 1),active AS ( SELECT DISTINCT user_pseudo_id, PARSE_DATE('%Y%m%d', event_date) AS day FROM `PROJECT.analytics_XXXX.events_*` WHERE event_name = 'session_start')SELECT cohort_day, COUNT(DISTINCT f.user_pseudo_id) AS new_users, COUNT(DISTINCT IF(DATE_DIFF(a.day, cohort_day, DAY) = 1, a.user_pseudo_id, NULL)) / COUNT(DISTINCT f.user_pseudo_id) AS d1, COUNT(DISTINCT IF(DATE_DIFF(a.day, cohort_day, DAY) = 7, a.user_pseudo_id, NULL)) / COUNT(DISTINCT f.user_pseudo_id) AS d7FROM first_seen f LEFT JOIN active a USING (user_pseudo_id)GROUP BY cohort_day ORDER BY cohort_day;Then ask a product question of the data: do users who finish one title in their first
session retain better at D7? This joins Firebase user_id to the backend user_id
(setUserId is already called on login).
Stage 4: playback QoE (after Section 3 ships)
Section titled “Stage 4: playback QoE (after Section 3 ships)”Concepts: percentiles (p50/p90/p99), segmenting, correlating quality with engagement.
- p50/p90
startup_msbydevice_model,network_typeand app version. - Rebuffering ratio per session, then ask: do sessions with more than 1% rebuffering have lower completion? Answering that ties engineering work to product outcomes, and it is the argument that gets player work prioritised.
- Compare app versions before and after a player change (e.g. the stability branch).
Stage 5: experiments (ongoing)
Section titled “Stage 5: experiments (ongoing)”Concepts: hypothesis, primary metric, guardrail metric, sample size, not peeking.
firebase_remote_config is already a dependency, and Firebase A/B Testing runs on
top of it. Good first experiments for this app:
- Auto-next countdown length: primary metric is episodes per session; guardrail is
play_end reason=exitwithin 60 s of auto-next. - Trailer autoplay on the content page: primary metric is
play_attemptfromsource=content_page; guardrail is data usage or complaints.
Recommended reading
Section titled “Recommended reading”- Trustworthy Online Controlled Experiments by Kohavi, Tang and Xu (experiments).
- Lean Analytics by Croll and Yoskovitz (choosing the one metric that matters).
- The Firebase BigQuery export schema docs (the
event_params/user_propertiesshape). - Conviva and Mux write-ups on video QoE metrics (VST, EBVS, rebuffering ratio).
5. Suggested order of work
Section titled “5. Suggested order of work”- Fix D1 (watch history saves the wrong episode). This is a small change, and it matters for both users and data.
- Enable the BigQuery export and get read-only DB access; do Stages 1–2 on data that already exists.
- Add the
analytics.darthelper, the routerscreen_viewobserver and theplay_*events withplay_session_idandsource. Cover them with tests, asmarktag_playalready has. - After one release has been live for two weeks, do Stages 3–4 and share the first QoE × completion finding.
- Run the first Remote Config experiment.