/** * Browser analytics (PostHog). Everything here is a no-op unless the server sent a config, which it only does * when POSTHOG_KEY is set. posthog-js is loaded lazily, so instances without analytics never download it. * * Privacy rules enforced here: * - Honors Do Not Track and Global Privacy Control, and a per-browser opt-out (setAnalyticsOptOut). * - Runs only on the forge's own origin, never on editor, user-content, sites or pages origins. * - URLs lose query strings and fragments; repository paths are redacted unless the repo is known public. * - Autocapture never records element text or attributes; inputs are masked in replays, and replays pause on * private repositories and on account, settings and auth pages. */ import type { PostHog } from "posthog-js"; import { scrubErrorMessage } from "./scrub-error"; import { APP_ROOTS, isRouteTemplate, routeTemplate } from "./route-template"; export type ClientAnalyticsConfig = { key: string; host: string; origin: string; personEmail: boolean }; export type AnalyticsViewer = { id: string; email?: string; isAdmin?: boolean } | null; const OPT_OUT_KEY = "gitfor:analytics-opt-out"; export const INGEST_PREFIX = "/ingest"; /** PostHog's app host for toolbar links: us.i.posthog.com -> us.posthog.com. */ function uiHost(host: string): string { return host.replace(/^https:\/\/(us|eu)\.i\.posthog\.com$/, "https://$1.posthog.com"); } const REDACTED = "[private]"; const NO_RECORDING = [ /^\/settings(\/|$)/, /^\/admin(\/|$)/, /^\/(login|signup|reset-password|forgot-password|verify-email)/, /^\/[^/]+\/[^/]+\/settings(\/|$)/, /^\/orgs\/[^/]+\/settings/, ]; let client: PostHog | null = null; let loading: Promise | null = null; const publicRepos = new Set(); function repoKey(owner: string, repo: string): string { return `${owner.toLowerCase()}/${repo.toLowerCase()}`; } /** Called by the repository layout once it knows the repo's visibility; only public repos are recorded. */ export function setRepoVisibility(owner: string, repo: string, isPrivate: boolean): void { const key = repoKey(owner, repo); if (isPrivate) publicRepos.delete(key); else publicRepos.add(key); syncRecording(); } function isRepoPath(pathname: string): { owner: string; repo: string } | null { const [, owner = "", repo] = pathname.split("/"); if (APP_ROOTS.has(owner.toLowerCase()) || !repo) return null; return { owner, repo }; } /** A pathname safe to send: repository paths keep only their section unless the repo is known public. */ export function sanitizePath(pathname: string): string { const target = isRepoPath(pathname); if (!target || publicRepos.has(repoKey(target.owner, target.repo))) return pathname; const section = pathname.split("/")[3]; return `/${REDACTED}/${REDACTED}${section ? `/${section}` : ""}`; } /** Replay text is shown only on public repository pages, and never inside elements marked data-ph-mask. */ function textVisibleInReplay(element: HTMLElement | undefined): boolean { if (element?.closest("[data-ph-mask]")) return false; const target = isRepoPath(window.location.pathname); return target !== null && publicRepos.has(repoKey(target.owner, target.repo)); } /** Replays pause on sensitive pages and on any repository not yet known to be public. */ function recordingBlocked(pathname: string): boolean { if (NO_RECORDING.some((pattern) => pattern.test(pathname))) return true; const target = isRepoPath(pathname); return target !== null && !publicRepos.has(repoKey(target.owner, target.repo)); } /** Drops query and fragment, and redacts repository paths on this origin; other origins keep only their host. */ export function sanitizeUrl(raw: unknown, origin: string): unknown { if (typeof raw !== "string" || raw === "" || raw === "$direct") return raw; try { const url = new URL(raw, origin); if (url.origin !== origin) return url.origin; return `${url.origin}${sanitizePath(url.pathname)}`; } catch { return null; } } const PATH_PROPS = new Set(["$pathname", "$initial_pathname", "$prev_pageview_pathname"]); /** Page titles name repositories and issues, and can lag a navigation, so they never leave the browser. */ const DROPPED_PROPS = new Set(["$search", "$hash", "title"]); const MAX_DEPTH = 6; /** Repository-looking paths inside free text such as `$elements_chain` (href="/acme/secret/blob/..."). */ const EMBEDDED_PATH = /(href=")(\/[^"]*)/g; /** Already reduced to a shape with no names (lib/route-template.ts). */ const TEMPLATE_PROPS = new Set(["route_template"]); function scrubString(value: string, key: string, origin: string): string | null { if (TEMPLATE_PROPS.has(key)) return isRouteTemplate(value) ? value : null; if (PATH_PROPS.has(key) || value.startsWith("/")) return sanitizePath(value.split(/[?#]/)[0] ?? value); if (key === "$elements_chain") return value.replace( EMBEDDED_PATH, (_, prefix: string, path: string) => prefix + sanitizePath(path.split(/[?#]/)[0] ?? path), ); if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith(origin)) { const clean = sanitizeUrl(value, origin); return typeof clean === "string" ? clean : null; } return value; } /** * Rewrites every URL-ish string anywhere in an event (including nested web-vitals and heatmap payloads, whose * object keys are URLs), and drops page titles. */ function scrubValue(value: unknown, key: string, origin: string, depth: number): unknown { if (typeof value === "string") return scrubString(value, key, origin); if (value === null || typeof value !== "object") return value; if (depth > MAX_DEPTH) return null; if (Array.isArray(value)) return value.map((item) => scrubValue(item, key, origin, depth + 1)); const out: Record = {}; for (const [childKey, child] of Object.entries(value)) { if (DROPPED_PROPS.has(childKey)) continue; const cleanKey = scrubString(childKey, "", origin) ?? childKey; out[cleanKey] = scrubValue(child, childKey, origin, depth + 1); } return out; } export function scrubProperties(properties: Record, origin: string): Record { return scrubValue(properties, "", origin, 0) as Record; } function privacySignal(): boolean { const nav = navigator as Navigator & { globalPrivacyControl?: boolean; msDoNotTrack?: string }; const dnt = nav.doNotTrack ?? (window as Window & { doNotTrack?: string }).doNotTrack ?? nav.msDoNotTrack; return nav.globalPrivacyControl === true || dnt === "1" || dnt === "yes"; } export function analyticsOptedOut(): boolean { try { return localStorage.getItem(OPT_OUT_KEY) === "1"; } catch { return false; } } export function analyticsBlockedByBrowser(): boolean { return typeof navigator !== "undefined" && privacySignal(); } export function setAnalyticsOptOut(optOut: boolean, config: ClientAnalyticsConfig | null = null): void { try { if (optOut) localStorage.setItem(OPT_OUT_KEY, "1"); else localStorage.removeItem(OPT_OUT_KEY); } catch { // Storage blocked: the opt-out lasts for this page only. } if (optOut) client?.opt_out_capturing(); else if (client) client.opt_in_capturing(); else void initAnalytics(config); } function syncRecording(pathname?: string): void { if (!client) return; if (recordingBlocked(pathname ?? window.location.pathname)) { if (client.sessionRecordingStarted()) client.stopSessionRecording(); } else if (!client.sessionRecordingStarted()) { client.startSessionRecording(); } } /** Starts PostHog once, on the forge origin, unless the browser or the user opted out. */ export function initAnalytics(config: ClientAnalyticsConfig | null): Promise { if (!config || typeof window === "undefined" || import.meta.env.DEV) return Promise.resolve(null); if (window.location.origin !== config.origin || privacySignal() || analyticsOptedOut()) return Promise.resolve(null); loading ??= import("posthog-js") .then(({ default: posthog }) => { posthog.init(config.key, { // Same-origin reverse proxy (src/server/analytics-proxy.ts), so blockers and CSP never see a third party. api_host: `${config.origin}${INGEST_PREFIX}`, ui_host: uiHost(config.host), defaults: "2026-05-30", person_profiles: "identified_only", // Sent on router resolve (capturePageview), once loaders have reported repository visibility. capture_pageview: false, capture_pageleave: true, respect_dnt: true, // First-party only: one cookie and localStorage on this host, never shared with subdomains or third parties. persistence: "localStorage+cookie", cross_subdomain_cookie: false, secure_cookie: true, mask_all_text: true, mask_all_element_attributes: true, disable_session_recording: true, // Every text node is masked unless the page is a repository known to be public: dashboards, profiles, // search and notifications all list private repository names. session_recording: { maskAllInputs: true, maskTextSelector: "*", maskTextFn: (text: string, element?: HTMLElement) => textVisibleInReplay(element) ? text : text.replace(/\S/g, "*"), }, before_send: (event) => { if (!event) return null; event.properties = scrubProperties(event.properties ?? {}, config.origin); if (event.$set) event.$set = scrubProperties(event.$set, config.origin); if (event.$set_once) event.$set_once = scrubProperties(event.$set_once, config.origin); return event; }, loaded: () => { client = posthog; syncRecording(); capturePageview(); posthog.onFeatureFlags(() => { for (const listener of flagListeners) listener(); }); }, }); return posthog; }) .catch(() => null); return loading; } /** Ties events to the signed-in account by opaque id; email only when the operator opted in. */ export function identifyViewer(config: ClientAnalyticsConfig | null, viewer: AnalyticsViewer): void { if (!client || !config) return; if (!viewer) { if (client.get_property("$user_state") === "identified") client.reset(); return; } const person = { site_admin: viewer.isAdmin ?? false, ...(config.personEmail && viewer.email ? { email: viewer.email } : {}), }; // Already identified in this browser: still refresh the person, so turning on POSTHOG_PERSON_EMAIL (or an // email change) reaches people who signed in before. if (client.get_distinct_id() === viewer.id) client.setPersonProperties(person); else client.identify(viewer.id, person); } let navigationStartedAt: number | null = null; /** Call before a client-side navigation renders, so replay pauses before sensitive content appears. */ export function onNavigate(pathname: string): void { navigationStartedAt = performance.now(); syncRecording(pathname); } /** Records a pageview for the current location; called after each navigation resolves. */ let lastPageview: string | null = null; /** * Pageviews carry `route_template` (the path's shape, never names) and, for client-side navigations, * `navigation_ms` from navigation start to loaders resolved, so slow pages group by route in PostHog. */ export function capturePageview(): void { if (!client) return; const href = window.location.href; if (href === lastPageview) return; lastPageview = href; const navigationMs = navigationStartedAt === null ? null : Math.round(performance.now() - navigationStartedAt); navigationStartedAt = null; client.capture("$pageview", { route_template: routeTemplate(window.location.pathname), ...(navigationMs === null ? {} : { navigation_ms: navigationMs }), }); } /** Forgets the identified person and session, so the next account in this browser starts fresh. */ export function resetAnalytics(): void { lastPageview = null; client?.reset(); } /** Reports an unexpected error, scrubbed; HttpErrors and expected failures should not be sent. */ export function captureException(error: unknown): void { if (!client) return; const name = error instanceof Error ? error.name : typeof error; const message = error instanceof Error ? scrubErrorMessage(error.message) : ""; client.capture("$exception", { $exception_type: name, $exception_message: message, $exception_source: "client" }); } /** Sends at once by beacon, so events right before a navigation away (to Stripe, say) are not lost. */ export function trackEvent(event: string, properties: Record = {}): void { client?.capture(event, properties, { send_instantly: true, transport: "sendBeacon" }); } /** Current value of a PostHog feature flag; `fallback` while loading or when analytics is off. */ export function featureFlag(key: string, fallback = false): boolean { const value = client?.isFeatureEnabled(key); return value === undefined ? fallback : value; } const flagListeners = new Set<() => void>(); /** Subscribes to flag updates, including the first load after analytics starts. */ export function onFeatureFlags(callback: () => void): () => void { flagListeners.add(callback); return () => flagListeners.delete(callback); }