Files
supabase/apps/studio/lib/telemetry.tsx
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

78 lines
3.1 KiB
TypeScript

import * as Sentry from '@sentry/nextjs'
import { PageTelemetry, posthogClient, useParams, useUser } from 'common'
import { useEffect, useRef } from 'react'
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
// IS_PLATFORM never changes within a session, so this won't cause any issues
// eslint-disable-next-line react-hooks/rules-of-hooks
const { hasAcceptedConsent } = IS_PLATFORM ? useConsentToast() : { hasAcceptedConsent: true }
// Get org from selected organization query because it's not
// always available in the URL params
const { data: organization } = useSelectedOrganizationQuery()
const user = useUser()
// Project ref from the URL params, mirroring the backend's `request.params.ref`
const { ref: projectRef } = useParams()
// Mirror the user's org-list length into a PostHog person property so feature
// flags and analytics can segment by current org membership. signup_timestamp
// is set on the same identify so flag audiences requiring both properties see
// them together on /decide. Only fires when the value changes.
const { data: organizations } = useOrganizationsQuery()
const lastSentRef = useRef<{
userId: string
orgCount: number
signupTimestamp?: string
} | null>(null)
useEffect(() => {
if (!user?.id || !organizations) return
const orgCount = organizations.length
const signupTimestamp = user.created_at ?? undefined
const last = lastSentRef.current
if (
last?.userId === user.id &&
last.orgCount === orgCount &&
last.signupTimestamp === signupTimestamp
) {
return
}
lastSentRef.current = { userId: user.id, orgCount, signupTimestamp }
posthogClient.identify(user.id, {
org_count: orgCount,
...(signupTimestamp && { signup_timestamp: signupTimestamp }),
})
}, [user?.id, user?.created_at, organizations])
useEffect(() => {
// don't set the sentry user id if the user hasn't logged in (so that Sentry errors show null user id instead of anonymous id)
if (user?.id) {
Sentry.setUser({ id: user.id })
}
// Tag Sentry events with the current project ref and customer org slug so backend/
// frontend errors can be filtered by project / org. Passing a null value clears
// the tag, so stale values don't leak across navigation.
Sentry.setTag('project_ref', projectRef ?? null)
Sentry.setTag('org_slug', organization?.slug ?? null)
}, [user?.id, projectRef, organization?.slug])
return (
<PageTelemetry
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
organizationSlug={organization?.slug}
sessionReplay={IS_SESSION_REPLAY_ENABLED ? SESSION_REPLAY_CONFIG : undefined}
/>
)
}