Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions public/brief-requested.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
<link rel="stylesheet" href="shared.css">

<!-- ── Google Ads conversion ──────────────────────────────────────────────
Replace AW-XXXXXXXXX and the send_to label with the real values from
Google Ads › Goals › Conversions › "Brief requested" › Tag setup.
Fires once, on page load. This page is the ONLY page that fires it, and
is noindex so it can never be reached organically.
The conversion tag is NOT hardcoded here: a placeholder tag is dead by
construction. The worker injects the gtag loader into this page's
response (and generates brief-requested.js) at request time when
GOOGLE_ADS_CONVERSION_ID and GOOGLE_ADS_CONVERSION_LABEL are configured
— see specs/003-wellness-clinic-launch/tracking-setup.md. Without them
nothing fires, so a dead tag can never ship. Fires once, on page load,
on this noindex page only.
────────────────────────────────────────────────────────────────────────── -->
<script async src="https://www.googletagmanager.com/gtag/js?id=AW-XXXXXXXXX"></script>



<link rel="stylesheet" href="brief-requested.css">
Expand Down
15 changes: 8 additions & 7 deletions public/brief-requested.js
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.
37 changes: 37 additions & 0 deletions scripts/check-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,43 @@ if (renderBlockingScript) {
}
}

// ---- Google Ads conversion tag (funnel measurement) ------------------------
// The funnel's only conversion measurement was dead by construction: the
// brief-requested page hardcoded a gtag loader with the AW-XXXXXXXXX
// placeholder, and the production CSP blocked googletagmanager.com entirely,
// so the event could never record. The tag is now generated by the worker at
// request time from GOOGLE_ADS_CONVERSION_ID / GOOGLE_ADS_CONVERSION_LABEL
// and only emitted on /brief-requested when both are configured (see
// specs/003-wellness-clinic-launch/tracking-setup.md). These STATIC SOURCE
// GUARDS make the dead-by-construction shape impossible again: no placeholder
// may exist in public/ or src/worker.js, no public file may hardcode the gtag
// loader, the static brief-requested.js may not fire anything, and the worker
// must keep the env-driven injection wired.
const adsHtml = read("public/brief-requested.html");
const adsScript = read("public/brief-requested.js");
for (const placeholder of ["AW-XXXXXXXXX", "YYYYYYYYYYYYYYYYYYY"]) {
for (const [label, content] of [
["public/brief-requested.html", adsHtml],
["public/brief-requested.js", adsScript],
["src/worker.js", worker]
]) {
if (content.includes(placeholder)) {
failures.push(`Google Ads placeholder must never ship (dead conversion): ${placeholder} in ${label}.`);
}
}
}
if (adsHtml.includes("googletagmanager.com")) {
failures.push("public/brief-requested.html must not hardcode the Google Ads tag; the worker injects it from env at request time.");
}
if (adsScript.includes("gtag(") || adsScript.includes("dataLayer")) {
failures.push("public/brief-requested.js must not fire a conversion statically; the worker generates it from env when configured.");
}
for (const needle of ["GOOGLE_ADS_CONVERSION_ID", "GOOGLE_ADS_CONVERSION_LABEL", "gtag/js", "/brief-requested.js"]) {
if (!worker.includes(needle)) {
failures.push(`Worker must keep the env-driven Google Ads conversion wiring (${needle}).`);
}
}

if (existsSync(new URL("../public/pipeline-sprint/index.html", import.meta.url))) {
failures.push("Pipeline Sprint page should not remain as a separate stale public asset.");
}
Expand Down
74 changes: 74 additions & 0 deletions scripts/test-agent-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1045,3 +1045,77 @@ test("worker does not serve unlisted asset-like paths outside the public allow-l
const res = await worker.fetch(new Request("https://tinystudio.io/not-listed.js"), env);
assert.equal(res.status, 404);
});

// ---- Google Ads conversion tag (funnel measurement) ------------------------
// The funnel's only conversion measurement was dead by construction: the
// brief-requested page hardcoded the AW-XXXXXXXXX placeholder, and the
// production CSP blocked googletagmanager.com, so the event could never
// record. The tag must therefore be generated by the worker from env values
// and only emitted on /brief-requested when both are configured; with either
// missing or malformed, the page ships with no tag at all.
const BRIEF_REQUESTED_HTML = '<!doctype html><html><head><title>brief</title></head><body>ok</body></html>';

function adsEnv(overrides = {}) {
return {
ASSETS: {
fetch: async () =>
new Response(BRIEF_REQUESTED_HTML, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" }
})
},
GOOGLE_ADS_CONVERSION_ID: "AW-1234567890",
GOOGLE_ADS_CONVERSION_LABEL: "AbCdEfGhIjKlMnOpQrSt",
...overrides
};
}

test("brief-requested ships no Google Ads tag while the conversion env is not configured", async () => {
const env = adsEnv({ GOOGLE_ADS_CONVERSION_ID: "", GOOGLE_ADS_CONVERSION_LABEL: "" });
const res = await worker.fetch(new Request("https://tinystudio.io/brief-requested"), env);
assert.equal(res.status, 200);
const body = await res.text();
assert.ok(!body.includes("googletagmanager.com"), "no tag may ship while unconfigured");
const csp = res.headers.get("Content-Security-Policy") || "";
assert.ok(!csp.includes("googletagmanager.com"), "CSP must stay strict while unconfigured");
const js = await (await worker.fetch(new Request("https://tinystudio.io/brief-requested.js"), env)).text();
assert.ok(!js.includes("gtag("), "no conversion event may fire while unconfigured");
});

test("worker injects the configured Google Ads conversion tag on /brief-requested only", async () => {
const env = adsEnv();
const res = await worker.fetch(new Request("https://tinystudio.io/brief-requested"), env);
assert.equal(res.status, 200);
const body = await res.text();
assert.ok(
body.includes('src="https://www.googletagmanager.com/gtag/js?id=AW-1234567890"'),
"gtag loader must be injected with the real conversion id"
);
assert.ok(body.includes("</head>"), "injected loader must sit inside the head");
const csp = res.headers.get("Content-Security-Policy") || "";
assert.ok(csp.includes("https://www.googletagmanager.com"), "brief-requested CSP must allow the gtag script origin");
assert.ok(csp.includes("googleads.g.doubleclick.net"), "brief-requested CSP must allow the conversion beacon");

const js = await (await worker.fetch(new Request("https://tinystudio.io/brief-requested.js"), env)).text();
assert.ok(js.includes("gtag('config', 'AW-1234567890')"), "generated script must configure the real conversion id");
assert.ok(js.includes("AW-1234567890/AbCdEfGhIjKlMnOpQrSt"), "generated script must send the real conversion label");

// Every other page keeps the strict CSP even when the tag is configured.
const other = await worker.fetch(new Request("https://tinystudio.io/pricing.html"), env);
const otherCsp = other.headers.get("Content-Security-Policy") || "";
assert.ok(!otherCsp.includes("googletagmanager.com"), "other pages must keep the strict CSP");
});

test("worker refuses to emit a Google Ads tag for a partial or malformed conversion config", async () => {
const malformed = [
adsEnv({ GOOGLE_ADS_CONVERSION_LABEL: "" }),
adsEnv({ GOOGLE_ADS_CONVERSION_ID: "AW-123" }),
adsEnv({ GOOGLE_ADS_CONVERSION_ID: "javascript:alert(1)" })
];
for (const env of malformed) {
const res = await worker.fetch(new Request("https://tinystudio.io/brief-requested"), env);
const body = await res.text();
assert.ok(!body.includes("googletagmanager.com"), "no tag may emit for a partial or malformed config");
assert.equal(body, BRIEF_REQUESTED_HTML, "unconfigured page must pass through untouched");
}
});
15 changes: 12 additions & 3 deletions specs/003-wellness-clinic-launch/tracking-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,17 @@ I cannot create this without account access. Exact steps:
- Click-through window: **30 days**
- Attribution: **Data-driven**, or last-click if data-driven is unavailable
4. Tag setup → copy the **conversion ID** (`AW-…`) and the **conversion label**
5. Paste both into `brief-requested.html`, replacing `AW-XXXXXXXXX` and the
`send_to` label. **Four placeholder occurrences — replace all of them.**
5. Set both on the Worker so the tag is emitted at request time — no code
change, and no placeholder can ever ship as a dead conversion:
- `wrangler secret put GOOGLE_ADS_CONVERSION_ID` → the `AW-…` ID
- `wrangler secret put GOOGLE_ADS_CONVERSION_LABEL` → the conversion label
- (For `wrangler dev --remote`, put both in `.dev.vars` instead.)
The Worker validates both (`AW-` + digits; a 10+ character alphanumeric
label) and injects the gtag loader + conversion event into
`/brief-requested` **only** when both are set and well-formed. With either
missing or malformed, the page ships with no tag at all — a dead tag is
never served. The CSP allowances for gtag are scoped to that one noindex
page's response; every other page keeps the strict CSP.

## 4. GA4 + Ads link

Expand All @@ -94,7 +103,7 @@ works is a day of data you cannot use.
- [ ] `htmlRedirect()` in `src/worker.js` points at `/brief-requested`
- [ ] JS success path redirects to `/brief-requested`
- [ ] Conversion action created — Submit lead form, no value, count One, 30 days
- [ ] `AW-…` ID and label pasted into `brief-requested.html` (all four spots)
- [ ] `GOOGLE_ADS_CONVERSION_ID` and `GOOGLE_ADS_CONVERSION_LABEL` set on the Worker (`wrangler secret put`; `.dev.vars` for dev)
- [ ] GA4 linked to Ads
- [ ] Live test submission verified as a recorded conversion
- [ ] Negatives loaded, ads approved
Expand Down
80 changes: 78 additions & 2 deletions src/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow Google Ads pixels under img-src

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 in connect-src does not authorize image loads, while img-src permits only www.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 to img-src and exercise the configured Worker response in the browser check.

Useful? React with 👍 / 👎.


const PUBLIC_ASSET_PATHS = new Set([
"/",
"/index.html",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Load the Google tag before the confirmation page

When a paid-search visitor lands on / or /audit with a gclid/wbraid, this condition excludes that landing response, so Google's conversion linker never gets a chance to persist the click identifier; htmlRedirect() then clears the query before navigating to /brief-requested. In browsers where third-party attribution is unavailable, the resulting event cannot be tied back to the campaign, causing the paid-search experiment to undercount conversions. Load the base Google tag/conversion linker on the landing pages while keeping the conversion event restricted to the confirmation page, or otherwise preserve the click identifier through the flow.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark the generated conversion script non-cacheable

When the Google Ads ID or label is rotated after a visitor has loaded this endpoint, this generated 200 response has neither Cache-Control nor a config-specific URL, so a browser cache may reuse JavaScript containing the previous send_to value. The newly injected HTML can then load gtag for the new ID while the unchanged /brief-requested.js attributes the conversion to the old action; return this env-derived script with Cache-Control: no-store (and apply equivalent cache protection to the generated HTML).

Useful? React with 👍 / 👎.

);
}
const assetResponse = await env.ASSETS.fetch(request);
if (assetResponse.ok) {
Comment on lines +1404 to +1405

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle conditional 304s before injecting the tag

When a browser revalidates a previously cached, unconfigured /brief-requested page after the Ads secrets are enabled, the preserved static-asset ETag can make this fetch return 304. Because a 304 has ok === false, execution falls through and repeats the same conditional asset request, returning a strict-CSP 304 and causing the browser to reuse the cached tag-free HTML. Returning visitors—and potentially the post-activation verification browser—therefore remain untracked until their cache is bypassed; fetch the source HTML without request validators or prevent the unconfigured confirmation response from being cached.

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);
}
Expand Down
Loading