# Analytics gitfor can send product analytics to [PostHog](https://posthog.com): web analytics (pageviews), autocapture, heatmaps, session replay, feature flags and a few server-side events. Like billing, it's optional. With no `POSTHOG_KEY`, nothing loads: `posthog-js` isn't downloaded, no server events are sent, and the settings opt-out panel is hidden. ## Configuration | Variable | Purpose | | ---------------------- | --------------------------------------------------------------------------------------------------------------------- | | `POSTHOG_KEY` | Project token (`phc_...`). Enables analytics. `VITE_POSTHOG_PROJECT_TOKEN` is accepted as an alias. | | `POSTHOG_HOST` | Ingestion host, `https://us.i.posthog.com` by default (`https://eu.i.posthog.com` for EU). `VITE_POSTHOG_HOST` alias. | | `POSTHOG_PERSON_EMAIL` | `true` attaches the signed-in user's email to their PostHog person. Off by default. | These are read at runtime, so self-hosters don't need to rebuild to turn analytics on or off. Analytics runs only in production. It is off under `vite dev` (`import.meta.env.DEV` in the browser, `GITFOR_LOCAL=true` on the server), whenever `PUBLIC_URL` is a loopback address, and whenever `GITFOR_ENV` is set to anything other than `production` (set `GITFOR_ENV=staging` on staging deployments). ### Ingest proxy The browser never talks to PostHog directly. `posthog-js` uses `api_host: /ingest`, and the Worker (`src/server/analytics-proxy.ts`) forwards `/ingest/*` to `POSTHOG_HOST` (`/ingest/static/*` and `/ingest/array/*` go to the matching `*-assets.i.posthog.com` host). Only content type, encoding, accept and user agent headers are forwarded, plus `Origin`/`Referer` rewritten to the bare forge origin (replay's authorized-domain check needs it; the page path is never sent): no cookies, credentials or client IP. Upstream `Set-Cookie` is dropped. `/ingest` answers 404 while analytics is off, and `ingest` is a reserved account name. The page CSP currently restricts only `img-src`, where the PostHog host is added when configured. `src/server/security-headers.test.ts` fails if a future `connect-src`, `script-src` or `default-src` directive leaves the PostHog host out. ## Privacy - **Consent signals.** Do Not Track and Global Privacy Control turn analytics off. Each browser can also opt out under **Settings -> Account -> Product analytics**. - **Origins.** It runs only on the forge's own origin (`PUBLIC_URL`), never on the editor, user-content or sites origins. - **No PII by default.** People are identified by their opaque account id. Emails are attached only with `POSTHOG_PERSON_EMAIL=true`. gitfor.dev turns this on. Once on, the email is also added to people who were identified earlier, on their next page load. The project also has IP anonymization on. - **URLs.** - Query strings and fragments are always dropped. - Repository paths are reduced to `/[private]/[private]/
` unless the page has confirmed the repository is public, so private repository names and file paths never leave the browser. - Links to other sites keep only their origin. - **Autocapture** records no element text or attributes. - **Session replay.** - All inputs are masked, and all text is masked except on public repository pages (dashboards, profiles, search and notifications list private repository names). `data-ph-mask` masks an element even there. - Replay pauses before navigating to a repository not known to be public, and to settings, admin and sign-in pages. - Console logs and network bodies and headers are not captured. - **Server events** carry only enums and counts, never names, emails, subjects or private repository names. ## Events Browser: `$pageview`, `$pageleave`, autocapture, heatmaps, web vitals and replay. Pageviews are sent when a navigation resolves, after route loaders have reported whether a repository is public, so a public repository's first pageview is not recorded as `[private]`. `$exception` is sent from `DefaultCatchBoundary` for errors that are not `HttpError`s, with only the error type and a scrubbed message (`src/lib/scrub-error.ts` removes emails, URLs, tokens, paths, `owner/repo` names and quoted values; no stack). Signing out, and **Sign out everywhere**, call `posthog.reset()`, so the next account in that browser starts with a new anonymous id. Server (`src/server/analytics.ts`, sent via `waitUntil` with a 3 second timeout; failures are swallowed so they can never block or fail a request): | Event | Properties | | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | | `user_signed_up` | `method` (password or github) | | `user_signed_in` | | | `org_created` | `org_id` | | `repo_created` | `visibility`, `owner_kind`, `init_readme` | | `email_sent` | `outcome` (sent, disabled, dev_sink), `thread_reply`; no person | | `subscription_changed` | `status`, `plan` | | `credit_purchased` | `pool`, `quantity`, `pack`, `amount_cents` | | `mergefix_started` | `trigger`, `mode`, `private_repo` | | `pull_description_suggested` | `byok` | | `push` | `refs`, `deletes`, `job_token`, `private_repo` | | `pull_opened` | `private_repo`, `cross_repo` | | `pull_merged` | `method`, `private_repo`, `cross_repo` | | `ci_run_started` | `runner_size`, `event`, `private_repo`, `fork` (one per job) | | `ci_run_completed` | as started, plus `status` (conclusion) and `minutes` | | `issue_created` | `source` (web or api), `private_repo` | | `abuse_report_created` | `source`, `category`, `duplicate`, `site`, `paused`; no person | | `ai_usage` | `feature`, `provider_kind`, `model`, `input_tokens`, `output_tokens`, `calls` | | `domain_addon_changed` | `status` | | `account_disabled` | `reason` | | `$exception` | `$exception_type`, scrubbed `$exception_message`, `$exception_source` (worker.fetch, worker.queue); no person | `runner_size` is the billed size class (`standard`, `large`, `xlarge`, `self_hosted`), never the raw `runs-on` labels. For bring-your-own-key AI calls `model` is `byok`, because those model ids are free text. ### Groups Events about something an organization owns carry `$groups: { org: }` for PostHog group analytics: pushes, pulls, CI, issues, repositories, Merge Fix, AI usage, billing and `org_created`. Callers pass `{ ownerAccountId }` to `captureServerEvent`; the account's kind is looked up in the background and only org accounts become groups. User-owned subjects get no group. ## Feature flags `useFeatureFlag("flag-key", fallback)` (`src/hooks/use-feature-flag.ts`) returns the PostHog flag, or `fallback` while flags load and on instances without analytics. Code must always work with the fallback. ## Project settings The hosted project allows `https://gitfor.dev` for the toolbar and replay, and has these settings: - On: session replay, autocapture, heatmaps, web vitals, performance capture and IP anonymization. - Off: console log capture, exception autocapture (exceptions are captured explicitly and scrubbed, see above), and network headers and bodies in replays. Self-hosters should set their own domain in PostHog's project settings. ## Persistence The client stores a random visitor and session id in one first-party cookie (`ph__posthog`, `Secure`, host-only) and localStorage on the forge host. Session replay needs persistent ids, and posthog-js refuses replay under `cookieless_mode: "always"`. Do Not Track, Global Privacy Control and the Settings opt-out still stop the SDK before it loads. The cookie and privacy pages (`src/routes/cookies.tsx`, `src/routes/privacy.tsx`) describe this; keep them in sync.