Skip to content

Add a module that writes Google's tags on the page from the settings - #1371

Open
jwrosewell wants to merge 56 commits into
feature/29-viewport-sizesfrom
feature/30-google-tag
Open

jwrosewell wants to merge 56 commits into
feature/29-viewport-sizesfrom
feature/30-google-tag

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/tag/google-tag 5 files, 2062 lines added, 0 removed
crates/ad-tag/google 3 files, 421 lines added, 6 removed
crates/trusted-server-core/src 2 files, 69 lines added, 0 removed
crates/tag/google-tag-manager 1 file, 62 lines added, 0 removed
docs/guide 3 files, 58 lines added, 30 removed
trusted-server.example.toml 1 file, 25 lines added, 2 removed
Cargo.lock 1 file, 18 lines added, 0 removed
crates/testing/seam-probe 1 file, 17 lines added, 0 removed
AGENTS.md 1 file, 11 lines added, 11 removed
.cargo/config.toml 1 file, 6 lines added, 6 removed
crates/trusted-server-cli/src 1 file, 6 lines added, 2 removed
crates/trusted-server-modules/src 1 file, 6 lines added, 0 removed
scripts/clippy-wasm-clock 1 file, 4 lines added, 0 removed
CHANGELOG.md 1 file, 3 lines added, 0 removed
Cargo.toml 1 file, 2 lines added, 0 removed
clippy.toml 1 file, 2 lines added, 0 removed
crates/trusted-server-modules/Cargo.toml 1 file, 1 line added, 0 removed

Closes

Closes #1288

Spec

Spec: google-tag-module

Test plan

CI on this pull request at d8a8f15 passed all 20 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at d8a8f15

Checklist

  • Feature PRs: spec linked above, ending in a review log of two or more review rounds
  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on sizes by viewport width (#1370).

What changed after the 51Degrees PR review

Agreed at the review on 23 October, point 1. No configuration or permission policy is compiled into the WASM, and a WASM without a policy does not start.

Kept current

What this pull request does

[tag]
modules = ["google-tag"]

[tag.google-tag]
analytics_tag_id = "G-XXXXXXXXXX"
container_id = "GTM-EXAMPLE1"

[[serve]]
media_type = "text/html"
middleware = ["tag.google-tag"]

A module, crates/tag/google-tag, that writes Google's own snippets from the settings. In the head, the consent mode defaults, then Tag Manager's container script, then the gtag.js snippet, then the AdSense loader. Immediately after the opening body tag, Tag Manager's noscript iframe. Each snippet is copied from Google's documentation rather than paraphrased and carries the page it came from, so a reader can compare the two.

The head snippets do not land at the start of head. A serve middleware's head inserts are written immediately before the script bundle's tag, after the permission state script, the ad-slots script and every fetch-phase head insert. In the stock layout cmp.inmobi is a fetch middleware, so the consent defaults land after its stubs and loader. Google asks for the snippets as high in the head as possible, and within this module's own inserts the consent defaults come first.

The consent snippet sets the four consent types Google requires for its own services, ad_storage, ad_user_data, ad_personalization and analytics_storage, and each defaults to denied. It writes no wait_for_update and no region, so with a CMP in the deployment the tag fires with denied defaults the moment it loads rather than waiting for the CMP's update.

Core gains one place, where a middleware may write markup immediately after the opening body tag, body_open_inserts on its action, once per document, beside the places it had for the head and for straight after the script bundle. A deployment whose middleware write nothing there registers no handler for it.

How it relates to tag.google-tag-manager

Tag Manager is a fetch middleware that re-serves a Google tag the page already carries from the publisher's own origin. This module puts the tag on a page that carries none. When both are selected and Tag Manager serves the same tag or container, the loader this module writes names Tag Manager's first-party path, otherwise Google's own address. Tag Manager's crate offers the two paths and the two id shapes it accepts, so the two modules cannot disagree about an id. The noscript iframe always names Google, because ns.html has no first-party route behind it.

A page also loading gtag.js for the configured tag has that duplicate loader removed. An inline configuration call in the publisher's own page is not removed and still sends a second page view, which the guide says plainly, because removing it would mean buffering every response.

There is no guide page under docs/guide/integrations/ for this module. Its whole contract is the four settings and what it writes, which the configuration guide's [tag.google-tag] section states.

Why

A publisher whose page templates forbid third-party scripts cannot put a Google tag on the page, so it has to come from the proxy. Nothing did that, because the Tag Manager module re-serves a tag the page already carries, which needs a page that already loads Google. And a page that loads Google's tags itself sends measurement data before any consent default is set.

The seam probe's middleware writes a marker immediately after the opening body tag, and its page test reads it back once, directly after <body>, through the fetch processor.

How it was verified

Google's own handling of the snippets is not exercised. The tests hold the snippets to the text Google publishes and prove where each lands on the page.

References #1288.

jwrosewell and others added 17 commits October 11, 2026 21:34
`MiddlewareAction` gains `body_open_inserts`, markup written once per
document immediately after the opening `<body>` tag, in the order given,
beside the places it had for the head and for straight after the script
bundle. A vendor's two-part installation may put its second part there
and nowhere else, as Tag Manager's noscript frame is, and until now a
middleware had no way to reach that place. A document with no body gets
none, and a second body tag in a template fragment gets nothing.
A publisher whose page templates forbid third-party scripts cannot put a
Google tag on the page, so it has to come from the proxy, and nothing did
that: the Tag Manager module re-serves a tag the page already carries,
which needs a page that already loads Google.

`crates/tag/google-tag`, selected by `[tag] modules` with `[tag.google-tag]`,
writes Google's own snippets. At the start of head, the consent mode
defaults, then Tag Manager's container script, then the gtag.js snippet,
then the AdSense loader. Immediately after the opening body tag, Tag
Manager's noscript iframe. Each is copied from Google's documentation
rather than paraphrased and carries the page it came from, so a reader
can compare the two. Consent defaults come first because Google's consent
guide requires the default command before anything that sends measurement
data, and all four types default to denied because no Google identifier
may be stored before a permission signal has been processed.

Where each loader points is settled at startup, because head and body
insertions never reach the attribute rewriters. When the Tag Manager
module serves the same tag or container, the loader names the first-party
path, otherwise Google's own address, and the Tag Manager crate offers
the two paths and the two id shapes it accepts so the two modules cannot
disagree about an id. The noscript iframe always names Google, because
`ns.html` has no first-party route behind it. A page also loading gtag.js
for the same tag has that duplicate loader removed, which is why the
module registers before Tag Manager in the stock list, since a removal
short-circuits the rewriter chain. An inline configuration call in the
publisher's own page is not removed and still sends a second page view,
which the guide says plainly, because removing it would mean buffering
every response.
The docs said the head snippets land at the start of head, where a serve middleware writes them immediately before the script bundle's tag. The registration id is google_tag and private. The consent types are named as the four Google requires for its own services. The validate doc in the Tag Manager crate is back on validate, and unused links, repeated reasoning and the builders order comment are cut.
# Conflicts:
#	docs/guide/configuration.md
#	trusted-server.example.toml
# Conflicts:
#	docs/guide/configuration.md
A page that carries no GPT loader of its own got the bootstrap, the shim
and the slot handoff from this module and nothing that loads GPT, so its
slots never filled. `[ad-tag.google] write_loader = true` writes the
loader tag into the head, last among the module's inserts, after the shim
and the bootstrap it relies on. The tag asks for the loader on the
module's own route, `/integrations/gpt/script`, so the whole chain stays
first party. It is off by default, because a page that already carries
the loader would otherwise load it twice.

The guide's two tables and the GPT page name the setting, the example
settings file shows it, and the changelog says what it adds. Three tests
cover the tag written when asked, written last beside the slim-Prebid
address, and absent by default.
rustfmt breaks the two assertions over several lines, and prettier pads
the middleware table's row to the table's width.
…vice

The Google tag module wrote Tag Manager's container script, the gtag.js
snippet, the AdSense loader and Tag Manager's noscript iframe on every
reader's copy, whatever the reader's permissions said, so a reader who had
not agreed, or who sent an opt-out such as Global Privacy Control, still
got them. The middleware now declares storage on the device, the
permission each of them needs because each stores and reads Google's
identifiers there, and reads the reader's permissions in its preparation,
which core hands every serve preparation. It writes the loaders and the
iframe only where that permission is set, and none where no permission
state reached it. The consent defaults load nothing and are still written
for every reader, and the page's own loader for the configured tag is
still removed for every reader, so a reader without the permission ends
with none.

New tests plan the middleware through core's serve preparation for a
reader with every permission but storage, a reader with storage alone, a
preparation handed no permission state and a document nothing was
prepared for, and run a page for a reader in Germany who has given no
signal and a reader in California who has opted out, under the policy
core ships. The page tests now prepare both phases as core does for a
page it does not store. The changelog, the configuration guide and the
example settings say who gets the loaders.
With consent_defaults.region set, the Google tag module wrote one default
command carrying the regions and none without a region, so a visitor
outside the listed regions got no default from it, and Google's guide
says that by default no consent mode value is set. The guide also says a
default command with no region sets the default for every visitor no
regional command covers, and gives it after the regional one. The module
now writes that command after the regional one, with all four types
denied and the same wait, because a grant in the settings holds only in
the regions they list. With no region set it writes the one command as
before.

A new test sets a grant and a wait for one region and checks that two
commands are written, the regional one carrying the grant and the second
naming no region and denying all four with the same wait. The changelog
and the configuration guide say what every other visitor gets.
Every page test of the Google tag module selected it alone, so the case
where the Tag Manager module's fetch middleware has already pointed the
page's own loader at the first-party path was simulated by a page that
carried that path from the start. The new test selects both modules,
names the Tag Manager middleware in a [[fetch]] entry and this module's
in a [[serve]] entry, and runs a page that loads the configured tag from
Google's host. The fetch phase rewrites the page's loader, the serve
phase removes it and writes its own, and the page ends with one loader,
on the first-party path, and none on Google's host.
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15
@jwrosewell
jwrosewell added this pull request to stack #1418 October 19, 2026 12:18
@jwrosewell
jwrosewell removed this pull request from stack #1418 October 19, 2026 16:30
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:23
@jwrosewell
jwrosewell removed this pull request from stack #1422 October 28, 2026 11:47
@jwrosewell
jwrosewell added this pull request to stack #1465 October 28, 2026 18:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A publisher whose pages forbid third-party scripts cannot put a Google tag on the page

2 participants