-
Notifications
You must be signed in to change notification settings - Fork 0
fix(worker): make the Google Ads conversion tag env-driven instead of a dead placeholder #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
33eb558
60dda49
4d9e326
2671258
29919a5
e7dbbc2
8371d1c
e133a35
20d2f9a
65435c4
c6a5d0d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,8 @@ | ||
| window.dataLayer = window.dataLayer || []; | ||
| function gtag(){dataLayer.push(arguments);} | ||
| gtag('js', new Date()); | ||
| gtag('config', 'AW-XXXXXXXXX'); | ||
| gtag('event', 'conversion', { | ||
| 'send_to': 'AW-XXXXXXXXX/YYYYYYYYYYYYYYYYYYY' | ||
| }); | ||
| // Google Ads conversion script. | ||
| // | ||
| // This file's real content is generated by the worker at request time from | ||
| // the GOOGLE_ADS_CONVERSION_ID and GOOGLE_ADS_CONVERSION_LABEL env values — | ||
| // see specs/003-wellness-clinic-launch/tracking-setup.md. A hardcoded tag | ||
| // with a placeholder id is dead by construction (it can never record a | ||
| // conversion), so without those env values this script stays a no-op and | ||
| // nothing fires. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,17 @@ const SECURITY_HEADERS = { | |
| "default-src 'self'; img-src 'self' data:; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" | ||
| }; | ||
|
|
||
| // Page-scoped CSP for /brief-requested ONLY when the Google Ads conversion | ||
| // tag is configured. gtag.js loads from googletagmanager.com and beacons to | ||
| // Google's measurement endpoints; the global CSP above blocks both, which | ||
| // made even a real conversion id dead on arrival. The allowances are scoped | ||
| // to this one noindex page's response so every other page keeps the strict | ||
| // CSP. Only reachable when GOOGLE_ADS_CONVERSION_ID / _LABEL are configured | ||
| // (see googleAdsConversion below); when they are not, the page ships with | ||
| // the strict CSP and no tag at all. | ||
| const GOOGLE_ADS_CSP = | ||
| "default-src 'self'; img-src 'self' data: https://www.googleadservices.com; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://static.cloudflareinsights.com https://www.googletagmanager.com; connect-src 'self' https://cloudflareinsights.com https://www.googletagmanager.com https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google-analytics.com https://stats.g.doubleclick.net; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"; | ||
|
|
||
| const PUBLIC_ASSET_PATHS = new Set([ | ||
| "/", | ||
| "/index.html", | ||
|
|
@@ -94,10 +105,10 @@ const WEEKLY_METRIC_LABELS = [ | |
| "Cash collected" | ||
| ]; | ||
|
|
||
| function withSecurityHeaders(response) { | ||
| function withSecurityHeaders(response, contentSecurityPolicy) { | ||
| const headers = new Headers(response.headers); | ||
| for (const [key, value] of Object.entries(SECURITY_HEADERS)) { | ||
| headers.set(key, value); | ||
| headers.set(key, key === "Content-Security-Policy" && contentSecurityPolicy ? contentSecurityPolicy : value); | ||
| } | ||
| return new Response(response.body, { | ||
| status: response.status, | ||
|
|
@@ -1316,6 +1327,41 @@ function isHtmlNavigation(request) { | |
| return (request.method === "GET" || request.method === "HEAD") && accept.includes("text/html"); | ||
| } | ||
|
|
||
| // ---- Google Ads conversion tag (funnel measurement) ----------------------- | ||
| // The funnel's only conversion measurement used to be dead by construction: | ||
| // brief-requested.html shipped a hardcoded gtag loader with a placeholder | ||
| // conversion id, and the production CSP blocked googletagmanager.com | ||
| // entirely, so the event could never record. The tag is now generated at | ||
| // request time from env values and only emitted on /brief-requested when | ||
| // BOTH are configured and well-formed; a partial or malformed config emits | ||
| // nothing rather than a dead tag. The strict patterns also mean the values | ||
| // are safe to interpolate into the generated script. | ||
| const GOOGLE_ADS_ID_PATTERN = /^AW-\d{6,15}$/; | ||
| const GOOGLE_ADS_LABEL_PATTERN = /^[A-Za-z0-9_-]{10,50}$/; | ||
|
|
||
| function googleAdsConversion(env) { | ||
| const id = String(env.GOOGLE_ADS_CONVERSION_ID || "").trim(); | ||
| const label = String(env.GOOGLE_ADS_CONVERSION_LABEL || "").trim(); | ||
| if (!GOOGLE_ADS_ID_PATTERN.test(id) || !GOOGLE_ADS_LABEL_PATTERN.test(label)) return null; | ||
| return { id, label }; | ||
| } | ||
|
|
||
| function googleAdsLoader({ id }) { | ||
| return `<!-- Google Ads conversion: injected by the worker from env (fires once, on this noindex page only) --> | ||
| <script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script>`; | ||
| } | ||
|
|
||
| function googleAdsScript({ id, label }) { | ||
| return `window.dataLayer = window.dataLayer || []; | ||
| function gtag(){dataLayer.push(arguments);} | ||
| gtag('js', new Date()); | ||
| gtag('config', '${id}'); | ||
| gtag('event', 'conversion', { | ||
| 'send_to': '${id}/${label}' | ||
| }); | ||
| `; | ||
| } | ||
|
|
||
| export default { | ||
| async fetch(request, env) { | ||
| const url = new URL(request.url); | ||
|
|
@@ -1342,6 +1388,36 @@ export default { | |
| } | ||
|
|
||
| if (PUBLIC_ASSET_PATHS.has(url.pathname)) { | ||
| const ads = googleAdsConversion(env); | ||
| const isBriefRequestedPage = | ||
| url.pathname === "/brief-requested" || url.pathname === "/brief-requested.html"; | ||
| const isBriefRequestedScript = url.pathname === "/brief-requested.js"; | ||
|
|
||
| if (ads && request.method === "GET" && (isBriefRequestedPage || isBriefRequestedScript)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a paid-search visitor lands on Useful? React with 👍 / 👎. |
||
| if (isBriefRequestedScript) { | ||
| return withSecurityHeaders( | ||
| new Response(googleAdsScript(ads), { | ||
| headers: { "Content-Type": "text/javascript;charset=UTF-8" } | ||
| }) | ||
|
Comment on lines
+1398
to
+1401
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the Google Ads ID or label is rotated after a visitor has loaded this endpoint, this generated 200 response has neither Useful? React with 👍 / 👎. |
||
| ); | ||
| } | ||
| const assetResponse = await env.ASSETS.fetch(request); | ||
| if (assetResponse.ok) { | ||
|
Comment on lines
+1404
to
+1405
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a browser revalidates a previously cached, unconfigured Useful? React with 👍 / 👎. |
||
| const html = await assetResponse.text(); | ||
| const rewritten = html.includes("</head>") | ||
| ? html.replace("</head>", `${googleAdsLoader(ads)}\n</head>`) | ||
| : html; | ||
| return withSecurityHeaders( | ||
| new Response(rewritten, { | ||
| status: assetResponse.status, | ||
| statusText: assetResponse.statusText, | ||
| headers: { "Content-Type": "text/html; charset=utf-8" } | ||
| }), | ||
| GOOGLE_ADS_CSP | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const assetResponse = await env.ASSETS.fetch(request); | ||
| return withSecurityHeaders(assetResponse); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When gtag sends a conversion or view-through beacon as an image request to
googleads.g.doubleclick.net(and, for some Google Ads flows,www.google.com), this policy blocks it: placing DoubleClick only inconnect-srcdoes not authorize image loads, whileimg-srcpermits onlywww.googleadservices.com. This can silently discard conversion signals even though the tag loads successfully, corrupting the paid-search experiment; add every Google Ads image-beacon origin toimg-srcand exercise the configured Worker response in the browser check.Useful? React with 👍 / 👎.