Files
supabase/apps/studio/lib/session-replay.ts
Sean Oliver e88a3723e1 feat(studio): add PostHog session replay with masked-by-default policy (#48515)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Telemetry feature.

## What is the current behavior?

- Session replay is off, and nothing in the code keeps it off.
- `packages/common/posthog-client.ts` sets no recording config at all.
- So PostHog's project setting alone decides, for every app sharing that
project.
- Studio, www and docs share one project.
- Studio shows customer data almost everywhere: SQL editor, table rows,
connection strings, API keys.
- posthog-js masks inputs by default. It does not mask rendered text.
- [GROWTH-1055](https://linear.app/supabase/issue/GROWTH-1055)

## What is the new behavior?

- `posthogClient.init()` takes a masking config, and disables recording
when it gets none.
- Studio passes one behind `NEXT_PUBLIC_POSTHOG_SESSION_REPLAY`.
- Every other app passes nothing, so it never loads the recorder.
- Studio masks all text and all inputs.
- `data-ph-capture="true"` opts one element's text back in. Unused so
far.
- Canvas is blocked, because it records as images that text masking
cannot reach.
- Query strings and fragments are stripped from recorded URLs, where
auth callbacks carry tokens.
- Request and response bodies are never recorded.
- Console logs are never recorded, since masking only reaches DOM text.
- Masking is set in code, so PostHog's settings cannot loosen it.
- Consent gating is unchanged. Nothing records before a user accepts.

## Additional context

- Recording needs three things: this env var, the PostHog project
toggle, and user consent.
- All three are off or unset, so merging this changes nothing at
runtime.
- `NEXT_PUBLIC_POSTHOG_SESSION_REPLAY` goes into Vercel on Preview scope
first, to test on a preview build.
- Production scope comes later, once we are ready to record there.
- `NEXT_PUBLIC_*` is inlined at build time, so each scope needs a
rebuild afterwards.
- Text inside HTML attributes (`title`, `alt`, `href`) is still recorded
as-is.
- posthog-js exposes no hook for masking attributes, so covering it
needs `ph-no-capture` per component.
- Staging has no server-side masking config, so that is where this gets
verified.
- Plan: enable recording on staging, verify masked text on a preview,
then decide on production.
- Network timing stays on for the dashboard performance work. Payloads
stay off.
- Tests cover both masking functions and the config values.

## Screenshots


https://github.com/user-attachments/assets/aa064a04-f977-4453-a3da-2fe0cdcead08

<img width="889" height="651" alt="CleanShot 2026-07-31 at 10 13 43"
src="https://github.com/user-attachments/assets/f1d07946-fd68-42b2-89f1-d201bc605638"
/>



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
  * Added privacy-focused session replay for Studio.
* Text and form inputs are masked by default, with explicit opt-in
capture.
  * Network recordings remove query strings and fragments.
  * Headers, request bodies, canvas data, and console logs are excluded.

* **Bug Fixes**
  * Improved whitespace and capture-attribute handling during masking.
* Session replay remains disabled without a masking policy or explicit
enablement.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 15:37:05 -07:00

51 lines
1.7 KiB
TypeScript

import type { CapturedNetworkRequest, SessionRecordingOptions } from 'common'
/**
* Enables session replay in Studio. Recording also requires "Record user
* sessions" in PostHog, which www and docs share.
*/
export const IS_SESSION_REPLAY_ENABLED = process.env.NEXT_PUBLIC_POSTHOG_SESSION_REPLAY === 'true'
/**
* Setting `data-ph-capture="true"` on an element opts its text in to session
* recording. All text is opted out by default.
*/
const CAPTURE_DATASET_KEY = 'phCapture'
/**
* Returns asterisks for all text except text inside elements marked
* `data-ph-capture="true"`.
*/
export function maskReplayText(text: string, element?: HTMLElement): string {
if (element?.dataset[CAPTURE_DATASET_KEY] === 'true') return text
return '*'.repeat(text.trim().length)
}
/**
* Strips query strings and fragments from recorded URLs, which posthog-js applies
* to page URLs as well as network requests. Auth callbacks carry tokens in the
* fragment.
*/
export function maskReplayNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest {
if (request.name) {
const separatorIndex = request.name.search(/[?#]/)
if (separatorIndex !== -1) {
request.name = request.name.slice(0, separatorIndex)
}
}
return request
}
export const SESSION_REPLAY_CONFIG: SessionRecordingOptions = {
// Match posthog-js defaults, but set here so the PostHog UI can't relax them.
maskAllInputs: true,
maskTextSelector: '*',
maskTextFn: maskReplayText,
// Keeps network capture to URL, status and timing. Overrides the PostHog UI.
recordHeaders: false,
recordBody: false,
// Canvas is captured as images, which text masking can't reach.
captureCanvas: { recordCanvas: false },
maskCapturedNetworkRequestFn: maskReplayNetworkRequest,
}