From 2dd87272a744502aac8e3dc9e198c9709b1421bf Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Mon, 8 Jun 2026 21:33:32 +0530 Subject: [PATCH 1/4] feat(analytics): add video watch percentage and drop-off tracking --- apps/web/actions/videos/get-analytics.ts | 48 ++++++++++ apps/web/app/api/analytics/track/route.ts | 35 +++++++ apps/web/app/s/[videoId]/Share.tsx | 51 ++++++++++ .../_components/tabs/Activity/Analytics.tsx | 92 +++++++++++++++++-- packages/web-backend/src/Tinybird/index.ts | 2 + 5 files changed, 218 insertions(+), 10 deletions(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index 35e1ca743aa..dbbe88a57b8 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -94,3 +94,51 @@ export async function getVideoAnalytics( }), ); } + +export async function getVideoEngagement(videoId: string) { + if (!videoId) throw new Error("Video ID is required"); + + const safeId = videoId.replace(/'/g, "''"); + + return runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + + const result = yield* tinybird + .querySql<{ + total: number; + reached_25: number; + reached_50: number; + reached_75: number; + reached_95: number; + avg_percent: number; + }>( + `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(() => + Effect.succeed({ + data: [] as { + total: number; + reached_25: number; + reached_50: number; + reached_75: number; + reached_95: number; + avg_percent: number; + }[], + }), + ), + ); + + const row = result.data?.[0]; + return { + total: Number(row?.total ?? 0), + reached25: Number(row?.reached_25 ?? 0), + reached50: Number(row?.reached_50 ?? 0), + reached75: Number(row?.reached_75 ?? 0), + reached95: Number(row?.reached_95 ?? 0), + avgPercent: Number(row?.avg_percent ?? 0), + }; + }), + ); +} diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 9386d1d249a..33c61098e52 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -23,6 +23,8 @@ interface TrackPayload { hostname?: string | null; userAgent?: string; occurredAt?: string; + action?: string; + percentWatched?: number | null; } const VIEW_TRACKING_DELAY_MS = 2 * 60 * 1000; @@ -85,6 +87,39 @@ export async function POST(request: NextRequest) { ""; const pathname = body.pathname ?? `/s/${body.videoId}`; + const action = body.action ?? "page_hit"; + + 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 tinybird = yield* Tinybird; + yield* tinybird.appendEvents([ + { + timestamp: new Date().toISOString(), + action: "video_progress", + version: "1.0", + session_id: sessionId ?? "anon", + video_id: body.videoId, + percent_watched: percentWatched, + }, + ]); + }), + ); + } + return Response.json({ success: true }); + } await runPromise( Effect.gen(function* () { diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index f3a45d62783..bfc1b469d95 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -134,6 +134,35 @@ const trackVideoView = (payload: { }); }; +const PROGRESS_MILESTONES = [25, 50, 75, 95] as const; + +const trackVideoProgress = (videoId: string, percentWatched: number) => { + if (typeof window === "undefined") return; + const sessionId = ensureAnalyticsSessionId(); + const body = JSON.stringify({ + videoId, + sessionId, + action: "video_progress", + percentWatched, + }); + if ( + typeof navigator !== "undefined" && + typeof navigator.sendBeacon === "function" + ) { + navigator.sendBeacon( + "/api/analytics/track", + new Blob([body], { type: "application/json" }), + ); + } else { + void fetch("/api/analytics/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: true, + }); + } +}; + type AiGenerationStatus = | "QUEUED" | "PROCESSING" @@ -338,6 +367,28 @@ export const Share = ({ }); }, [data.id, data.orgId, data.owner.id, viewerId]); + useEffect(() => { + if (viewerId && viewerId === data.owner.id) return; + const video = playerRef.current; + if (!video) return; + + const fired = new Set(); + + const onTimeUpdate = () => { + if (!video.duration || video.duration === 0) return; + const pct = (video.currentTime / video.duration) * 100; + for (const milestone of PROGRESS_MILESTONES) { + if (!fired.has(milestone) && pct >= milestone) { + fired.add(milestone); + trackVideoProgress(data.id, milestone); + } + } + }; + + video.addEventListener("timeupdate", onTimeUpdate); + return () => video.removeEventListener("timeupdate", onTimeUpdate); + }, [data.id, data.owner.id, viewerId]); + const isDisabled = (setting: ViewerSettingKey) => videoSettings?.[setting] ?? data.orgSettings?.[setting] ?? false; diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx index d4e8c5dde63..4388913e6aa 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx @@ -1,10 +1,41 @@ "use client"; import { use, useEffect, useMemo, useState } from "react"; -import { getVideoAnalytics } from "@/actions/videos/get-analytics"; +import { + getVideoAnalytics, + getVideoEngagement, +} from "@/actions/videos/get-analytics"; import { CapCardAnalytics } from "@/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics"; import type { CommentType } from "../../../Share"; +type EngagementData = Awaited>; + +const DropOffBar = ({ + label, + count, + total, +}: { + label: string; + count: number; + total: number; +}) => { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+
+ {label} + {count} +
+
+
+
+
+ ); +}; + const Analytics = (props: { videoId: string; views: MaybePromise; @@ -15,12 +46,12 @@ const Analytics = (props: { const [views, setViews] = useState( props.views instanceof Promise ? use(props.views) : props.views, ); + const [engagement, setEngagement] = useState(null); useEffect(() => { const fetchAnalytics = async () => { try { const result = await getVideoAnalytics(props.videoId); - setViews(result.count); } catch (error) { console.error("Error fetching analytics:", error); @@ -30,6 +61,13 @@ const Analytics = (props: { fetchAnalytics(); }, [props.videoId]); + useEffect(() => { + if (!props.isOwner) return; + getVideoEngagement(props.videoId) + .then(setEngagement) + .catch(() => {}); + }, [props.videoId, props.isOwner]); + const totalComments = useMemo( () => props.comments.filter((c) => c.type === "text").length, [props.comments], @@ -41,14 +79,48 @@ const Analytics = (props: { ); return ( - +
+ + {props.isOwner && engagement && engagement.total > 0 && ( +
+
+ Avg watched + + {engagement.avgPercent}% + +
+
+ + + + +
+
+ )} +
); }; diff --git a/packages/web-backend/src/Tinybird/index.ts b/packages/web-backend/src/Tinybird/index.ts index 39dd7a6ac02..0be7e8f1f8f 100644 --- a/packages/web-backend/src/Tinybird/index.ts +++ b/packages/web-backend/src/Tinybird/index.ts @@ -23,6 +23,7 @@ export interface TinybirdEventRow { browser?: string | null; device?: string | null; os?: string | null; + percent_watched?: number | null; } export class Tinybird extends Effect.Service()("Tinybird", { @@ -185,6 +186,7 @@ export class Tinybird extends Effect.Service()("Tinybird", { browser: row.browser ?? "unknown", device: row.device ?? "desktop", os: row.os ?? "unknown", + percent_watched: row.percent_watched ?? null, }), ) .join("\n"); From 9c5aca3afcb3be36e5f5ff7fde786b1976f2c853 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Mon, 8 Jun 2026 21:39:14 +0530 Subject: [PATCH 2/4] fix(analytics): add auth guard, skip owner self-views, robust player listener --- apps/web/actions/videos/get-analytics.ts | 14 +++++++++++++- apps/web/app/api/analytics/track/route.ts | 18 +++++++++++++++++- apps/web/app/s/[videoId]/Share.tsx | 19 ++++++++++++++----- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index dbbe88a57b8..252896c6176 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -1,6 +1,7 @@ "use server"; import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; import { Tinybird } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -98,7 +99,18 @@ export async function getVideoAnalytics( export async function getVideoEngagement(videoId: string) { if (!videoId) throw new Error("Video ID is required"); - const safeId = videoId.replace(/'/g, "''"); + const user = await getCurrentUser(); + if (!user?.id) throw new Error("Unauthorized"); + + const [video] = await db() + .select({ ownerId: videos.ownerId }) + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))) + .limit(1); + + if (!video || video.ownerId !== user.id) throw new Error("Unauthorized"); + + const safeId = escapeLiteral(videoId); return runPromise( Effect.gen(function* () { diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 33c61098e52..94267a07cbb 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -104,6 +104,22 @@ export async function POST(request: NextRequest) { 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([ { @@ -115,7 +131,7 @@ export async function POST(request: NextRequest) { percent_watched: percentWatched, }, ]); - }), + }).pipe(provideOptionalAuth), ); } return Response.json({ success: true }); diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index bfc1b469d95..d10304d5ec8 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -369,12 +369,11 @@ export const Share = ({ useEffect(() => { if (viewerId && viewerId === data.owner.id) return; - const video = playerRef.current; - if (!video) return; const fired = new Set(); - const onTimeUpdate = () => { + const onTimeUpdate = (e: Event) => { + const video = e.currentTarget as HTMLVideoElement; if (!video.duration || video.duration === 0) return; const pct = (video.currentTime / video.duration) * 100; for (const milestone of PROGRESS_MILESTONES) { @@ -385,8 +384,18 @@ export const Share = ({ } }; - video.addEventListener("timeupdate", onTimeUpdate); - return () => video.removeEventListener("timeupdate", onTimeUpdate); + 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); }, [data.id, data.owner.id, viewerId]); const isDisabled = (setting: ViewerSettingKey) => From c29503c5d28e1830145b4e186378271261f30677 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Mon, 8 Jun 2026 21:48:48 +0530 Subject: [PATCH 3/4] fix(analytics): validate videoId format instead of escapeLiteral for ClickHouse safety --- apps/web/actions/videos/get-analytics.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index 252896c6176..5d470bbd07d 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -110,7 +110,9 @@ export async function getVideoEngagement(videoId: string) { if (!video || video.ownerId !== user.id) throw new Error("Unauthorized"); - const safeId = escapeLiteral(videoId); + if (!/^[0-9a-zA-Z_-]+$/.test(videoId)) + throw new Error("Invalid video ID format"); + const safeId = videoId; return runPromise( Effect.gen(function* () { From 6fb2399a1a2afb0b894fac327f39717a663664ab Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 11 Jun 2026 17:14:56 +0530 Subject: [PATCH 4/4] fix(analytics): drop progress events for missing videos and avoid anon session collision --- apps/web/app/api/analytics/track/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 94267a07cbb..dee56c88f85 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { provideOptionalAuth, Tinybird } from "@cap/web-backend"; @@ -118,7 +119,7 @@ export async function POST(request: NextRequest) { .limit(1), ).pipe(Effect.orElseSucceed(() => [] as { ownerId: string }[])); - if (videoRecord && userId === videoRecord.ownerId) return; + if (!videoRecord || userId === videoRecord.ownerId) return; const tinybird = yield* Tinybird; yield* tinybird.appendEvents([ @@ -126,7 +127,7 @@ export async function POST(request: NextRequest) { timestamp: new Date().toISOString(), action: "video_progress", version: "1.0", - session_id: sessionId ?? "anon", + session_id: sessionId ?? randomUUID(), video_id: body.videoId, percent_watched: percentWatched, },