Skip to content

Suppress fabricated empty Prebid bidder params - #910

Merged
prk-Jr merged 5 commits into
feat/ssat-render-inline-creativefrom
fix/prebid-suppress-empty-bidder-params
Jul 22, 2026
Merged

Suppress fabricated empty Prebid bidder params#910
prk-Jr merged 5 commits into
feat/ssat-render-inline-creativefrom
fix/prebid-suppress-empty-bidder-params

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #899 (feat/ssat-render-inline-creative) — base branch is that PR's head, not main.

A configured Prebid bidder with no inline params and no matching bid-param override was expanded into "bidder": {} in the OpenRTB request. PBS rejects an empty bidder object, surfacing as the intermittent auction error.

Fix

In to_openrtb (crates/trusted-server-core/src/integrations/prebid.rs):

  • Track the bidders the publisher explicitly supplied (including an explicit empty {}).
  • Reorder to: build bidder map → apply overrides → drop fabricated empties → decide stored-request.
  • Drop any bidder still an empty object after overrides unless it was explicitly supplied.
  • Recompute the PBS stored-request fallback after the drop, so a slot whose configured bidders all resolve to fabricated empties falls back to the stored-request/no-bid path instead of shipping invalid bidder objects.

Behavior contract:

  • Absent bidder → dropped (never "bidder": {}).
  • Publisher-supplied {} → preserved (misconfiguration stays visible).
  • Bidder populated by a global/zone override → kept.
  • Valid bidders still ship even when a sibling has no params.
  • No eligible bidders → stored-request fallback.

The browser half of this defense (prevBidderParams retention on refresh) already shipped; this completes the server half.

Tests

New unit tests in prebid.rs:

  • to_openrtb_drops_fabricated_empty_bidder_params
  • to_openrtb_preserves_an_explicitly_empty_bidder
  • to_openrtb_keeps_a_fabricated_bidder_that_an_override_populates
  • to_openrtb_falls_back_to_stored_request_when_all_bidders_are_fabricated_empty

Verification

  • cargo fmt --all -- --check
  • cargo clippy-fastly
  • cargo test-fastly ✓ (1628 core + 99 fastly, 0 failed)
  • Other adapters (axum/cloudflare/spin) + clippy variants left to CI.

Test plan

  • CI green across all adapters
  • Manual: slot with a configured bidder lacking params no longer emits "bidder": {} to PBS

@prk-Jr
prk-Jr marked this pull request as draft July 14, 2026 17:51
@prk-Jr prk-Jr self-assigned this Jul 14, 2026
A configured bidder with no inline params and no matching override
expanded to `"bidder": {}`, which PBS rejects. After applying overrides,
drop fabricated empty bidders, preserve an explicitly supplied empty
object so genuine misconfiguration stays visible, and fall back to the
stored-request path when no eligible bidders remain.
@prk-Jr
prk-Jr force-pushed the fix/prebid-suppress-empty-bidder-params branch from d3fa16b to ef61953 Compare July 14, 2026 17:56
@prk-Jr
prk-Jr marked this pull request as ready for review July 15, 2026 16:22
@ChristianPavilonis
ChristianPavilonis requested review from ChristianPavilonis and aram356 and removed request for ChristianPavilonis July 15, 2026 16:47

@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

Approved with one non-blocking medium-severity finding documented inline. The primary fabricated-empty suppression, override ordering, and stored-request fallback behavior are covered by focused tests.

Please address the inline mixed-representation precedence case in follow-up work before relying on this path for clients that can send both direct bidder params and trustedServer.bidderParams.

Comment thread crates/trusted-server-core/src/integrations/prebid.rs Outdated

@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

The fabricated-empty suppression, override ordering, stored-request fallback, and focused tests are otherwise sound, but mixed bidder representations can still recreate the invalid empty bidder object nondeterministically.

Blocking

🔧 wrench

  • Mixed bidder representations have nondeterministic precedence: direct bidder params can be overwritten by a fabricated empty object depending on HashMap iteration order (crates/trusted-server-core/src/integrations/prebid.rs:1440).

CI Status

  • GitHub integration checks: PASS (4/4)
  • fmt: PASS locally
  • focused native core tests: PASS (44/44)
  • Fastly test runtime: BLOCKED locally by unavailable macOS certificate keychain after successful compilation
  • clippy-fastly: BLOCKED locally by a build-script tooling error (Unrecognized option: 'p')
  • JS tests: not run; no JS/TS files changed

Comment thread crates/trusted-server-core/src/integrations/prebid.rs Outdated
A slot can carry both a direct bidder entry with valid inline params and a
trustedServer entry whose bidderParams omits that bidder. Because slot.bidders
is a HashMap, the trustedServer expansion (which fabricates an empty {} for the
omitted bidder) could run after the direct entry and overwrite its valid params
via extend. The direct entry already marked the bidder explicit, so the empty
object survived the retention drop and PBS rejected the impression — the failure
this path is meant to eliminate — nondeterministically, depending on iteration
order.

Replace the extend with an entry().or_insert() loop so trustedServer-expanded
bidders only fill in absent entries and direct params win regardless of order.
Add a looped regression test exercising a slot with both representations.
@prk-Jr
prk-Jr requested a review from aram356 July 16, 2026 07:19
…' into fix/prebid-suppress-empty-bidder-params

@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

The or_insert fix in bfea103d9 does resolve the case flagged in the previous round — I confirmed the new 64-iteration test genuinely fails against the old extend and passes now. The reordering (build → override → drop → decide stored-request) is sound, and tracing the history shows this PR is finishing a migration #680 already intended rather than inventing a new policy: e06af4b0f abandoned "fill config.bidders with empty {}" in favour of the stored-request path a year ago, and that intent only leaked because expand_trusted_server_bidders fabricates the same empties one layer up. That evidence retired my initial concern that broadening the fallback was risky.

What holds the PR up is that the fix is one-directional, and that the central design choice re-creates the payload the PR exists to eliminate. PBS cannot distinguish a fabricated {} from an explicitly supplied one — they are identical bytes on the wire — so preserving explicit empties keeps emitting "bidder": {} through the primary shipped JS path, and parse_response turns the resulting PBS rejection into an auction-wide bid wipeout rather than a visible misconfiguration signal.

Blocking

🔧 wrench

  • Preserving an explicit {} still ships the payload PBS rejects: provenance is invisible to PBS; reachable via the normal Prebid adapter path (params: {}bidderParams: {kargo: {}}), and a PBS non-2xx zeroes every slot in the auction, not just the offending imp (prebid.rs:1480-1483, prebid.rs:2157-2166).
  • The merge is asymmetric — an empty direct entry still wins over real params: the direct branch keeps an unconditional insert, so a direct {} deterministically discards trustedServer-supplied params and emits "bidder": {"kargo": {}} (prebid.rs:1453).

❓ question

  • The premise underpinning the whole design is unverified in-repo: there is no test, fixture, or PBS mock anywhere backing either "PBS rejects "bidder": {}" or "an unknown stored-request ID yields a per-imp no-bid rather than a request-wide 400". Both exist only as prose in comments and commit messages. If a stored-request miss is in fact a 400, this trades one whole-auction failure mode for another instead of fixing it. Related: auction/endpoints.rs:60-63 already requires a stored request to exist for every slot code that omits inline params, and this PR widens that set to include all-fabricated-empty slots — operators with config.bidders set but no stored requests provisioned move from one broken state to another. Has the empty-bidder rejection been confirmed against the deployed PBS?

Non-blocking

🤔 thinking

  • The new "cannot fire" comment is inaccurate: real inline params for a bidder outside config.bidders do fall back to the stored request, silently dropping those params (prebid.rs:1490-1493).
  • Non-object params bypass the drop: null params ship as "bidder": {"kargo": null}; #[serde(default)] on BidConfig.params makes this reachable from a POST that omits params (prebid.rs:1481).

♻️ refactor

  • Extract an is_empty_object helper: the emptiness predicate is inlined in the retain at prebid.rs:1481 and is needed again by both suggested fixes above. A shared fn is_empty_object(params: &Json) -> bool { params.as_object().is_some_and(serde_json::Map::is_empty) } keeps the rule in one place.

📌 out of scope

  • zone leaks into every bidder's params: when a trustedServer entry has no bidderParams, expand_trusted_server_bidders (prebid.rs:942-957) hands each configured bidder the entire trustedServer object as its params, so a zone-only slot ships {"kargo": {"zone": "header"}, "triplelift": {"zone": "header"}} — a TS-internal orchestration key sent to PBS as a bidder param. These are non-empty, so the new drop never catches them; it's the third route by which fabricated params reach PBS. Pre-existing and not this PR's job, but it limits the "fabricated params never ship" contract and is worth a follow-up issue.

CI Status

  • GitHub checks: PASS (4/4)
  • fmt: PASS (verified locally)
  • clippy (trusted-server-core, native host, -D warnings): PASS (verified locally)
  • rust tests (trusted-server-core, native host): PASS (1631/1631, verified locally)
  • js tests: not run — no JS/TS files changed

Note: #899 is still open, so this stays stacked and cannot merge first.

Comment thread crates/trusted-server-core/src/integrations/prebid.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/prebid.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/prebid.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/prebid.rs Outdated
expand_trusted_server_bidders and the direct merge could both put an
unusable value into imp.ext.prebid.bidder — an empty {} (fabricated or
publisher-supplied) or a non-object like null. PBS rejects such an imp, and
parse_response collapses one non-2xx into an auction-wide bid wipeout, so a
single misconfigured slot zeroed every slot's bids.

- Add is_unusable_bidder_params (empty object or non-object).
- Collect the trustedServer expansion and direct entries separately, then
  merge so a direct entry wins but an unusable value never clobbers real
  params from the expansion (fixes the asymmetric direct-{} overwrite).
- Drop every remaining unusable value after overrides and log::warn each
  drop; the slot then falls back to its stored request. Explicit empties are
  no longer preserved — PBS cannot tell them from fabricated ones.
- Correct the stored-request comment: the fallback also fires for real
  inline params naming a bidder absent from config.bidders.
- Tests: explicit-empty now drops to stored request; add direct-empty
  no-clobber, null drop, and unconfigured-bidder fallback.

@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

All four findings from the previous round are resolved, and I verified each against 6a75cc499 rather than taking the fix on description:

Finding Before After
🔧 Explicit {} ships the rejected payload {"bidder":{"kargo":{}}} {"storedrequest":{"id":"s"}}
🔧 Asymmetric merge drops real params LOST PARAMS on iter 0 0/64 bad
🤔 null params bypass the drop {"bidder":{"kargo":null}} {"storedrequest":{"id":"s"}}
🤔 Inaccurate "cannot fire" comment inaccurate corrected, pinned by test

The merge rewrite is the right shape. Collecting expanded and direct separately and merging with an explicit clobbers_real guard is what makes this order-independent — no reordering of the previous single loop could have achieved it, since whichever branch ran first would win. I also checked the null-clobbers-real variant I hadn't explicitly raised (0/64 bad), and confirmed valid params still ship unchanged. is_unusable_bidder_params folds in the shared predicate, and #927 tracks the zone leak.

Verification on this head: cargo fmt --all -- --check PASS; clippy (trusted-server-core, --all-targets, -D warnings) clean; 1634/1634 core tests pass (+3 new); GitHub checks green 4/4.

Non-blocking

🤔 thinking

  • The unconditional drop now warns on a designed path: creative-opportunity slots intentionally emit bidderParams: {}, so each one logs a warn per configured bidder calling its intended behavior a misconfiguration (prebid.rs:1498-1503, creative_opportunities.rs:294-305). Detail inline.

📌 out of scope

  • The PBS premise remains unverified: your answer is fair — whether a stored-request miss is a per-imp no-bid or a request-wide 400 is deployed-PBS behavior and genuinely not knowable from this repo, and this PR does verifiably remove definitely-rejected payloads via the fallback #680 already established. It shouldn't hold the PR. But it stays an untested assumption underpinning the design, and there's still no PBS mock or fixture anywhere in the repo to pin it. Worth an issue alongside #927 so it's tracked rather than dropped — happy to file it if you'd prefer.

Approving. Note this stays stacked on #899, which is still open.

CI Status

  • GitHub checks: PASS (4/4)
  • fmt: PASS (verified locally)
  • clippy (trusted-server-core, native host, -D warnings): PASS (verified locally)
  • rust tests (trusted-server-core, native host): PASS (1634/1634, verified locally)
  • js tests: not run — no JS/TS files changed

bidder.retain(|name, params| {
if is_unusable_bidder_params(params) {
log::warn!(
"prebid: dropping bidder '{}' on slot '{}' — empty or non-object params; slot falls back to its stored request",

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.

🤔 thinking — This now warns on a designed path, not just a misconfigured one.

Making the drop unconditional was the right call, but it widened what reaches this warn. creative_opportunities.rs:294-305 deliberately emits bidderParams: {} — its own comment says it gives "each bidder an empty params object that the override engine then fills with zone-aware rules". When no override matches that bidder/zone, every configured bidder is dropped here and each one logs a warning. I confirmed the shape lands in the fallback:

N2 creative-opp-empty-bidderParams => {"storedrequest":{"id":"s"}}

So a routine creative-opportunity slot emits N warnings (one per configured bidder) describing its intended behavior, and the message tells the operator it's a misconfiguration falling back to a stored request. Warn-level noise on an expected path erodes the signal this log exists to provide — the genuine publisher misconfiguration you added it for gets buried under the designed case.

Since the two are distinguishable at this point, they could log differently — e.g. keep warn when the slot supplied some inline params (a real publisher mistake), and drop to debug when the expansion fabricated everything (creative-opportunity / stored-request path). Even just collapsing to one line per slot rather than one per bidder would help.

Non-blocking — behavior is correct either way, this is purely about operability.

The unconditional drop of empty/non-object PBS bidder params warned once
per bidder, including on the designed creative-opportunity path where the
trustedServer expansion fabricates empty params and no override rule fills
them. Routine slots emitted N warnings describing intended behavior, burying
the genuine publisher misconfiguration the log exists to surface.

Track which bidders the slot supplied inline and split the drop log by
source: slot-supplied unusable params warn, fabricated empties log at debug.
Both now emit one line per slot with sorted bidder names instead of one line
per bidder. Drop behavior is unchanged.
@prk-Jr
prk-Jr merged commit 07890a9 into feat/ssat-render-inline-creative Jul 22, 2026
1 check passed
prk-Jr added a commit that referenced this pull request Jul 27, 2026
* Add design spec for SSAT inline creative rendering

* Add implementation plan for SSAT inline creative rendering

* Correct plan call graph, add TsjsApi type + beacon steps, note precondition

* Revise SSAT inline-creative plan/spec per review

- Drop render_adm param: always include adm when creative present
- Gate GAM-bypass on per-bid debug_bid instead of a global window.tsjs flag
  (removes SPA-staleness edge case, TsjsApi change, and whole flag-emit task)
- Correct fallback scope: cache fallback only when adm absent; render failure
  after adm is supplied is not detectable
- Qualify sandbox claim: TS guarantees script-context escaping; bridge frame
  isolation depends on the Prebid Universal Creative
- Reconcile with existing ad_init.test.ts coverage (rename, no duplicates)
- Fix test assertion messages to expect("should ..."); list exact clippy gates

* Refine plan test details per second review

- Task 3: assert observable DOM (GAM iframe src) instead of spying on the
  module-private injectAdmIntoSlot
- Use existing make_bid helper + set .creative (make_test_bid_with_creative
  does not exist on this branch)
- Hostile-adm regression test covers both U+2028 and U+2029
- Add 'cd docs && npm run format' to verification

* Always include adm in bid map; gate only debug_bid blob

build_bid_map now always inserts the winning creative as adm so the pbRender
bridge can render it locally (no PBS Cache round trip); the verbose debug_bid
blob and the GAM-bypass gate stay behind inject_adm_for_testing. Rename the
param include_adm -> include_debug_bid and thread it through write_bids_to_state.
Reconcile the by-default test to the new behavior, drop the now-redundant
debug-only-adm test, and pin script-context escaping for a hostile adm
(</script> + U+2028/U+2029).

* Gate GAM-bypass adm injection on per-bid debug_bid

The direct GAM-replace path (injectAdmIntoSlot) now fires only when the bid
carries debug_bid, which is present only under inject_adm_for_testing. In
production the always-present adm is rendered by the pbRender bridge and GAM
stays in the loop. Add observable-DOM tests (GAM iframe src unchanged without
debug_bid, rewritten with it), strengthen the bridge test to prove inline adm
is preferred even when cache coords are present, and rename debug-adm
terminology to inline adm.

* Apply prettier formatting to spec/plan docs

* Run SSAT inline creative through the sanitize/rewrite boundary and decode cached bids

The inline-adm path fed raw bid.creative into window.tsjs.bids, bypassing the
creative-processing boundary the /auction path applies (sanitize_creative_html
then rewrite_creative_html, which also enforces the 1 MiB creative cap). Run the
same boundary in build_bid_map before inserting adm; omit adm when the creative
is rejected so the bridge falls back to the PBS Cache coordinates. Thread
&Settings through build_bid_map and write_bids_to_state for rewrite_creative_html.

The pbRender bridge's PBS Cache fallback forwarded the raw GET body as the ad,
but PBS Cache returns a JSON bid object and the Prebid Universal Creative renders
bidObject.adm. Add extractCachedAdm to parse the cached bid and extract its adm,
keep raw-markup compatibility, and decline to render when no adm is present.

Add hostile and oversized creative coverage in Rust, and realistic
returnCreative=false, malformed, and raw-markup cache-response coverage in JS.
Update the inject_adm_for_testing, AuctionBidData, GPT bridge, and design-spec
docs to distinguish production inline adm from the testing-only debug_bid path.

* Emit absolute first-party URLs for inline SSAT creatives

The inline `adm` in window.tsjs.bids is rendered by the Prebid Universal
Creative inside GAM's iframe (f.srcdoc = d.ad), a foreign origin.
rewrite_creative_html emitted root-relative /first-party/... URLs that
resolve against GAM's origin there and 404, and injected the tsjs bundle
into every creative iframe.

Add rewrite_inline_creative_html: emits absolute
https://<publisher.domain>/first-party/... URLs (resolve regardless of the
document base URL or whether PUC honors the custom renderer) and skips the
tsjs bundle injection. Route build_bid_map's inline adm through it; the
first-party /auction path keeps the root-relative rewriter. One shared
rewriter via base_origin/inject_tsjs. Add tests for the absolute-URL shape
and absent tsjs injection at the creative and build_bid_map boundaries.

* Resolve pbRender bid by requesting slot, not first hb_adid match

hb_adid is not unique per bid: absent PBS Cache it falls back to a
creative id a bidder may reuse across slots (observed: three IX bids
sharing one id). The bridge matched the first bid whose hb_adid equalled
the requested adId, then rejected on the slot-ownership guard, so every
slot but the first rendered blank.

Resolve the bid by the requesting slot and verify its hb_adid matches
the request, so each slot renders its own creative regardless of
duplicate ids. The adId check still blocks a slot A iframe from pulling
slot B's creative and beacons.

* Size inline creative render from the winning bid dimensions

The pbRender bridge sized every inline response from the first configured
slot format, while the winning creative's own width/height were emitted
only inside the testing-only debug_bid. A multi-size slot whose winner is
not the first format therefore rendered at the wrong size (clipping or
whitespace).

Emit w/h in the normal bid map and AuctionBidData, and prefer them in the
inline bridge response, falling back to the first slot format only when
absent.

* Expand ${AUCTION_PRICE} before rewriting inline creatives

The inline render path forwarded adm without resolving the auction-price
macro. URL rewriting then serialized query pairs, encoding the literal
${AUCTION_PRICE} into %24%7BAUCTION_PRICE%7D inside the signed proxy/click
URL — so trackers received an encoded macro rather than the clearing
price, and signing locked the wrong value.

Add expand_auction_price_macro and call it from build_bid_map before
sanitize_creative_html and rewrite_inline_creative_html, using the exact
winning CPM. Only the clear-price token is expanded; the encrypted
${AUCTION_PRICE:B64} variant is left for the DSP.

* Build inline creative URLs from the request origin

rewrite_inline_creative_html hard-coded https://{publisher.domain},
discarding the incoming scheme, subdomain, and port. publisher.domain
cannot carry a port, deployments may serve a subdomain, and Axum/Viceroy
dev runs over HTTP with a port — so inline proxy/click URLs resolved
against the wrong origin with no render-time fallback.

Thread the trusted request origin (scheme://host, host including any
port) through write_bids_to_state and build_bid_map into
rewrite_inline_creative_html. Initial navigation derives it from the
buffered request host/scheme; SPA page-bids from RequestInfo. Falls back
to the configured publisher domain only when the origin is unknown.

Covers HTTP localhost with a port, a request subdomain differing from
publisher.domain, and a non-default HTTPS port.

* Preserve cached bid dimensions and price in the PBS Cache fallback

extractCachedAdm reduced the cached bid to its adm string, so the cache
fallback sized every render from the first slot format and left price
macros unresolved. Replace it with parseCachedBid, which retains the
cached creative dimensions (w/h or width/height) and clearing price.

The fallback now sizes from the cached dimensions (slot format only when
absent) and expands ${AUCTION_PRICE} from the cached price before
responding. Raw-markup bodies stay supported as the adm-only variant.

Firing a cached win-notification URL is deferred: it is a billing side
effect and the exact cache field/dedup contract needs a real PBS Cache
payload to verify before emitting.

* Reconcile SSAT inline-creative design and plan with implementation

The design named rewrite_creative_html and omitted the render-metadata
requirements the code now enforces; the plan's tasks were unchecked and
predated the shipped divergences. Add an "Implementation reconciliation"
section to the design (inline rewriter, request-origin URLs, w/h and
${AUCTION_PRICE} render metadata, structured cache decode) and update the
components table; mark the plan superseded-but-completed pointing at it.

* Allow too_many_arguments on collect_stream_auction

Threading the request origin pushed the private collect_stream_auction
helper to 8 arguments. Its arguments mirror the AuctionCollectCtx fields
the caller destructures, so a parameter struct would only duplicate that
context; suppress the lint instead.

* Omit zero creative dimensions so the bridge falls back

Missing OpenRTB w/h parse to 0, and build_bid_map emitted them as w:0/h:0.
The bridge nullish-coalesces (matchedBid.w ?? fallback), so 0 was kept
rather than falling back to the slot format — sizing the frame to 0.
Omit a non-positive dimension server-side, and treat a zero cached
dimension as absent in parseCachedBid, so the bridge falls back.

* Scope the pbRender in-flight guard to the slot, not the bare adId

The cache-fallback concurrency guard keyed on adId (hb_adid), which is not
unique per bid — two distinct slots sharing an hb_adid would have the
second's render cross-blocked. Not reachable today (a duplicate hb_adid
means no cache coordinates, so the cache path never runs), but the guard
relied on that invariant silently. Key the in-flight set on slotId|adId so
it dedups a genuine same-render race without colliding across slots.

* Expand ${AUCTION_PRICE} in win and billing notification URLs

Per OpenRTB, nurl and burl are the canonical carriers of the
${AUCTION_PRICE} macro, and the render bridge fires both verbatim via
sendBeacon. Only the creative was expanded, so every TS-won impression
sent the SSP a literal macro instead of the clearing price — a
revenue-reporting error, and the kind of malformed notification some
SSPs reject outright. Expand both from the same winning CPM already in
scope for the creative.

The PBS Cache fallback in the bridge is covered by the same change: it
fires matchedBid's beacons, which now arrive expanded from the auction
clearing price rather than the cached copy's price. Note that at the
fire site so the expansion is not duplicated client-side.

The debug_bid mirror keeps its bidder-supplied creative, nurl, and burl
verbatim: it is diagnostic, nothing renders or fires from it, and
showing what the bidder actually sent is the point.

Also drop the too_many_arguments allow on collect_stream_auction. Split
AuctionCollectCtx into the per-auction state that is moved at collect
time and the borrowed dependencies that outlive it, so the helper takes
three arguments and request-origin threading no longer widens the
signature.

* Suppress fabricated empty Prebid bidder params (#910)

* Suppress fabricated empty Prebid bidder params

A configured bidder with no inline params and no matching override
expanded to `"bidder": {}`, which PBS rejects. After applying overrides,
drop fabricated empty bidders, preserve an explicitly supplied empty
object so genuine misconfiguration stays visible, and fall back to the
stored-request path when no eligible bidders remain.

* Prefer direct bidder params over fabricated-empty in mixed slots

A slot can carry both a direct bidder entry with valid inline params and a
trustedServer entry whose bidderParams omits that bidder. Because slot.bidders
is a HashMap, the trustedServer expansion (which fabricates an empty {} for the
omitted bidder) could run after the direct entry and overwrite its valid params
via extend. The direct entry already marked the bidder explicit, so the empty
object survived the retention drop and PBS rejected the impression — the failure
this path is meant to eliminate — nondeterministically, depending on iteration
order.

Replace the extend with an entry().or_insert() loop so trustedServer-expanded
bidders only fill in absent entries and direct params win regardless of order.
Add a looped regression test exercising a slot with both representations.

* Drop empty and non-object PBS bidder params instead of shipping them

expand_trusted_server_bidders and the direct merge could both put an
unusable value into imp.ext.prebid.bidder — an empty {} (fabricated or
publisher-supplied) or a non-object like null. PBS rejects such an imp, and
parse_response collapses one non-2xx into an auction-wide bid wipeout, so a
single misconfigured slot zeroed every slot's bids.

- Add is_unusable_bidder_params (empty object or non-object).
- Collect the trustedServer expansion and direct entries separately, then
  merge so a direct entry wins but an unusable value never clobbers real
  params from the expansion (fixes the asymmetric direct-{} overwrite).
- Drop every remaining unusable value after overrides and log::warn each
  drop; the slot then falls back to its stored request. Explicit empties are
  no longer preserved — PBS cannot tell them from fabricated ones.
- Correct the stored-request comment: the fallback also fires for real
  inline params naming a bidder absent from config.bidders.
- Tests: explicit-empty now drops to stored request; add direct-empty
  no-clobber, null drop, and unconfigured-bidder fallback.

* Split unusable-bidder drop logging by source and collapse per slot

The unconditional drop of empty/non-object PBS bidder params warned once
per bidder, including on the designed creative-opportunity path where the
trustedServer expansion fabricates empty params and no override rule fills
them. Routine slots emitted N warnings describing intended behavior, burying
the genuine publisher misconfiguration the log exists to surface.

Track which bidders the slot supplied inline and split the drop log by
source: slot-supplied unusable params warn, fabricated empties log at debug.
Both now emit one line per slot with sorted bidder names instead of one line
per bidder. Drop behavior is unchanged.
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