Always emit hb_adid so server-side ad template creatives render - #996
Always emit hb_adid so server-side ad template creatives render#996prk-Jr wants to merge 2 commits into
Conversation
…e absent Bidders that return neither a Prebid Cache UUID nor an `adid` produced no `hb_adid` in `window.tsjs.bids` at all. `adInit` only sets targeting keys that exist on the bid, so GAM never received an `hb_adid` key, the Universal Creative's `%%PATTERN:hb_adid%%` expanded to empty, and the render bridge rejected the resulting `Prebid Request` message for want of an ad ID. The line item won and served its wrapper, but the creative never rendered. Add `Bid::bid_id`, populated from the OpenRTB bid object's own `id`, and use it as the last-resort `hb_adid` source. Per spec `id` is mandatory, so this closes the gap for every bidder. It is unique per bid instance rather than a creative identifier, which is exactly what `hb_adid` needs here: a stable value GAM echoes back verbatim so the bridge can find this winning bid. `cache_id` and `ad_id` keep priority in that order — locked in by test, since the Universal Creative treats `hb_adid` as the Prebid Cache lookup key whenever `hb_cache_host`/`hb_cache_path` are present. `bid_id` is carried as its own field rather than folded into `ad_id`, which is exposed raw in the debug bid and would mislead consumers treating it as a creative identifier. Verified: cargo fmt, all six clippy targets, test-fastly / test-axum / test-cloudflare / test-spin, the parity suite, JS vitest, and JS + docs prettier checks all pass. The new bid_map test was confirmed to fail with the fallback removed.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Review Summary
Reviewed PR #996 against main. The direct Prebid fallback is correctly wired, but the inline findings cover an incomplete mediated path and a defensive fallback edge case.
aram356
left a comment
There was a problem hiding this comment.
Summary
Well-executed, tightly scoped fix: the cache_id → ad_id → bid_id precedence is locked in by tests from both directions, the new fallback test was verified to fail without the fix, and the render loop stays symmetric end-to-end (build_bid_map → GPT targeting → %%PATTERN:hb_adid%% → bridge equality check). One blocking gap on the mediated path, plus a few non-blocking observations.
Blocking
🔧 wrench
- Mediation passthrough drops
bid_id— see inline comment (crates/trusted-server-core/src/integrations/adserver_mock.rs:321).
Non-blocking
🤔 thinking
- GAM 40-char targeting-value limit — see inline comment (
crates/trusted-server-core/src/publisher.rs:3293). hb_cache_host/hb_cache_pathare emitted independently ofcache_id: if PBS ever returnsext.prebid.cache.bids.urlwithoutcacheId, the map now carrieshb_adid = bid_idalongside cache coordinates, and (absent inlineadm) the Universal Creative would fetchhttps://<host><path>?uuid=<bid_id>— a guaranteed miss. Pre-existing with thead_idfallback, butbid_idextends it to every bidder. A cheap guard is to gate thehb_cache_host/hb_cache_pathinserts oncache_id.is_some()(crates/trusted-server-core/src/publisher.rs:3300-3315).
🌱 seedling
/auctionresponses could now echo the real OpenRTB bid id:crates/trusted-server-core/src/auction/formats.rs:281synthesizesid: Some(format!("{}-{}", bid.bidder, slot_id)). Now thatBidcarries the upstream bid's ownid, echoingbid.bid_id(with the synthetic as fallback) would preserve win-notification correlation and debugging traceability for/auctionconsumers. Follow-up scope, not this PR.
⛏ nitpick
- Empty-string
idflows into the map — see inline comment (crates/trusted-server-core/src/integrations/prebid.rs:2228). - Stale comment in the JS bridge:
crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1162-1164explainshb_adidnon-uniqueness solely via the creative-id fallback; there is now a third tier (bid_id, unique per bid instance). The slot-scoped lookup rationale still holds, but the comment is incomplete. File isn't in this diff, hence body-level.
CI Status
All checks pass on GitHub: fmt, clippy (all six target-matched variants), Rust tests (fastly/axum/cloudflare/spin/CLI), cross-adapter parity, vitest, browser and Fastly EC integration tests, docs/TS format, CodeQL.
Carry bid_id through adserver_mock mediation. The reconstruction restored
nurl, burl, ad_id and the cache fields from the original SSP bid but
hard-coded bid_id to None, so a mediated bid whose only hb_adid source is
the OpenRTB bid id lost it and never rendered — the exact failure this
branch fixes, re-opened on the mediated path. Mediated APS bids were
worse off still: they carry no ad_id or cache_id for the restore to
recover, so they reached the page with no hb_adid at all. The mediation
response is itself OpenRTB, so prefer the mediated bid's own id and fall
back to the original SSP bid's.
Reject blank identity strings before applying the cache_id -> ad_id ->
bid_id precedence. Option::or treats Some("") as present, so a blank
cacheId or adid outranked a valid bid id and emitted an empty hb_adid —
falsey on the page, so GPT skips the targeting key and the render bridge
has nothing to match. Prebid's parse_bid likewise treats an empty
OpenRTB id as absent.
Emit hb_cache_host and hb_cache_path only alongside a real Prebid Cache
UUID. PBS reports the cache url and cacheId independently, so a bid with
coordinates but no UUID pointed the Universal Creative at
?uuid=<non-cache-id>, a guaranteed miss, instead of letting it fall
through to the inline adm.
Warn when the chosen hb_adid exceeds GAM's 40-character targeting value
limit. GAM drops an over-long value, so the creative echoes nothing and
the bridge's equality check never matches. Log rather than truncate: a
truncated id is no longer unique per bid, which is what lets one slot's
render claim another slot's creative.
Brings the PR #996 review fixes onto rc/july. The original fallback commit was already merged; this adds the follow-ups plus two conflict resolutions where rc/july had since moved. hb_adid precedence keeps rc/july's renderer tier and gains blank rejection on every tier, so a bidder emitting an empty cacheId or adid no longer outranks a usable identifier: non_empty(cache_id) -> non_empty(renderer_bid_id) -> non_empty(ad_id) -> non_empty(bid_id) Cache coordinates keep rc/july's placement inside the processed_adm match and gain the cache_id requirement as a second, independent condition: absent a UUID the coordinates would send the Universal Creative to ?uuid=<non-cache-id>. Mediation bid_id precedence is inverted relative to the main-targeted branch, deliberately. On main, APS bids carry no bid_id, so preferring the mediation response's own id was the only way to give them an hb_adid. On rc/july, aps.rs populates bid_id and a typed renderer envelope is minted against it, and build_bid_map derives hb_adid from that pairing — so substituting the mediator's id would key targeting to an id the renderer does not know. The original SSP bid's id therefore wins here, with the mediated id as the fallback for an upstream bid that carried none.
Summary
adidproduced nohb_adidinwindow.tsjs.bids.adInitonly sets targeting keys that exist on the bid, so GAM never received anhb_adidkey, the Universal Creative's%%PATTERN:hb_adid%%expanded to empty, and the render bridge rejected the resultingPrebid Requestfor want of an ad ID. The line item won and served its wrapper, but the creative never rendered — a silent, per-bidder loss of every SSAT impression.Bid::bid_id, populated from the OpenRTB bid object's ownid, and uses it as the last-resorthb_adidsource.idis mandatory per the OpenRTB spec, so this closes the gap for every bidder.cache_idandad_idkeep priority, in that order. This is not cosmetic: whenhb_cache_host/hb_cache_pathare present the Universal Creative useshb_adidas the Prebid Cache lookup key (https://<host><path>?uuid=<hb_adid>), so an arbitrary value there would break cache retrieval. Both priorities are locked in by test.Why a separate field rather than folding into
ad_idad_idis the creative/ad identifier and is exposed raw in the debug bid. The OpenRTBidis unique per bid instance, not per creative — surfacing it asad_idwould mislead any consumer treating that field as a creative identifier. The existing comment inparse_bidsays as much, so the bid ID is carried as its own field instead.Changes
crates/trusted-server-core/src/auction/types.rsBid::bid_id: Option<String>with#[serde(default, skip_serializing_if = "Option::is_none")], so the serialized wire shape is unchanged when absentcrates/trusted-server-core/src/integrations/prebid.rsbid_idfrom the OpenRTB bididinparse_bid; two tests covering it alongside and withoutadid/cachecrates/trusted-server-core/src/publisher.rsbuild_bid_maphb_adidchain becomescache_id → ad_id → bid_id; test for the new fallback, renamed omit-case test, andbid_idadded to the cache/ad-id tests to prove precedencecrates/trusted-server-core/src/auction/orchestrator.rsbid_id: NoneatBidliterals (tests)crates/trusted-server-core/src/auction/formats.rsbid_id: NoneatBidliteralcrates/trusted-server-core/src/auction/telemetry.rsbid_id: NoneatBidliteral (test)crates/trusted-server-core/src/integrations/adserver_mock.rsbid_id: NoneatBidliteralscrates/trusted-server-core/src/integrations/aps.rsbid_id: NoneatBidliteralCHANGELOG.mdTest plan
cargo test-fastly && cargo test-axumcargo clippy-fastly && cargo clippy-axumcargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute servecargo test-cloudflare,cargo test-spin,cargo clippy-cloudflare,cargo clippy-cloudflare-wasm,cargo clippy-spin-native,cargo clippy-spin-wasm, and the parity suite (cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity)The new
bid_map_falls_back_to_bid_id_when_cache_id_and_ad_id_absenttest was confirmed to fail with the fallback removed (left: None), so it genuinely gates the fix rather than passing vacuously.Not covered by automated tests: the browser-side half of the loop — that GAM echoes the new
hb_adidback and the bridge serves the creative. That path is the subject of #926.Checklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)