Skip to content

Commit 8fc4c1a

Browse files
authored
fix(analytics): protect production Google measurement (#8710)
1 parent 5ee756f commit 8fc4c1a

9 files changed

Lines changed: 518 additions & 34 deletions

File tree

‎apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
9898
useEffect(() => {
9999
let cancelled = false
100100
const trackDemoBooked = () => {
101+
if (cancelled) return
101102
if (measurement) {
102103
trackGoogleEvent('get_a_demo', {
103104
page_path: '/demo',

‎apps/sim/app/_shell/consent/consent-store-provider.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
CONSENT_CATEGORIES,
88
DEV_CONSENT_COUNTRY,
99
} from '@/lib/consent/constants'
10-
import { GLOBAL_CONSENT_SCRIPTS } from '@/lib/consent/scripts'
10+
import { getGlobalConsentScripts } from '@/lib/consent/scripts'
1111
import { CONSENT_STORAGE_CONFIG } from '@/lib/consent/storage'
1212

1313
/**
@@ -28,7 +28,7 @@ const CONSENT_OPTIONS = {
2828
mode: 'hosted',
2929
backendURL: CONSENT_BACKEND_URL,
3030
consentCategories: [...CONSENT_CATEGORIES],
31-
scripts: [...GLOBAL_CONSENT_SCRIPTS],
31+
scripts: getGlobalConsentScripts(),
3232
store: {
3333
storageConfig: CONSENT_STORAGE_CONFIG,
3434
reloadOnConsentRevoked: true,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* @vitest-environment jsdom
3+
* @vitest-environment-options {"url":"https://www.sim.ai"}
4+
*/
5+
import { writeFileSync } from 'node:fs'
6+
import { act } from 'react'
7+
import { type ConsentManagerOptions, ConsentManagerProvider } from '@c15t/nextjs/headless'
8+
import { clearConsentRuntimeCache, getOrCreateConsentRuntime } from 'c15t'
9+
import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'
10+
import { createRoot, type Root } from 'react-dom/client'
11+
import { afterEach, describe, expect, it, vi } from 'vitest'
12+
import { getGlobalConsentScripts } from '@/lib/consent/scripts'
13+
import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker'
14+
15+
let root: Root | undefined
16+
17+
afterEach(() => {
18+
act(() => root?.unmount())
19+
root = undefined
20+
clearConsentRuntimeCache()
21+
vi.useRealTimers()
22+
window.dataLayer = []
23+
Reflect.deleteProperty(window, 'gtag')
24+
window.history.replaceState({}, '', '/')
25+
})
26+
27+
describe('Google navigation measurement', () => {
28+
it.each([
29+
{ initialPath: '/', navigateBeforeConsent: false, navigateBeforeReady: false },
30+
{ initialPath: '/', navigateBeforeConsent: true, navigateBeforeReady: false },
31+
{ initialPath: '/', navigateBeforeConsent: true, navigateBeforeReady: true },
32+
{
33+
initialPath: '/workspace/workspace-id',
34+
navigateBeforeConsent: false,
35+
navigateBeforeReady: false,
36+
},
37+
])(
38+
'preserves consent and navigation semantics ($initialPath, $navigateBeforeConsent, $navigateBeforeReady)',
39+
({ initialPath, navigateBeforeConsent, navigateBeforeReady }) => {
40+
vi.stubEnv('NODE_ENV', 'production')
41+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
42+
vi.stubGlobal('matchMedia', (media: string) => ({
43+
matches: false,
44+
media,
45+
onchange: null,
46+
addListener: () => {},
47+
removeListener: () => {},
48+
addEventListener: () => {},
49+
removeEventListener: () => {},
50+
dispatchEvent: () => true,
51+
}))
52+
vi.useFakeTimers()
53+
const initialUrl = `${initialPath}?utm_source=proof`
54+
window.history.replaceState({}, '', initialUrl)
55+
const google = getGlobalConsentScripts().find((script) => script.id === 'gtag')
56+
const deniedConsent = {
57+
necessary: true,
58+
functionality: false,
59+
experience: false,
60+
measurement: false,
61+
marketing: false,
62+
}
63+
google?.onBeforeLoad?.({
64+
id: 'gtag',
65+
elementId: 'gtag',
66+
hasConsent: false,
67+
consents: deniedConsent,
68+
})
69+
70+
const options = {
71+
mode: 'offline',
72+
colorScheme: 'light',
73+
consentCategories: ['necessary', 'measurement', 'marketing'],
74+
store: { enabled: false },
75+
} satisfies ConsentManagerOptions
76+
const { consentStore } = getOrCreateConsentRuntime(options)
77+
consentStore.setState({
78+
hasFetchedBanner: true,
79+
loadedScripts: { gtag: true },
80+
consents: deniedConsent,
81+
policyCategories: ['necessary', 'measurement', 'marketing'],
82+
})
83+
const container = document.createElement('div')
84+
root = createRoot(container)
85+
const render = (path: string) => {
86+
window.history.pushState({}, '', path)
87+
act(() => {
88+
root?.render(
89+
<ConsentManagerProvider options={options}>
90+
<PathnameContext.Provider value={new URL(window.location.href).pathname}>
91+
<GoogleAnalyticsPageViewTracker />
92+
</PathnameContext.Provider>
93+
</ConsentManagerProvider>
94+
)
95+
})
96+
}
97+
const pageViews = () =>
98+
window.dataLayer.filter(
99+
(row) => Array.isArray(row) && row[0] === 'event' && row[1] === 'page_view'
100+
)
101+
const latestContext = () =>
102+
window.dataLayer.filter((row) => Array.isArray(row) && row[0] === 'set').at(-1)
103+
104+
render(navigateBeforeReady ? '/pricing?secret=private' : initialUrl)
105+
if (navigateBeforeConsent) {
106+
render('/pricing?secret=private')
107+
expect(latestContext()).toEqual([
108+
'set',
109+
expect.objectContaining({
110+
page_location: 'https://www.sim.ai/pricing',
111+
page_referrer: 'https://www.sim.ai/?utm_source=proof',
112+
}),
113+
])
114+
}
115+
expect(pageViews()).toHaveLength(0)
116+
117+
act(() => {
118+
consentStore.setState({ consents: { ...deniedConsent, measurement: true } })
119+
})
120+
expect(pageViews()).toHaveLength(navigateBeforeConsent ? 1 : 0)
121+
render('/demo')
122+
render('/demo')
123+
expect(pageViews()).toHaveLength(navigateBeforeConsent ? 2 : 1)
124+
125+
act(() => {
126+
consentStore.setState({ consents: deniedConsent })
127+
})
128+
render('/workspace/workspace-id/files/file-id')
129+
expect(pageViews()).toHaveLength(navigateBeforeConsent ? 2 : 1)
130+
expect(latestContext()).toEqual([
131+
'set',
132+
expect.objectContaining({
133+
page_location: 'https://www.sim.ai/workspace',
134+
page_referrer: 'https://www.sim.ai/demo',
135+
page_title: 'Sim',
136+
}),
137+
])
138+
139+
const reportPath = process.env.GAUGE_TRACKER_REPORT_PATH
140+
if (reportPath && navigateBeforeConsent && !navigateBeforeReady) {
141+
writeFileSync(
142+
reportPath,
143+
JSON.stringify(
144+
{
145+
status: 'passed',
146+
checks: [
147+
'denied navigation context',
148+
'consent emission gate',
149+
'delayed consent after navigation',
150+
'navigation deduplication',
151+
'withdrawn consent context',
152+
],
153+
commands: window.dataLayer,
154+
limitation:
155+
'Real React, Next router context and c15t store; Google SDK collection is not exercised.',
156+
},
157+
null,
158+
2
159+
)
160+
)
161+
}
162+
}
163+
)
164+
})

‎apps/sim/app/_shell/consent/google-analytics-page-view-tracker.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
44
import { useConsentManager } from '@c15t/nextjs/headless'
55
import { usePathname } from 'next/navigation'
66
import { trackGooglePageView } from '@/lib/analytics/google'
7+
import { getInitialGooglePagePathname, updateGooglePageContext } from '@/lib/consent/google-context'
78

89
/** Tracks Next.js client navigations after c15t has loaded the consent-aware tag. */
910
export function GoogleAnalyticsPageViewTracker() {
@@ -12,12 +13,13 @@ export function GoogleAnalyticsPageViewTracker() {
1213
const lastTrackedPathRef = useRef<string | null>(null)
1314

1415
useEffect(() => {
15-
if (!hasFetchedBanner || !has('measurement') || !loadedScripts.gtag) return
16+
if (!hasFetchedBanner || !loadedScripts.gtag) return
17+
updateGooglePageContext(window.location.href)
1618

1719
if (lastTrackedPathRef.current === null) {
18-
lastTrackedPathRef.current = pathname
19-
return
20+
lastTrackedPathRef.current = getInitialGooglePagePathname() ?? pathname
2021
}
22+
if (!has('measurement')) return
2123
if (lastTrackedPathRef.current === pathname) return
2224

2325
lastTrackedPathRef.current = pathname

‎apps/sim/lib/analytics/google.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getGooglePageLocation, updateGooglePageContext } from '@/lib/consent/google-context'
12
import { GOOGLE_ADS_ID, GOOGLE_ANALYTICS_ID } from '@/lib/consent/scripts'
23

34
/** Conversion labels registered in Google Ads, keyed by the action they measure. */
@@ -21,24 +22,34 @@ export function trackGoogleEvent<E extends keyof GoogleAnalyticsEventMap>(
2122
name: E,
2223
parameters: GoogleAnalyticsEventMap[E]
2324
): void {
24-
window.gtag?.('event', name, parameters)
25+
if (!getGooglePageLocation(window.location.href)) return
26+
updateGooglePageContext(window.location.href)
27+
window.gtag?.('event', name, { ...parameters, send_to: GOOGLE_ANALYTICS_ID })
2528
}
2629

2730
/**
2831
* Records a Google Ads conversion, addressed as `<tag id>/<conversion label>`.
29-
* Call only after the caller has verified marketing consent: without it Consent
30-
* Mode keeps `ad_storage` denied and the hit could not be attributed to a click.
32+
* Call only after the caller has verified marketing consent, matching the
33+
* application's policy for explicitly sending conversion events.
3134
*/
3235
export function trackGoogleAdsConversion(conversion: GoogleAdsConversion): void {
36+
if (!getGooglePageLocation(window.location.href)) return
37+
updateGooglePageContext(window.location.href)
3338
window.gtag?.('event', 'conversion', {
3439
send_to: `${GOOGLE_ADS_ID}/${GOOGLE_ADS_CONVERSION_LABELS[conversion]}`,
3540
})
3641
}
3742

3843
export function trackGooglePageView(path: string): void {
44+
const url = new URL(window.location.href)
45+
url.pathname = path
46+
const pageLocation = getGooglePageLocation(url.href)
47+
if (!pageLocation) return
48+
49+
updateGooglePageContext(pageLocation)
3950
window.gtag?.('event', 'page_view', {
40-
page_path: path,
41-
page_location: `${window.location.origin}${path}`,
51+
page_path: new URL(pageLocation).pathname,
52+
page_location: pageLocation,
4253
send_to: GOOGLE_ANALYTICS_ID,
4354
})
4455
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { isNoindexPath } from '@/lib/navigation/paths'
2+
3+
const GOOGLE_ANALYTICS_ORIGINS = new Set(['https://sim.ai', 'https://www.sim.ai'])
4+
5+
const TOKEN_UTILITY_PATHS = [
6+
'/slack-search',
7+
'/credential-groups/enroll',
8+
'/enterprise/claim',
9+
] as const
10+
11+
const GOOGLE_CAMPAIGN_PARAMETERS = [
12+
'utm_source',
13+
'utm_medium',
14+
'utm_campaign',
15+
'utm_id',
16+
'utm_term',
17+
'utm_content',
18+
'gclid',
19+
'dclid',
20+
'gbraid',
21+
'wbraid',
22+
] as const
23+
24+
interface GooglePageContext {
25+
page_location: string
26+
page_referrer: string
27+
page_title: string
28+
}
29+
30+
let currentGooglePageContext: GooglePageContext | undefined
31+
let initialGooglePagePathname: string | undefined
32+
33+
/** Returns the page queued for Google's initial configuration, before its script finishes loading. */
34+
export function getInitialGooglePagePathname(): string | undefined {
35+
return initialGooglePagePathname
36+
}
37+
38+
function getPrivatePageRoot(pathname: string): string | undefined {
39+
const routePath = decodeURIComponent(pathname)
40+
if (isNoindexPath(routePath)) return `/${routePath.split('/')[1]}`
41+
return TOKEN_UTILITY_PATHS.find((root) => routePath === root || routePath.startsWith(`${root}/`))
42+
}
43+
44+
function getPageReferrer(value: string): string {
45+
try {
46+
const url = new URL(value)
47+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return ''
48+
const pathname = GOOGLE_ANALYTICS_ORIGINS.has(url.origin)
49+
? (getPrivatePageRoot(url.pathname) ?? url.pathname)
50+
: '/'
51+
return `${url.origin}${pathname}`
52+
} catch {
53+
return ''
54+
}
55+
}
56+
57+
/** Restricts Sim's Google destinations to production origins and approved campaign parameters. */
58+
export function getGooglePageLocation(value: string): string | undefined {
59+
if (process.env.NODE_ENV !== 'production') return undefined
60+
61+
try {
62+
const url = new URL(value)
63+
if (!GOOGLE_ANALYTICS_ORIGINS.has(url.origin)) return undefined
64+
65+
const campaign = new URLSearchParams()
66+
for (const parameter of GOOGLE_CAMPAIGN_PARAMETERS) {
67+
const value = url.searchParams.get(parameter)
68+
if (value) campaign.set(parameter, value)
69+
}
70+
71+
const query = campaign.toString()
72+
const pathname = getPrivatePageRoot(url.pathname) ?? url.pathname
73+
return `${url.origin}${pathname}${query ? `?${query}` : ''}`
74+
} catch {
75+
return undefined
76+
}
77+
}
78+
79+
/** Keeps shared Google event context aligned with the current sanitized virtual page. */
80+
export function updateGooglePageContext(value: string, initialReferrer?: string): void {
81+
const pageLocation = getGooglePageLocation(value)
82+
if (!pageLocation) return
83+
if (initialReferrer !== undefined) initialGooglePagePathname = new URL(value).pathname
84+
85+
const pathname = new URL(pageLocation).pathname
86+
const privatePage = getPrivatePageRoot(pathname) !== undefined
87+
const pageReferrer =
88+
initialReferrer !== undefined
89+
? getPageReferrer(initialReferrer)
90+
: currentGooglePageContext?.page_location === pageLocation
91+
? currentGooglePageContext.page_referrer
92+
: (currentGooglePageContext?.page_location ?? getPageReferrer(document.referrer))
93+
94+
currentGooglePageContext = {
95+
page_location: pageLocation,
96+
page_referrer: pageReferrer,
97+
page_title: privatePage ? 'Sim' : document.title,
98+
}
99+
window.gtag?.('set', currentGooglePageContext)
100+
}

0 commit comments

Comments
 (0)