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 -->
This commit is contained in:
Sean Oliver authored and GitHub committed 2026-07-31 15:37:05 -07:00
1 parent 8dda0c3910
commit e88a3723e1
5 files changed
+266 -6

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
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)
})
})
+50
View File
@@ -0,0 +1,50 @@
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,
}
+2
View File
@@ -6,6 +6,7 @@ import { useConsentToast } from 'ui-patterns/consent'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { API_URL, IS_PLATFORM } from '@/lib/constants'
import { IS_SESSION_REPLAY_ENABLED, SESSION_REPLAY_CONFIG } from '@/lib/session-replay'
export function Telemetry() {
// Although this is "technically" breaking the rules of hooks
@@ -70,6 +71,7 @@ export function Telemetry() {
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
organizationSlug={organization?.slug}
sessionReplay={IS_SESSION_REPLAY_ENABLED ? SESSION_REPLAY_CONFIG : undefined}
/>
)
}
+33 -2
View File
@@ -1,7 +1,13 @@
import posthog, { PostHogConfig } from 'posthog-js'
import posthog, {
type CapturedNetworkRequest,
type PostHogConfig,
type SessionRecordingOptions,
} from 'posthog-js'
import { safeSessionStorage } from './safe-storage'
export type { CapturedNetworkRequest, SessionRecordingOptions }
// Limit the max number of queued events
// (e.g. if a user navigates around a lot before accepting consent)
const MAX_PENDING_EVENTS = 20
@@ -23,6 +29,30 @@ interface PostHogClientConfig {
uiHost?: string
}
interface PostHogInitOptions {
hasConsent?: boolean
/**
* Masking policy for session replay. Omit to disable recording, which every app
* sharing this PostHog project does unless it passes a policy of its own.
*/
sessionReplay?: SessionRecordingOptions
}
/**
* Enables session recording when given a masking config, and disables it when
* given nothing.
*/
export function buildSessionRecordingConfig(
sessionReplay?: SessionRecordingOptions
): Partial<PostHogConfig> {
return {
disable_session_recording: !sessionReplay,
// Console output is not in the DOM, so text masking cannot reach it.
enable_recording_console_log: false,
...(sessionReplay && { session_recording: sessionReplay }),
}
}
class PostHogClient {
/** True after posthog.init() is called (prevents double-init) */
private initStarted = false
@@ -50,7 +80,7 @@ class PostHogClient {
}
}
init(hasConsent: boolean = true) {
init({ hasConsent = true, sessionReplay }: PostHogInitOptions = {}) {
if (this.initStarted || typeof window === 'undefined' || !hasConsent) return
if (!this.config.apiKey) {
@@ -64,6 +94,7 @@ class PostHogClient {
autocapture: false, // We'll manually track events
capture_pageview: false, // We'll manually track pageviews
capture_pageleave: false, // We'll manually track page leaves
...buildSessionRecordingConfig(sessionReplay),
loaded: (posthog) => {
// Apply pending properties that were set before PostHog
// initialized due to poor connection or user not accepting
+19 -4
View File
@@ -21,7 +21,13 @@ import {
} from './first-referrer-cookie'
import { ensurePlatformSuffix, isBrowser } from './helpers'
import { useFirstTouchStore, useParams } from './hooks'
import { posthogClient, type ClientTelemetryEvent } from './posthog-client'
import {
buildSessionRecordingConfig,
posthogClient,
type CapturedNetworkRequest,
type ClientTelemetryEvent,
type SessionRecordingOptions,
} from './posthog-client'
import { TelemetryEvent } from './telemetry-constants'
import {
clearFirstTouchData,
@@ -30,7 +36,13 @@ import {
} from './telemetry-first-touch-store'
import { getSharedTelemetryData, getTelemetryCookieOptions } from './telemetry-utils'
export { posthogClient, type ClientTelemetryEvent }
export {
buildSessionRecordingConfig,
posthogClient,
type CapturedNetworkRequest,
type ClientTelemetryEvent,
type SessionRecordingOptions,
}
export const TelemetryTagManager = () => {
const { hasAccepted } = useConsentState()
@@ -255,12 +267,15 @@ export const PageTelemetry = ({
enabled = true,
organizationSlug,
projectRef,
sessionReplay,
}: {
API_URL: string
hasAcceptedConsent: boolean
enabled?: boolean
organizationSlug?: string
projectRef?: string
/** Masking policy for session replay. Omit to disable recording. */
sessionReplay?: SessionRecordingOptions
}) => {
const router = useRouter()
@@ -315,9 +330,9 @@ export const PageTelemetry = ({
useEffect(() => {
if (hasAcceptedConsent && IS_PLATFORM) {
posthogClient.init(true)
posthogClient.init({ sessionReplay })
}
}, [hasAcceptedConsent, IS_PLATFORM])
}, [hasAcceptedConsent, IS_PLATFORM, sessionReplay])
// Waiting for router.isReady before sending to avoid dynamic route placeholders
useEffect(() => {