Files
supabase/packages/common/posthog-client.ts
Sean Oliver 4c77ab5fef feat(telemetry): mirror org_count to PostHog person property (#45946)
## 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?

Feature + two follow-on fixes — small, scoped to telemetry / experiment
plumbing.

## What is the current behavior?

PostHog feature flags evaluated in Studio only have access to the
`gotrue_id` person property (set in `useTelemetryIdentify`) and the
`organization`/`project` group associations from pageviews. Flags can't
target users by org membership without a behavioral cohort, which
refreshes on a ~hourly schedule and lags behind real-time signup state.

This is blocking the rollout of the `dataApiRevokeOnCreateDefault`
experiment ahead of the May 30 default-privileges breaking change — we
need to target brand-new dashboard signups with no prior org membership,
and there's no person property to filter on.

## What is the new behavior?

Three changes, scoped tightly to make experiment targeting reliable for
brand-new signups:

### 1. Mirror `org_count` to a PostHog person property
(`apps/studio/lib/telemetry.tsx`)

The Studio `Telemetry` component now mirrors the user's current org-list
length to a PostHog person property `org_count` via
`posthog.identify(user.id, { org_count })`. The effect:

- Subscribes to `useOrganizationsQuery` (shares the same React Query
cache as `useSelectedOrganizationQuery`, so no extra network requests).
- Dedupes via a ref keyed on `{ userId, orgCount }` so we only call
identify when the value actually changes — handles user-switch
(logout/login as different user with same count) correctly.
- Generic enough to be useful beyond this experiment — analytics
segmentation by org membership, future flags that depend on multi-org
behavior, etc.

### 2. Merge pre-init identify properties
(`packages/common/posthog-client.ts`)

The previous `pendingIdentification` slot was a single-write buffer —
calling `posthogClient.identify()` before the PostHog SDK initialized
would overwrite any prior queued identify. Latent until this PR added a
second identify caller (`org_count`), which exposed the last-write-wins
behavior on first-visitor-before-consent flows. Now merges properties
across pre-init calls for the same user so both `{ gotrue_id }` and `{
org_count }` land on the person record when the SDK flushes. Caught
during Codex review.

### 3. Gate the exposure event on `org_count` being present
(`apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts`)

`useTrackDefaultPrivilegesExposure` previously fired on the first
non-undefined value of the `dataApiRevokeOnCreateDefault` flag. For
brand-new signups, this races the `org_count` identify: the initial
`/flags/` response (before targeting can match) returns the untargeted
variant, the exposure locks it in via `hasTracked`, then our identify
fires and a subsequent `/flags/` refresh updates the flag — but the
exposure has already recorded the wrong variant.

Fix: gate the exposure on `org_count` being present on the SDK person,
subscribing via `onFeatureFlags` so we pick up the post-identify
`/flags/` response. Adds `posthogClient.getPersonProperty` as the
local-state reader. Without this, the experiment would have a ~5-15%
noise floor on cohort assignment for new signups.

## Verification

End-to-end verified locally against the staging PostHog project (34343):

- Local Studio's PostHog SDK has `$stored_person_properties: {
gotrue_id: <uuid>, org_count: 1 }` after sign-in.
- Both `$set` events landed server-side within ~300ms of each other, and
the staging person record now shows `org_count = 1.0` with `gotrue_id`
preserved.
- Targeting query `person.properties.org_count == 1` works end-to-end
against staging.

## Additional context

Ref: [GROWTH-853](https://linear.app/supabase/issue/GROWTH-853)

Targeting plan for the flag once shipped: `person.org_count == 1` plus a
behavioral filter on recent `sign_up` event, at 5% rollout.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Telemetry now records and syncs the user's organization count as an
analytics person property and avoids redundant identifications when
unchanged.
* Analytics client now merges queued identification properties made
before initialization and exposes a method to read stored person
properties.

* **Bug Fixes**
* Tracking now waits for organization-count readiness before firing
certain exposure events to prevent missing data.

* **Tests**
* Added/updated tests to cover person-property behavior and gating
logic.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45946)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 12:52:43 -07:00

426 lines
14 KiB
TypeScript

import posthog, { PostHogConfig } from 'posthog-js'
// Limit the max number of queued events
// (e.g. if a user navigates around a lot before accepting consent)
const MAX_PENDING_EVENTS = 20
export interface ClientTelemetryEvent {
id: string
timestamp: number
eventType: 'capture' | 'identify' | 'pageview' | 'pageleave'
eventName: string
distinctId?: string
properties?: Record<string, unknown>
}
type ClientTelemetryListener = (event: ClientTelemetryEvent) => void
interface PostHogClientConfig {
apiKey?: string
apiHost?: string
uiHost?: string
}
class PostHogClient {
/** True after posthog.init() is called (prevents double-init) */
private initStarted = false
/** True after the `loaded` callback fires, meaning PostHog has fully bootstrapped */
private initialized = false
private pendingGroups: Record<string, string> = {}
private pendingIdentification: { userId: string; properties?: Record<string, any> } | null = null
private pendingEvents: Array<{ event: string; properties: Record<string, any> }> = []
private pendingExposures: Array<{ experimentId: string; properties: Record<string, any> }> = []
private config: PostHogClientConfig
private readonly maxPendingEvents = MAX_PENDING_EVENTS
private devListeners: Set<ClientTelemetryListener> = new Set()
private pendingFeatureFlagCallbacks: Set<() => void> = new Set()
constructor(config: PostHogClientConfig = {}) {
const apiHost =
config.apiHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://ph.supabase.green'
const uiHost =
config.uiHost || process.env.NEXT_PUBLIC_POSTHOG_UI_HOST || 'https://eu.posthog.com'
this.config = {
apiKey: config.apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY,
apiHost,
uiHost,
}
}
init(hasConsent: boolean = true) {
if (this.initStarted || typeof window === 'undefined' || !hasConsent) return
if (!this.config.apiKey) {
console.warn('PostHog API key not found. Skipping initialization.')
return
}
const config: Partial<PostHogConfig> = {
api_host: this.config.apiHost,
ui_host: this.config.uiHost,
autocapture: false, // We'll manually track events
capture_pageview: false, // We'll manually track pageviews
capture_pageleave: false, // We'll manually track page leaves
loaded: (posthog) => {
// Apply pending properties that were set before PostHog
// initialized due to poor connection or user not accepting
// consent right away
// Apply any pending groups
Object.entries(this.pendingGroups).forEach(([type, id]) => {
posthog.group(type, id)
})
this.pendingGroups = {}
// Apply any pending identification
if (this.pendingIdentification) {
try {
posthog.identify(
this.pendingIdentification.userId,
this.pendingIdentification.properties
)
} catch (error) {
console.error('PostHog identify failed:', error)
}
this.pendingIdentification = null
}
// Flush any pending events
this.pendingEvents.forEach(({ event, properties }) => {
try {
posthog.capture(event, properties, { transport: 'sendBeacon' })
} catch (error) {
console.error('PostHog capture failed:', error)
}
})
this.pendingEvents = []
this.initialized = true
// Flush any pending experiment exposures (with deduplication)
this.pendingExposures.forEach(({ experimentId, properties }) => {
this.fireExposureIfNew(experimentId, properties)
})
this.pendingExposures = []
},
}
this.initStarted = true
posthog.init(this.config.apiKey, config)
// Register any feature flag callbacks that were queued before init
this.pendingFeatureFlagCallbacks.forEach((cb) => posthog.onFeatureFlags(cb))
this.pendingFeatureFlagCallbacks.clear()
}
capturePageView(properties: Record<string, any>, hasConsent: boolean = true) {
if (!hasConsent) return
if (!this.initialized) {
// Queue the event for when PostHog initializes (up to cap)
// (e.g. poor connection or user not accepting consent right away)
if (this.pendingEvents.length >= this.maxPendingEvents) {
this.pendingEvents.shift() // Remove oldest event
}
this.pendingEvents.push({ event: '$pageview', properties })
return
}
try {
// Store groups from properties if present (for later group() calls)
if (properties.$groups) {
Object.entries(properties.$groups).forEach(([type, id]) => {
if (id) posthog.group(type, id as string)
})
}
posthog.capture('$pageview', properties, { transport: 'sendBeacon' })
this.emitToDevListeners('pageview', '$pageview', properties)
} catch (error) {
console.error('PostHog pageview capture failed:', error)
}
}
capturePageLeave(properties: Record<string, any>, hasConsent: boolean = true) {
if (!hasConsent) return
if (!this.initialized) {
// Queue the event for when PostHog initializes (up to cap)
// (e.g. poor connection or user not accepting consent right away)
if (this.pendingEvents.length >= this.maxPendingEvents) {
this.pendingEvents.shift() // Remove oldest event
}
this.pendingEvents.push({ event: '$pageleave', properties })
return
}
try {
// Use sendBeacon for page leave to survive tab close
posthog.capture('$pageleave', properties, { transport: 'sendBeacon' })
this.emitToDevListeners('pageleave', '$pageleave', properties)
} catch (error) {
console.error('PostHog pageleave capture failed:', error)
}
}
identify(userId: string, properties?: Record<string, any>, hasConsent: boolean = true) {
if (!hasConsent) return
if (!this.initialized) {
// Queue the identification for when PostHog initializes. Merge properties
// across pre-init calls for the same user so callers don't clobber each
// other (e.g. useTelemetryIdentify sets gotrue_id, then a separate effect
// sets org_count — both should land when the SDK flushes).
const pending = this.pendingIdentification
this.pendingIdentification =
pending && pending.userId === userId
? { userId, properties: { ...pending.properties, ...properties } }
: { userId, properties }
return
}
try {
posthog.identify(userId, properties)
this.emitToDevListeners('identify', '$identify', { userId, ...properties })
} catch (error) {
console.error('PostHog identify failed:', error)
}
}
reset() {
this.pendingIdentification = null
this.pendingGroups = {}
this.pendingEvents = []
this.pendingExposures = []
if (!this.initStarted) return
try {
posthog.reset()
} catch (error) {
console.error('PostHog reset failed:', error)
}
}
/**
* Returns PostHog's distinct_id, which holds first-touch attribution data.
* Falls back to reading from PostHog cookie if SDK isn't initialized yet
* (e.g., immediately after OAuth redirect before PostHog loads).
*/
getDistinctId(): string | undefined {
if (this.initialized) {
try {
return posthog.get_distinct_id()
} catch (error) {
console.error('PostHog getDistinctId failed:', error)
}
}
// Fallback: parse distinct_id from PostHog cookie
return this.getDistinctIdFromCookie()
}
/**
* Parse distinct_id from PostHog cookie.
* PostHog stores data in a cookie named `ph_<api_key>_posthog` with format:
* { distinct_id: "...", ... }
*/
private getDistinctIdFromCookie(): string | undefined {
if (typeof document === 'undefined') return undefined
try {
const cookieName = `ph_${this.config.apiKey}_posthog`
const cookies = document.cookie.split(';')
for (const cookie of cookies) {
const trimmed = cookie.trim()
const eqIndex = trimmed.indexOf('=')
if (eqIndex === -1) continue
const name = trimmed.substring(0, eqIndex)
if (name !== cookieName) continue
// Use substring instead of split to handle '=' chars in the value
const cookieValue = decodeURIComponent(trimmed.substring(eqIndex + 1))
const phData = JSON.parse(cookieValue)
if (phData.distinct_id && typeof phData.distinct_id === 'string') {
return phData.distinct_id
}
}
} catch {
// No op, cookie may not exist (first visit) or be malformed
}
return undefined
}
/**
* Returns the current value of a person property as stored locally by posthog-js.
* Returns undefined if PostHog hasn't initialized or the property hasn't been set.
* Use this to gate behavior on whether a property has actually landed in the SDK
* (e.g., waiting for an identify to complete before evaluating flag-dependent UI).
*
* Person properties set via `identify(id, props)` are stored under the
* `$stored_person_properties` bucket in persistence — `get_property(key)`
* reads top-level super properties, not person properties, so we index in.
*/
getPersonProperty(key: string): unknown {
if (!this.initialized) return undefined
try {
const stored = posthog.get_property('$stored_person_properties')
if (!stored || typeof stored !== 'object') return undefined
return (stored as Record<string, unknown>)[key]
} catch {
return undefined
}
}
/**
* Returns a PostHog feature flag value directly from the client-side SDK.
* Use this for www/docs pages where server-side evaluation lacks full person context.
* In local dev, DevToolbar overrides (x-ph-flag-overrides cookie) take priority.
*/
getFeatureFlag(key: string): string | boolean | undefined {
if (typeof document === 'undefined') return undefined
if (process.env.NODE_ENV === 'development') {
try {
const cookieEntry = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('x-ph-flag-overrides='))
if (cookieEntry) {
const overrides = JSON.parse(
decodeURIComponent(cookieEntry.substring('x-ph-flag-overrides='.length))
)
if (key in overrides) return overrides[key]
}
} catch {}
}
if (!this.initialized) return undefined
try {
return posthog.getFeatureFlag(key)
} catch {
return undefined
}
}
/**
* Subscribe to PostHog feature flag loads/reloads.
* Returns an unsubscribe function.
*/
onFeatureFlags(callback: () => void): () => void {
if (!this.initStarted) {
// Queue until init() is called
this.pendingFeatureFlagCallbacks.add(callback)
return () => this.pendingFeatureFlagCallbacks.delete(callback)
}
if (typeof posthog.onFeatureFlags !== 'function') return () => {}
return posthog.onFeatureFlags(callback) ?? (() => {})
}
/**
* Returns PostHog's session_id for the current session.
* Returns undefined until PostHog's `loaded` callback fires.
*/
getSessionId(): string | undefined {
if (!this.initialized) return undefined
try {
return posthog.get_session_id()
} catch (error) {
console.error('PostHog getSessionId failed:', error)
return undefined
}
}
/**
* Captures an experiment exposure event with session-based deduplication.
* Events are queued if PostHog is not yet initialized, then deduped on flush.
*/
captureExperimentExposure(
experimentId: string,
properties: Record<string, any>,
hasConsent: boolean = true
) {
if (!hasConsent) return
if (!this.initialized) {
// Only queue if not already queued for this experiment (first exposure wins)
if (!this.pendingExposures.some((e) => e.experimentId === experimentId)) {
if (this.pendingExposures.length >= this.maxPendingEvents) {
this.pendingExposures.shift()
}
this.pendingExposures.push({ experimentId, properties })
}
return
}
this.fireExposureIfNew(experimentId, properties)
}
private fireExposureIfNew(experimentId: string, properties: Record<string, any>) {
const sessionId = this.getSessionId()
if (!sessionId) return
const storageKey = `ph_exposed:${experimentId}`
try {
if (sessionStorage.getItem(storageKey) === sessionId) return
const eventName = `${experimentId}_experiment_exposed`
posthog.capture(eventName, { experiment_id: experimentId, ...properties })
sessionStorage.setItem(storageKey, sessionId)
} catch (error) {
console.error('PostHog experiment exposure capture failed:', error)
}
}
subscribeToEvents(listener: ClientTelemetryListener): () => void {
this.devListeners.add(listener)
return () => this.devListeners.delete(listener)
}
private emitToDevListeners(
eventType: ClientTelemetryEvent['eventType'],
eventName: string,
properties?: Record<string, unknown>
) {
if (this.devListeners.size === 0) return
let distinctId: string | undefined
try {
const id = posthog.get_distinct_id?.()
if (id && id.length > 0) {
distinctId = id
}
} catch {}
const event: ClientTelemetryEvent = {
id: `client-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
timestamp: Date.now(),
eventType,
eventName,
distinctId,
properties,
}
this.devListeners.forEach((listener) => {
try {
listener(event)
} catch (e) {
console.error('Dev telemetry listener error:', e)
}
})
}
}
export const posthogClient = new PostHogClient()