telemetry.tsx466 lines · main
1'use client'
2
3import { components } from 'api-types'
4import { useRouter } from 'next/compat/router'
5import { usePathname } from 'next/navigation'
6import Script from 'next/script'
7import { useCallback, useEffect, useRef } from 'react'
8import { useLatest } from 'react-use'
9
10import { useUser } from './auth'
11import { hasConsented, useConsentState } from './consent-state'
12import { IS_PLATFORM } from './constants'
13import { useFeatureFlags } from './feature-flags'
14import { post } from './fetchWrappers'
15import type { FirstReferrerData, MwDiagData } from './first-referrer-cookie'
16import {
17 isExternalReferrer,
18 isOAuthRedirectReferrer,
19 parseFirstReferrerCookie,
20 parseMwDiagCookie,
21} from './first-referrer-cookie'
22import { ensurePlatformSuffix, isBrowser } from './helpers'
23import { useFirstTouchStore, useParams } from './hooks'
24import { posthogClient, type ClientTelemetryEvent } from './posthog-client'
25import { TelemetryEvent } from './telemetry-constants'
26import {
27 clearFirstTouchData,
28 getFirstTouchData,
29 type SharedTelemetryData,
30} from './telemetry-first-touch-store'
31import { getSharedTelemetryData, getTelemetryCookieOptions } from './telemetry-utils'
32
33export { posthogClient, type ClientTelemetryEvent }
34
35export const TelemetryTagManager = () => {
36 const { hasAccepted } = useConsentState()
37
38 const isGTMEnabled = Boolean(
39 IS_PLATFORM && process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID && hasAccepted
40 )
41
42 if (!isGTMEnabled) return null
43
44 return (
45 <Script
46 id="consent"
47 strategy="afterInteractive"
48 dangerouslySetInnerHTML={{
49 __html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;j.src="https://ss.supabase.com/4icgbaujh.js?"+i;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','60a389s=aWQ9R1RNLVdDVlJMTU43&page=2');`,
50 }}
51 />
52 )
53}
54
55function getFirstTouchAttributionProps(telemetryData: SharedTelemetryData) {
56 const urlString = telemetryData.page_url
57
58 try {
59 const url = new URL(urlString)
60 url.hash = ''
61 const params = url.searchParams
62
63 const getParam = (key: string) => {
64 const value = params.get(key)
65 return value && value.length > 0 ? value : undefined
66 }
67
68 const utmProps = {
69 ...(getParam('utm_source') && { $utm_source: getParam('utm_source') }),
70 ...(getParam('utm_medium') && { $utm_medium: getParam('utm_medium') }),
71 ...(getParam('utm_campaign') && { $utm_campaign: getParam('utm_campaign') }),
72 ...(getParam('utm_content') && { $utm_content: getParam('utm_content') }),
73 ...(getParam('utm_term') && { $utm_term: getParam('utm_term') }),
74 }
75
76 const clickIdProps = {
77 ...(getParam('gclid') && { gclid: getParam('gclid') }), // Google Ads
78 ...(getParam('gbraid') && { gbraid: getParam('gbraid') }), // Google Ads (iOS)
79 ...(getParam('wbraid') && { wbraid: getParam('wbraid') }), // Google Ads (iOS)
80 ...(getParam('msclkid') && { msclkid: getParam('msclkid') }), // Microsoft Ads (Bing)
81 ...(getParam('fbclid') && { fbclid: getParam('fbclid') }), // Meta (Facebook/Instagram)
82 ...(getParam('rdt_cid') && { rdt_cid: getParam('rdt_cid') }), // Reddit Ads
83 ...(getParam('ttclid') && { ttclid: getParam('ttclid') }), // TikTok Ads
84 ...(getParam('twclid') && { twclid: getParam('twclid') }), // X Ads (Twitter)
85 ...(getParam('li_fat_id') && { li_fat_id: getParam('li_fat_id') }), // LinkedIn Ads
86 }
87
88 return {
89 ...utmProps,
90 ...clickIdProps,
91 first_touch_url: url.href,
92 first_touch_pathname: url.pathname,
93 ...(url.search && { first_touch_search: url.search }),
94 }
95 } catch {
96 return {}
97 }
98}
99
100interface HandlePageTelemetryOptions {
101 apiUrl: string
102 pathname?: string
103 featureFlags?: Record<string, unknown>
104 slug?: string
105 ref?: string
106 telemetryDataOverride?: SharedTelemetryData
107 firstReferrerData?: FirstReferrerData | null
108 mwDiagData?: MwDiagData | null
109}
110
111function handlePageTelemetry({
112 pathname,
113 featureFlags,
114 slug,
115 ref,
116 telemetryDataOverride,
117 firstReferrerData,
118 mwDiagData,
119}: HandlePageTelemetryOptions) {
120 if (typeof window !== 'undefined') {
121 const livePageData = getSharedTelemetryData(pathname)
122 const liveReferrer = livePageData.ph.referrer
123 const storedReferrer = telemetryDataOverride?.ph?.referrer
124
125 const shouldUseStoredReferrer = Boolean(
126 storedReferrer &&
127 isExternalReferrer(storedReferrer) &&
128 !isOAuthRedirectReferrer(storedReferrer) &&
129 (!isExternalReferrer(liveReferrer) || isOAuthRedirectReferrer(liveReferrer))
130 )
131
132 const pageData = telemetryDataOverride
133 ? {
134 ...livePageData,
135 ph: {
136 ...livePageData.ph,
137 referrer: shouldUseStoredReferrer ? storedReferrer! : liveReferrer,
138 },
139 }
140 : { ...livePageData, ph: { ...livePageData.ph } }
141 const firstTouchAttributionProps: Record<string, string> = {
142 ...(telemetryDataOverride ? getFirstTouchAttributionProps(telemetryDataOverride) : {}),
143 }
144
145 const firstReferrerCookiePresent = Boolean(firstReferrerData)
146 let firstReferrerCookieConsumed = false
147
148 if (
149 firstReferrerData &&
150 isExternalReferrer(firstReferrerData.referrer) &&
151 !isOAuthRedirectReferrer(firstReferrerData.referrer) &&
152 (!isExternalReferrer(pageData.ph.referrer) || isOAuthRedirectReferrer(pageData.ph.referrer))
153 ) {
154 pageData.ph.referrer = firstReferrerData.referrer
155 firstReferrerCookieConsumed = true
156
157 const { utms, click_ids, landing_url } = firstReferrerData
158
159 Object.entries(utms).forEach(([key, value]) => {
160 const phKey = key.startsWith('utm_') ? `$${key}` : key
161 firstTouchAttributionProps[phKey] = value
162 })
163
164 Object.entries(click_ids).forEach(([key, value]) => {
165 firstTouchAttributionProps[key] = value
166 })
167
168 try {
169 const url = new URL(landing_url)
170 firstTouchAttributionProps.first_touch_url = url.href
171 firstTouchAttributionProps.first_touch_pathname = url.pathname
172
173 if (url.search) {
174 firstTouchAttributionProps.first_touch_search = url.search
175 } else {
176 delete firstTouchAttributionProps.first_touch_search
177 }
178 } catch {
179 // Skip if landing URL is malformed
180 }
181 }
182
183 const $referrer = pageData.ph.referrer
184 const $referring_domain = (() => {
185 if (!$referrer) return undefined
186 try {
187 return new URL($referrer).hostname
188 } catch {
189 return undefined
190 }
191 })()
192
193 if (pageData.session_id) {
194 document.cookie = `session_id=${pageData.session_id}; ${getTelemetryCookieOptions()}`
195 }
196
197 posthogClient.capturePageView({
198 $current_url: pageData.page_url,
199 $pathname: pageData.pathname,
200 $host: new URL(pageData.page_url).hostname,
201 ...($referrer && { $referrer }),
202 ...($referring_domain && { $referring_domain }),
203 ...firstTouchAttributionProps,
204 $groups: {
205 ...(slug ? { organization: slug } : {}),
206 ...(ref ? { project: ref } : {}),
207 },
208 page_title: pageData.page_title,
209 ...(pageData.session_id && { $session_id: pageData.session_id }),
210 ...pageData.ph,
211 ...Object.fromEntries(
212 Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v])
213 ),
214 // Only included on the initial pageview — subsequent pageviews omit firstReferrerData entirely
215 ...(firstReferrerData !== undefined && {
216 first_referrer_cookie_present: firstReferrerCookiePresent,
217 first_referrer_cookie_consumed: firstReferrerCookieConsumed,
218 }),
219 ...(mwDiagData && {
220 mw_diag_hit: mwDiagData.hit,
221 mw_diag_would_stamp: mwDiagData.would_stamp,
222 mw_diag_has_existing_cookie: mwDiagData.has_existing_cookie,
223 }),
224 })
225 }
226
227 return Promise.resolve()
228}
229
230export function handlePageLeaveTelemetry(
231 _API_URL: string,
232 pathname: string,
233 _featureFlags?: {
234 [key: string]: unknown
235 },
236 _slug?: string,
237 _ref?: string
238) {
239 if (typeof window !== 'undefined') {
240 const pageData = getSharedTelemetryData(pathname)
241 posthogClient.capturePageLeave({
242 $current_url: pageData.page_url,
243 $pathname: pageData.pathname,
244 page_title: pageData.page_title,
245 ...(pageData.session_id && { $session_id: pageData.session_id }),
246 })
247 }
248
249 return Promise.resolve()
250}
251
252export const PageTelemetry = ({
253 API_URL,
254 hasAcceptedConsent,
255 enabled = true,
256 organizationSlug,
257 projectRef,
258}: {
259 API_URL: string
260 hasAcceptedConsent: boolean
261 enabled?: boolean
262 organizationSlug?: string
263 projectRef?: string
264}) => {
265 const router = useRouter()
266
267 const pagesPathname = router?.pathname
268 const appPathname = usePathname()
269
270 const params = useParams()
271 const slug = organizationSlug || params.slug
272 const ref = projectRef || params.ref
273
274 const featureFlags = useFeatureFlags()
275
276 useFirstTouchStore({ enabled: enabled && IS_PLATFORM })
277
278 const pathname =
279 pagesPathname ?? appPathname ?? (isBrowser ? window.location.pathname : undefined)
280 const pathnameRef = useLatest(pathname)
281 const featureFlagsRef = useLatest(featureFlags.posthog)
282
283 const sendPageTelemetry = useCallback(() => {
284 if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
285
286 return handlePageTelemetry({
287 apiUrl: API_URL,
288 pathname: pathnameRef.current,
289 featureFlags: featureFlagsRef.current,
290 slug,
291 ref,
292 }).catch((e) => {
293 console.error('Problem sending telemetry page:', e)
294 })
295 }, [API_URL, enabled, hasAcceptedConsent, slug, ref])
296
297 const sendPageLeaveTelemetry = useCallback(() => {
298 if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
299
300 if (!pathnameRef.current) return Promise.resolve()
301
302 return handlePageLeaveTelemetry(
303 API_URL,
304 pathnameRef.current,
305 featureFlagsRef.current,
306 slug,
307 ref
308 ).catch((e) => {
309 console.error('Problem sending telemetry page-leave:', e)
310 })
311 }, [API_URL, enabled, hasAcceptedConsent, slug, ref])
312
313 const hasSentInitialPageTelemetryRef = useRef(false)
314 const previousAppPathnameRef = useRef<string | null>(null)
315
316 useEffect(() => {
317 if (hasAcceptedConsent && IS_PLATFORM) {
318 posthogClient.init(true)
319 }
320 }, [hasAcceptedConsent, IS_PLATFORM])
321
322 // Waiting for router.isReady before sending to avoid dynamic route placeholders
323 useEffect(() => {
324 if (
325 (router?.isReady ?? true) &&
326 enabled &&
327 hasAcceptedConsent &&
328 !hasSentInitialPageTelemetryRef.current
329 ) {
330 const cookieHeader = document.cookie
331 const firstReferrerData = parseFirstReferrerCookie(cookieHeader)
332 const mwDiagData = parseMwDiagCookie(cookieHeader)
333 const firstTouchData = getFirstTouchData()
334
335 try {
336 handlePageTelemetry({
337 apiUrl: API_URL,
338 pathname: pathnameRef.current,
339 featureFlags: featureFlagsRef.current,
340 slug,
341 ref,
342 ...(firstTouchData && { telemetryDataOverride: firstTouchData }),
343 firstReferrerData,
344 mwDiagData,
345 })
346 } finally {
347 clearFirstTouchData()
348 hasSentInitialPageTelemetryRef.current = true
349 }
350 }
351 }, [router?.isReady, enabled, hasAcceptedConsent, slug, ref])
352
353 useEffect(() => {
354 if (router === null) return
355
356 function handleRouteChange() {
357 if (!hasSentInitialPageTelemetryRef.current) return
358 sendPageTelemetry()
359 }
360
361 router.events.on('routeChangeComplete', handleRouteChange)
362
363 return () => {
364 router.events.off('routeChangeComplete', handleRouteChange)
365 }
366 }, [router])
367
368 useEffect(() => {
369 if (router !== null) return
370
371 if (
372 appPathname &&
373 previousAppPathnameRef.current !== null &&
374 previousAppPathnameRef.current !== appPathname
375 ) {
376 sendPageTelemetry()
377 }
378
379 previousAppPathnameRef.current = appPathname
380 }, [appPathname, router, sendPageTelemetry])
381
382 useEffect(() => {
383 if (!enabled) return
384
385 const handleBeforeUnload = () => sendPageLeaveTelemetry()
386
387 window.addEventListener('beforeunload', handleBeforeUnload)
388 return () => window.removeEventListener('beforeunload', handleBeforeUnload)
389 }, [enabled, sendPageLeaveTelemetry])
390
391 useTelemetryIdentify(API_URL)
392
393 return null
394}
395
396type EventBody = components['schemas']['TelemetryEventBodyV2']
397
398export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathname?: string) {
399 const consent = hasConsented()
400 if (!consent) return
401
402 const body: EventBody = {
403 ...getSharedTelemetryData(pathname),
404 action: event.action,
405 custom_properties: 'properties' in event ? event.properties : {},
406 groups: 'groups' in event ? { ...event.groups } : {},
407 }
408
409 if (body.groups?.project === 'Unknown') {
410 delete body.groups.project
411 if (body.groups?.organization === 'Unknown') {
412 delete body.groups
413 }
414 }
415
416 return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, {
417 headers: { Version: '2' },
418 })
419}
420
421//---
422// TELEMETRY IDENTIFY
423//---
424
425type IdentifyBody = components['schemas']['TelemetryIdentifyBodyV2']
426
427export function sendTelemetryIdentify(API_URL: string, body: IdentifyBody) {
428 const consent = hasConsented()
429
430 if (!consent) return Promise.resolve()
431
432 return post(`${ensurePlatformSuffix(API_URL)}/telemetry/identify`, body, {
433 headers: { Version: '2' },
434 })
435}
436
437export function useTelemetryIdentify(API_URL: string) {
438 const user = useUser()
439
440 useEffect(() => {
441 if (user?.id) {
442 const anonymousId = posthogClient.getDistinctId()
443
444 sendTelemetryIdentify(API_URL, {
445 user_id: user.id,
446 ...(anonymousId && { anonymous_id: anonymousId }),
447 })
448
449 // user.created_at is gotrue's immutable signup timestamp — safe to $set on
450 // every identify because the value never changes per user. Lets flag
451 // targeting distinguish brand-new signups from returning single-org users.
452 posthogClient.identify(user.id, {
453 gotrue_id: user.id,
454 ...(user.created_at && { signup_timestamp: user.created_at }),
455 })
456 }
457 }, [API_URL, user?.id])
458}
459
460//---
461// TELEMETRY RESET
462//---
463
464export function handleResetTelemetry(API_URL: string) {
465 return post(`${API_URL}/telemetry/reset`, {})
466}