diff --git a/README.md b/README.md
index 08120545543e0..0b5a5e6f4556c 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ You can also [self-host](https://supabase.com/docs/guides/hosting/overview) and
- [Storage](https://github.com/supabase/storage-api) a RESTful API for managing files in S3, with Postgres handling permissions.
- [pg_graphql](http://github.com/supabase/pg_graphql/) a PostgreSQL extension that exposes a GraphQL API.
- [postgres-meta](https://github.com/supabase/postgres-meta) is a RESTful API for managing your Postgres, allowing you to fetch tables, add roles, and run queries, etc.
-- [Kong](https://github.com/Kong/kong) is a cloud-native API gateway.
+- [Envoy](https://github.com/envoyproxy/envoy) is a cloud-native, high-performance edge and service proxy.
#### Client libraries
diff --git a/apps/docs/content/guides/auth/architecture.mdx b/apps/docs/content/guides/auth/architecture.mdx
index b4c0a53092b64..3665526c40c12 100644
--- a/apps/docs/content/guides/auth/architecture.mdx
+++ b/apps/docs/content/guides/auth/architecture.mdx
@@ -6,12 +6,12 @@ subtitle: 'The architecture behind Supabase Auth.'
There are four major layers to Supabase Auth:
1. [Client layer.](#client-layer) This can be one of the Supabase client SDKs, or manually made HTTP requests using the HTTP client of your choice.
-1. Kong API gateway. This is shared between all Supabase products.
+1. Envoy API gateway. This is shared between all Supabase products.
1. [Auth service](#auth-service) (formerly known as GoTrue).
1. [Postgres database.](#postgres) This is shared between all Supabase products.
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -171,4 +171,23 @@
+
+ENVOY
+envoyproxy.io
+POSTGRESQL
+postgresql.org
+GoTrue
+PostgREST
+Realtime
+Storage
+pg-meta
+Functions
+pg_graphql
+/auth
+/rest
+/realtime
+/storage
+/pg
+/functions
+/graphql
diff --git a/apps/docs/public/img/supabase-architecture.svg b/apps/docs/public/img/supabase-architecture.svg
index 2b0ad6325a99a..6b4dfd3dc8f15 100644
--- a/apps/docs/public/img/supabase-architecture.svg
+++ b/apps/docs/public/img/supabase-architecture.svg
@@ -15,69 +15,69 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -171,4 +171,23 @@
+
+ENVOY
+envoyproxy.io
+POSTGRESQL
+postgresql.org
+GoTrue
+PostgREST
+Realtime
+Storage
+pg-meta
+Functions
+pg_graphql
+/auth
+/rest
+/realtime
+/storage
+/pg
+/functions
+/graphql
diff --git a/apps/studio/lib/session-replay.test.ts b/apps/studio/lib/session-replay.test.ts
new file mode 100644
index 0000000000000..eac2fa5864619
--- /dev/null
+++ b/apps/studio/lib/session-replay.test.ts
@@ -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) => {
+ 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)
+ })
+})
diff --git a/apps/studio/lib/session-replay.ts b/apps/studio/lib/session-replay.ts
new file mode 100644
index 0000000000000..db7283d1dee75
--- /dev/null
+++ b/apps/studio/lib/session-replay.ts
@@ -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,
+}
diff --git a/apps/studio/lib/telemetry.tsx b/apps/studio/lib/telemetry.tsx
index cdf1a5b1c0011..cd7f53762a1f0 100644
--- a/apps/studio/lib/telemetry.tsx
+++ b/apps/studio/lib/telemetry.tsx
@@ -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}
/>
)
}
diff --git a/packages/common/posthog-client.ts b/packages/common/posthog-client.ts
index 9374535025c77..19dad1f57f79c 100644
--- a/packages/common/posthog-client.ts
+++ b/packages/common/posthog-client.ts
@@ -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 {
+ 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
diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx
index b92aae494cca1..4e8e13aa9f897 100644
--- a/packages/common/telemetry.tsx
+++ b/packages/common/telemetry.tsx
@@ -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(() => {