feat: add client-side PostHog telemetry tracking (#37442)

Adds client-side PostHog tracking to run in parallel with server-side telemetry across studio, docs, and www. This enables session replays and resolves a race condition where page views arrive before group assignments resulting in attribution errors.

Changes:
- Created PostHog client wrapper with consent-aware initialization in common package
- Integrated PostHog client calls into existing telemetry functions to send events to both PostHog (client) and backend (server)
- Updated CSP to allow connections to PostHog endpoints
- Added environment variable support for all apps
- PostHog client accepts consent as a parameter and respects user preferences
- Events can be distinguished in PostHog by $lib property (posthog-js vs posthog-node)
- PostHog URL configured based on environment (staging/local uses ph.supabase.green)
- Maintains full backward compatibility with existing telemetry system

Resolves GROWTH-438
Resolves GROWTH-271
This commit is contained in:
Sean Oliver
2025-08-06 09:15:51 -07:00
committed by GitHub
parent f6a5ddf2f9
commit e3e8528f72
7 changed files with 242 additions and 31 deletions

View File

@@ -26,6 +26,11 @@ const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL
? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin
: ''
const isDevOrStaging =
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red'
const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red'
const SUPABASE_COM_URL = 'https://supabase.com'
@@ -58,6 +63,7 @@ const SUPABASE_ASSETS_URL =
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
? 'https://frontend-assets.supabase.green'
: 'https://frontend-assets.supabase.com'
const POSTHOG_URL = isDevOrStaging ? 'https://ph.supabase.green' : 'https://ph.supabase.com'
const USERCENTRICS_URLS = 'https://*.usercentrics.eu'
const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu'
@@ -89,6 +95,7 @@ module.exports.getCSP = function getCSP() {
USERCENTRICS_URLS,
STAPE_URL,
GOOGLE_MAPS_API_URL,
POSTHOG_URL,
]
const SCRIPT_SRC_URLS = [
CLOUDFLARE_CDN_URL,
@@ -96,6 +103,7 @@ module.exports.getCSP = function getCSP() {
STRIPE_JS_URL,
SUPABASE_ASSETS_URL,
STAPE_URL,
POSTHOG_URL,
]
const FRAME_SRC_URLS = [HCAPTCHA_ASSET_URL, STRIPE_JS_URL, STAPE_URL]
const IMG_SRC_URLS = [
@@ -111,11 +119,6 @@ module.exports.getCSP = function getCSP() {
const STYLE_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL]
const FONT_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL]
const isDevOrStaging =
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
const defaultSrcDirective = [
`default-src 'self'`,
...DEFAULT_SRC_URLS,

View File

@@ -37,6 +37,12 @@ export const GOTRUE_ERRORS = {
export const STRIPE_PUBLIC_KEY =
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY || 'pk_test_XVwg5IZH3I9Gti98hZw6KRzd00v5858heG'
export const POSTHOG_URL =
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local'
? 'https://ph.supabase.green'
: 'https://ph.supabase.com'
export const USAGE_APPROACHING_THRESHOLD = 0.75
export const OPT_IN_TAGS = {

View File

@@ -1,7 +1,7 @@
import { PageTelemetry } from 'common'
import GroupsTelemetry from 'components/ui/GroupsTelemetry'
import { API_URL, IS_PLATFORM } from 'lib/constants'
import { useConsentToast } from 'ui-patterns/consent'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
export function Telemetry() {
// Although this is "technically" breaking the rules of hooks
@@ -9,14 +9,16 @@ export function Telemetry() {
// 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()
return (
<>
<PageTelemetry
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
/>
<GroupsTelemetry hasAcceptedConsent={hasAcceptedConsent} />
</>
<PageTelemetry
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
organizationSlug={organization?.slug}
/>
)
}

View File

@@ -13,12 +13,13 @@
"dependencies": {
"@types/dat.gui": "^0.7.12",
"@usercentrics/cmp-browser-sdk": "^4.42.0",
"flags": "^4.0.0",
"api-types": "workspace:*",
"config": "workspace:*",
"dat.gui": "^0.7.9",
"flags": "^4.0.0",
"lodash": "^4.17.21",
"next-themes": "^0.3.0",
"posthog-js": "^1.257.2",
"react-use": "^17.4.0",
"valtio": "catalog:"
},

View File

@@ -0,0 +1,65 @@
import posthog from 'posthog-js'
import { PostHogConfig } from 'posthog-js'
interface PostHogClientConfig {
apiKey?: string
apiHost?: string
}
class PostHogClient {
private initialized = false
private pendingGroups: Record<string, string> = {}
private config: PostHogClientConfig
constructor(config: PostHogClientConfig = {}) {
this.config = {
apiKey: config.apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY,
apiHost:
config.apiHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://ph.supabase.green',
}
}
init(hasConsent: boolean = true) {
if (this.initialized || 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,
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 any pending groups
Object.entries(this.pendingGroups).forEach(([type, id]) => {
posthog.group(type, id)
})
this.pendingGroups = {}
},
}
posthog.init(this.config.apiKey, config)
this.initialized = true
}
capturePageView(properties: Record<string, any>, hasConsent: boolean = true) {
if (!hasConsent || !this.initialized) return
posthog.capture('$pageview', properties)
}
capturePageLeave(properties: Record<string, any>, hasConsent: boolean = true) {
if (!hasConsent || !this.initialized) return
posthog.capture('$pageleave', properties)
}
identify(userId: string, properties?: Record<string, any>, hasConsent: boolean = true) {
if (!hasConsent || !this.initialized) return
posthog.identify(userId, properties)
}
}
export const posthogClient = new PostHogClient()

View File

@@ -12,9 +12,10 @@ import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants'
import { useFeatureFlags } from './feature-flags'
import { post } from './fetchWrappers'
import { ensurePlatformSuffix, isBrowser } from './helpers'
import { useTelemetryCookie } from './hooks'
import { useParams, useTelemetryCookie } from './hooks'
import { TelemetryEvent } from './telemetry-constants'
import { getSharedTelemetryData } from './telemetry-utils'
import { posthogClient } from './posthog-client'
const { TELEMETRY_DATA } = LOCAL_STORAGE_KEYS
@@ -46,14 +47,45 @@ export function handlePageTelemetry(
featureFlags?: {
[key: string]: unknown
},
slug?: string,
ref?: string,
telemetryDataOverride?: components['schemas']['TelemetryPageBodyV2']
) {
// Send to PostHog client-side (only in browser)
if (typeof window !== 'undefined') {
const pageData = getSharedTelemetryData(pathname)
posthogClient.capturePageView({
$current_url: pageData.page_url,
$pathname: pageData.pathname,
$host: new URL(pageData.page_url).hostname,
$groups: {
...(slug ? { organization: slug } : {}),
...(ref ? { project: ref } : {}),
},
page_title: pageData.page_title,
...pageData.ph,
...Object.fromEntries(
Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v])
),
})
}
// Send to backend
// TODO: Remove this once migration to client-side page telemetry is complete
return post(
`${ensurePlatformSuffix(API_URL)}/telemetry/page`,
telemetryDataOverride !== undefined
? { feature_flags: featureFlags, ...telemetryDataOverride }
: {
...getSharedTelemetryData(pathname),
...(slug || ref
? {
groups: {
...(slug ? { organization: slug } : {}),
...(ref ? { project: ref } : {}),
},
}
: {}),
feature_flags: featureFlags,
},
{ headers: { Version: '2' } }
@@ -65,15 +97,35 @@ export function handlePageLeaveTelemetry(
pathname: string,
featureFlags?: {
[key: string]: unknown
}
},
slug?: string,
ref?: string
) {
// Send to PostHog client-side (only in browser)
if (typeof window !== 'undefined') {
const pageData = getSharedTelemetryData(pathname)
posthogClient.capturePageLeave({
$current_url: pageData.page_url,
$pathname: pageData.pathname,
page_title: pageData.page_title,
})
}
// Send to backend
// TODO: Remove this once migration to client-side page telemetry is complete
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/page-leave`, {
body: {
pathname,
page_url: isBrowser ? window.location.href : '',
page_title: isBrowser ? document?.title : '',
feature_flags: featureFlags,
},
pathname,
page_url: isBrowser ? window.location.href : '',
page_title: isBrowser ? document?.title : '',
feature_flags: featureFlags,
...(slug || ref
? {
groups: {
...(slug ? { organization: slug } : {}),
...(ref ? { project: ref } : {}),
},
}
: {}),
})
}
@@ -81,16 +133,25 @@ export const PageTelemetry = ({
API_URL,
hasAcceptedConsent,
enabled = true,
organizationSlug,
projectRef,
}: {
API_URL: string
hasAcceptedConsent: boolean
enabled?: boolean
organizationSlug?: string
projectRef?: string
}) => {
const router = useRouter()
const pagesPathname = router?.pathname
const appPathname = usePathname()
// Get from props or try to extract from URL params
const params = useParams()
const slug = organizationSlug || params.slug
const ref = projectRef || params.ref
const featureFlags = useFeatureFlags()
const title = typeof document !== 'undefined' ? document?.title : ''
@@ -105,21 +166,43 @@ export const PageTelemetry = ({
const sendPageTelemetry = useCallback(() => {
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => {
return handlePageTelemetry(
API_URL,
pathnameRef.current,
featureFlagsRef.current,
slug,
ref
).catch((e) => {
console.error('Problem sending telemetry page:', e)
})
}, [API_URL, enabled, hasAcceptedConsent])
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
const sendPageLeaveTelemetry = useCallback(() => {
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => {
if (!pathnameRef.current) return Promise.resolve()
return handlePageLeaveTelemetry(
API_URL,
pathnameRef.current,
featureFlagsRef.current,
slug,
ref
).catch((e) => {
console.error('Problem sending telemetry page-leave:', e)
})
}, [API_URL, enabled, hasAcceptedConsent])
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
// Handle initial page telemetry event
const hasSentInitialPageTelemetryRef = useRef(false)
// Initialize PostHog client when consent is accepted
useEffect(() => {
if (hasAcceptedConsent && IS_PLATFORM) {
posthogClient.init(true)
}
}, [hasAcceptedConsent, IS_PLATFORM])
useEffect(() => {
// Send page telemetry on first page load
// Waiting for router ready before sending page_view
@@ -136,19 +219,26 @@ export const PageTelemetry = ({
try {
const encodedData = telemetryCookie.split('=')[1]
const telemetryData = JSON.parse(decodeURIComponent(encodedData))
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, telemetryData)
handlePageTelemetry(
API_URL,
pathnameRef.current,
featureFlagsRef.current,
slug,
ref,
telemetryData
)
// remove the telemetry cookie
document.cookie = `${TELEMETRY_DATA}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
} catch (error) {
console.error('Invalid telemetry data:', error)
}
} else {
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current)
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref)
}
hasSentInitialPageTelemetryRef.current = true
}
}, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded])
}, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded, slug, ref])
useEffect(() => {
// For pages router
@@ -244,9 +334,13 @@ export function useTelemetryIdentify(API_URL: string) {
useEffect(() => {
if (user?.id) {
// Send to backend
sendTelemetryIdentify(API_URL, {
user_id: user.id,
})
// Also identify in PostHog client-side
posthogClient.identify(user.id)
}
}, [API_URL, user?.id])
}

42
pnpm-lock.yaml generated
View File

@@ -1853,6 +1853,9 @@ importers:
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
posthog-js:
specifier: ^1.257.2
version: 1.257.2
react:
specifier: 'catalog:'
version: 18.3.1
@@ -10275,6 +10278,9 @@ packages:
core-js@3.35.0:
resolution: {integrity: sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==}
core-js@3.44.0:
resolution: {integrity: sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==}
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -11316,7 +11322,6 @@ packages:
resolution: {integrity: sha512-t0q23FIpvHDTtnORW+bDJziGsal5uh9RJTJ1fyH8drd4lICOoXhJ5pLMUZ5C0VQei6dNmwTzzoTRgMkO9JgHEQ==}
peerDependencies:
eslint: '>= 5'
bundledDependencies: []
eslint-plugin-import@2.31.0:
resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==}
@@ -11674,6 +11679,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.7.4:
resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
@@ -15209,6 +15217,20 @@ packages:
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
posthog-js@1.257.2:
resolution: {integrity: sha512-E+8wI/ahaiUGrmkilOtAB9aTFL+oELwOEsH1eO/2NyXB5WWcSUk6Rm1loixq8/lC4f3oR+Qqp9rHyXTSYbBDRQ==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
preact@10.26.9:
resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -18077,6 +18099,9 @@ packages:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -29534,6 +29559,8 @@ snapshots:
core-js@3.35.0: {}
core-js@3.44.0: {}
core-util-is@1.0.2: {}
core-util-is@1.0.3: {}
@@ -31044,6 +31071,8 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.2.1
fflate@0.4.8: {}
fflate@0.7.4: {}
fflate@0.8.2: {}
@@ -35723,6 +35752,15 @@ snapshots:
postgres-range@1.1.4: {}
posthog-js@1.257.2:
dependencies:
core-js: 3.44.0
fflate: 0.4.8
preact: 10.26.9
web-vitals: 4.2.4
preact@10.26.9: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.0.3
@@ -39383,6 +39421,8 @@ snapshots:
web-streams-polyfill@4.0.0-beta.3: {}
web-vitals@4.2.4: {}
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: