telemetry.tsx466 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { components } from 'api-types' |
| 4 | import { useRouter } from 'next/compat/router' |
| 5 | import { usePathname } from 'next/navigation' |
| 6 | import Script from 'next/script' |
| 7 | import { useCallback, useEffect, useRef } from 'react' |
| 8 | import { useLatest } from 'react-use' |
| 9 | |
| 10 | import { useUser } from './auth' |
| 11 | import { hasConsented, useConsentState } from './consent-state' |
| 12 | import { IS_PLATFORM } from './constants' |
| 13 | import { useFeatureFlags } from './feature-flags' |
| 14 | import { post } from './fetchWrappers' |
| 15 | import type { FirstReferrerData, MwDiagData } from './first-referrer-cookie' |
| 16 | import { |
| 17 | isExternalReferrer, |
| 18 | isOAuthRedirectReferrer, |
| 19 | parseFirstReferrerCookie, |
| 20 | parseMwDiagCookie, |
| 21 | } from './first-referrer-cookie' |
| 22 | import { ensurePlatformSuffix, isBrowser } from './helpers' |
| 23 | import { useFirstTouchStore, useParams } from './hooks' |
| 24 | import { posthogClient, type ClientTelemetryEvent } from './posthog-client' |
| 25 | import { TelemetryEvent } from './telemetry-constants' |
| 26 | import { |
| 27 | clearFirstTouchData, |
| 28 | getFirstTouchData, |
| 29 | type SharedTelemetryData, |
| 30 | } from './telemetry-first-touch-store' |
| 31 | import { getSharedTelemetryData, getTelemetryCookieOptions } from './telemetry-utils' |
| 32 | |
| 33 | export { posthogClient, type ClientTelemetryEvent } |
| 34 | |
| 35 | export 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 | |
| 55 | function 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 | |
| 100 | interface 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 | |
| 111 | function 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 | |
| 230 | export 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 | |
| 252 | export 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 | |
| 396 | type EventBody = components['schemas']['TelemetryEventBodyV2'] |
| 397 | |
| 398 | export 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 | |
| 425 | type IdentifyBody = components['schemas']['TelemetryIdentifyBodyV2'] |
| 426 | |
| 427 | export 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 | |
| 437 | export 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 | |
| 464 | export function handleResetTelemetry(API_URL: string) { |
| 465 | return post(`${API_URL}/telemetry/reset`, {}) |
| 466 | } |