diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index ed1b00a..c18bc33 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -35,6 +35,7 @@ import type * as feedback from "../feedback.js"; import type * as http from "../http.js"; import type * as items from "../items.js"; import type * as itemsHttp from "../itemsHttp.js"; +import type * as lib_analytics from "../lib/analytics.js"; import type * as lib_auth from "../lib/auth.js"; import type * as lib_authUser from "../lib/authUser.js"; import type * as lib_bucket from "../lib/bucket.js"; @@ -101,6 +102,7 @@ declare const fullApi: ApiFromModules<{ http: typeof http; items: typeof items; itemsHttp: typeof itemsHttp; + "lib/analytics": typeof lib_analytics; "lib/auth": typeof lib_auth; "lib/authUser": typeof lib_authUser; "lib/bucket": typeof lib_bucket; diff --git a/convex/lib/analytics.ts b/convex/lib/analytics.ts new file mode 100644 index 0000000..859b645 --- /dev/null +++ b/convex/lib/analytics.ts @@ -0,0 +1,71 @@ +/** + * Server-side PostHog capture. Reads POSTHOG_KEY / POSTHOG_HOST (with VITE_* + * fallback). No-ops silently when unconfigured. Fire-and-forget — never blocks + * or throws into the caller. + */ + +export type CaptureInput = { + event: string; + distinctId: string; + properties?: Record; + timestamp?: Date; +}; + +function readConfig(): { apiKey: string; host: string } | null { + const apiKey = + process.env.POSTHOG_KEY || + process.env.VITE_POSTHOG_KEY || + ""; + const host = ( + process.env.POSTHOG_HOST || + process.env.VITE_POSTHOG_HOST || + "https://us.i.posthog.com" + ).replace(/\/+$/, ""); + if (!apiKey) return null; + return { apiKey, host }; +} + +export async function captureEvent(input: CaptureInput): Promise { + const cfg = readConfig(); + if (!cfg) return; + try { + const body = JSON.stringify({ + api_key: cfg.apiKey, + event: input.event, + distinct_id: input.distinctId, + properties: { + $lib: "boop-server", + $process_person_profile: false, + ...(input.properties ?? {}), + }, + timestamp: (input.timestamp ?? new Date()).toISOString(), + }); + const res = await fetch(`${cfg.host}/capture/`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + // PostHog returns 200 with { status: 1 } on success. + if (!res.ok && process.env.POSTHOG_DEBUG) { + console.warn(`[posthog] capture ${input.event} -> ${res.status}`); + } + } catch (err) { + if (process.env.POSTHOG_DEBUG) { + console.warn("[posthog] capture failed", err); + } + } +} + +/** + * Stable, daily-rotating anonymous id for a visitor. Same IP+UA on the same + * UTC day -> same distinct_id. No cross-day linkage, so no long-term tracking. + */ +export async function anonDistinctId(ip: string, ua: string, now: Date = new Date()): Promise { + const day = now.toISOString().slice(0, 10); + const input = `${ip}|${ua}|${day}`; + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + const hex = Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return `anon_${hex.slice(0, 24)}`; +} diff --git a/convex/sitesHttp.ts b/convex/sitesHttp.ts index ae94b38..dcc0133 100644 --- a/convex/sitesHttp.ts +++ b/convex/sitesHttp.ts @@ -114,6 +114,7 @@ export const resolveSiteAsset = httpAction(async (ctx, request) => { const presigned = await presignGet(asset.bucketKey, { expiresSec: 300 }); return json({ status: "active", + siteId: record.site._id, contentType: asset.contentType, byteLength: asset.byteLength, sha256: asset.sha256, diff --git a/scripts/analytics.test.mjs b/scripts/analytics.test.mjs new file mode 100644 index 0000000..f9aa3d8 --- /dev/null +++ b/scripts/analytics.test.mjs @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/analytics-test"; + +async function loadAnalytics(env = {}) { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["./convex/lib/analytics.ts"], + outfile: `${outdir}/analytics.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + for (const k of ["POSTHOG_KEY", "POSTHOG_HOST", "VITE_POSTHOG_KEY", "VITE_POSTHOG_HOST", "POSTHOG_DEBUG"]) { + delete process.env[k]; + } + for (const [k, v] of Object.entries(env)) process.env[k] = v; + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/analytics.mjs`).href}?t=${Date.now()}` + ); +} + +function stubFetch(handler) { + const calls = []; + globalThis.fetch = async (input, init = {}) => { + const url = typeof input === "string" ? input : input.url; + calls.push({ url, init }); + return handler(input, init, calls.length); + }; + return calls; +} + +test("captureEvent posts to /capture with the right payload", async () => { + const m = await loadAnalytics({ + POSTHOG_KEY: "phc_test", + POSTHOG_HOST: "https://example.posthog.com", + }); + const calls = stubFetch(async () => new Response('{"status":1}', { status: 200 })); + await m.captureEvent({ + event: "site_view", + distinctId: "anon_abc", + properties: { siteId: "s1", hostname: "x.boop.ad" }, + timestamp: new Date("2026-05-22T12:34:56.000Z"), + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://example.posthog.com/capture/"); + assert.equal(calls[0].init.method, "POST"); + const body = JSON.parse(calls[0].init.body); + assert.equal(body.api_key, "phc_test"); + assert.equal(body.event, "site_view"); + assert.equal(body.distinct_id, "anon_abc"); + assert.equal(body.properties.siteId, "s1"); + assert.equal(body.properties.hostname, "x.boop.ad"); + assert.equal(body.properties.$lib, "boop-server"); + assert.equal(body.properties.$process_person_profile, false); + assert.equal(body.timestamp, "2026-05-22T12:34:56.000Z"); +}); + +test("captureEvent no-ops silently when no api key", async () => { + const m = await loadAnalytics({}); + const calls = stubFetch(async () => new Response("nope", { status: 500 })); + await m.captureEvent({ event: "site_view", distinctId: "anon_abc" }); + assert.equal(calls.length, 0); +}); + +test("captureEvent falls back to VITE_POSTHOG_* and default host", async () => { + const m = await loadAnalytics({ + VITE_POSTHOG_KEY: "phc_vite", + }); + const calls = stubFetch(async () => new Response('{"status":1}', { status: 200 })); + await m.captureEvent({ event: "site_view", distinctId: "anon_abc" }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://us.i.posthog.com/capture/"); + const body = JSON.parse(calls[0].init.body); + assert.equal(body.api_key, "phc_vite"); +}); + +test("captureEvent swallows fetch errors", async () => { + const m = await loadAnalytics({ POSTHOG_KEY: "phc_test" }); + globalThis.fetch = async () => { + throw new Error("network down"); + }; + await m.captureEvent({ event: "site_view", distinctId: "anon_abc" }); + // Should not throw — no assertion needed beyond reaching this line. +}); + +test("anonDistinctId is stable across same day, different across days", async () => { + const m = await loadAnalytics({}); + const day1a = await m.anonDistinctId("1.2.3.4", "Mozilla/5.0", new Date("2026-05-22T01:00:00Z")); + const day1b = await m.anonDistinctId("1.2.3.4", "Mozilla/5.0", new Date("2026-05-22T23:00:00Z")); + const day2 = await m.anonDistinctId("1.2.3.4", "Mozilla/5.0", new Date("2026-05-23T01:00:00Z")); + assert.equal(day1a, day1b); + assert.notEqual(day1a, day2); + assert.match(day1a, /^anon_[0-9a-f]{24}$/); +}); + +test("anonDistinctId differs by IP", async () => { + const m = await loadAnalytics({}); + const a = await m.anonDistinctId("1.2.3.4", "ua", new Date("2026-05-22T00:00:00Z")); + const b = await m.anonDistinctId("5.6.7.8", "ua", new Date("2026-05-22T00:00:00Z")); + assert.notEqual(a, b); +}); diff --git a/server.ts b/server.ts index 3c30b9d..c6231cd 100644 --- a/server.ts +++ b/server.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { join, normalize } from "node:path"; +import { anonDistinctId, captureEvent } from "./convex/lib/analytics"; const port = Number(process.env.PORT || 3000); const distDir = join(process.cwd(), "dist"); @@ -103,9 +104,22 @@ async function resolveHostedSite(hostname: string) { bucketUrl?: string; contentType?: string; didLogJsonl?: string; + siteId?: string; + did?: string; + scid?: string; }>; } + +function clientIpFromRequest(request: Request): string { + return ( + request.headers.get("cf-connecting-ip") || + request.headers.get("x-real-ip") || + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + "unknown" + ); +} + async function resolveSiteAssetUrl( hostname: string, fileName: string @@ -122,6 +136,7 @@ async function resolveSiteAssetUrl( status: "active" | "missing" | "pending"; url?: string; contentType?: string; + siteId?: string; }>; } @@ -136,23 +151,77 @@ function missingSitePage(hostname: string): Response { } async function serveHostedSiteAsset( + request: Request, hostname: string, fileName: string ): Promise { const asset = await resolveSiteAssetUrl(hostname, fileName); if (asset.status === "active" && asset.url) { + void trackAssetView(request, hostname, fileName, asset).catch(() => {}); return Response.redirect(asset.url, 302); } return missingSitePage(hostname); } +async function trackSiteView( + request: Request, + hostname: string, + site: { siteId?: string; did?: string; scid?: string; contentType?: string } +): Promise { + const ip = clientIpFromRequest(request); + const ua = request.headers.get("user-agent") ?? ""; + const referrer = request.headers.get("referer") ?? ""; + const distinctId = await anonDistinctId(ip, ua); + await captureEvent({ + event: "site_view", + distinctId, + properties: { + $current_url: `https://${hostname}/`, + $referrer: referrer, + $ip: ip, + $useragent: ua, + site_id: site.siteId, + site_hostname: hostname, + site_did: site.did, + site_scid: site.scid, + content_type: site.contentType, + }, + }); +} + +async function trackAssetView( + request: Request, + hostname: string, + fileName: string, + asset: { siteId?: string; contentType?: string } +): Promise { + const ip = clientIpFromRequest(request); + const ua = request.headers.get("user-agent") ?? ""; + const referrer = request.headers.get("referer") ?? ""; + const distinctId = await anonDistinctId(ip, ua); + await captureEvent({ + event: "site_asset_view", + distinctId, + properties: { + $current_url: `https://${hostname}/_assets/${fileName}`, + $referrer: referrer, + $ip: ip, + $useragent: ua, + site_id: asset.siteId, + site_hostname: hostname, + file_name: fileName, + content_type: asset.contentType, + }, + }); +} + async function serveHostedSite(request: Request, hostname: string): Promise { const url = new URL(request.url); if (url.pathname.startsWith("/_assets/")) { const fileName = decodeURIComponent(url.pathname.slice("/_assets/".length)); if (!fileName || fileName.includes("/")) return missingSitePage(hostname); - return serveHostedSiteAsset(hostname, fileName); + return serveHostedSiteAsset(request, hostname, fileName); } const site = await resolveHostedSite(hostname); @@ -185,6 +254,7 @@ async function serveHostedSite(request: Request, hostname: string): Promise {}); return new Response(bucketResponse.body, { headers: { "Content-Type": site.contentType || "text/html; charset=utf-8",