Skip to content

Two channels leave the browser, and they have different rules.

  • Errors and performance (Sentry) start in every production page load. Consent decides only whether a user identity is attached.
  • Product analytics (PostHog, Vercel Web Analytics) start only after the player chooses usage analytics in the cookie dialog. Session replay needs a second opt-in on top.

Both go to static.flags.games, a Cloudflare worker that forwards to the vendor. The browser never talks to *.posthog.com or *.sentry.io directly when PUBLIC_STATIC_ASSETS_ORIGIN is set.

The TODO sits inside initProdClientObservability:

apps/web/src/lib/analytics/client-observability.ts
scheduleWhenIdle(
() => {
if (publicEnv.posthogKey) {
void import("posthog-js").then((posthog) => {
// TODO: Optional PostHog Cloud managed reverse proxy (vs Worker `/phi`); Cloudflare DNS must be DNS-only on the proxy CNAME — https://posthog.com/docs/advanced/proxy/managed-reverse-proxy
activatePosthogClient(posthog, { onSessionResolved });
});
}
apps/web/src/hooks.client.ts
tunnel: staticOrigin ? `${staticOrigin}/sentry` : undefined,
integrations: [
browserTracingIntegration(),
browserProfilingIntegration(),
replayIntegration(createSentryReplayIntegrationOptions()),
],
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
tracesSampleRate: 0.2,
profileSessionSampleRate: 0.2,
enableLogs: true,
enableMetrics: true,
sendDefaultPii: false,
ignoreErrors: CLIENT_SENTRY_IGNORE_ERRORS,
tracePropagationTargets: ["localhost", FLAGS_GAMES_API],

The DSN is hardcoded one line above this excerpt; Sentry DSNs are public by design. tracePropagationTargets adds trace headers to localhost and https://flags.games/api… only, so requests to the Bun server and Convex carry no sentry-trace header.

CLIENT_SENTRY_IGNORE_ERRORS drops network noise (Load failed, Failed to fetch) and chunk-load failures from https://assets.flags.games/, which the crash recovery handles by reloading.

bindSentryUser attaches { id: sessionToken, username } only when hasAnalyticsConsent() is true, and calls setUser(null) otherwise. It re-runs on every consent change.

SvelteKit calls the exported handleError for unexpected load and render errors. In production it first tries a one-shot reload, then forwards to Sentry:

apps/web/src/hooks.client.ts
export const handleError: SentryHandleError | undefined = isDevelopment
? undefined
: async (input: SentryHandleErrorInput) => {
recoverFromClientCrash({
assetsOrigin: publicEnv.sveltekitAssetsOrigin,
getPathname: () => window.location.pathname,
message:
input.error instanceof Error
? rejectionMessage(input.error)
: rejectionMessage(String(input.error)),
reload: () => {
window.location.reload();
},
});
const handler = await getSentryHandleError();
return handler(input);
};

recoverFromClientCrash reloads for two cases, each at most once per tab session (a sessionStorage flag): a dynamic-import failure whose message names the assets origin, and effect_update_depth_exceeded on /. installClientCrashRecovery runs the same check on window error and unhandledrejection. After that, routes/+error.svelte renders a “Page Not Found” or “Error” page with a bug-report link to /contact?reason=bug&page=….

apps/web/src/lib/analytics/client-observability.ts
posthogInstance.init(posthogKey, {
api_host: posthogProxyApiHost(staticOrigin),
ui_host: "https://eu.posthog.com",
defaults: "2025-05-24",
capture_exceptions: false,
secure_cookie: true,
// Host-only cookie. true probes parent domains like .games on flags.games; Firefox logs those rejections.
cross_subdomain_cookie: false,
disable_surveys: true,
disable_session_recording: true,
session_recording: posthogSessionRecordingPrivacy,
loaded: (loadedInstance) => {
configurePosthogAfterLoad(wrapPosthogLoadedClient(loadedInstance), options);
},
});

posthogProxyApiHost returns ${staticOrigin}/phi, or bare /phi when no static origin is set (Vite proxies that path in dev). The path is /phi because uBlock Origin’s privacy list blocks /ingest/*^ip=; posthog-proxy-path.ts and workers/static/src/config.js must stay in sync.

Identity: the anonymous session_token becomes the distinct ID through identifyPosthogSessionToken. After sign-in, AccountPosthogIdentify.svelte calls identifyPosthogUser with the Convex user ID and traits email, username, friend_code, and flags_session_token.

trackProductEvent refuses to load PostHog in dev, without consent, or when telemetry is silenced for the internal team (one hardcoded session token plus the xurify.com and flags.games email domains).

$lib/kupsised/preferences.ts stores { usageAnalytics, sessionReplay }. Normalisation forces sessionReplay off whenever usageAnalytics is off. Writing preferences dispatches flags:cookie-consent-changed; hooks.client.ts listens and either bootstraps analytics or calls revokeClientAnalytics, which stops recording, opts out, and resets PostHog.

workers/static/src/index.js
let response;
if (pathname.startsWith(POSTHOG_PROXY_PATH)) {
response = await proxyPostHog(request, pathname, search);
} else if (pathname.startsWith("/vemetric")) {
response = await proxyVemetric(request, pathname, search);
} else if (pathname.startsWith("/sentry") && request.method === "POST") {
response = await proxySentry(request);
} else {
response = new Response("Not Found", { status: 404 });
}
RouteUpstreamBody capNotes
/phi/static/*eu-assets.i.posthog.com—Response forced to Cache-Control: public, max-age=15768000, immutable
/phi/*eu.i.posthog.com32 MiBStrips cookie, sets X-Forwarded-For from CF-Connecting-IP
/vemetric/main.jscdn.vemetric.com—Cached 7 days, stale-while-revalidate 30 days
/vemetric/*hub.vemetric.com32 MiBAdds x-country, x-client-ip
POST /sentrySentry DE ingest2,000,000 bytesProject ID fixed in config.js

The Sentry upstream is https://o4510700297191424.ingest.de.sentry.io/api/4510708029718608/envelope/, built in proxy-sentry.js from SENTRY_INGEST and SENTRY_PROJECT_ID in config.js.

Before routing, shouldDropAnalyticsForIp checks CF-Connecting-IP against the comma-separated ANALYTICS_EXCLUDED__IPS worker variable (note the double underscore). Matching requests get a 200 stub instead of a forward: an empty script for .js, {"errorsWhileComputingFlags":false,"flags":{}} for PostHog flags, and {"status":"Ok"} otherwise. The stub is 200 because posthog-js treats 204 as a failure and retries.

VariableWhereEffect
PUBLIC_STATIC_ASSETS_ORIGINwebBase for /phi and /sentry; unset sends PostHog to same-origin /phi and Sentry to its default ingest
PUBLIC_POSTHOG_KEYwebPostHog project key; unset skips PostHog entirely
PUBLIC_POSTHOG_SESSION_RECORDING_SAMPLE_RATEwebReplay sample rate, clamped to [0,1][0, 1], default 1
PUBLIC_VEMETRIC_TOKENwebParsed into publicEnv.vemetricToken; unused while init is commented out
SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECTweb buildSource map upload, production Vercel builds only
ANALYTICS_EXCLUDED__IPSstatic workerIPs whose analytics are answered with stubs

Let rr be PUBLIC_POSTHOG_SESSION_RECORDING_SAMPLE_RATE after clamping. For a page load with replay consent, recording starts when Math.random() < r, so

P(PostHog recording)=1[usage consent]⋅1[replay consent]⋅rP(\text{PostHog recording}) = \mathbb{1}[\text{usage consent}] \cdot \mathbb{1}[\text{replay consent}] \cdot r

The draw happens once per page load in configurePosthogAfterLoad, not once per visitor. Across kk page loads a consenting visitor is recorded at least once with probability 1−(1−r)k1 - (1 - r)^k; with the default r=1r = 1 that is every load.

SignalRateWhere set
Browser traces0.2tracesSampleRate in hooks.client.ts
Browser profiling0.2 of sessionsprofileSessionSampleRate
Sentry Replay0 and 0replaysSessionSampleRate, replaysOnErrorSampleRate
SvelteKit server traces1.0instrumentation.server.ts
Bun server traces1.0apps/server/src/instrumentation.ts
Browser errorsall, minus ignoreErrorsno sampleRate set
Worker logs and traces0.1 head samplingworkers/static/wrangler.toml

Sentry waits for scheduleWhenIdle with delayMs: 8000, then an idle callback with a 4 s timeout. PostHog is scheduled from inside the Sentry callback with another delayMs: 8000 and a 3 s timeout. On a busy main thread the lower bounds are 8 s for Sentry and 16 s for PostHog after init(). Events captured with trackProductEvent before that point still trigger the dynamic import("posthog-js"). Whether posthog-js queues captures made before init runs is not verified here.

Both replay SDKs mask all inputs. isSensitiveSessionReplayInput also treats as sensitive any element with data-sensitive-input, type of email or password, or autocomplete of email, current-password, or new-password, and replaces its text with * repeated to the same length.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Open Sentry relay. proxySentry forwards any POST /sentry body under 2 MB to the fixed project. It does not parse the envelope header or check the DSN, so anyone can submit events to the web project through the worker. Sentry’s own rate limits and inbound filters are the only guard.
  • CORS headers restrict nobody. addCorsHeaders echoes credentials only for https://flags.games and two localhost origins, and sends Access-Control-Allow-Origin: * to everyone else. Server-to-server callers ignore CORS entirely.
  • Session token sent to vendors. The session_token value is the PostHog distinct ID, the flags_session_token trait, and the Sentry user.id (with consent). The same token signs solo ledgers and binds session trust on the server. Anyone with PostHog or Sentry read access can see it.
  • Errors before Sentry init. For the first 8 s or more, handleError lazy-loads @sentry/sveltekit and calls handleErrorWithSentry before init has run. Those early errors may not reach Sentry.
  • Ad blockers. A blocklist rule for static.flags.games/phi would stop PostHog again. The path already moved once from /ingest for this reason; see docs/plans/2026-06-27-posthog-proxy-path-blocklist-fix.md.
  • Reload loops. Crash recovery stores a per-key flag in sessionStorage, so a chunk that stays missing reloads once and then surfaces the error page.
  • Consent revoked mid-session. revokeClientAnalytics opts PostHog out and resets it, but Sentry keeps running without a user. Vercel Web Analytics stays injected; its beforeSend drops each event once consent is gone.
  • Internal team silencing relies on one hardcoded session token and two email domains in internal-team-observability.ts. A new team device sends telemetry until its token is added.
ChoiceAlternativeWhy
Worker proxy on static.flags.gamesPostHog managed reverse proxyThe worker also carries Sentry and Vemetric, and allows the IP drop list. The managed proxy is the open TODO and needs a DNS-only CNAME in Cloudflare.
Sentry regardless of consentGate errors on consentThe code sends anonymous error reports to every production visitor and attaches identity only after consent.
Replay through PostHog onlySentry Replay on errorSentry Replay is wired with both rates at 0. The code does not record a reason; turning either rate up would add a second replay stream next to PostHog’s.
Deferred init (8 s idle)Init at hydrationKeeps SDK parse and network off the first interaction; costs early errors and early events.
Single root +error.svelte<svelte:boundary> per featureSimpler, but one widget’s render error replaces the whole page.

Non-goals: first-party analytics storage, Sentry sessions in development, and capturing exceptions through PostHog (capture_exceptions: false).