From cb3939d9ff59201db9e468d106dbb7eb0905d53f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 4 Aug 2026 17:10:07 +0530 Subject: [PATCH] Fall back to the OpenRTB bid id for hb_adid when cache_id and adid are absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + .../src/auction/formats.rs | 1 + .../src/auction/orchestrator.rs | 6 ++ .../src/auction/telemetry.rs | 1 + .../trusted-server-core/src/auction/types.rs | 11 +++ .../src/integrations/adserver_mock.rs | 5 ++ .../src/integrations/aps.rs | 1 + .../src/integrations/prebid.rs | 36 +++++++++ crates/trusted-server-core/src/publisher.rs | 81 +++++++++++++++++-- 9 files changed, 138 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36655763c..620446bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected. +- Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. ### Added diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index e6f620e5b..638c4441c 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -439,6 +439,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 4a143b223..96fc3dae4 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1700,6 +1700,7 @@ mod tests { height: 90, nurl: nurl.clone(), burl: nurl, + bid_id: None, ad_id: Some("creative-123".to_string()), cache_id: Some("cache-abc".to_string()), cache_host: None, @@ -2039,6 +2040,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, @@ -2059,6 +2061,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, @@ -3154,6 +3157,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, @@ -3204,6 +3208,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, @@ -3240,6 +3245,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index 02752c6f9..b8abd3d53 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -960,6 +960,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: ad_id.map(str::to_owned), cache_id: None, cache_host: None, diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 14c7713f8..2039f3870 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -196,6 +196,14 @@ pub struct Bid { pub nurl: Option, /// Billing notification URL pub burl: Option, + /// `OpenRTB` bid identifier — the `id` of the bid object itself. + /// + /// Distinct from [`ad_id`](Self::ad_id): unique per bid instance rather + /// than a creative identifier. Always present per the `OpenRTB` spec, so it + /// is the last-resort `hb_adid` source for bidders that return neither a + /// Prebid Cache UUID nor `adid`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bid_id: Option, /// Ad ID from the bidder pub ad_id: Option, /// Prebid Cache UUID for this bid. @@ -338,6 +346,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, @@ -467,6 +476,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: Some("bid-id".to_string()), cache_id: Some("cache-uuid".to_string()), cache_host: Some("cache.example.com".to_string()), @@ -514,6 +524,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: Some("prebid-ad-id-abc".to_string()), cache_id: None, cache_host: None, diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 8fd3f9ddb..702d515f9 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -318,6 +318,7 @@ impl AdServerMockProvider { }), nurl: original.and_then(|b| b.nurl.clone()), burl: original.and_then(|b| b.burl.clone()), + bid_id: None, ad_id: original.and_then(|b| b.ad_id.clone()), cache_id: original.and_then(|b| b.cache_id.clone()), cache_host: original.and_then(|b| b.cache_host.clone()), @@ -642,6 +643,7 @@ mod tests { adomain: Some(vec!["amazon.com".to_string()]), nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, @@ -665,6 +667,7 @@ mod tests { adomain: None, nurl: Some("https://ssp.example/win?id=mock-bid-001".to_string()), burl: Some("https://ssp.example/bill?id=mock-bid-001".to_string()), + bid_id: None, ad_id: Some("mock-bid-001".to_string()), cache_id: None, cache_host: None, @@ -784,6 +787,7 @@ mod tests { height: 90, nurl: Some("https://ssp.example/win".to_string()), burl: Some("https://ssp.example/bill".to_string()), + bid_id: None, ad_id: Some("bid-impression-id".to_string()), cache_id: Some("cache-uuid".to_string()), cache_host: Some("cache.example".to_string()), @@ -903,6 +907,7 @@ mod tests { adomain: Some(vec!["amazon.com".to_string()]), nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 60c22265d..c8fa34e61 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -481,6 +481,7 @@ impl ApsAuctionProvider { height, nurl: None, // Real APS uses client-side event tracking burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 58a18c50b..ad4f8c35c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2223,6 +2223,8 @@ impl PrebidAuctionProvider { // not an ad ID, so it is not used as a fallback: surfacing it as `ad_id` // (which is exposed raw in the debug bid) would mislead any consumer that // treats `ad_id` as a creative identifier. Absent `adid`, `ad_id` is None. + // The bid ID is carried separately in `bid_id` instead. + let bid_id = bid_obj.get("id").and_then(|v| v.as_str()).map(String::from); let ad_id = bid_obj .get("adid") .and_then(|v| v.as_str()) @@ -2291,6 +2293,7 @@ impl PrebidAuctionProvider { height, nurl, burl, + bid_id, ad_id, cache_id, cache_host, @@ -7307,6 +7310,39 @@ set = { networkId = 42 } Some("cache-uuid-xyz"), "should extract cache UUID separately" ); + assert_eq!( + bid.bid_id.as_deref(), + Some("bid-impression-id"), + "should keep the OpenRTB bid id separate from ad_id" + ); + } + + #[test] + fn parse_bid_keeps_bid_id_when_adid_and_cache_are_absent() { + // The shape that leaves hb_adid with no other source: `id` is mandatory + // per OpenRTB, `adid` is optional and omitted by some bidders, and no + // Prebid Cache entry exists because the creative ships inline. + let bid_json = serde_json::json!({ + "id": "019f7e2a-b45b-70b0-a2d1-b651c430700b", + "impid": "atf_sidebar_ad", + "price": 1.0, + "w": 300, + "h": 250, + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "example-bidder") + .expect("should parse bid"); + assert_eq!( + bid.bid_id.as_deref(), + Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"), + "should populate bid_id from the OpenRTB bid id" + ); + assert!(bid.ad_id.is_none(), "should not synthesize ad_id from id"); + assert!( + bid.cache_id.is_none(), + "should not synthesize cache_id from id" + ); } #[test] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index d3410b4ed..0c4b5df30 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3277,7 +3277,20 @@ pub(crate) fn build_bid_map( // hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses // this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to // bid.ad_id for APS and other non-PBS providers. - let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); + // + // `bid.bid_id` (the OpenRTB bid's own `id`) is the last resort: it is + // always present per spec but only unique per bid instance, not a + // creative identifier. It still satisfies what hb_adid needs here — + // a stable value GAM's Universal Creative echoes back verbatim so + // the render bridge can find this exact winning bid — for bidders + // that return neither a cache UUID nor `adid`. Without it those + // bids carry no hb_adid at all, so no targeting key reaches GAM and + // the render handshake can never start. + let hb_adid = bid + .cache_id + .as_deref() + .or(bid.ad_id.as_deref()) + .or(bid.bid_id.as_deref()); if let Some(id) = hb_adid { obj.insert( "hb_adid".to_string(), @@ -4017,6 +4030,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, @@ -8120,6 +8134,7 @@ mod tests { height: 250, nurl: Some(nurl.to_string()), burl: Some(burl.to_string()), + bid_id: None, ad_id: Some(ad_id.to_string()), cache_id: None, cache_host: None, @@ -8858,6 +8873,9 @@ mod tests { height: 250, nurl: None, burl: None, + // Present alongside cache_id/ad_id to prove cache_id still wins + // — bid_id is the last resort, not a co-equal fallback. + bid_id: Some("should-be-ignored-bid-id".to_string()), ad_id: Some("bid-impression-id".to_string()), cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), cache_host: Some("openads.adsrvr.org".to_string()), @@ -8880,7 +8898,7 @@ mod tests { assert_eq!( obj.get("hb_adid").and_then(|v| v.as_str()), Some("f47447a0-b759-4f2f-9887-af458b79b570"), - "should use cache_id for hb_adid, not ad_id" + "should use cache_id for hb_adid, not ad_id or bid_id" ); assert_eq!( obj.get("hb_cache_host").and_then(|v| v.as_str()), @@ -8910,6 +8928,9 @@ mod tests { height: 250, nurl: None, burl: None, + // Present alongside ad_id to prove ad_id still wins — bid_id + // is the last resort, not a co-equal fallback. + bid_id: Some("should-be-ignored-bid-id".to_string()), ad_id: Some("aps-bid-token".to_string()), cache_id: None, cache_host: None, @@ -8932,7 +8953,7 @@ mod tests { assert_eq!( obj.get("hb_adid").and_then(|v| v.as_str()), Some("aps-bid-token"), - "should fall back to ad_id when cache_id absent" + "should fall back to ad_id when cache_id absent, ignoring bid_id" ); assert!( obj.get("hb_cache_host").is_none(), @@ -8945,7 +8966,55 @@ mod tests { } #[test] - fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { + fn bid_map_falls_back_to_bid_id_when_cache_id_and_ad_id_absent() { + // Real shape for bidders that return neither a Prebid Cache UUID nor + // `adid` in the OpenRTB response, but always carry `id` (the bid's own + // identifier) per spec. Without this fallback the bid reaches the page + // with no hb_adid, so no targeting key is set and the render bridge + // never receives a matching `Prebid Request`. + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(1.00), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "example-bidder".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()), + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map( + &winning_bids, + PriceGranularity::Dense, + &test_settings(), + "", + false, + ); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"), + "should fall back to bid_id when cache_id and ad_id are both absent" + ); + } + + #[test] + fn bid_map_omits_hb_adid_when_cache_id_ad_id_and_bid_id_all_absent() { let mut winning_bids = HashMap::new(); winning_bids.insert( "atf_sidebar_ad".to_string(), @@ -8960,6 +9029,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None, @@ -8981,7 +9051,7 @@ mod tests { .expect("should be object"); assert!( obj.get("hb_adid").is_none(), - "should omit hb_adid when no cache_id and no ad_id" + "should omit hb_adid when no cache_id, ad_id, or bid_id" ); } @@ -9001,6 +9071,7 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, cache_id: None, cache_host: None,