mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
8dda0c3910
commit
e88a3723e1
5 files changed
+266
-6
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user