Analytics
gitfor can send product analytics to PostHog: 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: <PUBLIC_URL>/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]/<section>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-maskmasks 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.
- All inputs are masked, and all text is masked except on public repository pages (dashboards, profiles, search and notifications list private repository names).
- 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 HttpErrors, 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.<task>, queue.<name>, alarm.account, alarm.repo.<task>) |
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 atPERF_SAMPLE_RATE(default0.02) withsample_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 (serverFnIdinvite.config.ts). - Browser.
$pageviewcarriesroute_templateand, for client-side navigations,navigation_ms(navigation start to loaders resolved).
Useful HogQL (PostHog SQL) for hotspot hunting:
-- 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: <org account id> } 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_<project>_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.