Client observability
1. Foundational mental model
Section titled “1. Foundational mental model”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.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”The TODO sits inside initProdClientObservability:
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 }); }); }3. Concrete implementation
Section titled “3. Concrete implementation”Sentry in the browser
Section titled “Sentry in the browser”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.
Error boundaries and crash recovery
Section titled “Error boundaries and crash recovery”SvelteKit calls the exported handleError for unexpected load and render errors. In production it first tries a one-shot reload, then forwards to Sentry:
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=….
PostHog
Section titled “PostHog”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).
Consent
Section titled “Consent”$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.
The static worker
Section titled “The static worker”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 });}| Route | Upstream | Body cap | Notes |
|---|---|---|---|
/phi/static/* | eu-assets.i.posthog.com | — | Response forced to Cache-Control: public, max-age=15768000, immutable |
/phi/* | eu.i.posthog.com | 32 MiB | Strips cookie, sets X-Forwarded-For from CF-Connecting-IP |
/vemetric/main.js | cdn.vemetric.com | — | Cached 7 days, stale-while-revalidate 30 days |
/vemetric/* | hub.vemetric.com | 32 MiB | Adds x-country, x-client-ip |
POST /sentry | Sentry DE ingest | 2,000,000 bytes | Project 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.
Environment variables
Section titled “Environment variables”| Variable | Where | Effect |
|---|---|---|
PUBLIC_STATIC_ASSETS_ORIGIN | web | Base for /phi and /sentry; unset sends PostHog to same-origin /phi and Sentry to its default ingest |
PUBLIC_POSTHOG_KEY | web | PostHog project key; unset skips PostHog entirely |
PUBLIC_POSTHOG_SESSION_RECORDING_SAMPLE_RATE | web | Replay sample rate, clamped to , default 1 |
PUBLIC_VEMETRIC_TOKEN | web | Parsed into publicEnv.vemetricToken; unused while init is commented out |
SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT | web build | Source map upload, production Vercel builds only |
ANALYTICS_EXCLUDED__IPS | static worker | IPs whose analytics are answered with stubs |
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Sampling
Section titled “Sampling”Let be PUBLIC_POSTHOG_SESSION_RECORDING_SAMPLE_RATE after clamping. For a page load with replay consent, recording starts when Math.random() < r, so
The draw happens once per page load in configurePosthogAfterLoad, not once per visitor. Across page loads a consenting visitor is recorded at least once with probability ; with the default that is every load.
| Signal | Rate | Where set |
|---|---|---|
| Browser traces | 0.2 | tracesSampleRate in hooks.client.ts |
| Browser profiling | 0.2 of sessions | profileSessionSampleRate |
| Sentry Replay | 0 and 0 | replaysSessionSampleRate, replaysOnErrorSampleRate |
| SvelteKit server traces | 1.0 | instrumentation.server.ts |
| Bun server traces | 1.0 | apps/server/src/instrumentation.ts |
| Browser errors | all, minus ignoreErrors | no sampleRate set |
| Worker logs and traces | 0.1 head sampling | workers/static/wrangler.toml |
Load delay
Section titled “Load delay”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.
Masking
Section titled “Masking”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.
proxySentryforwards anyPOST /sentrybody 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.
addCorsHeadersechoes credentials only forhttps://flags.gamesand two localhost origins, and sendsAccess-Control-Allow-Origin: *to everyone else. Server-to-server callers ignore CORS entirely. - Session token sent to vendors. The
session_tokenvalue is the PostHog distinct ID, theflags_session_tokentrait, and the Sentryuser.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,
handleErrorlazy-loads@sentry/sveltekitand callshandleErrorWithSentrybeforeinithas run. Those early errors may not reach Sentry. - Ad blockers. A blocklist rule for
static.flags.games/phiwould stop PostHog again. The path already moved once from/ingestfor this reason; seedocs/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.
revokeClientAnalyticsopts PostHog out and resets it, but Sentry keeps running without a user. Vercel Web Analytics stays injected; itsbeforeSenddrops 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.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why |
|---|---|---|
Worker proxy on static.flags.games | PostHog managed reverse proxy | The 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 consent | Gate errors on consent | The code sends anonymous error reports to every production visitor and attaches identity only after consent. |
| Replay through PostHog only | Sentry Replay on error | Sentry 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 hydration | Keeps SDK parse and network off the first interaction; costs early errors and early events. |
Single root +error.svelte | <svelte:boundary> per feature | Simpler, 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).