telemetry.tsx 14 KB

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