Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
71 changes: 71 additions & 0 deletions convex/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<void> {
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<string> {
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)}`;
}
1 change: 1 addition & 0 deletions convex/sitesHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions scripts/analytics.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
72 changes: 71 additions & 1 deletion server.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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
Expand All @@ -122,6 +136,7 @@ async function resolveSiteAssetUrl(
status: "active" | "missing" | "pending";
url?: string;
contentType?: string;
siteId?: string;
}>;
}

Expand All @@ -136,23 +151,77 @@ function missingSitePage(hostname: string): Response {
}

async function serveHostedSiteAsset(
request: Request,
hostname: string,
fileName: string
): Promise<Response> {
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<void> {
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<void> {
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<Response> {
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);
Expand Down Expand Up @@ -185,6 +254,7 @@ async function serveHostedSite(request: Request, hostname: string): Promise<Resp
if (!site.bucketUrl) return missingSitePage(hostname);
const bucketResponse = await fetch(site.bucketUrl);
if (!bucketResponse.ok) return missingSitePage(hostname);
void trackSiteView(request, hostname, site).catch(() => {});
return new Response(bucketResponse.body, {
headers: {
"Content-Type": site.contentType || "text/html; charset=utf-8",
Expand Down
Loading