Skip to content

Commit 850d1f0

Browse files
authored
feat(analytics): add the Google Ads conversion tag to the gtag loader (#7466)
* feat(analytics): add the Google Ads conversion tag to the gtag loader Adds AW-17916292239 as a second `config` destination on the existing GA4 gtag.js loader rather than a second `gtag/js` script tag, which is Google's documented pattern for sending one page to multiple Google products. It loads on every hosted route through GLOBAL_CONSENT_SCRIPTS, so conversion attribution is not split across a partial page set. Consent is unchanged in shape: Consent Mode v2 already maps ad_storage, ad_user_data, and ad_personalization to the `marketing` category, so a visitor who accepts measurement but declines marketing gets a cookieless ping instead of conversion tracking. Also allows the googleadservices/doubleclick origins the Ads tag reaches in script-src, connect-src, and frame-src; without them the tag would load and its conversion pings would be silently blocked. * fix(csp): gate the Google Ads frame origins behind isHosted The script-src and connect-src entries for the Ads tag were already inside the hosted-only branch, but the frame-src ones were not. The consent provider that loads the tag never mounts off hosted, so a self-hosted or dev deployment was permitting frames it can never use.
1 parent 69ba68b commit 850d1f0

3 files changed

Lines changed: 46 additions & 0 deletions

File tree

apps/sim/lib/consent/scripts.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from 'vitest'
55
import {
66
type ConsentScriptCallbackInfo,
77
GLOBAL_CONSENT_SCRIPTS,
8+
GOOGLE_ADS_ID,
9+
GOOGLE_ANALYTICS_ID,
810
HUBSPOT_SCRIPT,
911
X_PIXEL_SCRIPT,
1012
} from '@/lib/consent/scripts'
@@ -69,6 +71,22 @@ describe('consent scripts', () => {
6971
])
7072
})
7173

74+
it('configures Google Ads on the GA4 loader instead of a second gtag script', () => {
75+
window.dataLayer = []
76+
window.gtag = undefined
77+
78+
GLOBAL_CONSENT_SCRIPTS[0].onBeforeLoad?.(CALLBACK_INFO)
79+
80+
const configuredIds = window.dataLayer
81+
.filter((entry): entry is [string, string] => Array.isArray(entry) && entry[0] === 'config')
82+
.map(([, id]) => id)
83+
84+
expect(configuredIds).toEqual([GOOGLE_ANALYTICS_ID, GOOGLE_ADS_ID])
85+
expect(GLOBAL_CONSENT_SCRIPTS.map((script) => script.src)).not.toContain(
86+
`https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ADS_ID}`
87+
)
88+
})
89+
7290
it('gives HubSpot a query-free path before its automatic first page view', () => {
7391
window.history.replaceState({}, '', '/demo?email=private@example.com#booking')
7492
window._hsq = []

apps/sim/lib/consent/scripts.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import { gtag } from '@c15t/scripts/google-tag'
33
import { xPixel } from '@c15t/scripts/x-pixel'
44

55
export const GOOGLE_ANALYTICS_ID = 'G-DR7YBE70VS' as const
6+
7+
/**
8+
* Google Ads conversion tag. It rides the GA4 loader as a second `config`
9+
* destination rather than a second `gtag/js` script, which is Google's
10+
* documented pattern for sending one page to multiple Google products: a
11+
* second loader would re-run the same library, and c15t derives a script's
12+
* element id from the vendor name, so both entries would collide on `gtag`.
13+
*
14+
* Consent stays correct without a second consent category. The tag is loaded
15+
* under Consent Mode v2, where `ad_storage`, `ad_user_data`, and
16+
* `ad_personalization` are already mapped to the `marketing` category, so a
17+
* visitor who accepts measurement but declines marketing gets a cookieless
18+
* ping rather than conversion tracking.
19+
*/
20+
export const GOOGLE_ADS_ID = 'AW-17916292239' as const
621
export const X_PIXEL_ID = 'q5xbl' as const
722
export const X_DEMO_BOOKED_EVENT_ID = 'tw-q5xbl-q5xbn' as const
823

@@ -47,6 +62,7 @@ export const GLOBAL_CONSENT_SCRIPTS = [
4762
...(referrer ? { page_referrer: referrer } : {}),
4863
})
4964
initializeGoogleAnalytics?.(info)
65+
window.gtag('config', GOOGLE_ADS_ID)
5066
},
5167
},
5268
ahrefsAnalytics({ key: AHREFS_ANALYTICS_KEY }),

apps/sim/lib/core/security/csp.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ const STATIC_SCRIPT_SRC = [
7878
? [
7979
'https://www.googletagmanager.com',
8080
'https://www.google-analytics.com',
81+
// Google Ads conversion tag — gtag.js pulls conversion_async.js from
82+
// googleadservices and the remarketing tag from googleads.doubleclick
83+
'https://www.googleadservices.com',
84+
'https://googleads.g.doubleclick.net',
8185
'https://analytics.ahrefs.com',
8286
// HubSpot tracking (landing pages) — loader plus the
8387
// analytics/form-tracking/banner scripts it injects as <script> tags
@@ -128,6 +132,8 @@ const STATIC_CONNECT_SRC = [
128132
'https://www.google.com',
129133
'https://analytics.ahrefs.com',
130134
'https://*.g.doubleclick.net',
135+
// Google Ads conversion tag — conversion beacons
136+
'https://www.googleadservices.com',
131137
// HubSpot tracking — form-tracking API (hscollectedforms.js).
132138
// The visitor beacon itself is an image pixel (img-src, already
133139
// permitted below), not a connect-src request.
@@ -150,6 +156,12 @@ const STATIC_FRAME_SRC = [
150156
'https://drive.google.com',
151157
'https://docs.google.com',
152158
'https://*.google.com',
159+
// Google Ads conversion tag — the conversion linker writes its cookie from
160+
// a hidden iframe on these origins; without them the ping still fires but
161+
// cross-domain click attribution silently drops. Hosted-only, like the
162+
// script-src and connect-src entries: the consent provider that loads the
163+
// tag never mounts off hosted, so nothing self-hosted can frame these.
164+
...(isHosted ? ['https://td.doubleclick.net', 'https://www.googleadservices.com'] : []),
153165
'https://www.youtube.com',
154166
'https://player.vimeo.com',
155167
'https://www.dailymotion.com',

0 commit comments

Comments
 (0)