- {seekState.isHovering && seekableEnd > 0 && (
+ {seekState.isHovering && (
- {!withoutTooltip &&
- !context.withoutTooltip &&
- seekState.isHovering &&
- seekableEnd > 0 && (
-
+ {!withoutTooltip && !context.withoutTooltip && seekState.isHovering && (
+
+
+ {thumbnail?.src && (
+
+ )}
+ {currentChapterCue && (
+
+ {currentChapterCue.text}
+
+ )}
- {thumbnail?.src && (
-
- {thumbnail.coords ? (
-
- ) : (
-

- )}
-
- )}
- {currentChapterCue && (
-
- {currentChapterCue.text}
-
- )}
-
- {tooltipTimeVariant === "progress"
- ? `${hoverTime} / ${duration}`
- : hoverTime}
-
+ {tooltipTimeVariant === "progress"
+ ? `${hoverTime} / ${duration}`
+ : hoverTime}
-
- )}
+
+
+ )}
+
+ ) : (
+
);
diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx
index 38329a3c4d..c39961054f 100644
--- a/apps/web/app/s/[videoId]/page.tsx
+++ b/apps/web/app/s/[videoId]/page.tsx
@@ -11,7 +11,6 @@ import {
videos,
videoUploads,
} from "@cap/database/schema";
-import type { VideoMetadata } from "@cap/database/types";
import { buildEnv } from "@cap/env";
import { Logo } from "@cap/ui";
import { userIsPro } from "@cap/utils";
@@ -22,13 +21,7 @@ import {
Videos,
} from "@cap/web-backend";
import { VideosPolicy } from "@cap/web-backend/src/Videos/VideosPolicy";
-import {
- Comment,
- type ImageUpload,
- type Organisation,
- Policy,
- type Video,
-} from "@cap/web-domain";
+import { Comment, type Organisation, Policy, Video } from "@cap/web-domain";
import { and, eq, type InferSelectModel, isNull, sql } from "drizzle-orm";
import { Effect, Option } from "effect";
import type { Metadata } from "next";
@@ -36,7 +29,6 @@ import { headers } from "next/headers";
import Link from "next/link";
import { notFound } from "next/navigation";
import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata";
-import { getVideoAnalytics } from "@/actions/videos/get-analytics";
import {
getDashboardData,
type OrganizationSettings,
@@ -50,6 +42,7 @@ import { isAiGenerationEnabled } from "@/utils/flags";
import { PasswordOverlay } from "./_components/PasswordOverlay";
import { ShareHeader } from "./_components/ShareHeader";
import { Share } from "./Share";
+import type { ShareAnalyticsContext } from "./types";
// Helper function to fetch shared spaces data for a video
async function getSharedSpacesForVideo(videoId: Video.VideoId) {
@@ -134,7 +127,7 @@ export async function generateMetadata(
Option.match({
onNone: () => notFound(),
onSome: ([video]) => ({
- title: video.name + " | Cap Recording",
+ title: `${video.name} | Cap Recording`,
description: "Watch this video on Cap",
openGraph: {
images: [
@@ -161,7 +154,7 @@ export async function generateMetadata(
},
twitter: {
card: "player",
- title: video.name + " | Cap Recording",
+ title: `${video.name} | Cap Recording`,
description: "Watch this video on Cap",
images: [
new URL(
@@ -254,7 +247,7 @@ export async function generateMetadata(
export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) {
const params = await props.params;
const searchParams = await props.searchParams;
- const videoId = params.videoId as Video.VideoId;
+ const videoId = Video.VideoId.make(params.videoId);
return Effect.gen(function* () {
const videosPolicy = yield* VideosPolicy;
@@ -321,6 +314,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) {
)),
Effect.catchTags({
+ // biome-ignore lint/correctness/noNestedComponentDefinitions: inline Effect handler returning JSX is fine here
PolicyDenied: () =>
Effect.succeed(
@@ -765,3 +799,52 @@ async function AuthorizedContent({
>
);
}
+
+type UserAgentDetails = {
+ device?: string;
+ browser?: string;
+ os?: string;
+};
+
+const deriveUserAgentDetails = (ua?: string | null): UserAgentDetails => {
+ if (!ua) return {};
+ const value = ua.toLowerCase();
+ const details: UserAgentDetails = {};
+
+ if (
+ /ipad|tablet/.test(value) ||
+ (/android/.test(value) && !/mobile/.test(value))
+ ) {
+ details.device = "tablet";
+ } else if (/mobi|iphone|ipod|android/.test(value)) {
+ details.device = "mobile";
+ } else if (/mac|win|linux|cros|x11/.test(value)) {
+ details.device = "desktop";
+ }
+
+ if (/chrome|crios/.test(value) && !/edge|edg\//.test(value)) {
+ details.browser = "chrome";
+ } else if (/safari/.test(value) && !/chrome|crios/.test(value)) {
+ details.browser = "safari";
+ } else if (/firefox/.test(value)) {
+ details.browser = "firefox";
+ } else if (/edg\//.test(value)) {
+ details.browser = "edge";
+ } else if (/msie|trident/.test(value)) {
+ details.browser = "ie";
+ }
+
+ if (/windows nt/.test(value)) {
+ details.os = "windows";
+ } else if (/mac os x/.test(value)) {
+ details.os = "mac";
+ } else if (/android/.test(value)) {
+ details.os = "android";
+ } else if (/iphone|ipad|ipod/.test(value)) {
+ details.os = "ios";
+ } else if (/linux/.test(value)) {
+ details.os = "linux";
+ }
+
+ return details;
+};
diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts
index 6bd2536f80..81c5ab0860 100644
--- a/apps/web/app/s/[videoId]/types.ts
+++ b/apps/web/app/s/[videoId]/types.ts
@@ -17,3 +17,19 @@ export type VideoOwner = {
name?: string | null;
image?: ImageUpload.ImageUrl | null;
};
+
+export type ShareAnalyticsContext = {
+ city?: string | null;
+ country?: string | null;
+ referrer?: string | null;
+ referrerUrl?: string | null;
+ utmSource?: string | null;
+ utmMedium?: string | null;
+ utmCampaign?: string | null;
+ utmTerm?: string | null;
+ utmContent?: string | null;
+ userAgent?: string | null;
+ device?: string | null;
+ browser?: string | null;
+ os?: string | null;
+};
diff --git a/apps/web/app/s/[videoId]/useShareAnalytics.ts b/apps/web/app/s/[videoId]/useShareAnalytics.ts
new file mode 100644
index 0000000000..bcb1acf271
--- /dev/null
+++ b/apps/web/app/s/[videoId]/useShareAnalytics.ts
@@ -0,0 +1,273 @@
+"use client";
+
+import type * as WebDomain from "@cap/web-domain";
+import type { Schema } from "effect/Schema";
+import { useCallback, useEffect, useRef } from "react";
+import type { ShareAnalyticsContext } from "./types";
+
+type CapturePayload = Schema.Type<
+ typeof WebDomain.VideoAnalytics.VideoCaptureEvent
+>;
+
+
+type ClientAnalyticsExtras = {
+ locale?: string;
+ language?: string;
+ timezone?: string;
+ pathname?: string;
+ href?: string;
+ referrer?: string;
+ userAgent?: string;
+};
+
+type Options = {
+ videoId: WebDomain.Video.VideoId;
+ analyticsContext: ShareAnalyticsContext;
+ videoElement: HTMLVideoElement | null;
+ enabled: boolean;
+};
+
+const ANALYTICS_ENDPOINT = "/api/video/analytics";
+const SESSION_STORAGE_KEY = "cap.analytics.session";
+const SESSION_TTL_MS = 30 * 60 * 1000;
+
+const randomId = () =>
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
+ ? crypto.randomUUID()
+ : Math.random().toString(36).slice(2);
+
+export function useShareAnalytics({
+ videoId,
+ analyticsContext,
+ videoElement,
+ enabled,
+}: Options) {
+ const analyticsRef = useRef(analyticsContext);
+ const videoIdRef = useRef(videoId);
+ const fallbackSessionRef = useRef
(null);
+ const sessionIdRef = useRef(null);
+ const clientDataRef = useRef(null);
+ const sentRef = useRef(false);
+ const watchStartRef = useRef(null);
+ const watchedMsRef = useRef(0);
+
+ analyticsRef.current = analyticsContext;
+ videoIdRef.current = videoId;
+
+ const accumulateWatchTime = useCallback(() => {
+ if (watchStartRef.current === null) return;
+ watchedMsRef.current += performance.now() - watchStartRef.current;
+ watchStartRef.current = null;
+ }, []);
+
+ const ensureSessionId = useCallback(() => {
+ if (typeof window === "undefined") {
+ if (!fallbackSessionRef.current)
+ fallbackSessionRef.current = randomId();
+ return fallbackSessionRef.current;
+ }
+
+ try {
+ const storedValue = window.localStorage.getItem(SESSION_STORAGE_KEY);
+ const now = Date.now();
+ if (storedValue) {
+ const parsed = JSON.parse(storedValue) as { value?: string; expiry?: number };
+ if (
+ parsed?.value &&
+ typeof parsed.value === "string" &&
+ typeof parsed.expiry === "number" &&
+ parsed.expiry > now
+ ) {
+ const refreshed = JSON.stringify({
+ value: parsed.value,
+ expiry: now + SESSION_TTL_MS,
+ });
+ window.localStorage.setItem(SESSION_STORAGE_KEY, refreshed);
+ return parsed.value;
+ }
+ }
+
+ const nextSessionId =
+ window.crypto?.randomUUID?.() ?? randomId();
+ const serialized = JSON.stringify({
+ value: nextSessionId,
+ expiry: now + SESSION_TTL_MS,
+ });
+ window.localStorage.setItem(SESSION_STORAGE_KEY, serialized);
+ return nextSessionId;
+ } catch {
+ if (!fallbackSessionRef.current) fallbackSessionRef.current = randomId();
+ return fallbackSessionRef.current;
+ }
+ }, []);
+
+ const getClientAnalytics = useCallback((): ClientAnalyticsExtras => {
+ if (clientDataRef.current) return clientDataRef.current;
+ if (typeof window === "undefined") return {};
+
+ const data: ClientAnalyticsExtras = {
+ locale:
+ (window.navigator.languages?.[0]) ||
+ window.navigator.language,
+ language: window.navigator.language,
+ timezone: (() => {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
+ } catch {
+ return undefined;
+ }
+ })(),
+ pathname: window.location.pathname,
+ href: window.location.href,
+ referrer: document.referrer || undefined,
+ userAgent: window.navigator.userAgent,
+ };
+
+ clientDataRef.current = data;
+ return data;
+ }, []);
+
+ const buildPayload = useCallback(
+ (watchTimeSeconds: number): CapturePayload => {
+ const analytics = analyticsRef.current;
+ const client = getClientAnalytics();
+ const normalizedWatch = Math.max(0, Math.trunc(watchTimeSeconds));
+ const sessionId = sessionIdRef.current ?? ensureSessionId();
+ const normalize = (value?: string | null) =>
+ value && value.trim().length > 0 ? value : undefined;
+
+ const city = normalize(analytics.city);
+ const country = normalize(analytics.country);
+ const device = normalize(analytics.device);
+ const browser = normalize(analytics.browser);
+ const os = normalize(analytics.os);
+
+ const clientReferrer = normalize(client.referrer);
+ const referrer = normalize(analytics.referrer) ?? clientReferrer;
+ const referrerUrl = normalize(analytics.referrerUrl) ?? clientReferrer;
+ const utmSource = normalize(analytics.utmSource);
+ const utmMedium = normalize(analytics.utmMedium);
+ const utmCampaign = normalize(analytics.utmCampaign);
+ const utmTerm = normalize(analytics.utmTerm);
+ const utmContent = normalize(analytics.utmContent);
+ const userAgent = normalize(analytics.userAgent) ?? normalize(client.userAgent);
+ const locale = normalize(client.locale);
+ const language = normalize(client.language);
+ const timezone = normalize(client.timezone);
+ const pathname = normalize(client.pathname);
+ const href = normalize(client.href);
+
+ return {
+ video: videoIdRef.current,
+ watchTimeSeconds: normalizedWatch > 0 ? normalizedWatch : 0,
+ ...(sessionId ? { sessionId } : {}),
+ ...(city ? { city } : {}),
+ ...(country ? { country } : {}),
+ ...(device ? { device } : {}),
+ ...(browser ? { browser } : {}),
+ ...(os ? { os } : {}),
+ ...(referrer ? { referrer } : {}),
+ ...(referrerUrl ? { referrerUrl } : {}),
+ ...(utmSource ? { utmSource } : {}),
+ ...(utmMedium ? { utmMedium } : {}),
+ ...(utmCampaign ? { utmCampaign } : {}),
+ ...(utmTerm ? { utmTerm } : {}),
+ ...(utmContent ? { utmContent } : {}),
+ ...(userAgent ? { userAgent } : {}),
+ ...(locale ? { locale } : {}),
+ ...(language ? { language } : {}),
+ ...(timezone ? { timezone } : {}),
+ ...(pathname ? { pathname } : {}),
+ ...(href ? { href } : {}),
+ } satisfies CapturePayload;
+ },
+ [getClientAnalytics, ensureSessionId],
+ );
+
+ const sendPayload = useCallback(
+ (reason: string) => {
+ if (!enabled || sentRef.current) return;
+
+ accumulateWatchTime();
+ const watchSeconds = watchedMsRef.current / 1000;
+ const payload = buildPayload(watchSeconds);
+ sentRef.current = true;
+ const body = JSON.stringify(payload);
+
+ const sendWithBeacon = () => {
+ if (typeof navigator === "undefined" || !navigator.sendBeacon) return false;
+ try {
+ const blob = new Blob([body], { type: "application/json" });
+ return navigator.sendBeacon(ANALYTICS_ENDPOINT, blob);
+ } catch {
+ return false;
+ }
+ };
+
+ const fallbackFetch = () =>
+ fetch(ANALYTICS_ENDPOINT, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body,
+ keepalive: reason !== "manual",
+ }).catch(() => undefined);
+
+ if (!sendWithBeacon()) void fallbackFetch();
+ }, [accumulateWatchTime, buildPayload, enabled]);
+
+ useEffect(() => {
+ if (!enabled) return;
+ sessionIdRef.current = ensureSessionId();
+ }, [enabled, ensureSessionId]);
+
+ useEffect(() => {
+ if (!enabled || !videoElement) return;
+
+ const handlePlay = () => {
+ if (document.hidden) return;
+ if (watchStartRef.current === null) watchStartRef.current = performance.now();
+ };
+ const handlePauseLike = () => accumulateWatchTime();
+ const handleVisibility = () => {
+ if (document.visibilityState === "hidden") {
+ accumulateWatchTime();
+ } else if (!videoElement.paused) {
+ handlePlay();
+ }
+ };
+
+ videoElement.addEventListener("playing", handlePlay);
+ videoElement.addEventListener("pause", handlePauseLike);
+ videoElement.addEventListener("ended", handlePauseLike);
+ videoElement.addEventListener("waiting", handlePauseLike);
+ videoElement.addEventListener("seeking", handlePauseLike);
+ document.addEventListener("visibilitychange", handleVisibility);
+
+ if (!videoElement.paused && !document.hidden) handlePlay();
+
+ return () => {
+ accumulateWatchTime();
+ videoElement.removeEventListener("playing", handlePlay);
+ videoElement.removeEventListener("pause", handlePauseLike);
+ videoElement.removeEventListener("ended", handlePauseLike);
+ videoElement.removeEventListener("waiting", handlePauseLike);
+ videoElement.removeEventListener("seeking", handlePauseLike);
+ document.removeEventListener("visibilitychange", handleVisibility);
+ };
+ }, [accumulateWatchTime, enabled, videoElement]);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const handlePageHide = () => sendPayload("pagehide");
+
+ window.addEventListener("pagehide", handlePageHide);
+ window.addEventListener("beforeunload", handlePageHide);
+
+ return () => {
+ window.removeEventListener("pagehide", handlePageHide);
+ window.removeEventListener("beforeunload", handlePageHide);
+ sendPayload("manual");
+ };
+ }, [enabled, sendPayload]);
+}
diff --git a/apps/web/lib/Requests/AnalyticsRequest.ts b/apps/web/lib/Requests/AnalyticsRequest.ts
index da475c5bb2..331cd5e765 100644
--- a/apps/web/lib/Requests/AnalyticsRequest.ts
+++ b/apps/web/lib/Requests/AnalyticsRequest.ts
@@ -19,7 +19,7 @@ export namespace AnalyticsRequest {
const requestResolver = RequestResolver.makeBatched(
(requests: NonEmptyArray) =>
- rpc.VideosGetAnalytics(requests.map((r) => r.videoId)).pipe(
+ rpc.VideosGetViewCount(requests.map((r) => r.videoId)).pipe(
Effect.flatMap(
// biome-ignore lint/suspicious/useIterableCallbackReturn: effect
Effect.forEach((result, index) =>
diff --git a/apps/web/lib/effect-react-query.ts b/apps/web/lib/effect-react-query.ts
index f9d0d651fd..bbf79f44d5 100644
--- a/apps/web/lib/effect-react-query.ts
+++ b/apps/web/lib/effect-react-query.ts
@@ -265,7 +265,6 @@ export function makeUseEffectMutation(
// })
// );
const result = await runtime.runPromiseExit(effectToRun);
- console.log({ result });
if (Exit.isFailure(result)) {
// we always throw the cause
throw result.cause;
diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts
index 3c93c45b7f..ca206f90b5 100644
--- a/apps/web/lib/server.ts
+++ b/apps/web/lib/server.ts
@@ -15,6 +15,7 @@ import {
SpacesPolicy,
Users,
Videos,
+ VideosAnalytics,
VideosPolicy,
VideosRepo,
Workflows,
@@ -113,6 +114,7 @@ export const Dependencies = Layer.mergeAll(
Videos.Default,
VideosPolicy.Default,
VideosRepo.Default,
+ VideosAnalytics.Default,
Folders.Default,
SpacesPolicy.Default,
OrganisationsPolicy.Default,
diff --git a/apps/web/utils/public-env.tsx b/apps/web/utils/public-env.tsx
index 9499992390..0ec2cf4a4e 100644
--- a/apps/web/utils/public-env.tsx
+++ b/apps/web/utils/public-env.tsx
@@ -6,6 +6,7 @@ type PublicEnvContext = {
webUrl: string;
googleAuthAvailable: boolean;
workosAuthAvailable: boolean;
+ analyticsAvailable: boolean;
};
const Context = createContext(null);
diff --git a/biome.json b/biome.json
index 621aba7608..e231fd0063 100644
--- a/biome.json
+++ b/biome.json
@@ -23,6 +23,9 @@
"recommended": true,
"suspicious": {
"noShadowRestrictedNames": "off"
+ },
+ "correctness": {
+ "useYield": "off"
}
}
},
diff --git a/packages/env/server.ts b/packages/env/server.ts
index 8adcc34922..7afb2a1f42 100644
--- a/packages/env/server.ts
+++ b/packages/env/server.ts
@@ -114,6 +114,8 @@ function createServerEnv() {
NODE_ENV: z.string(),
WORKFLOWS_RPC_URL: z.string().optional(),
WORKFLOWS_RPC_SECRET: z.string().optional(),
+ TINYBIRD_HOST: z.string().optional(),
+ TINYBIRD_TOKEN: z.string().optional(),
},
experimental__runtimeEnv: {
S3_PUBLIC_ENDPOINT: process.env.CAP_AWS_ENDPOINT,
diff --git a/packages/web-backend/package.json b/packages/web-backend/package.json
index 7da887abb1..3fc1e954ad 100644
--- a/packages/web-backend/package.json
+++ b/packages/web-backend/package.json
@@ -20,6 +20,7 @@
"@cap/database": "workspace:*",
"@cap/utils": "workspace:*",
"@cap/web-domain": "workspace:*",
+ "@cap/env": "workspace:*",
"@effect/cluster": "^0.50.4",
"@effect/platform": "^0.92.1",
"@effect/rpc": "^0.71.0",
diff --git a/packages/web-backend/src/Rpcs.ts b/packages/web-backend/src/Rpcs.ts
index 0b4fb06328..ce0ce86ea8 100644
--- a/packages/web-backend/src/Rpcs.ts
+++ b/packages/web-backend/src/Rpcs.ts
@@ -11,9 +11,11 @@ import { FolderRpcsLive } from "./Folders/FoldersRpcs.ts";
import { OrganisationsRpcsLive } from "./Organisations/OrganisationsRpcs.ts";
import { UsersRpcsLive } from "./Users/UsersRpcs.ts";
import { VideosRpcsLive } from "./Videos/VideosRpcs.ts";
+import { VideosAnalyticsRpcsLive } from "./VideosAnalytics/VideosAnalyticsRpcs.ts";
export const RpcsLive = Layer.mergeAll(
VideosRpcsLive,
+ VideosAnalyticsRpcsLive,
FolderRpcsLive,
UsersRpcsLive,
OrganisationsRpcsLive,
diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts
index dc4aa4de8d..7e090cd4de 100644
--- a/packages/web-backend/src/Videos/VideosRpcs.ts
+++ b/packages/web-backend/src/Videos/VideosRpcs.ts
@@ -73,9 +73,9 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer(
Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })),
),
- VideosGetThumbnails: (videoIds) =>
+ VideosGetThumbnails: (videoIds: ReadonlyArray) =>
Effect.all(
- videoIds.map((id) =>
+ videoIds.map((id: Video.VideoId) =>
videos.getThumbnailURL(id).pipe(
Effect.catchTag(
"DatabaseError",
@@ -98,49 +98,10 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer(
{ concurrency: 10 },
).pipe(
provideOptionalAuth,
- Effect.catchTag(
- "DatabaseError",
- () => new InternalError({ type: "database" }),
- ),
- Effect.catchTag(
- "UnknownException",
- () => new InternalError({ type: "unknown" }),
- ),
- ),
-
- VideosGetAnalytics: (videoIds) =>
- Effect.all(
- videoIds.map((id) =>
- videos.getAnalytics(id).pipe(
- Effect.catchTag(
- "DatabaseError",
- () => new InternalError({ type: "database" }),
- ),
- Effect.catchTag(
- "UnknownException",
- () => new InternalError({ type: "unknown" }),
- ),
- Effect.matchEffect({
- onSuccess: (v) => Effect.succeed(Exit.succeed(v)),
- onFailure: (e) =>
- Schema.is(InternalError)(e)
- ? Effect.fail(e)
- : Effect.succeed(Exit.fail(e)),
- }),
- Effect.map((v) => Unify.unify(v)),
- ),
- ),
- { concurrency: 10 },
- ).pipe(
- provideOptionalAuth,
- Effect.catchTag(
- "DatabaseError",
- () => new InternalError({ type: "database" }),
- ),
- Effect.catchTag(
- "UnknownException",
- () => new InternalError({ type: "unknown" }),
- ),
+ Effect.catchTags({
+ DatabaseError: () => new InternalError({ type: "database" }),
+ UnknownException: () => new InternalError({ type: "unknown" }),
+ }),
),
};
}),
diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts
index 49f7587705..7ecd0d8df7 100644
--- a/packages/web-backend/src/Videos/index.ts
+++ b/packages/web-backend/src/Videos/index.ts
@@ -1,9 +1,10 @@
import * as Db from "@cap/database/schema";
import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
import { dub } from "@cap/utils";
-import { CurrentUser, type Folder, Policy, Video } from "@cap/web-domain";
+import { CurrentUser, Folder, Policy, Video } from "@cap/web-domain";
+import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform";
import * as Dz from "drizzle-orm";
-import { Array, Context, Effect, Option, pipe } from "effect";
+import { Array, Effect, Option, pipe } from "effect";
import type { Schema } from "effect/Schema";
import { Database } from "../Database.ts";
@@ -30,6 +31,7 @@ export class Videos extends Effect.Service()("Videos", {
const repo = yield* VideosRepo;
const policy = yield* VideosPolicy;
const s3Buckets = yield* S3Buckets;
+ const client = yield* HttpClient.HttpClient;
const getByIdForViewing = (id: Video.VideoId) =>
repo
@@ -359,6 +361,23 @@ export class Videos extends Effect.Service()("Videos", {
return yield* Effect.fail(new Video.NotFoundError());
const [video] = maybeVideo.value;
+ console.log("HERE GET");
+ const token = serverEnv().TINYBIRD_TOKEN;
+ const host = serverEnv().TINYBIRD_HOST;
+ if (token && host) {
+ const response2 = yield* client.get(
+ `${host}/v0/pipes/video_views.json?token=${token}&video_id=${video.id}`,
+ );
+ if (response2.status !== 200) {
+ // TODO
+ }
+ console.log("ANALYTICS", response2.status, yield* response2.text);
+
+ // TODO: Effect schema
+ const result = JSON.parse(yield* response2.text);
+ return { count: result.data[0].count };
+ }
+
const response = yield* Effect.tryPromise(() =>
dub().analytics.retrieve({
domain: "cap.link",
@@ -371,6 +390,39 @@ export class Videos extends Effect.Service()("Videos", {
return { count: clicks };
}),
+
+ captureAnalytics: Effect.fn("Videos.captureAnalytics")(function* (
+ videoId: Video.VideoId,
+ ) {
+ const token = serverEnv().TINYBIRD_TOKEN;
+ const host = serverEnv().TINYBIRD_HOST;
+ if (!token || !host) return;
+
+ console.log("TINYBIRD EVENT"); // TODO
+ const response = yield* client.post(
+ `${host}/v0/events?name=analytics_views`,
+ {
+ body: HttpBody.unsafeJson({
+ timestamp: new Date().toISOString(),
+ version: "1",
+ session_id: "todo", // TODO
+ video_id: videoId,
+ payload: JSON.stringify({
+ hello: "world", // TODO
+ }),
+ }),
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ );
+ // const response = yield* HttpClientResponse.filterStatusOk(response);
+ if (response.status !== 200) {
+ // TODO
+ }
+
+ console.log(response.status, yield* response.text);
+ }),
};
}),
dependencies: [
@@ -378,5 +430,6 @@ export class Videos extends Effect.Service()("Videos", {
VideosRepo.Default,
Database.Default,
S3Buckets.Default,
+ FetchHttpClient.layer,
],
}) {}
diff --git a/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts b/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts
new file mode 100644
index 0000000000..491c6c15a8
--- /dev/null
+++ b/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts
@@ -0,0 +1,67 @@
+import { InternalError, VideoAnalytics } from "@cap/web-domain";
+import { Effect, Exit, Schema, Unify } from "effect";
+
+import { provideOptionalAuth } from "../Auth.ts";
+import { VideosAnalytics } from "./index.ts";
+
+export const VideosAnalyticsRpcsLive =
+ VideoAnalytics.VideoAnalyticsRpcs.toLayer(
+ Effect.gen(function* () {
+ const videosAnalytics = yield* VideosAnalytics;
+
+ return {
+ VideosGetViewCount: (videoIds) =>
+ Effect.all(
+ videoIds.map((id) =>
+ videosAnalytics.getViewCount(id).pipe(
+ Effect.catchTags({
+ DatabaseError: () => new InternalError({ type: "database" }),
+ RequestError: () =>
+ new InternalError({ type: "httpRequest" }),
+ ResponseError: () =>
+ new InternalError({ type: "httpResponse" }),
+ UnknownException: () =>
+ new InternalError({ type: "unknown" }),
+ }),
+ Effect.matchEffect({
+ onSuccess: (v) => Effect.succeed(Exit.succeed(v)),
+ onFailure: (e) =>
+ Schema.is(InternalError)(e)
+ ? Effect.fail(e)
+ : Effect.succeed(Exit.fail(e)),
+ }),
+ Effect.map((v) => Unify.unify(v)),
+ ),
+ ),
+ { concurrency: 10 },
+ ).pipe(
+ provideOptionalAuth,
+ Effect.catchTags({
+ DatabaseError: () => new InternalError({ type: "database" }),
+ UnknownException: () => new InternalError({ type: "unknown" }),
+ }),
+ ),
+
+ VideosGetAnalytics: (videoId) =>
+ videosAnalytics.getAnalytics(videoId).pipe(
+ provideOptionalAuth,
+ Effect.catchTags({
+ DatabaseError: () => new InternalError({ type: "database" }),
+ UnknownException: () => new InternalError({ type: "unknown" }),
+ }),
+ ),
+
+ VideosCaptureEvent: (event) =>
+ videosAnalytics.captureEvent(event).pipe(
+ provideOptionalAuth,
+ Effect.catchTags({
+ DatabaseError: () => new InternalError({ type: "database" }),
+ HttpBodyError: () => new InternalError({ type: "httpRequest" }),
+ RequestError: () => new InternalError({ type: "httpRequest" }),
+ ResponseError: () => new InternalError({ type: "httpResponse" }),
+ UnknownException: () => new InternalError({ type: "unknown" }),
+ }),
+ ),
+ };
+ }),
+ );
diff --git a/packages/web-backend/src/VideosAnalytics/index.ts b/packages/web-backend/src/VideosAnalytics/index.ts
new file mode 100644
index 0000000000..a07a9394ec
--- /dev/null
+++ b/packages/web-backend/src/VideosAnalytics/index.ts
@@ -0,0 +1,162 @@
+import { serverEnv } from "@cap/env";
+import { dub } from "@cap/utils";
+import { Policy, Video, VideoAnalytics } from "@cap/web-domain";
+import {
+ FetchHttpClient,
+ HttpBody,
+ HttpClient,
+ HttpClientResponse,
+} from "@effect/platform";
+import { Effect } from "effect";
+import { VideosPolicy } from "../Videos/VideosPolicy";
+import { VideosRepo } from "../Videos/VideosRepo";
+
+export class VideosAnalytics extends Effect.Service()(
+ "VideosAnalytics",
+ {
+ effect: Effect.gen(function* () {
+ const repo = yield* VideosRepo;
+ const policy = yield* VideosPolicy;
+ const client = yield* HttpClient.HttpClient;
+
+ const getByIdForViewing = (id: Video.VideoId) =>
+ repo
+ .getById(id)
+ .pipe(
+ Policy.withPublicPolicy(policy.canView(id)),
+ Effect.withSpan("VideosAnalytics.getById"),
+ );
+
+ return {
+ getViewCount: Effect.fn("VideosAnalytics.getViewCount")(function* (
+ videoId: Video.VideoId,
+ ) {
+ const [video] = yield* getByIdForViewing(videoId).pipe(
+ Effect.flatten,
+ Effect.catchTag(
+ "NoSuchElementException",
+ () => new Video.NotFoundError(),
+ ),
+ );
+
+ console.log("HERE GET");
+ const token = serverEnv().TINYBIRD_TOKEN;
+ const host = serverEnv().TINYBIRD_HOST;
+ if (token && host) {
+ const response2 = yield* client.get(
+ `${host}/v0/pipes/video_views.json?token=${token}&video_id=${video.id}`,
+ );
+ if (response2.status !== 200) {
+ // TODO
+ }
+ console.log("ANALYTICS", response2.status, yield* response2.text);
+
+ // TODO: Effect schema
+ const result = JSON.parse(yield* response2.text);
+ return { count: result.data[0].count };
+ }
+
+ const response = yield* Effect.tryPromise(() =>
+ dub().analytics.retrieve({
+ domain: "cap.link",
+ key: video.id,
+ }),
+ );
+ const { clicks } = response as { clicks: unknown };
+
+ if (typeof clicks !== "number" || clicks === null)
+ return { count: 0 };
+
+ return { count: clicks };
+ }),
+
+ getAnalytics: Effect.fn("VideosAnalytics.getAnalytics")(function* (
+ _videoId: Video.VideoId,
+ ) {
+ // TODO: Implement this
+
+ return VideoAnalytics.VideoAnalytics.make({
+ views: 0,
+ });
+ }),
+
+ captureEvent: Effect.fn("VideosAnalytics.captureEvent")(function* (
+ event: VideoAnalytics.VideoCaptureEvent,
+ ) {
+ const videoId = event.video;
+
+ const token = serverEnv().TINYBIRD_TOKEN;
+ const host = serverEnv().TINYBIRD_HOST;
+ if (!token || !host) return;
+
+ const toNullableString = (value?: string | null) =>
+ value && value.trim().length > 0 ? value : null;
+ const toNullableNumber = (value?: number | null) =>
+ typeof value === "number" && Number.isFinite(value) ? value : null;
+ const toNullableInt = (value?: number | null) =>
+ typeof value === "number" && Number.isFinite(value)
+ ? Math.trunc(value)
+ : null;
+
+ const watchTime = toNullableNumber(event.watchTimeSeconds) ?? 0;
+
+ const serializedPayload = JSON.stringify({
+ city: event.city ?? null,
+ country: event.country ?? null,
+ device: event.device ?? null,
+ browser: event.browser ?? null,
+ os: event.os ?? null,
+ referrer: toNullableString(event.referrer),
+ referrerUrl: toNullableString(event.referrerUrl),
+ utmSource: toNullableString(event.utmSource),
+ utmMedium: toNullableString(event.utmMedium),
+ utmCampaign: toNullableString(event.utmCampaign),
+ utmTerm: toNullableString(event.utmTerm),
+ utmContent: toNullableString(event.utmContent),
+ locale: toNullableString(event.locale),
+ language: toNullableString(event.language),
+ timezone: toNullableString(event.timezone),
+ pathname: toNullableString(event.pathname),
+ href: toNullableString(event.href),
+ userAgent: toNullableString(event.userAgent),
+ watchTimeSeconds: watchTime,
+ });
+
+ const payload = {
+ timestamp: new Date().toISOString(),
+ version: "1",
+ session_id: toNullableString(event.sessionId),
+ video_id: videoId,
+ watch_time_seconds: watchTime,
+ city: event.city ?? null,
+ country: event.country ?? null,
+ device: event.device ?? null,
+ browser: event.browser ?? null,
+ os: event.os ?? null,
+ referrer: toNullableString(event.referrer),
+ referrer_url: toNullableString(event.referrerUrl),
+ utm_source: toNullableString(event.utmSource),
+ utm_medium: toNullableString(event.utmMedium),
+ utm_campaign: toNullableString(event.utmCampaign),
+ utm_term: toNullableString(event.utmTerm),
+ utm_content: toNullableString(event.utmContent),
+ payload: serializedPayload,
+ };
+
+ yield* client
+ .post(`${host}/v0/events?name=analytics_views`, {
+ body: yield* HttpBody.json(payload),
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ }),
+ };
+ }),
+ dependencies: [
+ VideosPolicy.Default,
+ VideosRepo.Default,
+ FetchHttpClient.layer,
+ ],
+ },
+) {}
diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts
index b3b4382dcf..dfbb2e227c 100644
--- a/packages/web-backend/src/index.ts
+++ b/packages/web-backend/src/index.ts
@@ -15,4 +15,5 @@ export { Users } from "./Users/index.ts";
export { Videos } from "./Videos/index.ts";
export { VideosPolicy } from "./Videos/VideosPolicy.ts";
export { VideosRepo } from "./Videos/VideosRepo.ts";
+export { VideosAnalytics } from "./VideosAnalytics/index.ts";
export * as Workflows from "./Workflows.ts";
diff --git a/packages/web-domain/src/Errors.ts b/packages/web-domain/src/Errors.ts
index 597d8c3d90..d077b445c7 100644
--- a/packages/web-domain/src/Errors.ts
+++ b/packages/web-domain/src/Errors.ts
@@ -2,5 +2,13 @@ import { Schema } from "effect";
export class InternalError extends Schema.TaggedError()(
"InternalError",
- { type: Schema.Literal("database", "s3", "unknown") },
+ {
+ type: Schema.Literal(
+ "database",
+ "s3",
+ "httpRequest",
+ "httpResponse",
+ "unknown",
+ ),
+ },
) {}
diff --git a/packages/web-domain/src/Rpcs.ts b/packages/web-domain/src/Rpcs.ts
index f4106430c1..a4366695a6 100644
--- a/packages/web-domain/src/Rpcs.ts
+++ b/packages/web-domain/src/Rpcs.ts
@@ -4,9 +4,11 @@ import { FolderRpcs } from "./Folder.ts";
import { OrganisationRpcs } from "./Organisation.ts";
import { UserRpcs } from "./User.ts";
import { VideoRpcs } from "./Video.ts";
+import { VideoAnalyticsRpcs } from "./VideoAnalytics.ts";
export const Rpcs = RpcGroup.make().merge(
VideoRpcs,
+ VideoAnalyticsRpcs,
FolderRpcs,
UserRpcs,
OrganisationRpcs,
diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts
index 53112f6c61..9f49fb8582 100644
--- a/packages/web-domain/src/Video.ts
+++ b/packages/web-domain/src/Video.ts
@@ -226,21 +226,4 @@ export class VideoRpcs extends RpcGroup.make(
),
error: InternalError,
}),
- Rpc.make("VideosGetAnalytics", {
- payload: Schema.Array(VideoId).pipe(
- Schema.filter((a) => a.length <= 50 || "Maximum of 50 videos at a time"),
- ),
- success: Schema.Array(
- Schema.Exit({
- success: Schema.Struct({ count: Schema.Int }),
- failure: Schema.Union(
- NotFoundError,
- PolicyDeniedError,
- VerifyVideoPasswordError,
- ),
- defect: Schema.Unknown,
- }),
- ),
- error: InternalError,
- }),
) {}
diff --git a/packages/web-domain/src/VideoAnalytics.ts b/packages/web-domain/src/VideoAnalytics.ts
new file mode 100644
index 0000000000..5f56f88044
--- /dev/null
+++ b/packages/web-domain/src/VideoAnalytics.ts
@@ -0,0 +1,69 @@
+import { Rpc, RpcGroup } from "@effect/rpc";
+import { Schema } from "effect";
+import { InternalError } from "./Errors.ts";
+import { PolicyDeniedError } from "./Policy.ts";
+import { NotFoundError, VerifyVideoPasswordError, VideoId } from "./Video.ts";
+
+export class VideoAnalytics extends Schema.Class(
+ "VideoAnalytics",
+)({
+ views: Schema.Int,
+}) {}
+
+// TODO: Break into enum for page vs watch event
+export class VideoCaptureEvent extends Schema.Class(
+ "VideoCaptureEvent",
+)({
+ video: VideoId,
+ sessionId: Schema.optional(Schema.String),
+ city: Schema.optional(Schema.String),
+ country: Schema.optional(Schema.String),
+ device: Schema.optional(Schema.String),
+ browser: Schema.optional(Schema.String),
+ os: Schema.optional(Schema.String),
+ referrer: Schema.optional(Schema.String),
+ referrerUrl: Schema.optional(Schema.String),
+ utmSource: Schema.optional(Schema.String),
+ utmMedium: Schema.optional(Schema.String),
+ utmCampaign: Schema.optional(Schema.String),
+ utmTerm: Schema.optional(Schema.String),
+ utmContent: Schema.optional(Schema.String),
+ watchTimeSeconds: Schema.optional(Schema.Int),
+ locale: Schema.optional(Schema.String),
+ language: Schema.optional(Schema.String),
+ timezone: Schema.optional(Schema.String),
+ pathname: Schema.optional(Schema.String),
+ href: Schema.optional(Schema.String),
+ userAgent: Schema.optional(Schema.String),
+}) {}
+
+export class VideoAnalyticsRpcs extends RpcGroup.make(
+ Rpc.make("VideosGetViewCount", {
+ payload: Schema.Array(VideoId).pipe(
+ Schema.filter((a) => a.length <= 50 || "Maximum of 50 videos at a time"),
+ ),
+ success: Schema.Array(
+ Schema.Exit({
+ success: Schema.Struct({ count: Schema.Int }),
+ failure: Schema.Union(
+ NotFoundError,
+ PolicyDeniedError,
+ VerifyVideoPasswordError, // TODO: Is this correct?
+ ),
+ defect: Schema.Unknown,
+ }),
+ ),
+ error: InternalError,
+ }),
+
+ Rpc.make("VideosGetAnalytics", {
+ payload: VideoId,
+ success: VideoAnalytics,
+ error: InternalError,
+ }),
+
+ Rpc.make("VideosCaptureEvent", {
+ payload: VideoCaptureEvent,
+ error: InternalError,
+ }),
+) {}
diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts
index 76c7755801..93abfc9ca9 100644
--- a/packages/web-domain/src/index.ts
+++ b/packages/web-domain/src/index.ts
@@ -18,3 +18,4 @@ export { SpaceMemberId, SpaceMemberRole } from "./Space.ts";
export * as User from "./User.ts";
export { UserId } from "./User.ts";
export * as Video from "./Video.ts";
+export * as VideoAnalytics from "./VideoAnalytics.ts";
diff --git a/tinybird/datasources/analytics_sessions.datasource b/tinybird/datasources/analytics_sessions.datasource
new file mode 100644
index 0000000000..5c04350d88
--- /dev/null
+++ b/tinybird/datasources/analytics_sessions.datasource
@@ -0,0 +1,18 @@
+DESCRIPTION >
+ Sessions
+
+SCHEMA >
+ `date` Date,
+ `session_id` String,
+ `tenant_id` String,
+ `domain` String,
+ `device` SimpleAggregateFunction(any, String),
+ `browser` SimpleAggregateFunction(any, String),
+ `location` SimpleAggregateFunction(any, String),
+ `first_hit` SimpleAggregateFunction(min, DateTime),
+ `latest_hit` SimpleAggregateFunction(max, DateTime),
+ `hits` AggregateFunction(count)
+
+ENGINE AggregatingMergeTree
+ENGINE_PARTITION_KEY toYYYYMM(date)
+ENGINE_SORTING_KEY tenant_id, domain, date, session_id
diff --git a/tinybird/datasources/analytics_views.datasource b/tinybird/datasources/analytics_views.datasource
new file mode 100644
index 0000000000..8ea9ddfeef
--- /dev/null
+++ b/tinybird/datasources/analytics_views.datasource
@@ -0,0 +1,32 @@
+DESCRIPTION >
+ Analytics events for when the share page is opened.
+
+TOKEN "dashboard" APPEND
+
+SCHEMA >
+ `timestamp` DateTime `json:$.timestamp`,
+ `version` LowCardinality(String) `json:$.version`,
+
+ `session_id` Nullable(String) `json:$.session_id`,
+ `video_id` String `json:$.video_id`,
+
+ `watch_time_seconds` Float32 `json:$.watch_time_seconds`,
+
+ `city` LowCardinality(String) `json:$.city`,
+ `country` LowCardinality(String) `json:$.country`,
+ `device` LowCardinality(String) `json:$.device`,
+ `browser` LowCardinality(String) `json:$.browser`,
+ `os` LowCardinality(String) `json:$.os`,
+ `referrer` LowCardinality(String) `json:$.referrer`,
+ `referrer_url` String `json:$.referrer_url`,
+ `utm_source` LowCardinality(String) `json:$.utm_source`,
+ `utm_medium` LowCardinality(String) `json:$.utm_medium`,
+ `utm_campaign` LowCardinality(String) `json:$.utm_campaign`,
+ `utm_term` LowCardinality(String) `json:$.utm_term`,
+ `utm_content` LowCardinality(String) `json:$.utm_content`,
+
+ `payload` String `json:$.payload`
+
+ENGINE MergeTree
+ENGINE_PARTITION_KEY toYYYYMM(timestamp)
+ENGINE_SORTING_KEY video_id, timestamp
diff --git a/tinybird/datasources/analytics_watch.datasource b/tinybird/datasources/analytics_watch.datasource
new file mode 100644
index 0000000000..3af4baa8dd
--- /dev/null
+++ b/tinybird/datasources/analytics_watch.datasource
@@ -0,0 +1,19 @@
+DESCRIPTION >
+ Analytics events for when a view is being viewed in the player.
+
+TOKEN "dashboard" APPEND
+
+SCHEMA >
+ `timestamp` DateTime `json:$.timestamp`,
+ `version` LowCardinality(String) `json:$.version`,
+
+ `session_id` Nullable(String) `json:$.session_id`,
+ `video_id` String `json:$.video_id`,
+
+ `action` LowCardinality(String) `json:$.action`,
+
+ `ts` DateTime64(3) `json:$.ts`,
+
+ENGINE MergeTree
+ENGINE_PARTITION_KEY toYYYYMM(timestamp)
+ENGINE_SORTING_KEY video_id, action, timestamp
diff --git a/tinybird/endpoints/video_views.pipe b/tinybird/endpoints/video_views.pipe
new file mode 100644
index 0000000000..db25008418
--- /dev/null
+++ b/tinybird/endpoints/video_views.pipe
@@ -0,0 +1,9 @@
+TOKEN dashboard READ
+DESCRIPTION endpoint to get views for video
+
+NODE result
+SQL >
+ %
+ SELECT count(*) as count FROM analytics_views
+ WHERE video_id = {{String(video_id)}}
+TYPE ENDPOINT