Skip to content

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:

  1. An audit of every analytics signal the app sends today (as of 2026-09-30).
  2. 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?
  3. 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.

┌──────────────────────────── 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.

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.

video_player_screen.dart sends:

  • a ping every 10 s while playing (ma_ps position, ma_le length, ma_st seconds watched this session, ma_fs fullscreen, player name);
  • events play, pause and finished, with the position in e_v.

Custom dimensions: dimension1 = user id, dimension2 = subscribed or not, dimension3 = app version.

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.


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 source parameter today, and that is the biggest engagement gap.
  • Series binge behaviour (auto-next accepted vs cancelled): not logged.

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.

  • 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.

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 dau
FROM `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 views
FROM `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 title
WITH 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_share
FROM last l JOIN contents c ON c.id = l.content_id
GROUP BY c.title
HAVING viewers >= 50
ORDER 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.

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 d7
FROM 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_ms by device_model, network_type and 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).

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=exit within 60 s of auto-next.
  • Trailer autoplay on the content page: primary metric is play_attempt from source=content_page; guardrail is data usage or complaints.
  • 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_properties shape).
  • Conviva and Mux write-ups on video QoE metrics (VST, EBVS, rebuffering ratio).

  1. Fix D1 (watch history saves the wrong episode). This is a small change, and it matters for both users and data.
  2. Enable the BigQuery export and get read-only DB access; do Stages 1–2 on data that already exists.
  3. Add the analytics.dart helper, the router screen_view observer and the play_* events with play_session_id and source. Cover them with tests, as marktag_play already has.
  4. After one release has been live for two weeks, do Stages 3–4 and share the first QoE × completion finding.
  5. Run the first Remote Config experiment.