Files
Ivan VasilovandAlaister Young 37e44bec9c feat: Add GTM (#35567)
* Add third-parties dependency for GTM. Reexport the GTM from the common package.

* Add the TelemetryTagManager to four of the production apps.

* Add the GOOGLE_TAG_MANAGER_ID env var as a turbo dependency to the 4 apps.

* Skip rendering the tag manager if the env var is not set or not running on the platform.

* Fix the prop type to be extracted from the component.

* Add default values for consent to GTM.

* Another try to mimic gtag function.

* Fix a link in www.

* Try another approach.

* try.

* Remove the data-redaction flag.

* Remove extra code.

* Send a sign-up event if GTM is enabled.

* Send only the email to GTM.

* Minor fixes.

* Remove third-parties from pnpm lockfile.

* Lets try to make studio work again.

* Add CSP rules for img loading for GTM.

* Add event for testing.

* Add www.googletagmanager.com to the CSP rules.

* Add Stape to CSP rules.

* Fix stape CSP.

* Clean up the code.

* Remove extra console.log.

* Fix the stape urls for CORS.

* Fix the wrong category for Stape URL.

* Add google ads urls.

* Bump the timeout.

* Add google.com to the img-src for google ads.

* update csp

* remove comment

* update to use google analytics without signals

* add stape to default-src in csp

* move csp to middleware

* add google ads support and fix middleware base path

* remove google tag manager / google ads references from csp. load via stape proxy instead

* add double click url to image src

* add Google Tag Manager URL to CSP configuration

* add ga4 urls to csp

* remove google urls from CSP

---------

Co-authored-by: Alaister Young <a@alaisteryoung.com>
2025-06-30 10:35:47 +00:00

261 lines
7.6 KiB
TypeScript

'use client'
import { components } from 'api-types'
import { useRouter } from 'next/compat/router'
import { usePathname } from 'next/navigation'
import Script from 'next/script'
import { useCallback, useEffect, useRef } from 'react'
import { useLatest } from 'react-use'
import { useUser } from './auth'
import { hasConsented } from './consent-state'
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 { TelemetryEvent } from './telemetry-constants'
import { getSharedTelemetryData } from './telemetry-utils'
const { TELEMETRY_DATA } = LOCAL_STORAGE_KEYS
// Reexports GoogleTagManager with the right API key set
export const TelemetryTagManager = () => {
const isGTMEnabled = Boolean(IS_PLATFORM && process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID)
if (!isGTMEnabled) {
return
}
return (
<Script
id="consent"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__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');`,
}}
/>
)
}
//---
// PAGE TELEMETRY
//---
export function handlePageTelemetry(
API_URL: string,
pathname?: string,
featureFlags?: {
[key: string]: unknown
},
telemetryDataOverride?: components['schemas']['TelemetryPageBodyV2']
) {
return post(
`${ensurePlatformSuffix(API_URL)}/telemetry/page`,
telemetryDataOverride !== undefined
? { feature_flags: featureFlags, ...telemetryDataOverride }
: {
...getSharedTelemetryData(pathname),
feature_flags: featureFlags,
},
{ headers: { Version: '2' } }
)
}
export function handlePageLeaveTelemetry(
API_URL: string,
pathname: string,
featureFlags?: {
[key: string]: unknown
}
) {
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/page-leave`, {
body: {
pathname,
page_url: isBrowser ? window.location.href : '',
page_title: isBrowser ? document?.title : '',
feature_flags: featureFlags,
},
})
}
export const PageTelemetry = ({
API_URL,
hasAcceptedConsent,
enabled = true,
}: {
API_URL: string
hasAcceptedConsent: boolean
enabled?: boolean
}) => {
const router = useRouter()
const pagesPathname = router?.pathname
const appPathname = usePathname()
const featureFlags = useFeatureFlags()
const title = typeof document !== 'undefined' ? document?.title : ''
const referrer = typeof document !== 'undefined' ? document?.referrer : ''
useTelemetryCookie({ hasAcceptedConsent, title, referrer })
const pathname =
pagesPathname ?? appPathname ?? (isBrowser ? window.location.pathname : undefined)
const pathnameRef = useLatest(pathname)
const featureFlagsRef = useLatest(featureFlags.posthog)
const sendPageTelemetry = useCallback(() => {
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => {
console.error('Problem sending telemetry page:', e)
})
}, [API_URL, enabled, hasAcceptedConsent])
const sendPageLeaveTelemetry = useCallback(() => {
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => {
console.error('Problem sending telemetry page-leave:', e)
})
}, [API_URL, enabled, hasAcceptedConsent])
// Handle initial page telemetry event
const hasSentInitialPageTelemetryRef = useRef(false)
useEffect(() => {
// Send page telemetry on first page load
// Waiting for router ready before sending page_view
// if not the path will be dynamic route instead of the browser url
if (
(router?.isReady ?? true) &&
hasAcceptedConsent &&
featureFlags.hasLoaded &&
!hasSentInitialPageTelemetryRef.current
) {
const cookies = document.cookie.split(';')
const telemetryCookie = cookies.find((cookie) => cookie.trim().startsWith(TELEMETRY_DATA))
if (telemetryCookie) {
try {
const encodedData = telemetryCookie.split('=')[1]
const telemetryData = JSON.parse(decodeURIComponent(encodedData))
handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, 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)
}
hasSentInitialPageTelemetryRef.current = true
}
}, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded])
useEffect(() => {
// For pages router
if (router === null) return
function handleRouteChange() {
// Wait until we've sent the initial page telemetry event
if (!hasSentInitialPageTelemetryRef.current) return
sendPageTelemetry()
}
// Listen for page changes after a navigation or when the query changes
router.events.on('routeChangeComplete', handleRouteChange)
return () => {
router.events.off('routeChangeComplete', handleRouteChange)
}
}, [router])
useEffect(() => {
// For app router
if (router !== null) return
// Wait until we've sent the initial page telemetry event
if (appPathname && !hasSentInitialPageTelemetryRef.current) {
sendPageTelemetry()
}
}, [appPathname, router, sendPageTelemetry])
useEffect(() => {
if (!enabled) return
const handleBeforeUnload = () => sendPageLeaveTelemetry()
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [enabled, sendPageLeaveTelemetry])
// Identify the user
useTelemetryIdentify(API_URL)
return null
}
// ---
// EVENT TELEMETRY
// ---
type EventBody = components['schemas']['TelemetryEventBodyV2']
export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathname?: string) {
const consent = hasConsented()
if (!consent) return
const body: EventBody = {
...getSharedTelemetryData(pathname),
action: event.action,
custom_properties: 'properties' in event ? event.properties : {},
groups: 'groups' in event ? { ...event.groups } : {},
}
if (body.groups?.project === 'Unknown') {
delete body.groups.project
if (body.groups?.organization === 'Unknown') {
delete body.groups
}
}
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, {
headers: { Version: '2' },
})
}
//---
// TELEMETRY IDENTIFY
//---
type IdentifyBody = components['schemas']['TelemetryIdentifyBodyV2']
export function sendTelemetryIdentify(API_URL: string, body: IdentifyBody) {
const consent = hasConsented()
if (!consent) return Promise.resolve()
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/identify`, body, {
headers: { Version: '2' },
})
}
export function useTelemetryIdentify(API_URL: string) {
const user = useUser()
useEffect(() => {
if (user?.id) {
sendTelemetryIdentify(API_URL, {
user_id: user.id,
})
}
}, [API_URL, user?.id])
}
//---
// TELEMETRY RESET
//---
export function handleResetTelemetry(API_URL: string) {
return post(`${API_URL}/telemetry/reset`, {})
}