Skip to content

feat(frontend): add posthog analytics for whats-new banner - #986

Closed
dembrane-sam-bot wants to merge 1 commit into
mainfrom
sam/whats-new-posthog
Closed

feat(frontend): add posthog analytics for whats-new banner#986
dembrane-sam-bot wants to merge 1 commit into
mainfrom
sam/whats-new-posthog

Conversation

@dembrane-sam-bot

Copy link
Copy Markdown
Contributor

What is this change?

Adds robust PostHog analytics for the 'What's new' banner (ReleaseVideoModal) to track user engagement with release update videos:

  1. Modal Open: Captures whats_new_modal_opened with user's UI language (language), opaque version string, and seconds elapsed since their last login.
  2. Video Play Start: Captures whats_new_video_started once when they play/start watching the embedded video.
  3. Modal Close: Captures whats_new_modal_closed on close/dismiss containing:
    • video_watched_seconds: Total elapsed wall-clock play duration (perfectly handles pause/resume intervals).
    • video_duration_seconds: Total length of the video queryable from the YouTube player API.
    • video_percent_watched: Proportion of the video watched.
    • video_watched: Boolean indicating if they played it at all.
    • language: User UI language.
    • seconds_since_login: Time elapsed since login.
    • version: Release version.

To support tracking "seconds since login", we now store the current timestamp in localStorage as last_login_time on successful logins/registrations and clear it on logout. If already logged in, we self-heal by initializing it to the current timestamp on the first modal render.

What led to this?

Operator request: "Can you make posthog events for the new 'What's new' banner? I need to know if / how long they watched the video / what language they are on / how long has it been since they logged in?"

Tier

Tier 1/2 (Frontend application feature code).

Confidence

High. The implementation uses the standard YouTube IFrame Player API headlessly (without loading any external scripts if already present, and gracefully degrading if unavailable) and aggregates precise, non-overlapping play intervals.

@spashii

spashii commented Aug 10, 2026

Copy link
Copy Markdown
Member

Superseded by #989. Same goal, different mechanics, for two reasons this branch could not measure what it set out to: the IFrame API script it injects is served from www.youtube.com, which script-src (vercel.json) does not allow, so the player never initialises in production and every watch metric silently reads zero while the events still fire; and the localStorage last_login_time self-heals to now on first render, which fabricates seconds_since_login for every already-logged-in user, exactly the cohort the 2026-08 video targets. #989 listens to the embed's own postMessage stream instead (enablejsapi=1, no script, no CSP change, nocookie origin kept) and answers login recency with a PostHog query against the existing user_logged_in history; it also sends progress milestones as they are crossed, so a closed tab keeps the record.

@spashii spashii closed this Aug 10, 2026
auto-merge was automatically disabled August 10, 2026 18:32

Pull request was closed

spashii added a commit that referenced this pull request Aug 10, 2026
### What is this change?

PostHog analytics for the "What's new" release video modal, answering:
did they watch, how long, in which language, and how the showing relates
to login recency.

**Events**

| event | when | notable properties |
|---|---|---|
| `whats_new_modal_opened` | the modal shows | `trigger`
(`auto`/`manual`), `seconds_since_page_load` |
| `whats_new_video_started` | first play | `seconds_after_open` |
| `whats_new_video_progress` | furthest position crosses 25/50/75/95% |
`milestone_percent` |
| `whats_new_video_completed` | the player reports ended |
`video_watched_seconds`, `video_duration_seconds` |
| `whats_new_modal_closed` | dismiss or pagehide | `reason`,
`video_watched`, `video_watched_seconds`, `video_duration_seconds`,
`video_percent_watched`, `video_max_percent`, `modal_open_seconds` |

Every event carries `language` (the UI language), `version` (the release
entry) and `trigger` (the automatic showing vs the sidebar's "What's
new").

**How playback is observed**

The embed runs on www.youtube-nocookie.com, the only YouTube origin
`frame-src` allows, and `script-src` allows no YouTube origin at all. So
the official IFrame API loader (a script served from www.youtube.com) is
not an option: the CSP would block it in production and every metric
would silently read zero. Instead the iframe URL gains `enablejsapi=1`
plus `origin`, and the modal speaks the player's own postMessage
protocol directly: a `listening` handshake, then `infoDelivery` messages
carrying `currentTime`, `duration` and `playerState`. No script loads,
no CSP change, and the nocookie privacy posture stays as it was.

Watch time is the sum of small forward `currentTime` steps: a seek does
not count as watching, double speed counts the content covered,
rewatching counts again but the percentage caps at 100.
`video_max_percent` is the furthest position reached. Milestones fire
the moment they are crossed, so a tab closed mid-video still leaves a
durable record; `pagehide` flushes the close summary for the walk-away
case (posthog-js flushes its queue with sendBeacon there).

**Time since login**

Deliberately not stamped from the client. A client-side breadcrumb
(localStorage written at login) fabricates the answer for every
already-logged-in user, misses sessions restored from a refresh token,
and dies with cleared site data. The honest source already exists:
PostHog holds each person's event history, including `user_logged_in`.
Recency is a query-side join, for example (HogQL):

```sql
select
  opens.person_id,
  any(opens.opened_at) as opened_at,
  max(prior.timestamp) as last_login_before,
  dateDiff('day', max(prior.timestamp), any(opens.opened_at)) as days_since_login
from (
  select person_id, min(timestamp) as opened_at
  from events
  where event = 'whats_new_modal_opened' and properties.version = '2026-08'
  group by person_id
) as opens
left join events as prior on prior.person_id = opens.person_id
where prior.event = 'user_logged_in' and prior.timestamp < opens.opened_at
group by opens.person_id
```

Swap the `user_logged_in` filter for any prior event with a 30 minute
guard to measure time since last activity instead, since sessions
persist across days and form logins are rarer than visits.

**Questions this schema answers**

1. Play rate: `whats_new_modal_opened` to `whats_new_video_started`
funnel.
2. Drop-off: milestone counts at 25/50/75/95, split by language.
3. Language reach: the video is English; completion by locale decides
subtitles vs localised videos.
4. Voluntary interest: `trigger=manual` reopens and replays vs the
automatic showing.
5. Quick dismissals: `modal_open_seconds` with `video_watched=false`.
6. Dormancy: the query above.

Supersedes #986: same goal, but that branch loads the IFrame API script
that `script-src` blocks (metrics would silently read zero in
production), stores last-login in localStorage (the self-heal fabricates
it for every existing user), and reports watch data only on close (a
closed tab loses the whole record).

### Test plan

- `vitest run src/components/release`: 49 tests pass, including new
tracker unit tests and modal analytics tests that hand-deliver widget
messages (origin and source checks, milestone dedupe, summary-once
semantics).
- `biome lint`, `tsc` and `vite build` are clean; lingui catalogs
re-extracted (line-reference shifts only, no new strings).
- The two failures in `useChunkAnchorScroll.test.tsx` reproduce on a
clean main checkout; unrelated and untouched here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants