# 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. | | `PERF_SAMPLE_RATE` | Share of cheap requests and jobs reported as `server_request`/`server_job` (default `0.02`); expensive ones always are. | 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` | | `comment_created` | `source` (web or api), `on_pull`, `private_repo` | | `review_submitted` | `state`, `comments` (count), `private_repo` | | `repo_imported` | `source` (github, gitlab, codeberg, bitbucket, gitea or other; never the host), `with_token`, `private_repo` | | `repo_forked` | `private_repo` | | `token_created` | `kind` (fine or deploy), `all_repos`, `repos` (count), `permission`, `delegated`, `expires` | | `runner_registered` | `scope`, `isolation`, `attested`, `os`, `arch`; no person | | `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. ### Performance and cost (`server_request`, `server_job`) `src/server/perf.ts` wraps every Worker request, queue batch, cron task and Durable Object alarm and counts what it cost: D1 statements, replica reads, calls into `RepoState`/`AccountState`, their time, and the rows each read and wrote (D1 and Durable Object SQL bill by rows). One event per unit of work, no person profile: | Event | Dimensions | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server_request` | `route` (template, e.g. `/:owner/:repo/issues/:n`, `/_serverFn/fns.repos.exploreRepos`), `method`, `status`, `surface` (page, fn, api, git, lfs, mcp, runner, hosted) | | `server_job` | `job` (`cron.`, `queue.`, `alarm.account`, `alarm.repo.`) | Both carry `duration_ms`, `d1_queries`, `d1_ms`, `d1_replica_queries`, `d1_rows_read`, `d1_rows_written`, `do_calls`, `do_ms`, `do_rows_read`, `do_rows_written`, `failed`, `expensive`, `cold_start` and `sample_weight`. - **Sampling.** Units that fail, take 1 s or more, or make 25 or more D1 or object calls are always sent (`expensive: true`, weight 1). The rest are sampled at `PERF_SAMPLE_RATE` (default `0.02`) with `sample_weight = 1 / rate`, so weighted sums estimate true totals. - **Privacy.** Routes are templates: only fixed route words survive and everything else becomes `:param` (`src/lib/route-template.ts`). Server function ids are code names (`serverFnId` in `vite.config.ts`). - **Browser.** `$pageview` carries `route_template` and, for client-side navigations, `navigation_ms` (navigation start to loaders resolved). Useful HogQL (PostHog SQL) for hotspot hunting: ```sql -- Slowest routes this week (p95), with estimated volume SELECT properties.route AS route, properties.surface AS surface, sum(toFloat(properties.sample_weight)) AS est_requests, quantile(0.95)(toFloat(properties.duration_ms)) AS p95_ms FROM events WHERE event = 'server_request' AND timestamp > now() - INTERVAL 7 DAY GROUP BY route, surface ORDER BY p95_ms DESC LIMIT 25 -- Where the money goes: estimated rows read per route SELECT properties.route AS route, sum(toFloat(properties.sample_weight) * (toFloat(properties.d1_rows_read) + toFloat(properties.do_rows_read))) AS est_rows_read, sum(toFloat(properties.sample_weight) * toFloat(properties.do_calls)) AS est_object_calls FROM events WHERE event = 'server_request' AND timestamp > now() - INTERVAL 7 DAY GROUP BY route ORDER BY est_rows_read DESC LIMIT 25 -- What is growing: week-over-week change in per-request object calls (fan-outs show up here first) SELECT properties.route AS route, avgIf(toFloat(properties.do_calls), timestamp > now() - INTERVAL 7 DAY) AS this_week, avgIf(toFloat(properties.do_calls), timestamp <= now() - INTERVAL 7 DAY) AS last_week, this_week / greatest(last_week, 0.01) AS growth FROM events WHERE event = 'server_request' AND timestamp > now() - INTERVAL 14 DAY GROUP BY route HAVING this_week > 5 ORDER BY growth DESC LIMIT 25 -- Background work by cost SELECT properties.job AS job, count() AS runs, quantile(0.95)(toFloat(properties.duration_ms)) AS p95_ms, sum(toFloat(properties.sample_weight) * toFloat(properties.do_calls)) AS est_object_calls FROM events WHERE event = 'server_job' AND timestamp > now() - INTERVAL 7 DAY GROUP BY job ORDER BY est_object_calls DESC ``` A request whose `do_calls` grows with the size of the instance (rather than with what the request touches) is a fan-out; fix it with an index (docs/state-layout.md). ### 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.