Skip to content

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

Closed
Jamesr51d wants to merge 691 commits into
IABTechLab:mainfrom
jwrosewell:split/30-google-tag
Closed

Jamesr51d wants to merge 691 commits into
IABTechLab:mainfrom
jwrosewell:split/30-google-tag

Conversation

@Jamesr51d

@Jamesr51d Jamesr51d commented Oct 12, 2026 •

Copy link
Copy Markdown

Stacks on sizes by viewport width (#1317). The additions that follow the seven pull requests open here are listed in #777.
Compare split/29-viewport-sizes with split/30-google-tag
to see only this pull request's change.

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: 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: 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

CI passed on 0831b7e5e on the fork. Not exercised: Google's own handling of the snippets. The tests hold the snippets to the text Google publishes and prove where each lands on the page.

References #1288.

`[cmp] module = "inmobi"` selects it, with the account's Choice loader in
`[cmp.inmobi] script_url` and the IAB CMP id in `cmp_id`, which is 10 unless
written. Where a `[[fetch]]` entry names `cmp.inmobi`, the module's
middleware writes three things into the head, in this order: the
IAB TCF v2 stub, which queues every `__tcfapi` call made before the prompt
arrives and answers `ping` as a stub; InMobi's GPP stub, which answers the
generic `__gpp` commands at once with the CMP id and queues the rest; and
the Choice loader, deferred, so a slow consent vendor never holds up the
page and the loader never runs ahead of a stub the publisher's own head
carries. Each stub defines nothing where the page already has its
interface. The module serves no browser module, because the stubs and the
Choice script are the whole client side. The prompt writes the reader's
answer to the cookie the TCF permission signal reads, so the answer reaches
the permission model on the next request the way every signal does.

The script URL becomes a `<script src>` on every page, and a settings
document reaches the service from a config store rather than from a person
reading it, so it is refused when it is empty, is not a URL, is not `https`,
is on a host other than `cmp.inmobi.com` or `cdn.inmobi.com`, or carries a
user name or password. A host that merely contains an allowed name is
refused by what it is rather than by what it looks like, and the URL is
escaped again when it is written, because a URL on the right host can still
carry a quote in its query. A document that selects the prompt and names it
in no entry is refused, because the prompt would never be written, and the
table refuses a field it does not read.

The two stubs are tested in a stand-in for the browser with Node's test
runner, `node --test "crates/cmp/inmobi/tests/*.test.mjs"`, which runs the
scripts as the page carries them. The module's tests cover each refusal,
the tag's parts and their order, that a vendor's tag named after the prompt
is written after it, and the page a reader receives, recorded in
`src/fixtures/page-change.recorded.html`.

The stock list offers the module after Didomi and lists its middleware in
the fetch phase, the example configuration names it in its fetch entry and
shows its table, and the cargo aliases build and test it with the other
modules. 51Degrees maintain the crate until InMobi adopt it, which its
manifest states.
A module that writes a script into pages keeps that script's tests beside
it, as `tests/*.test.mjs`, run with Node's test runner and no packages. The
vitest job runs every such file after the browser bundle's tests, from the
repository root, so a stub that changes is tested the way a page runs it.
The InMobi Choice module's two stubs are the first.
A guide page for the module, with its settings, where to place it, what is
checked, and what a consent prompt module keeps to, in the sidebar beside
the other consent management platforms. The configuration guide's section
table, its list of the modules this repository ships, its placing table and
its integration sections carry the module, the integrations overview lists
it, the crate list in AGENTS.md names it, and the changelog records it.
An entry may cover any media type core streams as text: `text/html`,
another `text/` type other than `text/x-component`, whose rows are length
prefixed, `application/javascript`, `application/json` or `image/svg+xml`,
written as a bare `type/subtype` in lower case. A response is matched by the
type and subtype of its content type, without the parameters.

A middleware says which media types it works on with `handles_media_type`,
HTML alone unless it says otherwise, so every module keeps its behavior
with no change to its code. An entry naming a middleware under a media type
it does not handle is refused when the registry is built, naming the entry
and the middleware. A chain carries its media type, and on one other than
HTML its plan refuses an action that decides about HTML, being head markup
or an element or text handler, by the middleware's name, because those are
decisions about HTML and a stream processor is the one thing that applies
to any body.

The publisher runs the covering entries' stream processors on a body of
such a media type after it has moved the origin's addresses in it: the
fetch entry's on what is stored, and the serve entry's on each reader's
copy, whether that copy is assembled from the store or fetched for the
request, as on a page.

Core's test fixture gains a middleware that works on `text/plain` and adds
a line at the end of a body, and the tests cover the entries accepted and
refused, the media type of a content type, a chain refusing an HTML
decision on text and taking a stream, the registry refusing a middleware
under a media type it does not handle in either direction, and a text body
through the publisher with and without a covering entry.
The configuration guide says which media types an entry may cover, how a
response is matched, what runs on a body that is not HTML and what is
refused, with an entry for a text file as the example. The integration
guide shows `handles_media_type` on a middleware that works on
`text/plain`. The changelog records the change.
`[ads-txt] modules = ["sellers"]` selects it, with the lines to add in
`[ads-txt.sellers] lines`. The module is a middleware for `text/plain`,
named in a `[[fetch]]` entry on the file's path, which passes the origin's
file through and writes the lines after its last one, on a line of their
own, so a seller the deployment introduces is declared beside the
publisher's own. The publisher's file stays authoritative, and a publisher
whose origin has no file gets the origin's answer, because the module adds
to the publisher's file and never invents one.

Every buyer's crawler reads the file and a typo there costs the publisher
revenue silently, so each line is checked as the settings load against the
file's grammar: a data record is a domain, an account id, DIRECT or
RESELLER and an optional certification authority id, separated by commas,
and a variable is one of CONTACT, SUBDOMAIN, INVENTORYPARTNERDOMAIN,
OWNERDOMAIN or MANAGERDOMAIN with a value. A comment, an empty line and an
empty list are refused, naming the line and what is wrong with it, and so
is a document that selects the module but names it in no entry, where the
lines would never be written.

The stock list offers the module after the page modules and lists its
middleware in the fetch phase, the example configuration shows its tables
and entry, the cargo aliases build and test it with the other modules, and
the crate is the project's own, which its manifest states. The tests cover
each refusal, the lines the grammar accepts, the file with and without a
final line break, an empty file, a file read in pieces, and the module's
place in a registry.
The configuration guide gains the section, its module's table, how the file
is made and what is refused, with the entry that places the middleware,
and lists the module in its key sections and placing tables. The rules
guide names the section among the types and in its table. The changelog
records the module.
`cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` declare both phases, so an
entry of either phase may name them. What a prompt writes is the same for
every reader, so the fetch phase stores it in the page every reader is
served from, which is what a generated configuration places, and the serve
phase writes it on each reader's copy for a publisher who wants the stored
page without it. Selecting the InMobi module with no entry of either phase
naming it is still refused.

The stock list keeps each prompt's row in the fetch phase, which is the
phase `ts audit generate` writes, and its test allows a middleware that
runs in several phases to be listed in one of them, checking that what is
listed for a phase is registered for it and that everything registered is
listed somewhere.
The InMobi guide places the prompt by an entry of either phase and says
what each phase does with it, the configuration guide's middleware table
says the same for every consent management platform, and the changelog
records the change.
…ts path

When the origin answers `404 Not Found` for a path a `[[fetch]]` entry with
a path names middleware for, core runs that middleware on an empty document
of the entry's media type and its output is the file, stored and served as a
fetched one is. A GET and a HEAD are answered alike. With no such entry, on
any other answer from the origin, or where only the entry covering every
path applies, the origin's answer stands, because a page the origin does not
have is the origin's answer.

`PhaseEntries::creating` finds the entry. The sellers crate says its file is
made from the lines alone where the publisher has none, which its middleware
already does on an empty document.
The configuration guide says it with the entries for other media types and
in the ads.txt section, the integration guide tells a middleware it may be
run on an empty document, and the changelog records the rule.
`Middleware::prepare` answers a future for a document whose decision needs
the request's services or has to wait, on the key-value store or the
network. The request's handler runs it before the document is processed,
once per phase the document goes through, as the middleware's module and
with the permissions it declares, and keeps what it answers under the
middleware's name in the context's `Prepared`, where `create` takes it as
the type it prepared. `create` and every streaming path stay synchronous,
so the Fastly adapter is as it was. A middleware that prepares nothing is
asked `create` as before.

A preparation may answer a `Refusal` instead, with a status, an optional
`Retry-After` and a message. The handler answers the request with that
status and the message as plain text, marks the response private and
unstorable, and processes no document, so a middleware responsible for a
file fails closed when it cannot make it. The first refusal ends the
preparations of a document. A copy read from the store prepares its serve
chain the same way before it is assembled.

The middleware fixture gains one that prepares and one that refuses, and the
tests cover what is prepared reaching `create` in either phase, the refusal's
status, headers and body, the chain keeping each answer under its name, and
the first refusal ending the preparation.
The section says when a middleware prepares, what it is called with, where
its answer goes, how a refusal is answered, and what each phase's
preparation must not depend on. The changelog records the addition.
`PlatformLoadingCache` in `platform/loading_cache.rs` takes a key and a
loader and returns the value, running the loader at most once per key at a
time, so however many requests ask for a missing value at the same moment
the source is asked once and every one of them continues with its value or
its error. A failed load is not stored, so the next caller loads again.
`NoLoadingCache` holds nothing and runs every load, and is what
`RuntimeServices` carries when no module supplies a cache.

Every key is a `StoreKey`, being the publisher's `ServerKeyPrefix`, the
cache's name and the caller's key. The prefix is made in one place,
`platform/server_key.rs`, from the publisher's domain in the loaded settings
with a single leading `www.` folded and `ts.` in front, so two publishers
sharing a store never share an entry.

`PlatformValueCache` is the host's shared cache of small values with
lifetimes, which a host supplies through `RuntimeServices` when it has one,
with `lookup_or_reserve` handing the caller a `ValueReservation` where the
host can make other callers for a key wait, and `remove`. A loading cache
keeps its shared copies there where the host has one and in the key-value
store otherwise.

An `IntegrationBuilder` offers a loading cache with `with_loading_cache`,
and `loading_cache_from` gives an adapter the one its linked builders offer,
refusing two, so no adapter names the crate that supplies it. Core's tests
get `MemoryLoadingCache`, which keeps what it loads for the lifetime the
load gave and records every call.
A contributor's answer is loaded through `RuntimeServices::load_cached`,
so the contributor is asked once however many requests want its rules at
the same moment, and the copy in the host's shared store answers every
instance until the contributor's refresh is due. The answer's lifetime in
the cache is the contributor's refresh, and `[cache.robots-txt]` sets how
long the copies last with `memory_seconds`, `store_seconds` and
`store_idle_seconds`, defaulting to a minute, a week and 30 days. A memory
lifetime may be 0, the other two must be at least 1, and none may be more
than a year, which `LoadingCacheConfig::check` refuses as the settings load,
naming the setting.

The last answer a contributor gave is also kept apart from the cache for 30
days, in the host's value cache where it has one and in the key-value store
otherwise, and read only when the contributor fails, because yesterday's
rules are a better answer than none. Every key starts with the publisher's
`ServerKeyPrefix`, so two publishers sharing a store never share an answer.
The handler hands the settings through in place of the publisher's domain
and the time, and a selection carries the name the contributor was declared
under.

The tests use `MemoryLoadingCache` and cover the one load for a held
answer, the key naming the publisher, changed settings asking again, the
kept answer served when the contributor fails, in a second instance and
from a value cache, and a failure with nothing kept being an error that is
not held.
Each adapter takes the cache from the builders it is given with
`loading_cache_from` when it builds its state, and hands it to every
request's services, so every request in a process shares the cache's
memory copy and waits for one load. Axum, Cloudflare and Spin take it from
the stock list followed by the builders a deployment passes, as they take
every vendor module, and hold nothing without one. No adapter names the
crate that supplies it.

On Fastly the finishing steps' services carry the same cache as the
request, so a lookup there shares the request's memory copy, and the entry
point builds those services once from the state it has, or with the
unavailable store and a cache that holds nothing when no state was built.

Fastly supplies `FastlyValueCache` over Fastly's cache as the value cache,
which needs nothing created or linked on the service. A transaction lookup
reserves a key nothing holds for this instance and makes every other
instance at the location wait for it to be filled, a stale entry is
reserved for a fresh load rather than served, and a reservation dropped
unfilled is released so a waiting instance takes it over. Each entry is
written with a surrogate key, a hash of its key, which `remove` purges. Its
tests run under Viceroy and cover the reservation, its release, a stale
entry and the purge.
`crates/loading-cache/51degrees` implements `PlatformLoadingCache` with the
published `fiftyone-caching` package, version 4.5.8-49, with its default
features off so it builds for every host. Each cache is a least recently
used copy in process memory with a short lifetime over the host's shared
store, being its value cache where it has one and its key-value store
otherwise, written in the package's entry format so a store that deletes
late or keeps no lifetimes never returns an entry past its drop time. The
memory layer is shared by every request in a process and loads a missing
key once, covering the store read and the source together. The store layer
is made for each call from that request's stores. A load a cache allows to
run apart from its caller runs on a pool of threads on a native host and
with the request's `wait_until` on Cloudflare, and every load runs in its
caller on Fastly and Spin.

The crate is the module `loading-cache.51degrees`, named by its folder, and
`builder()` offers the cache whatever the settings say, because a loading
cache is how a deployment caches rather than a module a document selects.
The stock list carries the builder, so every adapter takes the cache from
it with `loading_cache_from` and names no crate, and a test holds the list
to offering one cache that keeps what it loads. The crate carries the
maintainers table, the workspace lists it, and the Axum aliases lint and
test it, where its own tests run natively.

The crate's tests ask for one key from 16 callers at once on 16 threads
with the pool, on 16 threads with loads in the caller, and on one thread
with each load a task of its own, and the source runs once each time. Other
tests cover a failure reaching every waiter and not being kept, the store
answering a second process, the brief memory copy, idle expiry, a value's
own lifetime, a reserving value cache being filled and released, two
publishers kept apart, removal, the module's name and the builder's offer.
Spin's key-value store refuses a write with a lifetime, so a robots.txt
answer kept for a later failure, and a loading cache entry in the module's
key-value layer, are written without one there, as the held answer was
before. The entry format carries each entry's own drop time, so a copy in a
store that keeps no lifetimes still ends when it should, and a kept answer
stays until it is replaced, which is what the configuration guide says of
Spin.

The robots.txt load carries the name the contributor was declared under as
the static text the module context takes, and the handler hands it over
from the selection. Tests cover the kept answer's lifetime where the store
takes one, the answer kept where it refuses one, and a store that refuses
lifetimes still answering a second process through the module.
The configuration guide gains "Loading caches" under the cache section,
saying what is held, where the two copies live, how the keys start, which
crate a stock build caches with and the `[cache.robots-txt]` lifetimes, and
its robots.txt section says each answer is held through it. The integration
guide gains "Supplying a loading cache", being how a crate offers one with
`with_loading_cache` and how code loads through one with `load_cached`, a
`CacheSpec`, a `StoreKey` and a `Loader`, and the builder table lists the
offer. The example configuration shows `[cache.robots-txt]`, and the
changelog records the cache, the lifetimes, the key prefix and the value
cache under Unreleased.
The crate keeps a load running with the request's `wait_until` only where
the Worker's handling of the request is wrapped in `scope_request`, and
nothing on an adapter wraps it, so the crate's documentation and the
changelog say so rather than that the load keeps running after the
response on Cloudflare.
The item exists only on the Cloudflare target, so a link to it from the
crate's documentation is reported broken on every other target's
documentation build. The name is written as plain code instead.
The first build adds the published fiftyone-caching crate, hashlink and
tokio-util to the lock, which a build with --locked refuses until they are
committed.
A feature that holds answers in a loading cache names, in its own
`[cache.<name>]` table, the module whose cache holds them, as the module's
short name or its name in full. `loading_caches_from` resolves every
feature's selection against the caches the linked builders offer, makes
each module's cache once however many features select it, and refuses a
name no builder offers a cache from, naming the feature and the modules
that do. The services carry the selections as `LoadingCaches`, and a
feature that selected none loads through `NoLoadingCache`.

Before this the stock build's one offered cache held every feature's
copies, and a deployment linking two caches was refused. Now each feature
decides, so the asset cache and any later feature can choose a cache of
its own.

`[cache.robots-txt] module = "51degrees"` selects the stock cache for the
robots.txt modules' answers. The guides, the example configuration and
the changelog say so, and the adapters pass the selections where they
passed the one cache.
Core's own middleware `robots_txt`, in `robots_txt/middleware.rs`, makes
the file `[robots-txt]` describes where a `[[fetch]]` or `[[serve]]`
entry for `text/plain` on `/robots.txt` names it. Its preparation does
what the route's handler did, a refusal of every crawler from nothing and
otherwise every selected contributor's held answer assembled, and answers
a refusal with `503` and `Retry-After` when a contributor fails with
nothing held, so nothing is stored and no empty file is served. Its
action replaces whatever the origin sent with the file, and the creating
rule makes the file where the origin has none.

The registry makes the middleware itself once every registration is in,
because the contributors it asks are known only then and a middleware
cannot reach the registry. The settings refuse a `[robots-txt]` section
that no entry places, because the file would never be made.

The route goes from the four adapters, with `handle_robots_txt`, the
response builder and the route's own cache lifetime. The file takes the
deployment's cache policy for the path, as every file does. The adapters'
route tests go with it, and each now proves the path is no route of this
server; the file itself is proven through the publisher path in core.
The stock list carries the middleware on `text/plain`, and the example
configuration places it.
The configuration guide's robots.txt section says the file is made by
the `robots_txt` middleware where an entry names it, in place of the
origin's file or where the origin has none, that a section no entry
places is refused, and that the response takes the deployment's cache
policy for the path. Its example carries the entry, and the placing table
has the middleware's row. The changelog records the middleware and the
route's removal.
`[shared.<vendor>]` holds what every module of one vendor reads, such
as an endpoint and a license key, so the key is written once. Core
declares the tables and reads none of them. A vendor's crates read
their own as typed settings through `Settings::shared_config`, which
refuses a table that cannot be read as the type or fails its
validation, naming the table. A table whose name is not a folder name
is refused when the settings load.

A builder marked with `with_shared_settings` is run by its vendor's
table rather than by a selection, so a document without the table runs
none of that vendor's modules, and a selection naming one of them is
refused with the absent table named. A table that no builder reads is
refused when the registry is built, the one place every builder is
known. A secret in the table is declared with the builder's secret
settings at its path inside the table and resolved when the table is
present, as a module's own secrets are.

The seam probe gains a second builder its table runs, whose route
answers the table's marker, so the Axum adapter proves the table from
outside core.
The configuration guide lists `[shared]` among the sections and
describes the tables, what runs a builder, the refusals and how a
secret in a table is written. The integration guide's builder table
gains `.with_shared_settings(...)` and a section on reading the table
as typed settings. The example configuration notes the table beside
its list of sections, and the changelog records the addition.
Core gains a prefix module, `PrefixModule`, which gives two things for the
publisher a request resolved to: a public prefix that starts every name
the browser holds, being cookies and first-party storage keys, and a
server key prefix that starts every key in a store publishers share.
Core's own module gives `ts` and `ts.<publisher.domain>` and runs when
`[prefix] module` selects nothing, so a stock build's names are what they
were.

The registry resolves the selection as it does geo and device, refuses a
name nothing this deployment runs supplies, and asks the module once when
the registry is built, so a module naming a value its call never carries
is refused at startup. Every adapter puts the resolved prefixes on the
request's services, and the Fastly entry point keeps what its request
resolved to for the finishing steps that run after the request has been
handed to the router. The robots.txt holding and the loading cache take
the prefix from the request's services rather than from the settings.
The cookie constants lose their fixed `ts-` head and name the entry alone,
`ec`, `ecr`, `ec-pull-complete`, `eids` and `tester`, and every read and
write names the cookie through the request's public prefix, so a
deployment whose module gives `_ex` reads and writes `_ex-ec`. The Edge
Cookie state carries the prefix it was read under, and a module's response
header naming a cookie under that prefix is refused as a cookie under
`ts-` was.

The page's bundle tag tells the client script the prefix, as
`data-ts-public-prefix`, so the script names its own cookies after it and
holds no copy. The template fingerprint includes the prefix, so a page
stored under one is never served under another, and a key cookie naming
one of the request's own identity cookies shares no template.
A prefix's first part starts with a letter or a single `_`, so no cookie
name starts with `__`, where browsers give names such as `__Host-` rules
of their own and drop a cookie that breaks them. The Edge Cookie test
reads and writes under a prefix carrying a publisher's name as well, and
a test holds the client script to the attribute the server writes.
# Conflicts:
#	docs/guide/configuration.md
# Conflicts:
#	docs/guide/configuration.md
# 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.
@jwrosewell

Copy link
Copy Markdown
Collaborator

Moved into this repository as #1371, one branch of the stack, so the review continues there. This pull request is closed in its favor and its comments stay here for the record.

@jwrosewell jwrosewell closed this Oct 15, 2026
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.

2 participants