---
title: "PostHog Analytics and Session Replay"
slug: posthog-analytics-session-replay
section: Governance & Operations
status: stable
description: "How the Host Portal and the Applicant Portal (PPPToo) send product analytics and privacy-gated Session Replay to the shared PostHog project, and how events are tagged by environment."
last-updated: 2026-10-07
---

# PostHog Analytics and Session Replay

How the Host Portal and the Applicant Portal (PPPToo) send product analytics and privacy-gated Session Replay to the shared PostHog project, and how events are tagged by environment.

## Overview

Both portals load the `posthog-js` SDK in the browser with autocapture off, person profiles off and recording disabled at init. Replay is then started or stopped per route by a gate that blocks token-bearing pages. Events from every environment land in one shared PostHog project and are told apart by an `environment` property and a `source` property. [1] [2]

> **Public key, not a secret**
>
> The `phc_*` value compiled into the browser bundle is a public project write key. A personal API key (`phx_*`) must never be placed in client code. The Host Portal validation script enforces the `phc_` prefix at build time. [1] [5]

## The Two Pipelines

| Pipeline | Where | What it sends | Tag |
| --- | --- | --- | --- |
| Browser SDK | `client/src/lib/posthog.ts` in each portal | `$pageview`, captured exceptions, Session Replay | `source` = `host-portal` or `ppp2-portal`; `environment` when configured |
| Server forwarding | PPPToo BFF `server/src/analytics/` | Custom product events the client posts to `/api/v1/analytics/events` | `source` = `ppp2-portal`; `environment` from `PPP2_ENVIRONMENT` |

The Host Portal pipeline documented here is the browser one. The Applicant Portal has both. [1] [2] [6] [7]

## Browser Analytics and Session Replay

### SDK Configuration

`initializeBrowserPostHog()` is called once at module load in `client/src/main.tsx`. It returns false and does nothing when no key is configured, when already initialized, or when the SDK is already loaded. [1] [3] [4]

| Option | Value |
| --- | --- |
| `api_host` | configured host, default `https://us.i.posthog.com` |
| `autocapture`, `capture_pageview`, `capture_pageleave`, `capture_performance` | `false` |
| `capture_exceptions` | `false` (the portal attaches its own `error` and `unhandledrejection` listeners instead) |
| `disable_session_recording` | `true` at init; recording is started later by the gate |
| `person_profiles` | `never` |
| `maskAllInputs` | `true` |
| `blockSelector` | `img, video, audio, canvas, iframe, [data-ph-no-capture]` |
| `collectFonts`, `recordBody`, `recordHeaders`, `recordCrossOriginIframes`, console log capture | `false` |

After init the portal registers `source` and, if set, `environment` as super properties. Page views are sent manually with the query string and hash stripped from the URL. [1] [2]

Host Portal build values are read as `VITE_POSTHOG_KEY`, `VITE_POSTHOG_HOST` and `VITE_POSTHOG_ENVIRONMENT`, with `VITE_PUBLIC_POSTHOG_*` and `VITE_APP_ENVIRONMENT` accepted as fallbacks. PPPToo reads `VITE_POSTHOG_KEY`, `VITE_POSTHOG_HOST` and `VITE_APP_ENVIRONMENT`. [1] [2]

### Replay Gate

`updateBrowserSessionRecording` stops recording when the route is blocked and otherwise calls `startSessionRecording(true)`, which overrides remote sampling and linked-flag gates while keeping every local privacy control. It runs in a layout effect so replay stops before rrweb can serialize a newly committed token-bearing route. [1] [3] [8]

| Portal | Blocked routes | Extra checks |
| --- | --- | --- |
| Host Portal | `/activate`, `/host-invitations/accept` and any descendant path (trailing slash tolerated) | Also blocks any route whose query string or fragment carries a credential-shaped parameter |
| PPPToo | `/onboarding`, `/reference/respond` (exact match) | None |

The Host Portal parameter check fails closed. A parameter name containing a fragment such as `token`, `code`, `state`, `key`, `secret`, `session`, `auth`, `jwt`, `password` or `sig` blocks replay, as does any value shaped like a JWT or a 24-plus character opaque string. The cost of a false positive is one unrecorded page. [1]

**Resuming after login (Host Portal, HostPortalPrototype#520).** The Auth0 callback lands on an ordinary route carrying `?code=&state=`, so replay is off. The Auth0 SDK then strips those parameters with `history.replaceState`, which fires no event and does not change the router pathname. To avoid losing the whole post-login session, `attachReplayUrlGuards` re-runs the gate on `hashchange`, on `popstate`, and by wrapping `history.pushState` and `history.replaceState`. [1] [9]

### Masking and Sanitization

Replay keeps ordinary UI copy readable and redacts recognized sensitive strings rather than masking all text. [1] [2]

- Body text is passed through `maskReplayText`, which replaces emails, US SSN-shaped values, US phone numbers, `Bearer` tokens, JWTs and `access_token`, `code`, `token`-style query values with asterisks. `style` and `script` elements are excluded from the text selector.
- Attributes go through `maskReplayAttribute`. Rendering attributes (class, size, SVG geometry and similar) are kept; `style` and the synthetic `_cssText` attribute are kept after removing `@import`, `url(...)` and generated `content`; same-origin stylesheet `href` values without a query or hash are kept so production CSS renders in replay. All other attribute values are masked.
- Media elements and iframes are blocked entirely.

The Host Portal additionally sanitizes every outbound event in `before_send`: it strips query and hash from `$current_url`, `$referrer` and `$initial_referrer`, masks exception messages, types and stack-frame file names, and deletes `$exception_personURL`. PPPToo's `before_send` only strips query and hash from the three URL properties. [1] [2]

## Server-Side Event Forwarding (Applicant Portal)

The PPPToo client batches custom events and posts them to `/api/v1/analytics/events`. The BFF `AnalyticsController` accepts at most 50 events per request, truncates names to 120 characters, replaces props over 4 KiB with `{ truncated: true \}`, logs each event as structured JSON, and calls `forwardToPostHog`. The endpoint is deliberately unauthenticated because pre-login screens emit events. [6] [10]

`forwardToPostHog` is fire-and-forget and returns nothing to the caller:

- Forwarding is on only when both a key (`POSTHOG_PUBLIC_KEY`, with `POSTHOG_KEY` as a local fallback) and `POSTHOG_HOST` are set. Otherwise events are only logged.
- Each event is sent to `POSTHOG_HOST/batch/` with `distinct_id` `ppp2-portal`, `$process_person_profile` false and `source` `ppp2-portal`.
- When `PPP2_ENVIRONMENT` is set it is stamped on every event as `environment` (PPPToo#256). Without it QA and prod events would look identical to dev events in the shared project.
- A client-supplied `ts` prop becomes the PostHog timestamp.
- Delivery is hardened (PPPToo#289): two attempts, a 5 second timeout per attempt, retry only on network errors, 408, 429 and 5xx, and honoring `Retry-After` capped at 2 seconds. Failures are logged at DEBUG only, never ERROR. [7]

The client side (`client/src/lib/analytics.ts`) keeps a retry queue with exponential backoff up to 30 seconds, caps a request body near 60 KiB, and flushes on `pagehide` using Beacon for same-origin endpoints and `keepalive` fetch otherwise. [11]

**Hub server-side exception capture.** The Hub reports server exceptions to PostHog through `server/lib/serverAnalytics.ts`. It reads `POSTHOG_KEY` and `POSTHOG_HOST` (default `https://us.i.posthog.com`) and does nothing when the key is unset. Each captured exception carries `environment`, resolved from `CONFIG_ENV`, then `NODE_ENV`, then `development`. Dev and QA share one PostHog project, so that tag is what attributes an issue to a tier. Prod has its own project: its ExternalSecret syncs `POSTHOG_KEY` and `POSTHOG_HOST` into `globus-prod-secrets` from the Secret Manager entries `globus-prod-posthog-key` and `globus-prod-posthog-host`, and the prod container injects both as optional env vars, so a missing secret leaves capture as a no-op instead of blocking pod start.

## Build and Deploy Configuration

| Item | PPPToo | Host Portal |
| --- | --- | --- |
| Environment tag source | `PPP2_ENVIRONMENT` in `deploy/env/<env>.yaml` (`development`, `QA`, `prod`) | `VITE_POSTHOG_ENVIRONMENT` set in the client workflow |
| Key and host source | `POSTHOG_PUBLIC_KEY` and `POSTHOG_HOST` in the same YAML | Key read from Secret Manager in the client workflow; host `https://us.i.posthog.com` |
| Build wiring | Composite action `.github/actions/client-build-config` reads the YAML with `yq` and fails unless the environment is non-empty, the host starts with `https://` and the key starts with `phc_`; the deploy workflows pass the values to the client image as `VITE_*` build args | `scripts/validate-posthog-config.mjs` runs before the client build and exits non-zero on an empty environment, empty key, key not starting with `phc_`, or non-HTTPS host |

The same PPPToo YAML file drives both the server runtime env (forwarding) and the client build args, so the browser and server tag events consistently for a given environment.

The Hub also holds a PostHog key for the Applicant Portal. `PUT /api/admin/applicant-portal/config/posthog-key` (session, permission `system_settings.manage`) takes `{ posthogKey }` and stores it in `integration_configs` (category `applicant_portal`, key `posthog_key`). An empty string deletes the stored value (response source `cleared`), an invalid body returns `400` with `VALIDATION_ERROR`, and a failed write returns `500` with `PERSIST_FAILED`. `GET /api/admin/applicant-portal/config` (same permission) returns the stored `posthogKey` and `posthogKeyEnvFallback`. The public, unauthenticated `GET /api/portal/runtime-config` returns `{ posthog: { key, host } }` with host `https://us.i.posthog.com`; the key is the optional env var `APPLICANT_PORTAL_POSTHOG_KEY` unless a non-blank stored value exists, which takes precedence, and it is `null` when neither is set. This is separate from the build-time `VITE_*` keys above, and PPPToo does not call this endpoint: it takes its key from the deploy YAML described above. [5] [12] [13]

## Known Differences and Gotchas

- The Host Portal module is the more complete implementation. At `development`, PPPToo's module has the route blocklist but not the parameter check, the history-patching URL guards or the outbound exception sanitizer. [1] [2]
- A PostHog misconfiguration does not fail a deploy on its own because the key is baked into the static bundle; the validation steps above exist to make the pipeline fail loudly instead. [5] [12]
- Because person profiles are never created, events cannot be tied to a named user in PostHog.
- Replay never records `/activate`, `/host-invitations/accept`, `/onboarding` or `/reference/respond` because those URLs carry bearer-like tokens. [1] [2]

## Sources

- HostPortalPrototype#518, HostPortalPrototype#520
- PPPToo#256, PPPToo#289, PPPToo#291, PPPToo#293

[1]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/lib/posthog.ts "Host Portal browser PostHog module"
[2]: https://github.com/Alliance-Strategies/PPPToo/blob/development/client/src/lib/posthog.ts "PPPToo browser PostHog module"
[3]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/main.tsx "Host Portal entry and page view tracker"
[4]: https://github.com/Alliance-Strategies/PPPToo/blob/development/client/src/main.tsx "PPPToo entry"
[5]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/scripts/validate-posthog-config.mjs "Host Portal PostHog build validation"
[6]: https://github.com/Alliance-Strategies/PPPToo/blob/development/server/src/analytics/analytics.controller.ts "PPPToo analytics controller"
[7]: https://github.com/Alliance-Strategies/PPPToo/blob/development/server/src/analytics/analytics.forwarder.ts "PPPToo PostHog forwarder"
[8]: https://github.com/Alliance-Strategies/PPPToo/blob/development/client/src/App.tsx "PPPToo page view tracker"
[9]: https://github.com/Alliance-Strategies/HostPortalPrototype/pull/520 "Resume session replay after the Auth0 login callback"
[10]: https://github.com/Alliance-Strategies/PPPToo/pull/256 "Wire PostHog forwarding into QA/prod, tag events by environment"
[11]: https://github.com/Alliance-Strategies/PPPToo/blob/development/client/src/lib/analytics.ts "PPPToo client analytics queue"
[12]: https://github.com/Alliance-Strategies/PPPToo/blob/development/.github/actions/client-build-config/action.yml "PPPToo client build config action"
[13]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/.github/workflows/WorkflowCiCdClient.yml "Host Portal client CI/CD workflow"
