import { env, waitUntil } from "cloudflare:workers"; /** * Product analytics (PostHog), optional like Stripe: off unless POSTHOG_KEY (or the VITE_POSTHOG_PROJECT_TOKEN * alias) is set. Server events carry no PII: distinct ids are opaque account ids, and properties are enums * and counts only, never emails, names, private repository names or paths. */ export type AnalyticsConfig = { key: string; host: string }; const DEFAULT_HOST = "https://us.i.posthog.com"; const CAPTURE_TIMEOUT_MS = 3000; export function analyticsConfig(): AnalyticsConfig | null { const key = (env.POSTHOG_KEY || env.VITE_POSTHOG_PROJECT_TOKEN || "").trim(); if (!/^phc_[A-Za-z0-9]+$/.test(key)) return null; const raw = (env.POSTHOG_HOST || env.VITE_POSTHOG_HOST || DEFAULT_HOST).trim(); try { const url = new URL(raw); if (url.protocol !== "https:" || url.username || url.password) return null; return { key, host: url.origin }; } catch { return null; } } /** What the browser needs to start PostHog; null when analytics is off. */ export function clientAnalyticsConfig(): { key: string; host: string; origin: string; personEmail: boolean } | null { const config = analyticsConfig(); if (!config) return null; try { return { ...config, origin: new URL(env.PUBLIC_URL).origin, personEmail: env.POSTHOG_PERSON_EMAIL === "true" }; } catch { return null; } } type Value = string | number | boolean | null; /** * Fire-and-forget server event. Runs under waitUntil with a short timeout and swallows every error, so it can * never block or fail the request that triggered it. Pass `distinctId: null` for events not tied to an account. */ export function captureServerEvent( event: string, distinctId: string | null, properties: Record = {}, ): void { const config = analyticsConfig(); if (!config) return; const body = JSON.stringify({ api_key: config.key, event, distinct_id: distinctId ?? "gitfor-server", timestamp: new Date().toISOString(), properties: { ...properties, $lib: "gitfor-server", ...(distinctId ? {} : { $process_person_profile: false }) }, }); const send = fetch(`${config.host}/i/v0/e/`, { method: "POST", headers: { "content-type": "application/json" }, body, redirect: "manual", signal: AbortSignal.timeout(CAPTURE_TIMEOUT_MS), }) .then((response) => void response.body?.cancel()) .catch(() => undefined); try { waitUntil(send); } catch { // Outside a request context (tests, scripts): the promise still settles on its own. } }