import { env, waitUntil } from "cloudflare:workers"; import { scrubErrorMessage } from "~/lib/scrub-error"; import { first } from "./db"; import { isLoopbackUrl } from "./deployment"; /** * 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; /** * Analytics runs only in production: never under `vite dev`/preview (GITFOR_LOCAL), never on a loopback * PUBLIC_URL, and never when GITFOR_ENV names another environment (staging, test). */ function productionDeployment(): boolean { if (env.GITFOR_LOCAL === "true" || isLoopbackUrl(env.PUBLIC_URL)) return false; const name = (env.GITFOR_ENV ?? "").trim().toLowerCase(); return name === "" || name === "production"; } export function analyticsConfig(): AnalyticsConfig | null { if (!productionDeployment()) return 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; export type CaptureOptions = { /** Account owning the subject (repo owner, billed account); org accounts become the event's `org` group. */ ownerAccountId?: string | null; }; async function orgGroup(accountId: string | null | undefined): Promise | null> { if (!accountId) return null; const row = await first<{ kind: string }>("SELECT kind FROM accounts WHERE id = ?", accountId).catch(() => null); return row?.kind === "org" ? { org: accountId } : 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. * Org-scoped events carry `$groups: { org: }` for PostHog group analytics. */ export function captureServerEvent( event: string, distinctId: string | null, properties: Record = {}, options: CaptureOptions = {}, ): void { const config = analyticsConfig(); if (!config) return; const timestamp = new Date().toISOString(); const send = orgGroup(options.ownerAccountId) .then((groups) => fetch(`${config.host}/i/v0/e/`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ api_key: config.key, event, distinct_id: distinctId ?? "gitfor-server", timestamp, properties: { ...properties, $lib: "gitfor-server", ...(groups ? { $groups: groups } : {}), ...(distinctId ? {} : { $process_person_profile: false }), }, }), 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. } } /** Unexpected Worker errors: type and a scrubbed message only, never stacks, URLs, emails or tokens. */ export function captureServerException(error: unknown, source: string): void { captureServerEvent("$exception", null, { $exception_type: error instanceof Error ? error.name : typeof error, $exception_message: error instanceof Error ? scrubErrorMessage(error.message) : "", $exception_source: source, }); }