Skip to content

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

Merged
prk-Jr merged 4 commits into
mainfrom
fix/hb-adid-bid-id-fallback
Aug 7, 2026
Merged

Always emit hb_adid so server-side ad template creatives render#996
prk-Jr merged 4 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.
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
prk-Jr and others added 2 commits August 7, 2026 12:34
The gate used bid.cache_id.is_some() while hb_adid used non_empty(). PBS
maps cacheId straight through with no emptiness filter and parses the
cache url independently, so a bid carrying cacheId: "" alongside a
parseable url passed the gate but lost the hb_adid precedence to adid or
the bid id. The map then shipped cache coordinates against a non-cache
hb_adid, pointing the Universal Creative at ?uuid=<adid> — the guaranteed
cache miss the gate exists to prevent — instead of letting it fall
through to the inline adm.
@prk-Jr
prk-Jr merged commit cfb98f4 into main Aug 7, 2026
19 checks passed
prk-Jr added a commit that referenced this pull request Aug 7, 2026
Conflict in the Prebid refresh handler: main (#965) documented that the
delegated refresh preserves the publisher's original bare form, while
this branch replaced that call with the diagnostics recording plus the
scoped dispatch context. Both hold — `dispatchPrebidRefresh` passes
`slots` and `opts` through unchanged — so the resolution keeps the
diagnostics calls and main's comment, extended to say the wrapper only
scopes the shared context.

Also add `bid_id` to the auction-ID test provider's `Bid` literal, a
field main added in #996 after this branch introduced the provider.
prk-Jr added a commit that referenced this pull request Aug 7, 2026
Brings in the PR #997 review fixes: the read-only diagnostics facade
split from the internal recorder channel, lazily expiring request-intent
evidence with one shared delivery-boundary timer, source-agnostic IDs no
longer reported as reservations, the empty-render attempt eviction, and
the scoped type-check gate for the export contract.

The branch also carries a merge of main, whose #965 and #996 arrive here
as squashes of work rc/july already implements more fully. Where the two
sides describe the same feature, rc/july's implementation is kept:

- APS, adserver_mock, auction/types.rs, auction/formats.rs — rc/july's
  OpenRTB provider, renderer-aware bid_id precedence, and typed renderer
  envelope supersede main's versions, which drop fields rc/july needs.
- prebid.rs — rc/july canonicalizes the excluded-suffix list at both the
  startup and build paths already, so main's `load_config` helper adds
  nothing. Main's test is taken instead of rc/july's: it builds from raw
  settings rather than reusing the config `validate_config_for_startup`
  already canonicalized, so it actually exercises the build path.
- prebid/index.ts — a bare refresh that filtered slots must deliver the
  resolved target list, not stay bare, so rc/july's `deliveredSlots`
  behavior and its test expectation both stand.

Three fixes are ported into rc/july's shapes rather than resolved away:

- The EC-derived auction ID reached page JavaScript here too, through
  different plumbing: both collect paths inlined `request.id.as_str()`
  into `write_bids_to_state`, and page-bids passed it to
  `build_bid_map_with_auction_id`. All three now mint a per-auction
  token via `diagnostics_auction_id()`, gated on the diagnostics
  integration being enabled.
- A blank Prebid Cache UUID no longer ships cache coordinates. It loses
  the hb_adid precedence to `adid` or the bid id, so the Universal
  Creative would fetch `?uuid=<non-cache-id>` and miss instead of using
  the inline adm. The gate moves from `is_some()` to `non_empty()`, and
  main's regression test comes along with rc/july's `Bid` fields added.
- The browser-side excluded-suffix list is validated before use. The
  server only de-duplicates it, so an empty suffix matched every ad unit
  path and pulled every slot out of the refresh auction, and a non-array
  value threw inside the publisher's own `refresh()`.

Also collapses a duplicated `hb_auction_id` write in `build_bid_map`
down to one guarded insert, and points the Prebid refresh recorder at
`gptDiagnosticsRecorder` to match the new channel.
@aram356
aram356 deleted the fix/hb-adid-bid-id-fallback branch August 7, 2026 20:23
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