Skip to content

Implement server-side ad slot templates with PBS and APS auction#680

Draft
prk-Jr wants to merge 38 commits intomainfrom
server-side-ad-templates-impl
Draft

Implement server-side ad slot templates with PBS and APS auction#680
prk-Jr wants to merge 38 commits intomainfrom
server-side-ad-templates-impl

Conversation

@prk-Jr
Copy link
Copy Markdown
Collaborator

@prk-Jr prk-Jr commented May 6, 2026

Summary

  • Adds server-side ad slot discovery from `creative-opportunities.toml`: slots are matched against the request URL at the edge, and `window.__ts_ad_slots` is injected before `` closes so the browser never needs a separate fetch.
  • Runs a parallel auction (APS + Prebid providers, adserver_mock mediator) during page serving and injects `window.__ts_bids` with price-bucketed targeting before ``, enabling GAM to apply server-won bids synchronously on first render.
  • Adds `__tsAdInit` inline script that reads `__ts_bids` synchronously and drives the GPT/GAM auction flow client-side without a network round-trip for bid data.
  • PBS bidder params (mocktioneer, criteo) are configured inline in `creative-opportunities.toml` — no PBS stored-request config files needed.
  • Fixes double `__ts_bids` injection on pages where the origin HTML contains more than one `` element (CMS/template pages).

Changes

File Change
`creative-opportunities.toml` New config file — defines ad slots, GAM unit paths, URL patterns, formats, floor prices, and per-provider params (APS + PBS inline bidder params)
`crates/trusted-server-core/src/creative_opportunities.rs` URL glob matching, slot → AdSlot conversion, `PbsSlotParams` for inline bidder params, build-time TOML validation
`crates/trusted-server-core/src/publisher.rs` Async publisher handler: slot matching, server-side auction, `__ts_ad_slots` and `__ts_bids` injection via shared state
`crates/trusted-server-core/src/price_bucket.rs` Price granularity bucketing (dense default) for GAM key-value targeting
`crates/trusted-server-core/src/settings.rs` `CreativeOpportunitiesConfig` and auction config wired into `Settings`
`crates/trusted-server-core/src/integrations/aps.rs` APS TAM auction provider — sends bids to mock APS endpoint, parses contextual response
`crates/trusted-server-core/src/integrations/adserver_mock.rs` Mocktioneer mediator — collects provider bids, sends to mediation endpoint with decoded price
`crates/trusted-server-core/src/integrations/prebid.rs` PBS inline bidder param wiring; stored-request fallback when no inline params present; filters non-PBS provider keys (e.g. `aps`) from PBS imp bidder map
`crates/trusted-server-core/src/auction/orchestrator.rs` Parallel provider execution with select(); mediator integration; floor price filtering
`crates/trusted-server-core/src/auction/types.rs` `SiteInfo`, `AdSlot`, `AdFormat`, `Bid` extensions
`crates/trusted-server-core/src/html_processor.rs` Head-open and body-close injection points; `AtomicBool` guard prevents double `__ts_bids` injection on pages with multiple `` elements
`crates/trusted-server-core/src/openrtb.rs` `PrebidImpExt` with optional `storedrequest`; skips empty bidder map
`crates/trusted-server-core/src/integrations/gpt.rs` `__tsAdInit` script generation and GPT slot wiring
`crates/trusted-server-adapter-fastly/src/main.rs` Startup: parse `creative-opportunities.toml`, build orchestrator, pass to publisher handler
`crates/js/lib/src/integrations/gpt/index.ts` Client-side GPT integration consuming `__ts_bids` and `__ts_ad_slots`
`trusted-server.toml` Enable APS provider, adserver_mock mediator; set `auction_timeout_ms = 3000`

Closes

Closes #677

Test plan

Automated

  • `cargo test --workspace`
  • `cargo clippy --workspace --all-targets --all-features -- -D warnings`
  • `cargo fmt --all -- --check`
  • JS tests: `cd crates/js/lib && npx vitest run`
  • JS format: `cd crates/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 end-to-end (browser DevTools console)

The steps below build on each other — each step uses data from the previous one so no IDs need to be memorised.

Step 1 — Verify slot config is injected at `` open

Navigate to a news/article URL (a path matching `/20**` or `/news/**`).

window.__ts_ad_slots

Expected: array of slot objects. Each entry has `id`, `gamUnitPath`, `divId`, `formats`, and `targeting`. Note the `divId` value from the matching slot — you will use it in step 3.

What this proves: the edge matched the URL against the slot's page patterns and injected the slot config synchronously in ``, before any JS ran.


Step 2 — Verify server-side auction result is injected before ``

window.__ts_bids

Expected: object keyed by slot ID (same ID as in step 1), containing `hb_bidder` and `hb_pb` from the winning provider. Example: `{ atf_sidebar_ad: { hb_bidder: "aps", hb_pb: "1.50" } }`.

What this proves: the server-side auction (APS + PBS running in parallel) completed, the mediator picked a winner, price was bucketed, and the result was injected into the HTML before the page was sent to the browser. No client-side auction round-trip for these slots.


Step 3 — Verify `__tsAdInit` wired the GPT slot with bid targeting

Using the `divId` observed in step 1:

// Replace 'SLOT_DIV_ID' with the divId from step 1
googletag.pubads().getSlots()
  .filter(s => s.getSlotElementId() === 'SLOT_DIV_ID')
  .map(s => ({ path: s.getAdUnitPath(), targeting: s.getTargetingMap() }))

Expected: one entry with the GAM unit path from step 1 and targeting that includes `hb_pb`, `hb_bidder` (matching step 2), plus any slot-level keys (`pos`, `zone`) and `ts_initial: ["1"]`.

What this proves: `__tsAdInit` ran after `__ts_bids` was set, called `googletag.defineSlot()`, and applied all bid and slot-level targeting. `ts_initial: "1"` confirms it ran from the server-side bid injection path (not a fallback).


Step 4 — Verify slot matching is page-pattern-aware

Navigate to the homepage (`/`). Repeat step 2.

Expected: `window.__ts_bids` contains `homepage_header_ad` (not `atf_sidebar_ad`). Repeat step 3 with the `divId` from `window.__ts_ad_slots` on this page.

What this proves: glob-based page-pattern matching works — each slot only fires on the URLs it is configured for.


Step 5 — Confirm no duplicate injection

View page source (`Cmd+U` / `Ctrl+U`). Search for `__ts_bids`.

Expected: exactly one occurrence of `window.__ts_bids=` in the source, immediately before ``.

What this proves: the `AtomicBool` guard added in this PR prevents double injection on origin pages that contain more than one `` element (common in CMS/template-generated HTML).


Pending (GAM line items required)

Step 6 — In the Elements tab, find the `div` whose `id` matches the `divId` from step 1. When GAM price-priority line items targeting `hb_pb` and `hb_bidder` are configured, this div will contain an `<iframe>` with the winning creative. The pipeline on the Trusted Server side is verified complete by steps 1–5 above; creative delivery requires standard Prebid header-bidding line item setup in GAM (outside this PR's scope).

Checklist

  • Changes follow CLAUDE.md conventions
  • No `unwrap()` in production code — use `expect("should ...")`
  • Uses `log` macros (not `println!`)
  • New code has tests
  • No secrets or credentials committed

jevansnyc and others added 26 commits April 15, 2026 20:47
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Incorporate all review feedback (aram356 + jevansnyc): cache contract,
  consent/GDPR gating, async restructuring detail, CreativeOpportunityFormat
  schema, glob pattern fix, XSS escaping, win notifications, APS params,
  timeout config key, defineSlot fix, gpt.rs ownership, KV migration path,
  Phase 2 sketch
- Fix Prettier formatting (format-docs CI)
- Add implementation plan (12 tasks, TDD, ordered by dependency)
- Incorporate all review feedback (aram356 + jevansnyc): cache contract,
  consent/GDPR gating, async restructuring detail, CreativeOpportunityFormat
  schema, glob pattern fix, XSS escaping, win notifications, APS params,
  timeout config key, defineSlot fix, gpt.rs ownership, KV migration path,
  Phase 2 sketch
- Fix Prettier formatting (format-docs CI)
- Add implementation plan (12 tasks, TDD, ordered by dependency)
Replace the head-injected __ts_bids design with a server-cached bid
delivery model fetched by the client via a new /ts-bids endpoint. The
auction never blocks page rendering — </head> flushes immediately, body
parses without waiting for bids, and the client fetches bids in parallel
with content paint.

Key changes:
- §2 Goal: bid delivery decoupled from page rendering; FCP unchanged from
  no-TS baseline
- §4.3 Auction Trigger: drop buffered/streaming dichotomy; single mode
  forces chunked encoding on all origins (WordPress, NextJS, etc.)
- §4.4 Head Injection: only __ts_ad_slots and __ts_request_id injected at
  <head> open; bid results moved to /ts-bids endpoint
- §4.6 Client Residual: __tsAdInit defines slots immediately, fetches
  bids via /ts-bids, applies targeting and fires refresh() after resolve
- §4.7 (new) Caching Behavior: explicit cacheability table for HTML, JS,
  CSS, tsjs bundle, bid results; Fastly edge HTTP cache leveraged for
  origin HTML
- §5 Request-Time Sequence: full mermaid diagram covering content +
  creative + burl flow with cache-hit and cache-miss branches; separate
  text sequences for cache-hit (~80ms FCP, ~900ms ad-visible) and
  cache-miss (~250ms FCP, ~1,050ms ad-visible)
- §6 Performance Summary: cache-hit and cache-miss columns; FCP added
  as a tracked metric
- §7 Implementation Scope: add bid_cache.rs, /ts-bids endpoint, force
  chunked encoding step
- §8 Edge Cases: origin-agnostic entries; new entries for /ts-bids 404
  and client-never-fetches-/ts-bids

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pivot from the /ts-bids fetch endpoint + in-process bid_cache design to
inline __ts_bids injection before </body>. The earlier design relied on
shared state that doesn't reliably survive Fastly Compute's per-request Wasm
isolate model — body injection achieves the same FCP property in a single
response with no shared-state requirement.

Key changes:

- §4.3: replace /ts-bids long-poll with bounded </body> hold tied to
  A_deadline. Body content above </body> paints first; close-tag held
  until auction completes or A_deadline fires (graceful __ts_bids = {}
  fallback).
- §4.3: add auction-eligibility gating (consent, bot UA, prefetch hints,
  HEAD method, slot match) so auctions fire on real first-page-load
  impressions only.
- §4.4: replace __ts_request_id + /ts-bids machinery with two inline
  <script> blocks — __ts_ad_slots at <head> open, __ts_bids before
  </body> via lol_html el.on_end_tag().
- §4.5: move both nurl and burl to client-side firing from
  slotRenderEnded after hb_adid match. Server-side firing rejected to
  avoid billing inflation on bids that never render.
- §4.6: replace fetch+Promise pattern with synchronous __ts_bids read.
  Add lazy slim-Prebid loader (post-window.load) for scroll/refresh
  auctions and Phase B identity warm-up. Add ts_initial=1 slot-ownership
  sentinel.
- §4.7: switch Cache-Control from private, no-store to private,
  max-age=0 to preserve browser BFCache eligibility while still
  preventing intermediate-cache leaks.
- §4.8 (new): document the EC/KV identity model as load-bearing auction
  input — Phase A retrieval at request time, Phase B post-render
  enrichment via slim-Prebid userID modules. Add bare-EC first-impression
  caveat and auction_eid_count metric. Note federated-consortium
  passphrase property and clickstream-compounding speed win.
- §5: update mermaid + cache-hit/miss timelines for bounded body hold;
  ad-visible converges to ~870ms (hit) / ~1,020ms (miss).
- §6: drop /ts-bids RTT row; add DCL row; add clickstream-compounding,
  TS-overhead, identity-coverage, and confidence-interval framing.
- §7: drop bid_cache.rs and /ts-bids endpoint from scope; add
  auction-eligibility gating and slim-Prebid bundle build target. Add
  explicit "Deleted" subsection.
- §8: drop /ts-bids edge cases; add SPA/pushState, bare-EC, bot/prefetch,
  HEAD, BFCache restoration cases.
- §9.6: server-side GAM downgraded from "Phase 2 commitment" to
  aspirational and contingent on Google agreement. §9.8 (slim-Prebid
  bundle composition), §9.9 (Privacy Sandbox), §9.10 (per-bidder consent)
  added as follow-ups.

Implementation plan at docs/superpowers/plans/2026-04-30-server-side-ad-templates.md
is now stale relative to this spec; needs regenerating before code lands.
…ities.toml

Adds the creative_opportunities field to Settings struct to deserialize
configuration for the server-side ad auction feature. Includes build.rs
stubs for types required during build-time configuration validation.

Creates creative-opportunities.toml with example slot configuration and
updates trusted-server.toml with the [creative_opportunities] section
defining GAM network ID, auction timeout, and price granularity settings.

Tests pass with proper TOML parsing of the creative_opportunities section.
…ared auction state

- Add `ad_slots_script: Option<String>` and `ad_bids_state: Arc<RwLock<Option<String>>>` fields to `HtmlProcessorConfig`
- Update `from_settings` to initialize both new fields with safe defaults
- Prepend `ad_slots_script` inside the existing `<head>` handler before integration inserts
- Add `element!("body", ...)` handler that uses `end_tag_handlers()` to inject `__ts_bids` before `</body>`; falls back to empty `{}` when auction state is `None`
- Add `IntegrationRegistry::empty_for_tests()` test helper
- Add three new tests covering all injection paths
…gibility gates; max-age=0

- Make handle_publisher_request async; add orchestrator and slots_file params
- Dispatch origin request with send_async before running auction in parallel
- Gate auction on GET, no prefetch, no bot, matched slots, TCF purpose-1 consent
- Run server-side auction and write bucketed bids to ad_bids_state Arc<RwLock>
- Compute ad_slots_script after response headers; set Cache-Control: private, max-age=0
- Fix Stream arm to thread actual ad_slots_script and ad_bids_state through
- Add build_auction_request, build_bid_map, build_bids_script, build_ad_slots_script helpers
- Update route_tests.rs to pass empty slots_file to route_request
- build_bid_map now returns serde_json::Map with full bid objects (hb_pb,
  hb_bidder, hb_adid, nurl, burl) instead of a plain CPM string map
- build_bids_script / build_ad_slots_script now emit full <script> tags
  using JSON.parse("…") for safe inline embedding; add html_escape_for_script helper
- build_ad_slots_script uses correct property names (gam_unit_path, div_id,
  formats, targeting) matching the client-side TSJS bundle expectations
- Replace map_or(false, …) with is_some_and(…) on lines 546, 549, 567
- Add # Panics doc sections to handle_publisher_request and create_html_processor
… from slotRenderEnded; slim-Prebid lazy loader
- Enable APS and adserver_mock in auction config; set providers and mediator
- Increase auction_timeout_ms from 500ms to 3000ms — 500ms was too tight
  for HTTPS round-trips to mocktioneer, leaving the mediator zero budget
- Fix mediation request: send numeric price instead of opaque encoded_price;
  mocktioneer requires a decoded price field and does not support encoded_price
- Expand creative-opportunities slot page_patterns to include /news/**
@prk-Jr prk-Jr self-assigned this May 6, 2026
Define SlotRenderEndedEvent, SlotRenderEvent, and TestWindow types to
eliminate all @typescript-eslint/no-explicit-any violations in
gpt/index.ts and gpt/index.test.ts. Extend GptWindow with
__tsjs_slim_prebid_url so installSlimPrebidLoader avoids the any cast.
@prk-Jr prk-Jr changed the title Implement server-side ad slot templates with APS auction Implement server-side ad slot templates with PBS and APS auction May 6, 2026
Set gam_network_id to 88059007 (autoblog production network). Update
atf_sidebar_ad slot to /88059007/autoblog/news with div_id
ad-atf_sidebar-0-_r_2_ (desktop ATF sidebar, 300x250); restrict
page_patterns to article paths only (/20**, /news/**) since that div
does not exist on the homepage. Add homepage_header_ad slot targeting
/88059007/autoblog/homepage with ad-header-0-_R_jpalubtak5lb_ for
970x90/728x90/970x250 leaderboard formats. Reduce auction_timeout_ms
from 3000 to 500 to cap TTFB at the spec-recommended ceiling.
prk-Jr added 3 commits May 6, 2026 17:49
The bids script set window.__ts_bids but never invoked the
__tsAdInit function, leaving GPT slots undefined and server-side
targeting (hb_pb, hb_bidder) never applied. Both the winning-bid
path (build_bids_script) and the no-auction fallback (html_processor
None branch) now guard-call the function after the assignment.
@prk-Jr prk-Jr marked this pull request as draft May 6, 2026 13:32
prk-Jr added 7 commits May 6, 2026 19:36
Adds [slot.providers.pbs.bidders] support so PBS bidder params
live in creative-opportunities.toml alongside APS params, without
needing PBS stored requests configured server-side.

PrebidAuctionProvider now sends imp.ext.prebid.storedrequest.id
as a fallback for slots with no inline PBS params, and skips
non-PBS provider keys (e.g. "aps") that belong to separate auction
providers. PrebidImpExt gains an optional storedrequest field;
empty bidder maps are omitted during serialisation.

Wires mocktioneer and criteo (placeholder IDs) for both
autoblog creative-opportunity slots.
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.

implementation for Server side ad-templates

2 participants