From 2dd87272a744502aac8e3dc9e198c9709b1421bf Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Mon, 8 Jun 2026 21:33:32 +0530 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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, }, From e99a8bf8a5e82244934fd1756518c9fc06693262 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Sun, 14 Jun 2026 23:01:40 +0530 Subject: [PATCH 5/8] fix(analytics): validate video id before db lookup and stop swallowing tinybird query errors --- apps/web/actions/videos/get-analytics.ts | 5 ++-- packages/web-backend/src/Tinybird/index.ts | 30 +++++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index 5d470bbd07d..f024f08efa1 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -99,6 +99,9 @@ export async function getVideoAnalytics( export async function getVideoEngagement(videoId: string) { if (!videoId) throw new Error("Video ID is required"); + if (!/^[0-9a-zA-Z_-]+$/.test(videoId)) + throw new Error("Invalid video ID format"); + const user = await getCurrentUser(); if (!user?.id) throw new Error("Unauthorized"); @@ -110,8 +113,6 @@ export async function getVideoEngagement(videoId: string) { if (!video || video.ownerId !== user.id) throw new Error("Unauthorized"); - if (!/^[0-9a-zA-Z_-]+$/.test(videoId)) - throw new Error("Invalid video ID format"); const safeId = videoId; return runPromise( diff --git a/packages/web-backend/src/Tinybird/index.ts b/packages/web-backend/src/Tinybird/index.ts index 0be7e8f1f8f..ba1f73602a4 100644 --- a/packages/web-backend/src/Tinybird/index.ts +++ b/packages/web-backend/src/Tinybird/index.ts @@ -247,24 +247,30 @@ export class Tinybird extends Effect.Service()("Tinybird", { console.log("Tinybird empty response", { path }); return { data: [] } as TinybirdResponse; } + let parsed: unknown; try { - const parsed = JSON.parse(textBody); - const normalizedRes: TinybirdResponse = Array.isArray(parsed) - ? ({ data: parsed } as TinybirdResponse) - : parsed && typeof parsed === "object" && "data" in parsed - ? (parsed as TinybirdResponse) - : ({ data: [parsed as T] } as TinybirdResponse); - if ((normalizedRes as TinybirdResponse).error) { - throw new Error( - (normalizedRes as TinybirdResponse).error as string, - ); - } - return normalizedRes; + parsed = JSON.parse(textBody); } catch { const aliases = extractAliases(normalized); const objects = parseTsvToObjects(textBody, aliases); return { data: objects } as TinybirdResponse; } + + const normalizedRes: TinybirdResponse = Array.isArray(parsed) + ? ({ data: parsed } as TinybirdResponse) + : parsed && typeof parsed === "object" && "data" in parsed + ? (parsed as TinybirdResponse) + : ({ data: [parsed as T] } as TinybirdResponse); + + if (normalizedRes.error) { + console.error("Tinybird query error", { + path, + error: normalizedRes.error, + }); + throw new Error(normalizedRes.error); + } + + return normalizedRes; }, catch: (cause) => cause as Error, }); From df58819cd97070b87801aba3046df55809bc8369 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Sun, 14 Jun 2026 23:10:08 +0530 Subject: [PATCH 6/8] fix(analytics): reuse sanitized session id and timestamp for video_progress, escape video id literal --- apps/web/actions/videos/get-analytics.ts | 2 +- apps/web/app/api/analytics/track/route.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index f024f08efa1..efd76f1dab8 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -113,7 +113,7 @@ export async function getVideoEngagement(videoId: string) { if (!video || video.ownerId !== user.id) throw new Error("Unauthorized"); - const safeId = videoId; + 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 dee56c88f85..58c81211c7c 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -91,10 +91,6 @@ export async function POST(request: NextRequest) { 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 && @@ -124,7 +120,7 @@ export async function POST(request: NextRequest) { const tinybird = yield* Tinybird; yield* tinybird.appendEvents([ { - timestamp: new Date().toISOString(), + timestamp: timestamp.toISOString(), action: "video_progress", version: "1.0", session_id: sessionId ?? randomUUID(), From 19b779c34c7b066306e783e991efaa5b3463c60a Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Sun, 14 Jun 2026 23:19:47 +0530 Subject: [PATCH 7/8] fix(share): remove dangling timeupdate listener when raf-deferred attach succeeds --- apps/web/app/s/[videoId]/Share.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index d10304d5ec8..3762a0e7d98 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -384,18 +384,23 @@ export const Share = ({ } }; + let cleanup: (() => void) | null = null; + const attach = () => { const video = playerRef.current; - if (!video) return null; + if (!video) return false; video.addEventListener("timeupdate", onTimeUpdate); - return () => video.removeEventListener("timeupdate", onTimeUpdate); + cleanup = () => video.removeEventListener("timeupdate", onTimeUpdate); + return true; }; - const detach = attach(); - if (detach) return detach; + let raf = 0; + if (!attach()) raf = requestAnimationFrame(() => attach()); - const raf = requestAnimationFrame(() => attach()); - return () => cancelAnimationFrame(raf); + return () => { + cancelAnimationFrame(raf); + cleanup?.(); + }; }, [data.id, data.owner.id, viewerId]); const isDisabled = (setting: ViewerSettingKey) => From 6f7c8b730b04ba2449eec0ec85c3442113ffe2ed Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Sun, 14 Jun 2026 23:26:44 +0530 Subject: [PATCH 8/8] fix(analytics): add percent_watched to tinybird schema, log engagement query errors, tag video_progress with tenant_id --- apps/web/actions/videos/get-analytics.ts | 9 +++++---- apps/web/app/api/analytics/track/route.ts | 1 + .../tinybird/datasources/analytics_events.datasource | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index efd76f1dab8..5af9644c051 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -131,8 +131,9 @@ export async function getVideoEngagement(videoId: string) { `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({ + Effect.catchAll((e) => { + console.error("tinybird engagement query error", e); + return Effect.succeed({ data: [] as { total: number; reached_25: number; @@ -141,8 +142,8 @@ export async function getVideoEngagement(videoId: string) { reached_95: number; avg_percent: number; }[], - }), - ), + }); + }), ); const row = result.data?.[0]; diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 58c81211c7c..b5149ff2e18 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -124,6 +124,7 @@ export async function POST(request: NextRequest) { action: "video_progress", version: "1.0", session_id: sessionId ?? randomUUID(), + tenant_id: videoRecord.ownerId, video_id: body.videoId, percent_watched: percentWatched, }, diff --git a/scripts/analytics/tinybird/datasources/analytics_events.datasource b/scripts/analytics/tinybird/datasources/analytics_events.datasource index 9c33cb1b069..fcca31bfb75 100644 --- a/scripts/analytics/tinybird/datasources/analytics_events.datasource +++ b/scripts/analytics/tinybird/datasources/analytics_events.datasource @@ -15,7 +15,8 @@ SCHEMA > city LowCardinality(String) `json:$.city` DEFAULT '', browser LowCardinality(String) `json:$.browser` DEFAULT 'unknown', device LowCardinality(String) `json:$.device` DEFAULT 'desktop', - os LowCardinality(String) `json:$.os` DEFAULT 'unknown' + os LowCardinality(String) `json:$.os` DEFAULT 'unknown', + percent_watched UInt8 `json:$.percent_watched` DEFAULT 0 ENGINE MergeTree ENGINE_PARTITION_KEY toYYYYMM(timestamp) @@ -24,4 +25,4 @@ ENGINE_TTL timestamp + INTERVAL 90 DAY ENGINE_SETTINGS index_granularity = 8192 FORWARD_QUERY > - SELECT timestamp, session_id, defaultValueOfTypeName('LowCardinality(String)') AS user_id, tenant_id, action, version, defaultValueOfTypeName('String') AS pathname, defaultValueOfTypeName('LowCardinality(String)') AS video_id, defaultValueOfTypeName('LowCardinality(String)') AS country, defaultValueOfTypeName('LowCardinality(String)') AS region, defaultValueOfTypeName('LowCardinality(String)') AS city, defaultValueOfTypeName('LowCardinality(String)') AS browser, defaultValueOfTypeName('LowCardinality(String)') AS device, defaultValueOfTypeName('LowCardinality(String)') AS os + SELECT timestamp, session_id, defaultValueOfTypeName('LowCardinality(String)') AS user_id, tenant_id, action, version, defaultValueOfTypeName('String') AS pathname, defaultValueOfTypeName('LowCardinality(String)') AS video_id, defaultValueOfTypeName('LowCardinality(String)') AS country, defaultValueOfTypeName('LowCardinality(String)') AS region, defaultValueOfTypeName('LowCardinality(String)') AS city, defaultValueOfTypeName('LowCardinality(String)') AS browser, defaultValueOfTypeName('LowCardinality(String)') AS device, defaultValueOfTypeName('LowCardinality(String)') AS os, defaultValueOfTypeName('UInt8') AS percent_watched