Skip to content

feat(analytics): video watch percentage and drop-off tracking#1903

Open
ManthanNimodiya wants to merge 8 commits into
CapSoftware:mainfrom
ManthanNimodiya:feat/video-watch-engagement-analytics
Open

feat(analytics): video watch percentage and drop-off tracking#1903
ManthanNimodiya wants to merge 8 commits into
CapSoftware:mainfrom
ManthanNimodiya:feat/video-watch-engagement-analytics

Conversation

@ManthanNimodiya

@ManthanNimodiya ManthanNimodiya commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds watch engagement analytics to the video share page, requested by the community.

  • Tracking: fires video_progress events at 25%, 50%, 75%, 95% milestones per viewer session via sendBeacon, same pattern as existing page_hit tracking, zero extra latency
  • Backend: /api/analytics/track handles video_progress separately (no email/notification overhead); skips recording if the viewer is the video owner; stores percent_watched in Tinybird analytics_events
  • Query: getVideoEngagement() is auth-gated (owner only) and aggregates per-session max milestone via ClickHouse subquery — counts how many sessions reached each quartile and avg watch %
  • UI: engagement section shown in the Analytics tab (owner-only) — avg watched % + 4-step drop-off bars, hidden until first real data arrives

Security

  • getVideoEngagement throws Unauthorized if the caller is not the video owner
  • Owner self-views are excluded from engagement data

Greptile Summary

This PR adds video watch-engagement analytics (25/50/75/95% milestones) end-to-end: client-side sendBeacon tracking, a dedicated /api/analytics/track branch for video_progress events that skips owner self-views and non-existent videos, an owner-only getVideoEngagement() server action querying per-session max milestones via a ClickHouse subquery, and an in-Analytics-tab drop-off funnel UI. The same diff also fixes a pre-existing querySql bug in Tinybird where API-level errors were silently swallowed by the TSV fallback path instead of propagating.

  • route.ts: video_progress path adds a !videoRecord guard to block writes for unknown video IDs and uses randomUUID() as a sessionless fallback (replacing the previous "anon" literal that collapsed all anonymous viewers into one ClickHouse row).
  • get-analytics.ts: getVideoEngagement() is auth-gated server-side (throws Unauthorized for non-owners), validates videoId via regex before SQL interpolation, and aggregates the inner subquery correctly.
  • Tinybird/index.ts: Adds percent_watched to the event schema and separates JSON.parse from the error-field check so Tinybird query errors are no longer mis-classified as TSV and silently turned into garbage data.

Confidence Score: 4/5

Safe to merge once the dangling-listener concern raised in the open Share.tsx thread is confirmed resolved — all other previously flagged issues (non-existent-video writes, anon-session collapsing) are addressed in the current code.

The route and server-action logic are defensively written: the !videoRecord guard, owner-exclusion check, and randomUUID() fallback are all present and correct. The Tinybird querySql refactoring properly moves Tinybird error propagation outside the try-catch, a genuine correctness improvement. The main outstanding item is the Share.tsx RAF/cleanup pattern flagged in a prior review thread — examining the code, the cleanup variable IS read through its closure so the listener SHOULD be removed on unmount, but this warrants explicit confirmation before the thread is resolved.

apps/web/app/s/[videoId]/Share.tsx — the RAF-deferred event-listener attach/cleanup pattern has an open review thread that should be resolved before merging.

Important Files Changed

Filename Overview
apps/web/actions/videos/get-analytics.ts Adds getVideoEngagement() with server-side auth gating (owner only), regex + escapeLiteral validation for videoId, and a ClickHouse subquery that aggregates per-session max milestone correctly.
apps/web/app/api/analytics/track/route.ts New video_progress branch correctly excludes non-existent videos (!videoRecord guard) and owner self-views; uses randomUUID() fallback for sessionless viewers. Previously flagged issues appear addressed.
apps/web/app/s/[videoId]/Share.tsx Adds per-session milestone tracking via timeupdate listener with RAF-based deferred attachment; the RAF/cleanup pattern still has the dangling-listener concern raised in previous review threads.
apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx Clean engagement section — ownership-gated fetch and render, DropOffBar math is correct, errors silently suppressed so the section simply stays hidden when data is unavailable.
packages/web-backend/src/Tinybird/index.ts Adds percent_watched to TinybirdEventRow and fixes a pre-existing querySql bug where Tinybird API errors (JSON with an error field) were silently swallowed by the TSV fallback instead of propagating.

Comments Outside Diff (1)

  1. apps/desktop/src-tauri/src/auth.rs, line 107 (link)

    P1 organizations_updated_at set unconditionally outside the match

    The unconditional assignment on line 107 is now outside both arms of the match, so it runs on both success (redundant, since the Ok arm already sets it at line 101) and on every error. The original code intentionally skipped updating the timestamp on error when auth.organizations was 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
    This is a comment left during a code review.
    Path: apps/desktop/src-tauri/src/auth.rs
    Line: 107
    
    Comment:
    **`organizations_updated_at` set unconditionally outside the match**
    
    The unconditional assignment on line 107 is now outside both arms of the `match`, so it runs on both success (redundant, since the `Ok` arm already sets it at line 101) and on every error. The original code intentionally skipped updating the timestamp on error when `auth.organizations` was 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.
    
    How can I resolve this? If you propose a fix, please make it concise.

Reviews (5): Last reviewed commit: "fix(analytics): add percent_watched to t..." | Re-trigger Greptile

@ManthanNimodiya ManthanNimodiya force-pushed the feat/video-watch-engagement-analytics branch from 369013b to 9c5aca3 Compare June 8, 2026 16:13
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jun 8, 2026
Comment thread apps/web/app/s/[videoId]/Share.tsx Outdated
Comment on lines +397 to +398
const raf = requestAnimationFrame(() => attach());
return () => cancelAnimationFrame(raf);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +121 to +130
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "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.

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jun 8, 2026
@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review

Comment on lines +93 to +138
if (action === "video_progress") {
const sessionId =
typeof body.sessionId === "string"
? body.sessionId.trim().slice(0, 128) || null
: null;
const percentWatched =
typeof body.percentWatched === "number" &&
body.percentWatched >= 0 &&
body.percentWatched <= 100
? Math.round(body.percentWatched)
: null;

if (percentWatched !== null) {
await runPromise(
Effect.gen(function* () {
const maybeUser = yield* Effect.serviceOption(CurrentUser);
const userId = Option.match(maybeUser, {
onNone: () => null as string | null,
onSome: (user) => (user as { id: string }).id,
});

const [videoRecord] = yield* Effect.tryPromise(() =>
db()
.select({ ownerId: videos.ownerId })
.from(videos)
.where(eq(videos.id, Video.VideoId.make(body.videoId)))
.limit(1),
).pipe(Effect.orElseSucceed(() => [] as { ownerId: string }[]));

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 ?? randomUUID(),
video_id: body.videoId,
percent_watched: percentWatched,
},
]);
}).pipe(provideOptionalAuth),
);
}
return Response.json({ success: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

video_progress branch re-parses sessionId and shadows the already-sanitized sessionId above (including allowing the literal "anonymous" back in). That can collapse engagement sessions for clients where storage fails. Also seems better to use the already-computed timestamp.

Suggested change
if (action === "video_progress") {
const sessionId =
typeof body.sessionId === "string"
? body.sessionId.trim().slice(0, 128) || null
: null;
const percentWatched =
typeof body.percentWatched === "number" &&
body.percentWatched >= 0 &&
body.percentWatched <= 100
? Math.round(body.percentWatched)
: null;
if (percentWatched !== null) {
await runPromise(
Effect.gen(function* () {
const maybeUser = yield* Effect.serviceOption(CurrentUser);
const userId = Option.match(maybeUser, {
onNone: () => null as string | null,
onSome: (user) => (user as { id: string }).id,
});
const [videoRecord] = yield* Effect.tryPromise(() =>
db()
.select({ ownerId: videos.ownerId })
.from(videos)
.where(eq(videos.id, Video.VideoId.make(body.videoId)))
.limit(1),
).pipe(Effect.orElseSucceed(() => [] as { ownerId: string }[]));
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 ?? randomUUID(),
video_id: body.videoId,
percent_watched: percentWatched,
},
]);
}).pipe(provideOptionalAuth),
);
}
return Response.json({ success: true });
if (action === "video_progress") {
const percentWatched =
typeof body.percentWatched === "number" &&
body.percentWatched >= 0 &&
body.percentWatched <= 100
? Math.round(body.percentWatched)
: null;
if (percentWatched !== null) {
await runPromise(
Effect.gen(function* () {
const maybeUser = yield* Effect.serviceOption(CurrentUser);
const userId = Option.match(maybeUser, {
onNone: () => null as string | null,
onSome: (user) => (user as { id: string }).id,
});
const [videoRecord] = yield* Effect.tryPromise(() =>
db()
.select({ ownerId: videos.ownerId })
.from(videos)
.where(eq(videos.id, Video.VideoId.make(body.videoId)))
.limit(1),
).pipe(Effect.orElseSucceed(() => [] as { ownerId: string }[]));
if (!videoRecord || userId === videoRecord.ownerId) return;
const tinybird = yield* Tinybird;
yield* tinybird.appendEvents([
{
timestamp: timestamp.toISOString(),
action: "video_progress",
version: "1.0",
session_id: sessionId ?? randomUUID(),
video_id: body.videoId,
percent_watched: percentWatched,
},
]);
}).pipe(provideOptionalAuth),
);
}
return Response.json({ success: true });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in df58819 — the video_progress branch no longer re-parses sessionId; it reuses the sanitized sessionId (with randomUUID() fallback instead of letting "anonymous" back in) and the already-computed timestamp. tenant_id was re-added in 6f7c8b7.


if (!video || video.ownerId !== user.id) throw new Error("Unauthorized");

const safeId = videoId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: since this ends up interpolated into SQL, it’d be a bit more future-proof/consistent to reuse the existing literal escaping helper.

Suggested change
const safeId = videoId;
const safeId = escapeLiteral(videoId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in df58819 — now uses the shared escapeLiteral helper, and videoId is also regex-validated (/^[0-9a-zA-Z_-]+$/) before it reaches the query.

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@tembo please re-review

@tembo

tembo Bot commented Jun 14, 2026

Copy link
Copy Markdown

@tembo please re-review

re-reviewed pr #1903 at the latest commit (df58819). all four previously-flagged items are now fixed:

  • non-existent/failed-lookup video ids no longer written to tinybird (route.ts:118 guard)
  • the sessionId shadow that re-admitted the literal "anonymous" is gone — video_progress now uses the outer sanitized sessionId ?? randomUUID(), so per-session aggregation stays correct
  • reuses the existing timestamp
  • getVideoEngagement is owner-gated + sql-safe (regex validation + escapeLiteral)
  • bonus: the tinybird querySql refactor correctly propagates error objects instead of falling through to tsv parsing

one item still open: the requestAnimationFrame path in Share.tsx leaks the timeupdate listener (the cleanup returned by attach() inside the raf is discarded). low real-world impact but i left an inline suggestion with a concrete fix. good to merge once that's handled.

posted as a review on the pr.

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review

@tembo tembo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review

Thanks for the iterations — everything I (and Greptile) flagged previously is now addressed:

  • Non-existent / failed-lookup video IDs no longer written to Tinybirdif (!videoRecord || userId === videoRecord.ownerId) return (route.ts:118).
  • sessionId shadow no longer re-admits the literal "anonymous" — the inner re-declaration is gone; the video_progress branch uses the outer sanitized sessionId ?? randomUUID() (route.ts:126), giving each sessionless viewer a distinct id so the per-session GROUP BY in getVideoEngagement stays correct.
  • Reuses the existing timestamp (route.ts:123).
  • getVideoEngagement is owner-gated and SQL-safe — auth + ownership check, regex ^[0-9a-zA-Z_-]+$ validation, and escapeLiteral(videoId) before interpolation.
  • Tinybird querySql refactor is correct — moving the error-field check outside the JSON.parse try/catch means a Tinybird error object now propagates instead of silently falling through to TSV parsing.

One item still open

  • ⚠️ RAF path leaks the timeupdate listener in Share.tsx (inline suggestion above). Low real-world impact since playerRef is usually populated synchronously, but worth the small fix to avoid duplicate beacons after unmount / dep changes.

Looks good to merge once the listener cleanup is handled.

Comment thread apps/web/app/s/[videoId]/Share.tsx Outdated
Comment on lines +387 to +398
const attach = () => {
const video = playerRef.current;
if (!video) return null;
video.addEventListener("timeupdate", onTimeUpdate);
return () => video.removeEventListener("timeupdate", onTimeUpdate);
};

const detach = attach();
if (detach) return detach;

const raf = requestAnimationFrame(() => attach());
return () => cancelAnimationFrame(raf);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RAF cleanup still discards the listener detachment (open from prior review).

When playerRef.current is null at effect-mount time, a requestAnimationFrame is scheduled and the effect returns () => cancelAnimationFrame(raf). Once the RAF fires and attach() succeeds, the cleanup closure it returns is thrown away — React's cleanup only cancels the already-run RAF (a no-op), so the timeupdate listener is never removed. On unmount or when the deps (data.id, viewerId) change, the old listener keeps firing and sending beacons, and effect re-runs can stack duplicate listeners on the same element.

Capture the detach from whichever path attaches and call it in the cleanup:

Suggested change
const attach = () => {
const video = playerRef.current;
if (!video) return null;
video.addEventListener("timeupdate", onTimeUpdate);
return () => video.removeEventListener("timeupdate", onTimeUpdate);
};
const detach = attach();
if (detach) return detach;
const raf = requestAnimationFrame(() => attach());
return () => cancelAnimationFrame(raf);
let cleanup: (() => void) | null = null;
const attach = () => {
const video = playerRef.current;
if (!video) return false;
video.addEventListener("timeupdate", onTimeUpdate);
cleanup = () => video.removeEventListener("timeupdate", onTimeUpdate);
return true;
};
let raf = 0;
if (!attach()) raf = requestAnimationFrame(() => attach());
return () => {
cancelAnimationFrame(raf);
cleanup?.();
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 19b779c. The current effect captures cleanup from whichever path attaches (sync or RAF-deferred) and invokes it in the returned cleanup, so the timeupdate listener is now detached on unmount/dep-change.

One residual caveat (non-blocking): when playerRef.current is still null after the single RAF retry — e.g. the <video> mounts late after processing — attach() gives up and milestones for that view are missed. ShareVideo exposes the element via useImperativeHandle(ref, ..., []) (empty deps), so playerRef.current is a one-time snapshot and won't repopulate later. Pre-existing pattern, fine to defer.

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@tembo @greptileai please re-review

@tembo

tembo Bot commented Jun 14, 2026

Copy link
Copy Markdown

@tembo @greptileai please re-review

re-reviewed pr #1903 at the latest commit (19b779c) and posted findings: #1903 (comment)

quick summary:

✅ resolved since last review

  • the raf listener leak is fixed — cleanup now reliably detaches the timeupdate listener on both the sync and raf-deferred attach paths
  • the querySql refactor correctly splits json-parse fallback from tinybird app errors (fixes the prior swallow-the-error bug)
  • auth gating is solid — owner-only engagement query, owner self-views excluded, videoId validated by regex + escapeLiteral (no injection)

🔴 still blocking

  • scripts/analytics/tinybird/datasources/analytics_events.datasource still has no percent_watched column (verified — it appears nowhere under scripts/). so tinybird drops the field on ingest, and the engagement query errors on an unknown column, gets swallowed by catchAll, returns zeros, and the ui never renders. feature is dead-on-arrival until you add e.g. percent_watched UInt8 \json:$.percent_watched` DEFAULT 0` to the schema (and update FORWARD_QUERY).

🟡 minor

  • video_progress events have no tenant_id (defaults to ""), so the by-video_id lookup isn't covered by the (tenant_id, timestamp) sort key — works, just less selective
  • the randomUUID session fallback is fine since the client always sends a session id

recommendation: approve once the datasource schema change lands with this code. (the auth.rs greptile note is outside this pr's diff — ignored it.)

@tembo

tembo Bot commented Jun 14, 2026

Copy link
Copy Markdown

Re-review (Tembo)

Re-reviewed at 19b779c (latest commit; later than Greptile's last-reviewed df58819).

✅ Resolved since the previous review

  • RAF listener leak fixed (19b779c). The earlier version returned the detach fn from attach() but discarded it on the RAF-deferred path. It now stores cleanup in the effect's closure, so the timeupdate listener is reliably removed on unmount whether attach happens synchronously or via requestAnimationFrame.
  • querySql refactor is correct. Separating the JSON.parse fallback (TSV path) from Tinybird application errors fixes the prior bug where an error JSON body could fall through to the TSV parser. Errors now log + throw, JSON-parse failures still fall back to TSV.
  • Auth gating is solid. getVideoEngagement is owner-only (throws Unauthorized otherwise), owner self-views are excluded both client-side and in /api/analytics/track, and videoId is validated with ^[0-9a-zA-Z_-]+$ and escapeLiteral before interpolation — no injection surface.

🔴 Blocking — still unaddressed

scripts/analytics/tinybird/datasources/analytics_events.datasource does not define percent_watched. Greptile's earlier note still stands — I verified percent_watched appears nowhere under scripts/. Two consequences:

  1. Ingest: the NDJSON appendEvents payload now includes percent_watched, but Tinybird silently drops fields absent from the datasource SCHEMA, so the value is never stored.
  2. Query: getVideoEngagement runs SELECT ... max(toFloat32(percent_watched)) ... FROM analytics_events. ClickHouse returns an unknown-identifier error → swallowed by Effect.catchAll → returns all-zeros → the UI (guarded on engagement.total > 0) never renders.

Net: the feature is dead-on-arrival until the column is added. Add it to the SCHEMA block (and update FORWARD_QUERY accordingly), e.g.:

percent_watched UInt8 `json:$.percent_watched` DEFAULT 0

(Use Nullable(UInt8)/Float32 if you'd rather not coerce nulls to 0; the query's toFloat32 handles either.)

🟡 Minor (non-blocking)

  • video_progress events carry no tenant_id (defaults to ""). The datasource sorting key is (tenant_id, timestamp), so the WHERE video_id = … lookup isn't covered by the sort key. Functionally correct, just a less-selective scan as volume grows. Consider setting tenant_id to the video's ownerId for consistency with page_hit events.
  • session_id ?? randomUUID() fallback is fine in practice (the client always sends ensureAnalyticsSessionId()), but note that if a sessionId were ever missing, the four milestone beacons would each get a distinct UUID and inflate the session count. Not reachable through the current client.

The auth.rs "comment outside diff" from Greptile is unrelated to this PR's changes (not in the diff) — ignore for this review.

Recommendation: approve once the analytics_events.datasource schema change lands alongside this code.

`SELECT count() as total, countIf(max_percent >= 25) as reached_25, countIf(max_percent >= 50) as reached_50, countIf(max_percent >= 75) as reached_75, countIf(max_percent >= 95) as reached_95, round(avg(max_percent)) as avg_percent FROM (SELECT session_id, max(toFloat32(percent_watched)) as max_percent FROM analytics_events WHERE action = 'video_progress' AND video_id = '${safeId}' GROUP BY session_id)`,
)
.pipe(
Effect.catchAll(() =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching everything here and returning an empty dataset makes schema/query issues (e.g. missing percent_watched) invisible and hard to debug. Consider logging the error like getVideoAnalytics does.

Suggested change
Effect.catchAll(() =>
.pipe(
Effect.catchAll((error) => {
console.error("tinybird engagement query error", error);
return Effect.succeed({
data: [] as {
total: number;
reached_25: number;
reached_50: number;
reached_75: number;
reached_95: number;
avg_percent: number;
}[],
});
}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 6f7c8b7 — the catchAll now logs via console.error("tinybird engagement query error", e) before returning the empty dataset, matching getVideoAnalytics.

…t query errors, tag video_progress with tenant_id
@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@tembo please re-review

@tembo

tembo Bot commented Jun 14, 2026

Copy link
Copy Markdown

@tembo please re-review

re-reviewed pr #1903 at the latest commit (6f7c8b7) and posted a review on github.

verdict: looks good, no blocking issues. every previously-flagged item is now resolved:

  • raf cleanup now actually detaches the timeupdate listener on unmount (19b779c)
  • non-existent video ids are blocked from tinybird via the !videoRecord guard
  • sessionless viewers use a randomUUID() fallback instead of collapsing into one "anon" row
  • video_progress reuses the sanitized sessionId + existing timestamp (no re-parsing/shadowing), tenant_id re-added
  • getVideoEngagement is owner-gated, regex-validates videoId, and escapes the literal
  • engagement query errors are now logged instead of silently swallowed
  • tinybird querySql no longer mis-classifies api errors as tsv

i also verified auth gating, the drop-off bar math, and the new percent_watched schema/forward_query end-to-end.

two minor non-blocking notes left inline: (1) the localStorage-failure fallback can inflate session counts since each milestone beacon gets a fresh uuid, and (2) a late-mounting <video> (processing → ready) can miss milestones because useImperativeHandle uses empty deps — both are pre-existing/edge-case and fine to defer.

fyi the auth.rs:107 item greptile listed under "comments outside diff" isn't part of this pr — it's base-branch divergence noise.

@tembo tembo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — looks good ✅

Re-reviewed at 6f7c8b7. Every issue from the prior round is addressed:

Concern Status
RAF cleanup discarded the timeupdate detach (P1, greptile + tembo) 19b779ccleanup captured from sync/RAF path and called on unmount
Non-existent video IDs written to Tinybird (P1) 6fb2399/e99a8bf — explicit !videoRecord guard
"anon" collapsed all sessionless viewers into one session (P1) randomUUID() fallback
video_progress re-parsed/shadowed sanitized sessionId + recomputed timestamp df58819 — reuses sanitized sessionId and existing timestamp
videoId SQL safety in getVideoEngagement ✅ regex-validated and escapeLiteral
Engagement query swallowed errors silently 6f7c8b7 — logs before returning empty
Tinybird querySql mis-classified API errors as TSV ✅ JSON.parse separated from the error-field check; errors now propagate

Verified end-to-end

  • Auth: getVideoEngagement throws Unauthorized for non-owners and unauthenticated callers; owner self-views excluded from both the write path and the query.
  • UI: DropOffBar math is correct; section is owner-gated and hidden until total > 0, so the avg(empty) NaN case never renders.
  • Schema: percent_watched UInt8 DEFAULT 0 added to the datasource + FORWARD_QUERY; page_hit events send percent_watched: null → resolves to the column default.

Minor, non-blocking (fine to defer)

  1. Sessionless fallback inflates session counts. When the client can't persist a session id (localStorage failure → "anonymous" → server randomUUID()), each of the 4 milestone beacons gets a distinct UUID, so one such viewer shows up as up to 4 sessions. Strictly better than the old "anon" collapse, and only hits the storage-failure edge case — just noting the trade-off.
  2. Late-mounting <video> misses milestones. ShareVideo exposes the element via useImperativeHandle(ref, …, []) (empty deps), so playerRef.current is a one-time snapshot. If the video element mounts after the analytics effect runs (e.g. processing → ready), the single RAF retry can't recover it. Pre-existing pattern; doesn't affect already-ready videos.

The auth.rs:107 item in greptile's "Comments Outside Diff" is not part of this PR (the 6 changed files are all analytics) — it's base-branch divergence noise.

No blocking issues. 👍

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.

1 participant