mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 18:11:51 +08:00
## 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 -->
163 lines
5.7 KiB
TypeScript
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)
|
|
})
|
|
})
|