Skip to content

Always emit hb_adid so server-side ad template creatives render - #996

Open
prk-Jr wants to merge 2 commits into
mainfrom
fix/hb-adid-bid-id-fallback
Open

Always emit hb_adid so server-side ad template creatives render#996
prk-Jr wants to merge 2 commits into
mainfrom
fix/hb-adid-bid-id-fallback

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Bidders that return neither a Prebid Cache UUID nor an adid produced no hb_adid in window.tsjs.bids. 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 for 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.
  • Adds Bid::bid_id, populated from the OpenRTB bid object's own id, and uses it as the last-resort hb_adid source. id is mandatory per the OpenRTB spec, so this closes the gap for every bidder.
  • cache_id and ad_id keep priority, in that order. This is not cosmetic: when hb_cache_host/hb_cache_path are present the Universal Creative uses hb_adid as 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_id

ad_id is the creative/ad identifier and is exposed raw in the debug bid. The OpenRTB id is unique per bid instance, not per creative — surfacing it as ad_id would mislead any consumer treating that field as a creative identifier. The existing comment in parse_bid says as much, so the bid ID is carried as its own field instead.

Changes

File Change
crates/trusted-server-core/src/auction/types.rs Add Bid::bid_id: Option<String> with #[serde(default, skip_serializing_if = "Option::is_none")], so the serialized wire shape is unchanged when absent
crates/trusted-server-core/src/integrations/prebid.rs Populate bid_id from the OpenRTB bid id in parse_bid; two tests covering it alongside and without adid/cache
crates/trusted-server-core/src/publisher.rs build_bid_map hb_adid chain becomes cache_id → ad_id → bid_id; test for the new fallback, renamed omit-case test, and bid_id added to the cache/ad-id tests to prove precedence
crates/trusted-server-core/src/auction/orchestrator.rs bid_id: None at Bid literals (tests)
crates/trusted-server-core/src/auction/formats.rs bid_id: None at Bid literal
crates/trusted-server-core/src/auction/telemetry.rs bid_id: None at Bid literal (test)
crates/trusted-server-core/src/integrations/adserver_mock.rs bid_id: None at Bid literals
crates/trusted-server-core/src/integrations/aps.rs bid_id: None at Bid literal
CHANGELOG.md Entry under Unreleased → Fixed

Test plan

  • 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: cargo 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_absent test 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_adid back and the bridge serves the creative. That path is the subject of #926.

Checklist

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

…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.
@prk-Jr prk-Jr self-assigned this Aug 4, 2026
prk-Jr added a commit that referenced this pull request Aug 4, 2026
@prk-Jr
prk-Jr requested review from ChristianPavilonis and aram356 and removed request for aram356 August 4, 2026 16:51

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/trusted-server-core/src/integrations/adserver_mock.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/auction/types.rs

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_path are emitted independently of cache_id: if PBS ever returns ext.prebid.cache.bids.url without cacheId, the map now carries hb_adid = bid_id alongside cache coordinates, and (absent inline adm) the Universal Creative would fetch https://<host><path>?uuid=<bid_id> — a guaranteed miss. Pre-existing with the ad_id fallback, but bid_id extends it to every bidder. A cheap guard is to gate the hb_cache_host/hb_cache_path inserts on cache_id.is_some() (crates/trusted-server-core/src/publisher.rs:3300-3315).

🌱 seedling

  • /auction responses could now echo the real OpenRTB bid id: crates/trusted-server-core/src/auction/formats.rs:281 synthesizes id: Some(format!("{}-{}", bid.bidder, slot_id)). Now that Bid carries the upstream bid's own id, echoing bid.bid_id (with the synthetic as fallback) would preserve win-notification correlation and debugging traceability for /auction consumers. Follow-up scope, not this PR.

⛏ nitpick

  • Empty-string id flows 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-1164 explains hb_adid non-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.

Comment thread crates/trusted-server-core/src/integrations/adserver_mock.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/prebid.rs
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.
@prk-Jr
prk-Jr requested a review from aram356 August 6, 2026 08:31
prk-Jr added a commit that referenced this pull request Aug 6, 2026
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.
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.

3 participants