feat(analytics): video watch percentage and drop-off tracking#1903
feat(analytics): video watch percentage and drop-off tracking#1903ManthanNimodiya wants to merge 3 commits into
Conversation
369013b to
9c5aca3
Compare
| const raf = requestAnimationFrame(() => attach()); | ||
| return () => cancelAnimationFrame(raf); |
There was a problem hiding this comment.
RAF cleanup discards event-listener detachment
When playerRef.current is null at effect mount time, a requestAnimationFrame is scheduled and the effect returns () => cancelAnimationFrame(raf) as its cleanup. When the RAF fires and attach() successfully adds the timeupdate listener, the returned cleanup closure is thrown away — React's effect cleanup only cancels the RAF (a no-op if it already ran), so the listener is never removed on unmount. This leaves a dangling timeupdate callback that continues firing and sending beacons after the component is gone.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/Share.tsx
Line: 397-398
Comment:
**RAF cleanup discards event-listener detachment**
When `playerRef.current` is `null` at effect mount time, a `requestAnimationFrame` is scheduled and the effect returns `() => cancelAnimationFrame(raf)` as its cleanup. When the RAF fires and `attach()` successfully adds the `timeupdate` listener, the returned cleanup closure is thrown away — React's effect cleanup only cancels the RAF (a no-op if it already ran), so the listener is never removed on unmount. This leaves a dangling `timeupdate` callback that continues firing and sending beacons after the component is gone.
How can I resolve this? If you propose a fix, please make it concise.| if (videoRecord && userId === videoRecord.ownerId) return; | ||
|
|
||
| const tinybird = yield* Tinybird; | ||
| yield* tinybird.appendEvents([ | ||
| { | ||
| timestamp: new Date().toISOString(), | ||
| action: "video_progress", | ||
| version: "1.0", | ||
| session_id: sessionId ?? "anon", | ||
| video_id: body.videoId, |
There was a problem hiding this comment.
Non-existent video IDs written to Tinybird
When the DB lookup fails (caught by orElseSucceed) or the video simply doesn't exist, videoRecord is undefined. The combined guard if (videoRecord && userId === videoRecord.ownerId) return evaluates to false, so execution falls through and the event is appended to Tinybird with whatever arbitrary string was in body.videoId. An unauthenticated caller can pollute Tinybird with events for non-existent videos. Add an explicit if (!videoRecord) return before the owner check to prevent writes for unknown video IDs.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/analytics/track/route.ts
Line: 121-130
Comment:
**Non-existent video IDs written to Tinybird**
When the DB lookup fails (caught by `orElseSucceed`) or the video simply doesn't exist, `videoRecord` is `undefined`. The combined guard `if (videoRecord && userId === videoRecord.ownerId) return` evaluates to `false`, so execution falls through and the event is appended to Tinybird with whatever arbitrary string was in `body.videoId`. An unauthenticated caller can pollute Tinybird with events for non-existent videos. Add an explicit `if (!videoRecord) return` before the owner check to prevent writes for unknown video IDs.
How can I resolve this? If you propose a fix, please make it concise.| timestamp: new Date().toISOString(), | ||
| action: "video_progress", | ||
| version: "1.0", | ||
| session_id: sessionId ?? "anon", |
There was a problem hiding this comment.
"anon" collapses all sessionless viewers into one ClickHouse row
When sessionId is null, every event is stored with session_id = 'anon'. The aggregation query in getVideoEngagement does GROUP BY session_id, so all these events merge into a single session row. For a video with many anonymous viewers this makes count() (total sessions) report 1 and per-milestone counts wildly wrong. Using crypto.randomUUID() as the fallback would give each request a unique session and keep the per-session aggregation correct.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/analytics/track/route.ts
Line: 129
Comment:
**`"anon"` collapses all sessionless viewers into one ClickHouse row**
When `sessionId` is `null`, every event is stored with `session_id = 'anon'`. The aggregation query in `getVideoEngagement` does `GROUP BY session_id`, so all these events merge into a single session row. For a video with many anonymous viewers this makes `count()` (total sessions) report 1 and per-milestone counts wildly wrong. Using `crypto.randomUUID()` as the fallback would give each request a unique session and keep the per-session aggregation correct.
How can I resolve this? If you propose a fix, please make it concise.…ClickHouse safety
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
|
@greptileai please re-review |
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
Summary
Adds watch engagement analytics to the video share page, requested by the community.
Security
Greptile Summary
This PR adds video watch-engagement analytics: milestone beacons fired from the share page, a new
/api/analytics/trackbranch that recordsvideo_progressevents to Tinybird (skipping the owner), agetVideoEngagement()server action that aggregates per-session milestones, and a drop-off bar UI visible only to the video owner.querySqlhelper in Tinybird has its API-errorthrowinside the sametry/catchblock as the TSV fallback, causing Tinybird error responses (wrong token, missing column, etc.) to be swallowed and returned as garbage data instead of propagating.\"anon\"key (breaking per-session aggregation), and the RAF-based lazy-attach path discards thetimeupdatelistener cleanup on unmount.Confidence Score: 3/5
Multiple defects remain across the analytics pipeline, two of which corrupt the data the owner sees.
The querySql error-swallowing bug means Tinybird configuration problems silently produce empty or NaN engagement data with no way to detect the failure. Three issues from the prior review round are still unaddressed: non-existent video IDs pollute Tinybird storage, anonymous sessions share the anon key breaking per-session milestone counts, and the RAF-path cleanup omission leaves a dangling timeupdate listener after unmount.
packages/web-backend/src/Tinybird/index.ts (querySql error handling), apps/web/app/api/analytics/track/route.ts (missing video-existence guard, session-ID fallback), and apps/web/app/s/[videoId]/Share.tsx (RAF listener cleanup).
Important Files Changed
getVideoEngagement()with owner-only auth, Tinybird SQL aggregation, and a format-validation regex; the regex is positioned after the DB auth check rather than before it.video_progresstracking branch; previously flagged issues remain: non-existent video IDs still written to Tinybird and null sessions fall back to the shared"anon"key.timeupdate; the RAF-based lazy-attach path discards the cleanup returned byattach(), leaving a dangling listener on unmount (previously flagged).DropOffBarengagement UI; renders correctly and hides itself whentotal === 0.querySqlmethod added; its inner try/catch eats intentional Tinybird API error throws, causing error responses to fall through to TSV parsing and silently return garbage data.Comments Outside Diff (1)
apps/desktop/src-tauri/src/auth.rs, line 107 (link)organizations_updated_atset unconditionally outside the matchThe unconditional assignment on line 107 is now outside both arms of the
match, so it runs on both success (redundant, since theOkarm already sets it at line 101) and on every error. The original code intentionally skipped updating the timestamp on error whenauth.organizationswas non-empty, so that a subsequent call would still attempt a refresh. With this change, a transient network failure immediately marks the cache as "just checked", silencing retries for the full backoff window even when the stale data should be re-fetched.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (2): Last reviewed commit: "fix(analytics): validate videoId format ..." | Re-trigger Greptile