Files
supabase/apps/studio/lib/session-replay.test.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

163 lines
5.7 KiB
TypeScript

import { buildSessionRecordingConfig, type CapturedNetworkRequest } from 'common'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { maskReplayNetworkRequest, maskReplayText, SESSION_REPLAY_CONFIG } from './session-replay'
const elementWith = (attributes: Record<string, string>) => {
const element = document.createElement('span')
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value))
return element
}
const networkRequest = (name: string): CapturedNetworkRequest => ({
name,
entryType: 'resource',
startTime: 0,
duration: 0,
})
describe('maskReplayText', () => {
it('masks text by default', () => {
expect(maskReplayText('postgresql://postgres:hunter2@db.abc.supabase.co:5432')).toBe(
'*'.repeat('postgresql://postgres:hunter2@db.abc.supabase.co:5432'.length)
)
})
it('masks text when no element is given', () => {
expect(maskReplayText('secret', undefined)).toBe('******')
})
it('masks based on trimmed length so whitespace is not leaked', () => {
expect(maskReplayText(' abc ')).toBe('***')
})
it('captures text opted in with data-ph-capture', () => {
const element = elementWith({ 'data-ph-capture': 'true' })
expect(maskReplayText('Table editor', element)).toBe('Table editor')
})
it('masks text when data-ph-capture is not exactly "true"', () => {
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'false' }))).toBe('******')
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': '' }))).toBe('******')
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'TRUE' }))).toBe('******')
})
it('masks text on elements carrying unrelated data attributes', () => {
expect(maskReplayText('secret', elementWith({ 'data-capture': 'true' }))).toBe('******')
})
})
describe('maskReplayNetworkRequest', () => {
it('strips query strings', () => {
expect(
maskReplayNetworkRequest(networkRequest('https://api.supabase.com/v1/x?token=abc')).name
).toBe('https://api.supabase.com/v1/x')
})
it('strips fragments, which carry GoTrue access tokens on auth callbacks', () => {
expect(
maskReplayNetworkRequest(networkRequest('https://supabase.com/dashboard#access_token=abc'))
.name
).toBe('https://supabase.com/dashboard')
})
it('strips from the first separator when both are present', () => {
expect(maskReplayNetworkRequest(networkRequest('https://x.com/a?b=1#c=2')).name).toBe(
'https://x.com/a'
)
expect(maskReplayNetworkRequest(networkRequest('https://x.com/a#c=2?b=1')).name).toBe(
'https://x.com/a'
)
})
it('leaves URLs without a query string or fragment alone', () => {
expect(maskReplayNetworkRequest(networkRequest('https://x.com/project/abc/editor')).name).toBe(
'https://x.com/project/abc/editor'
)
})
it('returns the request rather than dropping it, so timings are still captured', () => {
const request = networkRequest('https://x.com/a?b=1')
expect(maskReplayNetworkRequest(request)).toBe(request)
})
})
describe('SESSION_REPLAY_CONFIG', () => {
it('masks all text and inputs', () => {
expect(SESSION_REPLAY_CONFIG.maskTextSelector).toBe('*')
expect(SESSION_REPLAY_CONFIG.maskAllInputs).toBe(true)
expect(SESSION_REPLAY_CONFIG.maskTextFn).toBe(maskReplayText)
})
it('never records request or response payloads', () => {
expect(SESSION_REPLAY_CONFIG.recordHeaders).toBe(false)
expect(SESSION_REPLAY_CONFIG.recordBody).toBe(false)
})
it('never records canvas, which text masking cannot reach', () => {
expect(SESSION_REPLAY_CONFIG.captureCanvas).toEqual({ recordCanvas: false })
})
it('strips sensitive URL parts via maskReplayNetworkRequest', () => {
expect(SESSION_REPLAY_CONFIG.maskCapturedNetworkRequestFn).toBe(maskReplayNetworkRequest)
})
})
describe('buildSessionRecordingConfig', () => {
it('disables recording when given no policy', () => {
const config = buildSessionRecordingConfig()
expect(config.disable_session_recording).toBe(true)
expect(config).not.toHaveProperty('session_recording')
})
it('disables recording when the policy is undefined', () => {
const config = buildSessionRecordingConfig(undefined)
expect(config.disable_session_recording).toBe(true)
expect(config).not.toHaveProperty('session_recording')
})
it('enables recording and forwards the policy when given one', () => {
const config = buildSessionRecordingConfig(SESSION_REPLAY_CONFIG)
expect(config.disable_session_recording).toBe(false)
expect(config.session_recording).toBe(SESSION_REPLAY_CONFIG)
})
it.each([undefined, SESSION_REPLAY_CONFIG])(
'never records console logs, which masking cannot reach (%#)',
(sessionReplay) => {
expect(buildSessionRecordingConfig(sessionReplay).enable_recording_console_log).toBe(false)
}
)
})
describe('IS_SESSION_REPLAY_ENABLED', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('is true only for the exact string "true"', async () => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', 'true')
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(true)
})
it.each(['false', '', 'TRUE', '1'])('is false for %o', async (value) => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', value)
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(false)
})
it('is false when unset', async () => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', undefined)
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(false)
})
})