Skip to content

Commit e3e8528

Browse files
authored
feat: add client-side PostHog telemetry tracking (supabase#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
1 parent f6a5ddf commit e3e8528

File tree

7 files changed

+242
-31
lines changed

7 files changed

+242
-31
lines changed

apps/studio/csp.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL
2626
? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin
2727
: ''
2828

29+
const isDevOrStaging =
30+
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
31+
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
32+
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
33+
2934
const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red'
3035
const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red'
3136
const SUPABASE_COM_URL = 'https://supabase.com'
@@ -58,6 +63,7 @@ const SUPABASE_ASSETS_URL =
5863
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
5964
? 'https://frontend-assets.supabase.green'
6065
: 'https://frontend-assets.supabase.com'
66+
const POSTHOG_URL = isDevOrStaging ? 'https://ph.supabase.green' : 'https://ph.supabase.com'
6167

6268
const USERCENTRICS_URLS = 'https://*.usercentrics.eu'
6369
const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu'
@@ -89,13 +95,15 @@ module.exports.getCSP = function getCSP() {
8995
USERCENTRICS_URLS,
9096
STAPE_URL,
9197
GOOGLE_MAPS_API_URL,
98+
POSTHOG_URL,
9299
]
93100
const SCRIPT_SRC_URLS = [
94101
CLOUDFLARE_CDN_URL,
95102
HCAPTCHA_JS_URL,
96103
STRIPE_JS_URL,
97104
SUPABASE_ASSETS_URL,
98105
STAPE_URL,
106+
POSTHOG_URL,
99107
]
100108
const FRAME_SRC_URLS = [HCAPTCHA_ASSET_URL, STRIPE_JS_URL, STAPE_URL]
101109
const IMG_SRC_URLS = [
@@ -111,11 +119,6 @@ module.exports.getCSP = function getCSP() {
111119
const STYLE_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL]
112120
const FONT_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL]
113121

114-
const isDevOrStaging =
115-
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
116-
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
117-
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
118-
119122
const defaultSrcDirective = [
120123
`default-src 'self'`,
121124
...DEFAULT_SRC_URLS,

apps/studio/lib/constants/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export const GOTRUE_ERRORS = {
3737
export const STRIPE_PUBLIC_KEY =
3838
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY || 'pk_test_XVwg5IZH3I9Gti98hZw6KRzd00v5858heG'
3939

40+
export const POSTHOG_URL =
41+
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' ||
42+
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local'
43+
? 'https://ph.supabase.green'
44+
: 'https://ph.supabase.com'
45+
4046
export const USAGE_APPROACHING_THRESHOLD = 0.75
4147

4248
export const OPT_IN_TAGS = {

apps/studio/lib/telemetry.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
import { PageTelemetry } from 'common'
2-
import GroupsTelemetry from 'components/ui/GroupsTelemetry'
32
import { API_URL, IS_PLATFORM } from 'lib/constants'
43
import { useConsentToast } from 'ui-patterns/consent'
4+
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
55

66
export function Telemetry() {
77
// Although this is "technically" breaking the rules of hooks
88
// IS_PLATFORM never changes within a session, so this won't cause any issues
99
// eslint-disable-next-line react-hooks/rules-of-hooks
1010
const { hasAcceptedConsent } = IS_PLATFORM ? useConsentToast() : { hasAcceptedConsent: true }
1111

12+
// Get org from selected organization query because it's not
13+
// always available in the URL params
14+
const { data: organization } = useSelectedOrganizationQuery()
15+
1216
return (
13-
<>
14-
<PageTelemetry
15-
API_URL={API_URL}
16-
hasAcceptedConsent={hasAcceptedConsent}
17-
enabled={IS_PLATFORM}
18-
/>
19-
<GroupsTelemetry hasAcceptedConsent={hasAcceptedConsent} />
20-
</>
17+
<PageTelemetry
18+
API_URL={API_URL}
19+
hasAcceptedConsent={hasAcceptedConsent}
20+
enabled={IS_PLATFORM}
21+
organizationSlug={organization?.slug}
22+
/>
2123
)
2224
}

packages/common/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
"dependencies": {
1414
"@types/dat.gui": "^0.7.12",
1515
"@usercentrics/cmp-browser-sdk": "^4.42.0",
16-
"flags": "^4.0.0",
1716
"api-types": "workspace:*",
1817
"config": "workspace:*",
1918
"dat.gui": "^0.7.9",
19+
"flags": "^4.0.0",
2020
"lodash": "^4.17.21",
2121
"next-themes": "^0.3.0",
22+
"posthog-js": "^1.257.2",
2223
"react-use": "^17.4.0",
2324
"valtio": "catalog:"
2425
},

packages/common/posthog-client.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import posthog from 'posthog-js'
2+
import { PostHogConfig } from 'posthog-js'
3+
4+
interface PostHogClientConfig {
5+
apiKey?: string
6+
apiHost?: string
7+
}
8+
9+
class PostHogClient {
10+
private initialized = false
11+
private pendingGroups: Record<string, string> = {}
12+
private config: PostHogClientConfig
13+
14+
constructor(config: PostHogClientConfig = {}) {
15+
this.config = {
16+
apiKey: config.apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY,
17+
apiHost:
18+
config.apiHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://ph.supabase.green',
19+
}
20+
}
21+
22+
init(hasConsent: boolean = true) {
23+
if (this.initialized || typeof window === 'undefined' || !hasConsent) return
24+
25+
if (!this.config.apiKey) {
26+
console.warn('PostHog API key not found. Skipping initialization.')
27+
return
28+
}
29+
30+
const config: Partial<PostHogConfig> = {
31+
api_host: this.config.apiHost,
32+
autocapture: false, // We'll manually track events
33+
capture_pageview: false, // We'll manually track pageviews
34+
capture_pageleave: false, // We'll manually track page leaves
35+
loaded: (posthog) => {
36+
// Apply any pending groups
37+
Object.entries(this.pendingGroups).forEach(([type, id]) => {
38+
posthog.group(type, id)
39+
})
40+
this.pendingGroups = {}
41+
},
42+
}
43+
44+
posthog.init(this.config.apiKey, config)
45+
this.initialized = true
46+
}
47+
48+
capturePageView(properties: Record<string, any>, hasConsent: boolean = true) {
49+
if (!hasConsent || !this.initialized) return
50+
posthog.capture('$pageview', properties)
51+
}
52+
53+
capturePageLeave(properties: Record<string, any>, hasConsent: boolean = true) {
54+
if (!hasConsent || !this.initialized) return
55+
posthog.capture('$pageleave', properties)
56+
}
57+
58+
identify(userId: string, properties?: Record<string, any>, hasConsent: boolean = true) {
59+
if (!hasConsent || !this.initialized) return
60+
61+
posthog.identify(userId, properties)
62+
}
63+
}
64+
65+
export const posthogClient = new PostHogClient()

packages/common/telemetry.tsx

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants'
1212
import { useFeatureFlags } from './feature-flags'
1313
import { post } from './fetchWrappers'
1414
import { ensurePlatformSuffix, isBrowser } from './helpers'
15-
import { useTelemetryCookie } from './hooks'
15+
import { useParams, useTelemetryCookie } from './hooks'
1616
import { TelemetryEvent } from './telemetry-constants'
1717
import { getSharedTelemetryData } from './telemetry-utils'
18+
import { posthogClient } from './posthog-client'
1819

1920
const { TELEMETRY_DATA } = LOCAL_STORAGE_KEYS
2021

@@ -46,14 +47,45 @@ export function handlePageTelemetry(
4647
featureFlags?: {
4748
[key: string]: unknown
4849
},
50+
slug?: string,
51+
ref?: string,
4952
telemetryDataOverride?: components['schemas']['TelemetryPageBodyV2']
5053
) {
54+
// Send to PostHog client-side (only in browser)
55+
if (typeof window !== 'undefined') {
56+
const pageData = getSharedTelemetryData(pathname)
57+
posthogClient.capturePageView({
58+
$current_url: pageData.page_url,
59+
$pathname: pageData.pathname,
60+
$host: new URL(pageData.page_url).hostname,
61+
$groups: {
62+
...(slug ? { organization: slug } : {}),
63+
...(ref ? { project: ref } : {}),
64+
},
65+
page_title: pageData.page_title,
66+
...pageData.ph,
67+
...Object.fromEntries(
68+
Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v])
69+
),
70+
})
71+
}
72+
73+
// Send to backend
74+
// TODO: Remove this once migration to client-side page telemetry is complete
5175
return post(
5276
`${ensurePlatformSuffix(API_URL)}/telemetry/page`,
5377
telemetryDataOverride !== undefined
5478
? { feature_flags: featureFlags, ...telemetryDataOverride }
5579
: {
5680
...getSharedTelemetryData(pathname),
81+
...(slug || ref
82+
? {
83+
groups: {
84+
...(slug ? { organization: slug } : {}),
85+
...(ref ? { project: ref } : {}),
86+
},
87+
}
88+
: {}),
5789
feature_flags: featureFlags,
5890
},
5991
{ headers: { Version: '2' } }
@@ -65,32 +97,61 @@ export function handlePageLeaveTelemetry(
6597
pathname: string,
6698
featureFlags?: {
6799
[key: string]: unknown
68-
}
100+
},
101+
slug?: string,
102+
ref?: string
69103
) {
104+
// Send to PostHog client-side (only in browser)
105+
if (typeof window !== 'undefined') {
106+
const pageData = getSharedTelemetryData(pathname)
107+
posthogClient.capturePageLeave({
108+
$current_url: pageData.page_url,
109+
$pathname: pageData.pathname,
110+
page_title: pageData.page_title,
111+
})
112+
}
113+
114+
// Send to backend
115+
// TODO: Remove this once migration to client-side page telemetry is complete
70116
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/page-leave`, {
71-
body: {
72-
pathname,
73-
page_url: isBrowser ? window.location.href : '',
74-
page_title: isBrowser ? document?.title : '',
75-
feature_flags: featureFlags,
76-
},
117+
pathname,
118+
page_url: isBrowser ? window.location.href : '',
119+
page_title: isBrowser ? document?.title : '',
120+
feature_flags: featureFlags,
121+
...(slug || ref
122+
? {
123+
groups: {
124+
...(slug ? { organization: slug } : {}),
125+
...(ref ? { project: ref } : {}),
126+
},
127+
}
128+
: {}),
77129
})
78130
}
79131

80132
export const PageTelemetry = ({
81133
API_URL,
82134
hasAcceptedConsent,
83135
enabled = true,
136+
organizationSlug,
137+
projectRef,
84138
}: {
85139
API_URL: string
86140
hasAcceptedConsent: boolean
87141
enabled?: boolean
142+
organizationSlug?: string
143+
projectRef?: string
88144
}) => {
89145
const router = useRouter()
90146

91147
const pagesPathname = router?.pathname
92148
const appPathname = usePathname()
93149

150+
// Get from props or try to extract from URL params
151+
const params = useParams()
152+
const slug = organizationSlug || params.slug
153+
const ref = projectRef || params.ref
154+
94155
const featureFlags = useFeatureFlags()
95156

96157
const title = typeof document !== 'undefined' ? document?.title : ''
@@ -105,21 +166,43 @@ export const PageTelemetry = ({
105166
const sendPageTelemetry = useCallback(() => {
106167
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
107168

108-
return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => {
169+
return handlePageTelemetry(
170+
API_URL,
171+
pathnameRef.current,
172+
featureFlagsRef.current,
173+
slug,
174+
ref
175+
).catch((e) => {
109176
console.error('Problem sending telemetry page:', e)
110177
})
111-
}, [API_URL, enabled, hasAcceptedConsent])
178+
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
112179

113180
const sendPageLeaveTelemetry = useCallback(() => {
114181
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
115182

116-
return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => {
183+
if (!pathnameRef.current) return Promise.resolve()
184+
185+
return handlePageLeaveTelemetry(
186+
API_URL,
187+
pathnameRef.current,
188+
featureFlagsRef.current,
189+
slug,
190+
ref
191+
).catch((e) => {
117192
console.error('Problem sending telemetry page-leave:', e)
118193
})
119-
}, [API_URL, enabled, hasAcceptedConsent])
194+
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
120195

121196
// Handle initial page telemetry event
122197
const hasSentInitialPageTelemetryRef = useRef(false)
198+
199+
// Initialize PostHog client when consent is accepted
200+
useEffect(() => {
201+
if (hasAcceptedConsent && IS_PLATFORM) {
202+
posthogClient.init(true)
203+
}
204+
}, [hasAcceptedConsent, IS_PLATFORM])
205+
123206
useEffect(() => {
124207
// Send page telemetry on first page load
125208
// Waiting for router ready before sending page_view
@@ -136,19 +219,26 @@ export const PageTelemetry = ({
136219
try {
137220
const encodedData = telemetryCookie.split('=')[1]
138221
const telemetryData = JSON.parse(decodeURIComponent(encodedData))
139-
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, telemetryData)
222+
handlePageTelemetry(
223+
API_URL,
224+
pathnameRef.current,
225+
featureFlagsRef.current,
226+
slug,
227+
ref,
228+
telemetryData
229+
)
140230
// remove the telemetry cookie
141231
document.cookie = `${TELEMETRY_DATA}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
142232
} catch (error) {
143233
console.error('Invalid telemetry data:', error)
144234
}
145235
} else {
146-
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current)
236+
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref)
147237
}
148238

149239
hasSentInitialPageTelemetryRef.current = true
150240
}
151-
}, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded])
241+
}, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded, slug, ref])
152242

153243
useEffect(() => {
154244
// For pages router
@@ -244,9 +334,13 @@ export function useTelemetryIdentify(API_URL: string) {
244334

245335
useEffect(() => {
246336
if (user?.id) {
337+
// Send to backend
247338
sendTelemetryIdentify(API_URL, {
248339
user_id: user.id,
249340
})
341+
342+
// Also identify in PostHog client-side
343+
posthogClient.identify(user.id)
250344
}
251345
}, [API_URL, user?.id])
252346
}

0 commit comments

Comments
 (0)