From 59255694f81330fff728d106eb15363c54d07cf7 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 19 Feb 2026 13:58:24 -0600 Subject: [PATCH 01/11] Adds configuration for bidder param overrides --- crates/common/src/integrations/prebid.rs | 241 +++++++++++++++++++++++ docs/guide/integrations/prebid.md | 42 ++++ trusted-server.toml | 2 + 3 files changed, 285 insertions(+) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 1ad84f9e..7054011d 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -62,6 +62,18 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + /// Per-bidder param overrides. Keys are bidder names, values are JSON objects + /// whose fields are shallow-merged into the bidder's params before sending + /// the `OpenRTB` request. Useful for replacing client-side placement IDs with + /// server-to-server equivalents. + /// + /// Example in TOML: + /// ```toml + /// [integrations.prebid.bid_param_overrides.kargo] + /// placementId = "server_side_placement_123" + /// ``` + #[serde(default)] + pub bid_param_overrides: HashMap, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -502,6 +514,20 @@ impl PrebidAuctionProvider { } } + // Apply bid_param_overrides from config (shallow merge) + for (name, params) in &mut bidder { + if let Some(Json::Object(ovr)) = self.config.bid_param_overrides.get(name) { + if let Json::Object(base) = params { + log::debug!( + "prebid: overriding bidder params for '{}': keys {:?}", + name, + ovr.keys().collect::>() + ); + base.extend(ovr.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + } + } + Imp { id: slot.id.clone(), banner: Some(Banner { format: formats }), @@ -849,6 +875,9 @@ pub fn register_auction_provider(settings: &Settings) -> Vec) -> AuctionRequest { + AuctionRequest { + id: "test-auction-1".to_string(), + slots, + publisher: PublisherInfo { + domain: "example.com".to_string(), + page_url: Some("https://example.com/page".to_string()), + }, + user: UserInfo { + id: "synth-123".to_string(), + fresh_id: "fresh-456".to_string(), + consent: None, + }, + device: Some(DeviceInfo { + user_agent: Some("test-agent".to_string()), + ip: None, + geo: None, + }), + site: None, + context: HashMap::new(), + } + } + + fn make_slot(id: &str, bidders: HashMap) -> AdSlot { + AdSlot { + id: id.to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders, + } + } + + fn call_to_openrtb( + config: PrebidIntegrationConfig, + request: &AuctionRequest, + ) -> OpenRtbRequest { + let provider = PrebidAuctionProvider::new(config); + let settings = make_settings(); + let fastly_req = Request::new(Method::POST, "https://example.com/auction"); + let context = AuctionContext { + settings: &settings, + request: &fastly_req, + timeout_ms: 1000, + provider_responses: None, + }; + provider.to_openrtb(request, &context, None) + } + + fn bidder_params(ortb: &OpenRtbRequest) -> &HashMap { + &ortb.imp[0] + .ext + .as_ref() + .expect("should have imp ext") + .prebid + .bidder + } + + #[test] + fn bid_param_overrides_replaces_existing_param() { + let mut config = base_config(); + config.bid_param_overrides.insert( + "kargo".to_string(), + json!({ "placementId": "server_side_456" }), + ); + + let bidders = HashMap::from([( + "kargo".to_string(), + json!({ "placementId": "client_side_123" }), + )]); + let request = make_auction_request(vec![make_slot("slot1", bidders)]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!( + bidder_params(&ortb)["kargo"]["placementId"], + "server_side_456", + "override should replace client-side placementId" + ); + } + + #[test] + fn bid_param_overrides_merges_without_removing_existing_fields() { + let mut config = base_config(); + config + .bid_param_overrides + .insert("kargo".to_string(), json!({ "placementId": "server_456" })); + + let bidders = HashMap::from([( + "kargo".to_string(), + json!({ "placementId": "client_123", "extra": "keep_me" }), + )]); + let request = make_auction_request(vec![make_slot("slot1", bidders)]); + + let ortb = call_to_openrtb(config, &request); + let kargo = &bidder_params(&ortb)["kargo"]; + assert_eq!( + kargo["placementId"], "server_456", + "overridden field should have new value" + ); + assert_eq!( + kargo["extra"], "keep_me", + "non-overridden fields should be preserved" + ); + } + + #[test] + fn bid_param_overrides_only_affects_matching_bidders() { + let mut config = base_config(); + config + .bid_param_overrides + .insert("kargo".to_string(), json!({ "placementId": "server_456" })); + + let bidders = HashMap::from([ + ("kargo".to_string(), json!({ "placementId": "client_123" })), + ("rubicon".to_string(), json!({ "accountId": 100 })), + ]); + let request = make_auction_request(vec![make_slot("slot1", bidders)]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + assert_eq!( + params["kargo"]["placementId"], "server_456", + "kargo should be overridden" + ); + assert_eq!( + params["rubicon"]["accountId"], 100, + "rubicon should be untouched" + ); + } + + #[test] + fn bid_param_overrides_noop_when_empty() { + let config = base_config(); + + let bidders = + HashMap::from([("kargo".to_string(), json!({ "placementId": "client_123" }))]); + let request = make_auction_request(vec![make_slot("slot1", bidders)]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!( + bidder_params(&ortb)["kargo"]["placementId"], + "client_123", + "params should pass through unchanged" + ); + } + + #[test] + fn bid_param_overrides_applies_to_fallback_bidders() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + config + .bid_param_overrides + .insert("kargo".to_string(), json!({ "placementId": "server_456" })); + + // Empty bidders on slot triggers fallback to config.bidders + let request = make_auction_request(vec![make_slot("slot1", HashMap::new())]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!( + bidder_params(&ortb)["kargo"]["placementId"], + "server_456", + "override should apply even to fallback bidders with empty params" + ); + } + + #[test] + fn bid_param_overrides_config_parsing_from_toml() { + let toml_str = r#" +[publisher] +domain = "test-publisher.com" +cookie_domain = ".test-publisher.com" +origin_url = "https://origin.test-publisher.com" +proxy_secret = "test-secret" + +[synthetic] +counter_store = "test-counter-store" +opid_store = "test-opid-store" +secret_key = "test-secret-key" +template = "{{client_ip}}:{{user_agent}}" + +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" + +[integrations.prebid.bid_param_overrides.kargo] +placementId = "server_side_123" + +[integrations.prebid.bid_param_overrides.rubicon] +accountId = 99999 +siteId = 88888 +"#; + + let settings = Settings::from_toml(toml_str).expect("should parse TOML"); + let config = settings + .integration_config::("prebid") + .expect("should get config") + .expect("should be enabled"); + + assert_eq!(config.bid_param_overrides.len(), 2); + assert_eq!( + config.bid_param_overrides["kargo"]["placementId"], + "server_side_123" + ); + assert_eq!(config.bid_param_overrides["rubicon"]["accountId"], 99999); + assert_eq!(config.bid_param_overrides["rubicon"]["siteId"], 88888); + } } diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index d3ff2801..96a99d88 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -24,6 +24,10 @@ debug = false # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] + +# Optional per-bidder param overrides (shallow merge) +[integrations.prebid.bid_param_overrides.kargo] +placementId = "server_side_placement_123" ``` ### Configuration Options @@ -34,6 +38,7 @@ script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.mi | `server_url` | String | Required | Prebid Server endpoint URL | | `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | | `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | +| `bid_param_overrides` | Table | `{}` | Per-bidder params merged into request bidder params (`override` values win on key conflicts) | | `debug` | Boolean | `false` | Enable debug logging | | `debug_query_params` | String | `None` | Extra query params appended for debugging | | `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | @@ -91,6 +96,42 @@ script_patterns = [] When a request matches a script pattern, Trusted Server returns an empty JavaScript file with aggressive caching (`max-age=31536000, immutable`). +### Bid Param Overrides + +Use `bid_param_overrides` to force specific bidder params before the request is sent to Prebid Server. This is useful when client-side and server-side bidder IDs differ (for example, Kargo `placementId` values). + +**Behavior**: + +- Overrides are matched by bidder name (for example, `kargo`, `rubicon`) +- Params are shallow-merged into incoming bidder params +- Override values replace incoming values when keys conflict +- Non-conflicting incoming fields are preserved +- Overrides also apply to fallback bidders created from `bidders = [...]` when slot params are empty + +**Example**: + +```toml +[integrations.prebid.bid_param_overrides.kargo] +placementId = "server_side_placement_123" + +[integrations.prebid.bid_param_overrides.rubicon] +accountId = 1001 +siteId = 2002 +zoneId = 3003 +``` + +If the incoming request has: + +```json +{"kargo": {"placementId": "client_side_abc", "foo": "bar"}} +``` + +the outgoing bidder params become: + +```json +{"kargo": {"placementId": "server_side_placement_123", "foo": "bar"}} +``` + ## Endpoints ### GET /first-party/ad @@ -157,6 +198,7 @@ The `to_openrtb()` method in `PrebidAuctionProvider` builds OpenRTB requests: - Injects synthetic ID in the user object - Includes device/geo information when available - Appends `debug_query_params` to page URL when configured +- Applies `bid_param_overrides` to `imp.ext.prebid.bidder` before request dispatch - Signs requests when request signing is enabled ## Best Practices diff --git a/trusted-server.toml b/trusted-server.toml index 5ad18888..492865a8 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -42,9 +42,11 @@ server_url = "http://68.183.113.79:8000" timeout_ms = 1000 bidders = ["kargo", "rubicon", "appnexus", "openx"] debug = false +# bid_param_overrides = {mocktioneer = {example = "example_value"}} # debug_query_params = "" # script_patterns = ["/prebid.js"] + [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] From ee0ecaf9b63b6554b878c9457fa7e64376fd4c8f Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 23 Feb 2026 11:49:41 -0600 Subject: [PATCH 02/11] Make map_from_obj_or_str generic to support Json values for bid_param_overrides --- crates/common/src/integrations/prebid.rs | 3 ++- crates/common/src/settings.rs | 13 +++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 7054011d..06f60177 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -9,6 +9,7 @@ use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as Json}; use validator::Validate; +use crate::settings::map_from_obj_or_str; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ @@ -72,7 +73,7 @@ pub struct PrebidIntegrationConfig { /// [integrations.prebid.bid_param_overrides.kargo] /// placementId = "server_side_placement_123" /// ``` - #[serde(default)] + #[serde(default, deserialize_with = "map_from_obj_or_str")] pub bid_param_overrides: HashMap, } diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index 57cb3951..b4f2d1ce 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -429,29 +429,26 @@ fn validate_path(value: &str) -> Result<(), ValidationError> { /// /// This allows setting map fields via environment variables while /// preserving key casing and special characters like hyphens. -pub(crate) fn map_from_obj_or_str<'de, D>( +pub(crate) fn map_from_obj_or_str<'de, D, V>( deserializer: D, -) -> Result, D::Error> +) -> Result, D::Error> where D: Deserializer<'de>, + V: DeserializeOwned, { let v = JsonValue::deserialize(deserializer)?; match v { JsonValue::Object(map) => map .into_iter() .map(|(k, v)| { - let val = match v { - JsonValue::String(s) => s, - other => other.to_string(), - }; + let val: V = serde_json::from_value(v).map_err(serde::de::Error::custom)?; Ok((k, val)) }) .collect(), JsonValue::String(s) => { let txt = s.trim(); if txt.starts_with('{') { - serde_json::from_str::>(txt) - .map_err(serde::de::Error::custom) + serde_json::from_str::>(txt).map_err(serde::de::Error::custom) } else { Err(serde::de::Error::custom( "expected JSON object string, e.g. '{\"Key\": \"value\"}'", From 3ea33fb06759674d38f301a56975c236f9329048 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 23 Feb 2026 12:04:50 -0600 Subject: [PATCH 03/11] update toml for now --- trusted-server.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/trusted-server.toml b/trusted-server.toml index 492865a8..e8ae5e9e 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -42,6 +42,7 @@ server_url = "http://68.183.113.79:8000" timeout_ms = 1000 bidders = ["kargo", "rubicon", "appnexus", "openx"] debug = false +bid_param_overrides = {kargo = {placementId = "_kn1KdG0VJY"}} # bid_param_overrides = {mocktioneer = {example = "example_value"}} # debug_query_params = "" # script_patterns = ["/prebid.js"] From c99c3202fc5bd9a7010c2ec8b5bd67da82dae5f3 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 24 Feb 2026 08:12:48 -0600 Subject: [PATCH 04/11] wip --- crates/common/src/integrations/prebid.rs | 306 +++++++++++++++++- crates/common/src/openrtb.rs | 2 + .../js/lib/src/integrations/prebid/index.ts | 27 +- .../test/integrations/prebid/index.test.ts | 77 +++++ docs/guide/integrations/prebid.md | 36 +++ trusted-server.toml | 10 +- 6 files changed, 452 insertions(+), 6 deletions(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 06f60177..2f603c54 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +use crate::settings::map_from_obj_or_str; use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use error_stack::{Report, ResultExt}; @@ -9,7 +10,6 @@ use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as Json}; use validator::Validate; -use crate::settings::map_from_obj_or_str; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ @@ -33,6 +33,7 @@ use crate::settings::{IntegrationConfig, Settings}; const PREBID_INTEGRATION_ID: &str = "prebid"; const TRUSTED_SERVER_BIDDER: &str = "trustedServer"; const BIDDER_PARAMS_KEY: &str = "bidderParams"; +const ZONE_KEY: &str = "zone"; #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct PrebidIntegrationConfig { @@ -75,6 +76,22 @@ pub struct PrebidIntegrationConfig { /// ``` #[serde(default, deserialize_with = "map_from_obj_or_str")] pub bid_param_overrides: HashMap, + /// Per-bidder, per-zone param overrides. The outer key is a bidder name, the + /// inner key is a zone name (sent by the JS adapter from the ad-unit code), + /// and the value is a JSON object shallow-merged into that bidder's params. + /// + /// When a matching zone override is found for a bidder it takes precedence + /// over any entry in [`bid_param_overrides`] for that bidder. + /// + /// Example in TOML: + /// ```toml + /// [integrations.prebid.bid_param_zone_overrides.kargo] + /// header = {placementId = "_s2sHeaderId"} + /// in_content = {placementId = "_s2sContentId"} + /// fixed_bottom = {placementId = "_s2sBottomId"} + /// ``` + #[serde(default, deserialize_with = "map_from_obj_or_str")] + pub bid_param_zone_overrides: HashMap>, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -495,6 +512,14 @@ impl PrebidAuctionProvider { }) .collect(); + // Extract zone from trustedServer params (sent by the JS + // adapter from the ad-unit code, e.g. "header", "fixed_bottom"). + let zone: Option<&str> = slot + .bidders + .get(TRUSTED_SERVER_BIDDER) + .and_then(|p| p.get(ZONE_KEY)) + .and_then(Json::as_str); + // Build the bidder map for PBS. // The JS adapter sends "trustedServer" as the bidder (our orchestrator // adapter name). Replace it with the real PBS bidders from config. @@ -515,9 +540,29 @@ impl PrebidAuctionProvider { } } - // Apply bid_param_overrides from config (shallow merge) + // Apply overrides. Zone-specific overrides take precedence + // over the blanket `bid_param_overrides` for the same bidder. for (name, params) in &mut bidder { - if let Some(Json::Object(ovr)) = self.config.bid_param_overrides.get(name) { + let zone_override = zone.and_then(|z| { + self.config + .bid_param_zone_overrides + .get(name.as_str()) + .and_then(|zones| zones.get(z)) + }); + + if let Some(Json::Object(ovr)) = zone_override { + if let Json::Object(base) = params { + log::debug!( + "prebid: zone override for '{}' zone '{}': keys {:?}", + name, + zone.unwrap_or(""), + ovr.keys().collect::>() + ); + base.extend(ovr.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + } else if let Some(Json::Object(ovr)) = + self.config.bid_param_overrides.get(name) + { if let Json::Object(base) = params { log::debug!( "prebid: overriding bidder params for '{}': keys {:?}", @@ -587,6 +632,7 @@ impl PrebidAuctionProvider { let ext = Some(RequestExt { prebid: Some(PrebidExt { debug: if self.config.debug { Some(true) } else { None }, + returnallbidstatus: Some(true), }), trusted_server: Some(TrustedServerExt { signature, @@ -793,6 +839,11 @@ impl AuctionProvider for PrebidAuctionProvider { message: "Failed to parse Prebid response".to_string(), })?; + match serde_json::to_string_pretty(&response_json) { + Ok(json) => log::debug!("Prebid OpenRTB response:\n{}", json), + Err(e) => log::warn!("Prebid: failed to serialize OpenRTB response for logging: {e}"), + } + let request_host = response_json .get("ext") .and_then(|ext| ext.get("trusted_server")) @@ -905,6 +956,7 @@ mod tests { debug_query_params: None, script_patterns: default_script_patterns(), bid_param_overrides: HashMap::new(), + bid_param_zone_overrides: HashMap::new(), } } @@ -1627,4 +1679,252 @@ siteId = 88888 assert_eq!(config.bid_param_overrides["rubicon"]["accountId"], 99999); assert_eq!(config.bid_param_overrides["rubicon"]["siteId"], 88888); } + + // ======================================================================== + // bid_param_zone_overrides tests + // ======================================================================== + + /// Helper: build a slot whose bidders entry is a trustedServer payload + /// with per-bidder params and an optional zone. + fn make_ts_slot(id: &str, bidder_params: &Json, zone: Option<&str>) -> AdSlot { + let mut ts_params = json!({ BIDDER_PARAMS_KEY: bidder_params }); + if let Some(z) = zone { + ts_params[ZONE_KEY] = json!(z); + } + make_slot( + id, + HashMap::from([(TRUSTED_SERVER_BIDDER.to_string(), ts_params)]), + ) + } + + #[test] + fn zone_override_replaces_placement_id() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + config.bid_param_zone_overrides.insert( + "kargo".to_string(), + HashMap::from([( + "header".to_string(), + json!({ "placementId": "s2s_header_id" }), + )]), + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "client_side_123" } }), + Some("header"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!( + bidder_params(&ortb)["kargo"]["placementId"], + "s2s_header_id", + "zone override should replace the client-side placementId" + ); + } + + #[test] + fn zone_override_skips_bid_param_overrides_for_matched_bidder() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + // Both override types configured for kargo + config + .bid_param_overrides + .insert("kargo".to_string(), json!({ "placementId": "blanket_id" })); + config.bid_param_zone_overrides.insert( + "kargo".to_string(), + HashMap::from([( + "header".to_string(), + json!({ "placementId": "zone_header_id" }), + )]), + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "client_123" } }), + Some("header"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!( + bidder_params(&ortb)["kargo"]["placementId"], + "zone_header_id", + "zone override should win over bid_param_overrides" + ); + } + + #[test] + fn zone_override_falls_back_to_bid_param_overrides_for_unknown_zone() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + config.bid_param_overrides.insert( + "kargo".to_string(), + json!({ "placementId": "blanket_fallback" }), + ); + config.bid_param_zone_overrides.insert( + "kargo".to_string(), + HashMap::from([( + "header".to_string(), + json!({ "placementId": "zone_header_id" }), + )]), + ); + + // Zone "sidebar" is NOT in the zone overrides map + let slot = make_ts_slot( + "ad-sidebar-0", + &json!({ "kargo": { "placementId": "client_123" } }), + Some("sidebar"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!( + bidder_params(&ortb)["kargo"]["placementId"], + "blanket_fallback", + "unrecognised zone should fall back to bid_param_overrides" + ); + } + + #[test] + fn zone_override_no_zone_uses_bid_param_overrides() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + config + .bid_param_overrides + .insert("kargo".to_string(), json!({ "placementId": "blanket_id" })); + config.bid_param_zone_overrides.insert( + "kargo".to_string(), + HashMap::from([( + "header".to_string(), + json!({ "placementId": "zone_header_id" }), + )]), + ); + + // No zone in the trustedServer params + let slot = make_ts_slot( + "slot1", + &json!({ "kargo": { "placementId": "client_123" } }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!( + bidder_params(&ortb)["kargo"]["placementId"], + "blanket_id", + "missing zone should use bid_param_overrides" + ); + } + + #[test] + fn zone_override_only_affects_configured_bidders() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string(), "rubicon".to_string()]; + config.bid_param_zone_overrides.insert( + "kargo".to_string(), + HashMap::from([( + "header".to_string(), + json!({ "placementId": "s2s_header_id" }), + )]), + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ + "kargo": { "placementId": "client_kargo" }, + "rubicon": { "accountId": 100 } + }), + Some("header"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + assert_eq!( + params["kargo"]["placementId"], "s2s_header_id", + "kargo should get zone override" + ); + assert_eq!( + params["rubicon"]["accountId"], 100, + "rubicon should be untouched" + ); + } + + #[test] + fn zone_override_merges_with_existing_params() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + config.bid_param_zone_overrides.insert( + "kargo".to_string(), + HashMap::from([("header".to_string(), json!({ "placementId": "s2s_header" }))]), + ); + + // Client sends extra field alongside placementId + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "client_123", "extra": "keep_me" } }), + Some("header"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let kargo = &bidder_params(&ortb)["kargo"]; + assert_eq!( + kargo["placementId"], "s2s_header", + "overridden field should have the zone value" + ); + assert_eq!( + kargo["extra"], "keep_me", + "non-overridden fields should be preserved" + ); + } + + #[test] + fn zone_overrides_config_parsing_from_toml() { + let toml_str = r#" +[publisher] +domain = "test-publisher.com" +cookie_domain = ".test-publisher.com" +origin_url = "https://origin.test-publisher.com" +proxy_secret = "test-secret" + +[synthetic] +counter_store = "test-counter-store" +opid_store = "test-opid-store" +secret_key = "test-secret-key" +template = "{{client_ip}}:{{user_agent}}" + +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" + +[integrations.prebid.bid_param_zone_overrides.kargo] +header = {placementId = "_s2sHeader"} +in_content = {placementId = "_s2sContent"} +fixed_bottom = {placementId = "_s2sBottom"} +"#; + + let settings = Settings::from_toml(toml_str).expect("should parse TOML"); + let config = settings + .integration_config::("prebid") + .expect("should get config") + .expect("should be enabled"); + + let kargo_zones = &config.bid_param_zone_overrides["kargo"]; + assert_eq!(kargo_zones.len(), 3, "should have three zone entries"); + assert_eq!( + kargo_zones["header"]["placementId"], "_s2sHeader", + "should parse header zone" + ); + assert_eq!( + kargo_zones["in_content"]["placementId"], "_s2sContent", + "should parse in_content zone" + ); + assert_eq!( + kargo_zones["fixed_bottom"]["placementId"], "_s2sBottom", + "should parse fixed_bottom zone" + ); + } } diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index 3b405209..b3aeccdf 100644 --- a/crates/common/src/openrtb.rs +++ b/crates/common/src/openrtb.rs @@ -109,6 +109,8 @@ pub struct RequestExt { pub struct PrebidExt { #[serde(skip_serializing_if = "Option::is_none")] pub debug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub returnallbidstatus: Option, } #[derive(Debug, Serialize, Default)] diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 43c369f0..00274150 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -22,6 +22,23 @@ import type { AuctionBid } from '../../core/auction'; const ADAPTER_CODE = 'trustedServer'; const BIDDER_PARAMS_KEY = 'bidderParams'; +const ZONE_KEY = 'zone'; + +/** + * Extract the ad-slot zone from a Prebid ad unit code. + * + * Publisher codes follow the pattern `ad-{zone}-...` where the zone is a + * lowercase identifier that may contain underscores (e.g. `"fixed_bottom"`). + * + * Examples: + * - `"ad-header-0"` → `"header"` + * - `"ad-fixed_bottom-0"` → `"fixed_bottom"` + * - `"ad-in_content-abc123-in_content-2"` → `"in_content"` + */ +export function extractZone(code: string): string | undefined { + const match = code.match(/^ad-([a-z][a-z_]*)/); + return match?.[1]; +} /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -118,7 +135,7 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: type PbjsConfig = Parameters[0]; type TrustedServerBid = { bidder?: string; params?: Record }; -type TrustedServerAdUnit = { bids?: TrustedServerBid[] }; +type TrustedServerAdUnit = { code?: string; bids?: TrustedServerBid[] }; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -218,7 +235,13 @@ export function installPrebidNpm(config?: Partial): typeof pbjs bidderParams[bid.bidder] = bid.params ?? {}; } - const tsParams = { [BIDDER_PARAMS_KEY]: bidderParams }; + // Include the ad-slot zone so the server can apply zone-specific + // bid-param overrides (e.g. mapping zones to s2s placement IDs). + const zone = extractZone(unit.code ?? ''); + const tsParams: Record = { + [BIDDER_PARAMS_KEY]: bidderParams, + ...(zone ? { [ZONE_KEY]: zone } : {}), + }; const existingTsBid = unit.bids.find((b) => b.bidder === ADAPTER_CODE); if (existingTsBid) { existingTsBid.params = { diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 77353bb2..5ef945a3 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -34,6 +34,7 @@ vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); import { collectBidders, + extractZone, getInjectedConfig, auctionBidsToPrebidBids, installPrebidNpm, @@ -67,6 +68,35 @@ describe('prebid/collectBidders', () => { }); }); +describe('prebid/extractZone', () => { + it('extracts zone from a simple ad unit code', () => { + expect(extractZone('ad-header-0')).toBe('header'); + }); + + it('extracts zone with underscores', () => { + expect(extractZone('ad-fixed_bottom-0')).toBe('fixed_bottom'); + }); + + it('extracts zone from codes with hash segments', () => { + expect(extractZone('ad-in_content-a7844bcbcdd34818b3e172fa33ff1539-in_content-0')).toBe( + 'in_content' + ); + }); + + it('returns undefined for codes that do not match the pattern', () => { + expect(extractZone('div-gpt-1')).toBeUndefined(); + expect(extractZone('slot-abc')).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(extractZone('')).toBeUndefined(); + }); + + it('does not match codes starting with uppercase after ad-', () => { + expect(extractZone('ad-Header-0')).toBeUndefined(); + }); +}); + describe('prebid/getInjectedConfig', () => { afterEach(() => { delete (window as any).__tsjs_prebid; @@ -448,6 +478,53 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); + it('includes zone extracted from ad unit code in trustedServer params', () => { + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + code: 'ad-header-0', + bids: [{ bidder: 'kargo', params: { placementId: '_abc' } }], + }, + { + code: 'ad-fixed_bottom-0', + bids: [{ bidder: 'kargo', params: { placementId: '_def' } }], + }, + ]; + pbjs.requestBids({ adUnits } as any); + + const tsBid0 = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid0.params.zone).toBe('header'); + + const tsBid1 = adUnits[1].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid1.params.zone).toBe('fixed_bottom'); + }); + + it('omits zone when ad unit code does not match the pattern', () => { + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + code: 'div-gpt-1', + bids: [{ bidder: 'appnexus', params: {} }], + }, + ]; + pbjs.requestBids({ adUnits } as any); + + const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid.params.zone).toBeUndefined(); + }); + + it('omits zone when ad unit has no code', () => { + const pbjs = installPrebidNpm(); + + const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }]; + pbjs.requestBids({ adUnits } as any); + + const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid.params.zone).toBeUndefined(); + }); + it('falls back to pbjs.adUnits when requestObj has no adUnits', () => { const pbjs = installPrebidNpm(); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 96a99d88..5224f442 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -132,6 +132,42 @@ the outgoing bidder params become: {"kargo": {"placementId": "server_side_placement_123", "foo": "bar"}} ``` +### Bid Param Zone Overrides + +Use `bid_param_zone_overrides` for per-zone, per-bidder param overrides. This is designed for bidders like Kargo that use different server-to-server placement IDs per ad zone. + +The JS adapter extracts the zone from the ad unit code (e.g., `ad-header-0` → `header`, `ad-fixed_bottom-0` → `fixed_bottom`) and sends it alongside the bidder params. The server then uses this zone to look up the correct override. + +**Behavior**: + +- When a zone override matches a bidder + zone combination, it is applied instead of any `bid_param_overrides` entry for that bidder +- When no zone override matches (unknown zone or missing zone), `bid_param_overrides` is used as a fallback +- Override params are shallow-merged, same as `bid_param_overrides` +- Both `bid_param_overrides` and `bid_param_zone_overrides` can coexist; zone overrides take priority + +**Example**: + +```toml +[integrations.prebid.bid_param_zone_overrides.kargo] +header = {placementId = "_s2sHeaderPlacement"} +in_content = {placementId = "_s2sContentPlacement"} +fixed_bottom = {placementId = "_s2sBottomPlacement"} +``` + +If the incoming request for zone `header` has: + +```json +{"kargo": {"placementId": "client_side_abc"}} +``` + +the outgoing bidder params become: + +```json +{"kargo": {"placementId": "_s2sHeaderPlacement"}} +``` + +For an unrecognised zone (e.g., `sidebar`), the override falls through to `bid_param_overrides` if configured, or leaves the incoming params unchanged. + ## Endpoints ### GET /first-party/ad diff --git a/trusted-server.toml b/trusted-server.toml index e8ae5e9e..7654cee5 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -42,8 +42,16 @@ server_url = "http://68.183.113.79:8000" timeout_ms = 1000 bidders = ["kargo", "rubicon", "appnexus", "openx"] debug = false -bid_param_overrides = {kargo = {placementId = "_kn1KdG0VJY"}} +# bid_param_overrides = {kargo = {placementId = "_kn1KdG0VJY"}} # bid_param_overrides = {mocktioneer = {example = "example_value"}} + +# Zone-specific bid param overrides for Kargo s2s placement IDs. +# The JS adapter extracts the zone from the ad unit code (e.g. "ad-header-0" → "header") +# and includes it in the request. The server maps zone → s2s placementId here. +[integrations.prebid.bid_param_zone_overrides.kargo] +header = {placementId = "_kn1KdG0VJY"} +in_content = {placementId = "_kn1KdG0VJY"} +fixed_bottom = {placementId = "_kn1KdG0VJY"} # debug_query_params = "" # script_patterns = ["/prebid.js"] From 5d90d1f779c9c89ead5a6ef8aad51fe2a25c386f Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 24 Feb 2026 13:38:00 -0600 Subject: [PATCH 05/11] clean up --- crates/common/src/integrations/prebid.rs | 338 +++--------------- crates/common/src/settings.rs | 13 +- .../js/lib/src/integrations/prebid/index.ts | 32 +- .../test/integrations/prebid/index.test.ts | 69 ++-- docs/guide/integrations/prebid.md | 5 +- trusted-server.toml | 6 +- 6 files changed, 110 insertions(+), 353 deletions(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 2f603c54..92ae34d2 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::settings::map_from_obj_or_str; use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use error_stack::{Report, ResultExt}; @@ -64,25 +63,11 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, - /// Per-bidder param overrides. Keys are bidder names, values are JSON objects - /// whose fields are shallow-merged into the bidder's params before sending - /// the `OpenRTB` request. Useful for replacing client-side placement IDs with - /// server-to-server equivalents. - /// - /// Example in TOML: - /// ```toml - /// [integrations.prebid.bid_param_overrides.kargo] - /// placementId = "server_side_placement_123" - /// ``` - #[serde(default, deserialize_with = "map_from_obj_or_str")] - pub bid_param_overrides: HashMap, /// Per-bidder, per-zone param overrides. The outer key is a bidder name, the - /// inner key is a zone name (sent by the JS adapter from the ad-unit code), + /// inner key is a zone name (sent by the JS adapter from `mediaTypes.banner.name` + /// — a non-standard Prebid.js field used as a temporary workaround), /// and the value is a JSON object shallow-merged into that bidder's params. /// - /// When a matching zone override is found for a bidder it takes precedence - /// over any entry in [`bid_param_overrides`] for that bidder. - /// /// Example in TOML: /// ```toml /// [integrations.prebid.bid_param_zone_overrides.kargo] @@ -90,7 +75,7 @@ pub struct PrebidIntegrationConfig { /// in_content = {placementId = "_s2sContentId"} /// fixed_bottom = {placementId = "_s2sBottomId"} /// ``` - #[serde(default, deserialize_with = "map_from_obj_or_str")] + #[serde(default)] pub bid_param_zone_overrides: HashMap>, } @@ -513,7 +498,7 @@ impl PrebidAuctionProvider { .collect(); // Extract zone from trustedServer params (sent by the JS - // adapter from the ad-unit code, e.g. "header", "fixed_bottom"). + // adapter from `mediaTypes.banner.name`, e.g. "header", "fixed_bottom"). let zone: Option<&str> = slot .bidders .get(TRUSTED_SERVER_BIDDER) @@ -540,8 +525,7 @@ impl PrebidAuctionProvider { } } - // Apply overrides. Zone-specific overrides take precedence - // over the blanket `bid_param_overrides` for the same bidder. + // Apply zone-specific bid param overrides when configured. for (name, params) in &mut bidder { let zone_override = zone.and_then(|z| { self.config @@ -560,17 +544,6 @@ impl PrebidAuctionProvider { ); base.extend(ovr.iter().map(|(k, v)| (k.clone(), v.clone()))); } - } else if let Some(Json::Object(ovr)) = - self.config.bid_param_overrides.get(name) - { - if let Json::Object(base) = params { - log::debug!( - "prebid: overriding bidder params for '{}': keys {:?}", - name, - ovr.keys().collect::>() - ); - base.extend(ovr.iter().map(|(k, v)| (k.clone(), v.clone()))); - } } } @@ -632,7 +605,7 @@ impl PrebidAuctionProvider { let ext = Some(RequestExt { prebid: Some(PrebidExt { debug: if self.config.debug { Some(true) } else { None }, - returnallbidstatus: Some(true), + returnallbidstatus: None, }), trusted_server: Some(TrustedServerExt { signature, @@ -955,7 +928,6 @@ mod tests { debug: false, debug_query_params: None, script_patterns: default_script_patterns(), - bid_param_overrides: HashMap::new(), bid_param_zone_overrides: HashMap::new(), } } @@ -973,6 +945,32 @@ mod tests { ) } + /// Shared TOML prefix for config-parsing tests (publisher + synthetic sections). + const TOML_BASE: &str = r#" +[publisher] +domain = "test-publisher.com" +cookie_domain = ".test-publisher.com" +origin_url = "https://origin.test-publisher.com" +proxy_secret = "test-secret" + +[synthetic] +counter_store = "test-counter-store" +opid_store = "test-opid-store" +secret_key = "test-secret-key" +template = "{{client_ip}}:{{user_agent}}" +"#; + + /// Parse a TOML string containing only the `[integrations.prebid]` section + /// (plus any sub-tables) into a [`PrebidIntegrationConfig`]. + fn parse_prebid_toml(prebid_section: &str) -> PrebidIntegrationConfig { + let toml_str = format!("{}{}", TOML_BASE, prebid_section); + let settings = Settings::from_toml(&toml_str).expect("should parse TOML"); + settings + .integration_config::("prebid") + .expect("should get config") + .expect("should be enabled") + } + #[test] fn attribute_rewriter_removes_prebid_scripts() { let integration = PrebidIntegration { @@ -1175,30 +1173,14 @@ mod tests { #[test] fn test_script_patterns_config_parsing() { - let toml_str = r#" -[publisher] -domain = "test-publisher.com" -cookie_domain = ".test-publisher.com" -origin_url = "https://origin.test-publisher.com" -proxy_secret = "test-secret" - -[synthetic] -counter_store = "test-counter-store" -opid_store = "test-opid-store" -secret_key = "test-secret-key" -template = "{{client_ip}}:{{user_agent}}" - + let config = parse_prebid_toml( + r#" [integrations.prebid] enabled = true server_url = "https://prebid.example" script_patterns = ["/prebid.js", "/custom/prebid.min.js"] -"#; - - let settings = Settings::from_toml(toml_str).expect("should parse TOML"); - let config = settings - .integration_config::("prebid") - .expect("should get config") - .expect("should be enabled"); +"#, + ); assert_eq!(config.script_patterns.len(), 2); assert!(config.script_patterns.contains(&"/prebid.js".to_string())); @@ -1209,31 +1191,14 @@ script_patterns = ["/prebid.js", "/custom/prebid.min.js"] #[test] fn test_script_patterns_defaults() { - let toml_str = r#" -[publisher] -domain = "test-publisher.com" -cookie_domain = ".test-publisher.com" -origin_url = "https://origin.test-publisher.com" -proxy_secret = "test-secret" - -[synthetic] -counter_store = "test-counter-store" -opid_store = "test-opid-store" -secret_key = "test-secret-key" -template = "{{client_ip}}:{{user_agent}}" - + let config = parse_prebid_toml( + r#" [integrations.prebid] enabled = true server_url = "https://prebid.example" -"#; - - let settings = Settings::from_toml(toml_str).expect("should parse TOML"); - let config = settings - .integration_config::("prebid") - .expect("should get config") - .expect("should be enabled"); +"#, + ); - // Should have default script patterns assert!(!config.script_patterns.is_empty()); assert!(config.script_patterns.contains(&"/prebid.js".to_string())); assert!(config @@ -1531,155 +1496,6 @@ server_url = "https://prebid.example" .bidder } - #[test] - fn bid_param_overrides_replaces_existing_param() { - let mut config = base_config(); - config.bid_param_overrides.insert( - "kargo".to_string(), - json!({ "placementId": "server_side_456" }), - ); - - let bidders = HashMap::from([( - "kargo".to_string(), - json!({ "placementId": "client_side_123" }), - )]); - let request = make_auction_request(vec![make_slot("slot1", bidders)]); - - let ortb = call_to_openrtb(config, &request); - assert_eq!( - bidder_params(&ortb)["kargo"]["placementId"], - "server_side_456", - "override should replace client-side placementId" - ); - } - - #[test] - fn bid_param_overrides_merges_without_removing_existing_fields() { - let mut config = base_config(); - config - .bid_param_overrides - .insert("kargo".to_string(), json!({ "placementId": "server_456" })); - - let bidders = HashMap::from([( - "kargo".to_string(), - json!({ "placementId": "client_123", "extra": "keep_me" }), - )]); - let request = make_auction_request(vec![make_slot("slot1", bidders)]); - - let ortb = call_to_openrtb(config, &request); - let kargo = &bidder_params(&ortb)["kargo"]; - assert_eq!( - kargo["placementId"], "server_456", - "overridden field should have new value" - ); - assert_eq!( - kargo["extra"], "keep_me", - "non-overridden fields should be preserved" - ); - } - - #[test] - fn bid_param_overrides_only_affects_matching_bidders() { - let mut config = base_config(); - config - .bid_param_overrides - .insert("kargo".to_string(), json!({ "placementId": "server_456" })); - - let bidders = HashMap::from([ - ("kargo".to_string(), json!({ "placementId": "client_123" })), - ("rubicon".to_string(), json!({ "accountId": 100 })), - ]); - let request = make_auction_request(vec![make_slot("slot1", bidders)]); - - let ortb = call_to_openrtb(config, &request); - let params = bidder_params(&ortb); - assert_eq!( - params["kargo"]["placementId"], "server_456", - "kargo should be overridden" - ); - assert_eq!( - params["rubicon"]["accountId"], 100, - "rubicon should be untouched" - ); - } - - #[test] - fn bid_param_overrides_noop_when_empty() { - let config = base_config(); - - let bidders = - HashMap::from([("kargo".to_string(), json!({ "placementId": "client_123" }))]); - let request = make_auction_request(vec![make_slot("slot1", bidders)]); - - let ortb = call_to_openrtb(config, &request); - assert_eq!( - bidder_params(&ortb)["kargo"]["placementId"], - "client_123", - "params should pass through unchanged" - ); - } - - #[test] - fn bid_param_overrides_applies_to_fallback_bidders() { - let mut config = base_config(); - config.bidders = vec!["kargo".to_string()]; - config - .bid_param_overrides - .insert("kargo".to_string(), json!({ "placementId": "server_456" })); - - // Empty bidders on slot triggers fallback to config.bidders - let request = make_auction_request(vec![make_slot("slot1", HashMap::new())]); - - let ortb = call_to_openrtb(config, &request); - assert_eq!( - bidder_params(&ortb)["kargo"]["placementId"], - "server_456", - "override should apply even to fallback bidders with empty params" - ); - } - - #[test] - fn bid_param_overrides_config_parsing_from_toml() { - let toml_str = r#" -[publisher] -domain = "test-publisher.com" -cookie_domain = ".test-publisher.com" -origin_url = "https://origin.test-publisher.com" -proxy_secret = "test-secret" - -[synthetic] -counter_store = "test-counter-store" -opid_store = "test-opid-store" -secret_key = "test-secret-key" -template = "{{client_ip}}:{{user_agent}}" - -[integrations.prebid] -enabled = true -server_url = "https://prebid.example" - -[integrations.prebid.bid_param_overrides.kargo] -placementId = "server_side_123" - -[integrations.prebid.bid_param_overrides.rubicon] -accountId = 99999 -siteId = 88888 -"#; - - let settings = Settings::from_toml(toml_str).expect("should parse TOML"); - let config = settings - .integration_config::("prebid") - .expect("should get config") - .expect("should be enabled"); - - assert_eq!(config.bid_param_overrides.len(), 2); - assert_eq!( - config.bid_param_overrides["kargo"]["placementId"], - "server_side_123" - ); - assert_eq!(config.bid_param_overrides["rubicon"]["accountId"], 99999); - assert_eq!(config.bid_param_overrides["rubicon"]["siteId"], 88888); - } - // ======================================================================== // bid_param_zone_overrides tests // ======================================================================== @@ -1725,44 +1541,9 @@ siteId = 88888 } #[test] - fn zone_override_skips_bid_param_overrides_for_matched_bidder() { - let mut config = base_config(); - config.bidders = vec!["kargo".to_string()]; - // Both override types configured for kargo - config - .bid_param_overrides - .insert("kargo".to_string(), json!({ "placementId": "blanket_id" })); - config.bid_param_zone_overrides.insert( - "kargo".to_string(), - HashMap::from([( - "header".to_string(), - json!({ "placementId": "zone_header_id" }), - )]), - ); - - let slot = make_ts_slot( - "ad-header-0", - &json!({ "kargo": { "placementId": "client_123" } }), - Some("header"), - ); - let request = make_auction_request(vec![slot]); - - let ortb = call_to_openrtb(config, &request); - assert_eq!( - bidder_params(&ortb)["kargo"]["placementId"], - "zone_header_id", - "zone override should win over bid_param_overrides" - ); - } - - #[test] - fn zone_override_falls_back_to_bid_param_overrides_for_unknown_zone() { + fn zone_override_noop_for_unknown_zone() { let mut config = base_config(); config.bidders = vec!["kargo".to_string()]; - config.bid_param_overrides.insert( - "kargo".to_string(), - json!({ "placementId": "blanket_fallback" }), - ); config.bid_param_zone_overrides.insert( "kargo".to_string(), HashMap::from([( @@ -1782,18 +1563,15 @@ siteId = 88888 let ortb = call_to_openrtb(config, &request); assert_eq!( bidder_params(&ortb)["kargo"]["placementId"], - "blanket_fallback", - "unrecognised zone should fall back to bid_param_overrides" + "client_123", + "unrecognised zone should pass through original params" ); } #[test] - fn zone_override_no_zone_uses_bid_param_overrides() { + fn zone_override_noop_when_no_zone() { let mut config = base_config(); config.bidders = vec!["kargo".to_string()]; - config - .bid_param_overrides - .insert("kargo".to_string(), json!({ "placementId": "blanket_id" })); config.bid_param_zone_overrides.insert( "kargo".to_string(), HashMap::from([( @@ -1813,8 +1591,8 @@ siteId = 88888 let ortb = call_to_openrtb(config, &request); assert_eq!( bidder_params(&ortb)["kargo"]["placementId"], - "blanket_id", - "missing zone should use bid_param_overrides" + "client_123", + "missing zone should pass through original params" ); } @@ -1883,19 +1661,8 @@ siteId = 88888 #[test] fn zone_overrides_config_parsing_from_toml() { - let toml_str = r#" -[publisher] -domain = "test-publisher.com" -cookie_domain = ".test-publisher.com" -origin_url = "https://origin.test-publisher.com" -proxy_secret = "test-secret" - -[synthetic] -counter_store = "test-counter-store" -opid_store = "test-opid-store" -secret_key = "test-secret-key" -template = "{{client_ip}}:{{user_agent}}" - + let config = parse_prebid_toml( + r#" [integrations.prebid] enabled = true server_url = "https://prebid.example" @@ -1904,13 +1671,8 @@ server_url = "https://prebid.example" header = {placementId = "_s2sHeader"} in_content = {placementId = "_s2sContent"} fixed_bottom = {placementId = "_s2sBottom"} -"#; - - let settings = Settings::from_toml(toml_str).expect("should parse TOML"); - let config = settings - .integration_config::("prebid") - .expect("should get config") - .expect("should be enabled"); +"#, + ); let kargo_zones = &config.bid_param_zone_overrides["kargo"]; assert_eq!(kargo_zones.len(), 3, "should have three zone entries"); diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index b4f2d1ce..57cb3951 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -429,26 +429,29 @@ fn validate_path(value: &str) -> Result<(), ValidationError> { /// /// This allows setting map fields via environment variables while /// preserving key casing and special characters like hyphens. -pub(crate) fn map_from_obj_or_str<'de, D, V>( +pub(crate) fn map_from_obj_or_str<'de, D>( deserializer: D, -) -> Result, D::Error> +) -> Result, D::Error> where D: Deserializer<'de>, - V: DeserializeOwned, { let v = JsonValue::deserialize(deserializer)?; match v { JsonValue::Object(map) => map .into_iter() .map(|(k, v)| { - let val: V = serde_json::from_value(v).map_err(serde::de::Error::custom)?; + let val = match v { + JsonValue::String(s) => s, + other => other.to_string(), + }; Ok((k, val)) }) .collect(), JsonValue::String(s) => { let txt = s.trim(); if txt.starts_with('{') { - serde_json::from_str::>(txt).map_err(serde::de::Error::custom) + serde_json::from_str::>(txt) + .map_err(serde::de::Error::custom) } else { Err(serde::de::Error::custom( "expected JSON object string, e.g. '{\"Key\": \"value\"}'", diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 00274150..a4a37003 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -24,22 +24,6 @@ const ADAPTER_CODE = 'trustedServer'; const BIDDER_PARAMS_KEY = 'bidderParams'; const ZONE_KEY = 'zone'; -/** - * Extract the ad-slot zone from a Prebid ad unit code. - * - * Publisher codes follow the pattern `ad-{zone}-...` where the zone is a - * lowercase identifier that may contain underscores (e.g. `"fixed_bottom"`). - * - * Examples: - * - `"ad-header-0"` → `"header"` - * - `"ad-fixed_bottom-0"` → `"fixed_bottom"` - * - `"ad-in_content-abc123-in_content-2"` → `"in_content"` - */ -export function extractZone(code: string): string | undefined { - const match = code.match(/^ad-([a-z][a-z_]*)/); - return match?.[1]; -} - /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { /** Auction endpoint path. Defaults to '/auction'. */ @@ -235,17 +219,27 @@ export function installPrebidNpm(config?: Partial): typeof pbjs bidderParams[bid.bidder] = bid.params ?? {}; } - // Include the ad-slot zone so the server can apply zone-specific + // WORKAROUND: Read the zone from mediaTypes.banner.name. This is NOT a + // standard Prebid.js field — publishers must add it as a custom property + // in their ad unit config. The server uses it to apply zone-specific // bid-param overrides (e.g. mapping zones to s2s placement IDs). - const zone = extractZone(unit.code ?? ''); + // TODO: Replace with a proper zone signal once available. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const zone = (unit as any).mediaTypes?.banner?.name as string | undefined; + const tsParams: Record = { [BIDDER_PARAMS_KEY]: bidderParams, ...(zone ? { [ZONE_KEY]: zone } : {}), }; const existingTsBid = unit.bids.find((b) => b.bidder === ADAPTER_CODE); if (existingTsBid) { - existingTsBid.params = { + const paramsWithoutZone = { ...(existingTsBid.params ?? {}), + }; + delete paramsWithoutZone[ZONE_KEY]; + + existingTsBid.params = { + ...paramsWithoutZone, ...tsParams, }; } else { diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 5ef945a3..4806e257 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -34,7 +34,6 @@ vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); import { collectBidders, - extractZone, getInjectedConfig, auctionBidsToPrebidBids, installPrebidNpm, @@ -68,35 +67,6 @@ describe('prebid/collectBidders', () => { }); }); -describe('prebid/extractZone', () => { - it('extracts zone from a simple ad unit code', () => { - expect(extractZone('ad-header-0')).toBe('header'); - }); - - it('extracts zone with underscores', () => { - expect(extractZone('ad-fixed_bottom-0')).toBe('fixed_bottom'); - }); - - it('extracts zone from codes with hash segments', () => { - expect(extractZone('ad-in_content-a7844bcbcdd34818b3e172fa33ff1539-in_content-0')).toBe( - 'in_content' - ); - }); - - it('returns undefined for codes that do not match the pattern', () => { - expect(extractZone('div-gpt-1')).toBeUndefined(); - expect(extractZone('slot-abc')).toBeUndefined(); - }); - - it('returns undefined for empty string', () => { - expect(extractZone('')).toBeUndefined(); - }); - - it('does not match codes starting with uppercase after ad-', () => { - expect(extractZone('ad-Header-0')).toBeUndefined(); - }); -}); - describe('prebid/getInjectedConfig', () => { afterEach(() => { delete (window as any).__tsjs_prebid; @@ -478,16 +448,18 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); - it('includes zone extracted from ad unit code in trustedServer params', () => { + it('includes zone from mediaTypes.banner.name in trustedServer params', () => { const pbjs = installPrebidNpm(); const adUnits = [ { code: 'ad-header-0', + mediaTypes: { banner: { name: 'header', sizes: [[728, 90]] } }, bids: [{ bidder: 'kargo', params: { placementId: '_abc' } }], }, { code: 'ad-fixed_bottom-0', + mediaTypes: { banner: { name: 'fixed_bottom', sizes: [[728, 90]] } }, bids: [{ bidder: 'kargo', params: { placementId: '_def' } }], }, ]; @@ -500,12 +472,13 @@ describe('prebid/installPrebidNpm', () => { expect(tsBid1.params.zone).toBe('fixed_bottom'); }); - it('omits zone when ad unit code does not match the pattern', () => { + it('omits zone when mediaTypes.banner.name is not set', () => { const pbjs = installPrebidNpm(); const adUnits = [ { - code: 'div-gpt-1', + code: 'ad-header-0', + mediaTypes: { banner: { sizes: [[300, 250]] } }, bids: [{ bidder: 'appnexus', params: {} }], }, ]; @@ -515,7 +488,7 @@ describe('prebid/installPrebidNpm', () => { expect(tsBid.params.zone).toBeUndefined(); }); - it('omits zone when ad unit has no code', () => { + it('omits zone when ad unit has no mediaTypes', () => { const pbjs = installPrebidNpm(); const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }]; @@ -525,6 +498,34 @@ describe('prebid/installPrebidNpm', () => { expect(tsBid.params.zone).toBeUndefined(); }); + it('clears stale zone when existing trustedServer bid is reused', () => { + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + code: 'ad-header-0', + mediaTypes: { banner: { name: 'header', sizes: [[300, 250]] } }, + bids: [ + { bidder: 'trustedServer', params: { custom: 'keep' } }, + { bidder: 'kargo', params: { placementId: '_abc' } }, + ], + }, + ]; + + pbjs.requestBids({ adUnits } as any); + + let tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid.params.zone).toBe('header'); + expect(tsBid.params.custom).toBe('keep'); + + delete adUnits[0].mediaTypes.banner.name; + pbjs.requestBids({ adUnits } as any); + + tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid.params.zone).toBeUndefined(); + expect(tsBid.params.custom).toBe('keep'); + }); + it('falls back to pbjs.adUnits when requestObj has no adUnits', () => { const pbjs = installPrebidNpm(); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 5224f442..dcc96a6e 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -39,6 +39,7 @@ placementId = "server_side_placement_123" | `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | | `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | | `bid_param_overrides` | Table | `{}` | Per-bidder params merged into request bidder params (`override` values win on key conflicts) | +| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; zone overrides take precedence over `bid_param_overrides` | | `debug` | Boolean | `false` | Enable debug logging | | `debug_query_params` | String | `None` | Extra query params appended for debugging | | `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | @@ -136,7 +137,7 @@ the outgoing bidder params become: Use `bid_param_zone_overrides` for per-zone, per-bidder param overrides. This is designed for bidders like Kargo that use different server-to-server placement IDs per ad zone. -The JS adapter extracts the zone from the ad unit code (e.g., `ad-header-0` → `header`, `ad-fixed_bottom-0` → `fixed_bottom`) and sends it alongside the bidder params. The server then uses this zone to look up the correct override. +The JS adapter reads the zone from `mediaTypes.banner.name` on each Prebid ad unit (e.g., `"header"`, `"in_content"`, `"fixed_bottom"`) and sends it alongside the bidder params. The server then uses this zone to look up the correct override. When `mediaTypes.banner.name` is not set, no zone is sent and zone overrides are skipped for that impression. **Behavior**: @@ -234,7 +235,7 @@ The `to_openrtb()` method in `PrebidAuctionProvider` builds OpenRTB requests: - Injects synthetic ID in the user object - Includes device/geo information when available - Appends `debug_query_params` to page URL when configured -- Applies `bid_param_overrides` to `imp.ext.prebid.bidder` before request dispatch +- Applies `bid_param_overrides` and `bid_param_zone_overrides` to `imp.ext.prebid.bidder` before request dispatch - Signs requests when request signing is enabled ## Best Practices diff --git a/trusted-server.toml b/trusted-server.toml index 7654cee5..45455e8f 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -42,11 +42,8 @@ server_url = "http://68.183.113.79:8000" timeout_ms = 1000 bidders = ["kargo", "rubicon", "appnexus", "openx"] debug = false -# bid_param_overrides = {kargo = {placementId = "_kn1KdG0VJY"}} -# bid_param_overrides = {mocktioneer = {example = "example_value"}} - # Zone-specific bid param overrides for Kargo s2s placement IDs. -# The JS adapter extracts the zone from the ad unit code (e.g. "ad-header-0" → "header") +# The JS adapter reads the zone from mediaTypes.banner.name on each ad unit # and includes it in the request. The server maps zone → s2s placementId here. [integrations.prebid.bid_param_zone_overrides.kargo] header = {placementId = "_kn1KdG0VJY"} @@ -55,7 +52,6 @@ fixed_bottom = {placementId = "_kn1KdG0VJY"} # debug_query_params = "" # script_patterns = ["/prebid.js"] - [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] From a9c79cfadbe5aec04f1991e62e1468e65663ebbf Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 24 Feb 2026 13:49:03 -0600 Subject: [PATCH 06/11] Fix PR review issues: remove ghost bid_param_overrides docs and unused returnallbidstatus field --- crates/common/src/integrations/prebid.rs | 1 - crates/common/src/openrtb.rs | 2 - docs/guide/integrations/prebid.md | 80 +++++++----------------- 3 files changed, 22 insertions(+), 61 deletions(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 92ae34d2..ef4a6196 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -605,7 +605,6 @@ impl PrebidAuctionProvider { let ext = Some(RequestExt { prebid: Some(PrebidExt { debug: if self.config.debug { Some(true) } else { None }, - returnallbidstatus: None, }), trusted_server: Some(TrustedServerExt { signature, diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index b3aeccdf..3b405209 100644 --- a/crates/common/src/openrtb.rs +++ b/crates/common/src/openrtb.rs @@ -109,8 +109,6 @@ pub struct RequestExt { pub struct PrebidExt { #[serde(skip_serializing_if = "Option::is_none")] pub debug: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub returnallbidstatus: Option, } #[derive(Debug, Serialize, Default)] diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index dcc96a6e..cad1f8b7 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -25,24 +25,24 @@ debug = false # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] -# Optional per-bidder param overrides (shallow merge) -[integrations.prebid.bid_param_overrides.kargo] -placementId = "server_side_placement_123" +# Optional per-bidder, per-zone param overrides (shallow merge) +[integrations.prebid.bid_param_zone_overrides.kargo] +header = {placementId = "_s2sHeaderPlacement"} +in_content = {placementId = "_s2sContentPlacement"} ``` ### Configuration Options -| Field | Type | Default | Description | -| -------------------- | ------------- | ---------------------------------------------------------------------- | ------------------------------------------- | -| `enabled` | Boolean | `true` | Enable Prebid integration | -| `server_url` | String | Required | Prebid Server endpoint URL | -| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | -| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | -| `bid_param_overrides` | Table | `{}` | Per-bidder params merged into request bidder params (`override` values win on key conflicts) | -| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; zone overrides take precedence over `bid_param_overrides` | -| `debug` | Boolean | `false` | Enable debug logging | -| `debug_query_params` | String | `None` | Extra query params appended for debugging | -| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| Field | Type | Default | Description | +| -------------------------- | ------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `enabled` | Boolean | `true` | Enable Prebid integration | +| `server_url` | String | Required | Prebid Server endpoint URL | +| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | +| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | +| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; zone values are shallow-merged into bidder params | +| `debug` | Boolean | `false` | Enable debug logging | +| `debug_query_params` | String | `None` | Extra query params appended for debugging | +| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | ## Features @@ -97,42 +97,6 @@ script_patterns = [] When a request matches a script pattern, Trusted Server returns an empty JavaScript file with aggressive caching (`max-age=31536000, immutable`). -### Bid Param Overrides - -Use `bid_param_overrides` to force specific bidder params before the request is sent to Prebid Server. This is useful when client-side and server-side bidder IDs differ (for example, Kargo `placementId` values). - -**Behavior**: - -- Overrides are matched by bidder name (for example, `kargo`, `rubicon`) -- Params are shallow-merged into incoming bidder params -- Override values replace incoming values when keys conflict -- Non-conflicting incoming fields are preserved -- Overrides also apply to fallback bidders created from `bidders = [...]` when slot params are empty - -**Example**: - -```toml -[integrations.prebid.bid_param_overrides.kargo] -placementId = "server_side_placement_123" - -[integrations.prebid.bid_param_overrides.rubicon] -accountId = 1001 -siteId = 2002 -zoneId = 3003 -``` - -If the incoming request has: - -```json -{"kargo": {"placementId": "client_side_abc", "foo": "bar"}} -``` - -the outgoing bidder params become: - -```json -{"kargo": {"placementId": "server_side_placement_123", "foo": "bar"}} -``` - ### Bid Param Zone Overrides Use `bid_param_zone_overrides` for per-zone, per-bidder param overrides. This is designed for bidders like Kargo that use different server-to-server placement IDs per ad zone. @@ -141,10 +105,10 @@ The JS adapter reads the zone from `mediaTypes.banner.name` on each Prebid ad un **Behavior**: -- When a zone override matches a bidder + zone combination, it is applied instead of any `bid_param_overrides` entry for that bidder -- When no zone override matches (unknown zone or missing zone), `bid_param_overrides` is used as a fallback -- Override params are shallow-merged, same as `bid_param_overrides` -- Both `bid_param_overrides` and `bid_param_zone_overrides` can coexist; zone overrides take priority +- Overrides are matched by bidder name + zone combination +- Override params are shallow-merged into incoming bidder params (override values win on key conflicts) +- Non-conflicting incoming fields are preserved +- When no zone override matches (unknown zone or missing zone), incoming params are left unchanged **Example**: @@ -158,16 +122,16 @@ fixed_bottom = {placementId = "_s2sBottomPlacement"} If the incoming request for zone `header` has: ```json -{"kargo": {"placementId": "client_side_abc"}} +{ "kargo": { "placementId": "client_side_abc" } } ``` the outgoing bidder params become: ```json -{"kargo": {"placementId": "_s2sHeaderPlacement"}} +{ "kargo": { "placementId": "_s2sHeaderPlacement" } } ``` -For an unrecognised zone (e.g., `sidebar`), the override falls through to `bid_param_overrides` if configured, or leaves the incoming params unchanged. +For an unrecognised zone (e.g., `sidebar`), the incoming params are left unchanged. ## Endpoints @@ -235,7 +199,7 @@ The `to_openrtb()` method in `PrebidAuctionProvider` builds OpenRTB requests: - Injects synthetic ID in the user object - Includes device/geo information when available - Appends `debug_query_params` to page URL when configured -- Applies `bid_param_overrides` and `bid_param_zone_overrides` to `imp.ext.prebid.bidder` before request dispatch +- Applies `bid_param_zone_overrides` to `imp.ext.prebid.bidder` before request dispatch - Signs requests when request signing is enabled ## Best Practices From 38849095c8aa9461b9aa54fef5d7ee7b0f67d620 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 24 Feb 2026 14:36:03 -0600 Subject: [PATCH 07/11] Add per-provider bid summaries to auction response Adds ProviderSummary struct to expose bid counts and bidder names (e.g. kargo, pubmatic) from each auction provider. Includes provider-specific metadata via AuctionResponse.metadata field for future integration data. Response now includes provider_details array in ext.orchestrator with one summary per provider showing status, bid count, unique bidders, and response time. --- crates/common/src/auction/formats.rs | 12 +++++++-- crates/common/src/auction/types.rs | 37 ++++++++++++++++++++++++++++ crates/common/src/openrtb.rs | 4 ++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/common/src/auction/formats.rs b/crates/common/src/auction/formats.rs index 71804e26..261db9ea 100644 --- a/crates/common/src/auction/formats.rs +++ b/crates/common/src/auction/formats.rs @@ -12,7 +12,6 @@ use serde_json::Value as JsonValue; use std::collections::HashMap; use uuid::Uuid; -use crate::auction::types::OrchestratorExt; use crate::creative; use crate::error::TrustedServerError; use crate::geo::GeoInfo; @@ -22,7 +21,8 @@ use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; use super::orchestrator::OrchestrationResult; use super::types::{ - AdFormat, AdSlot, AuctionRequest, DeviceInfo, MediaType, PublisherInfo, SiteInfo, UserInfo, + AdFormat, AdSlot, AuctionRequest, DeviceInfo, MediaType, OrchestratorExt, ProviderSummary, + PublisherInfo, SiteInfo, UserInfo, }; /// Request body format for auction endpoints (tsjs/Prebid.js format). @@ -230,6 +230,13 @@ pub fn convert_to_openrtb_response( "parallel_only" }; + // Build per-provider summaries from the orchestration result + let provider_details: Vec = result + .provider_responses + .iter() + .map(ProviderSummary::from) + .collect(); + let response_body = OpenRtbResponse { id: auction_request.id.to_string(), seatbid: seatbids, @@ -239,6 +246,7 @@ pub fn convert_to_openrtb_response( providers: result.provider_responses.len(), total_bids: result.total_bids(), time_ms: result.total_time_ms, + provider_details, }, }), }; diff --git a/crates/common/src/auction/types.rs b/crates/common/src/auction/types.rs index 6c6c4d63..a9c97454 100644 --- a/crates/common/src/auction/types.rs +++ b/crates/common/src/auction/types.rs @@ -146,6 +146,41 @@ pub struct Bid { pub metadata: HashMap, } +/// Per-provider summary included in the auction response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderSummary { + /// Provider name (e.g., "prebid", "aps"). + pub name: String, + /// Bid status from this provider. + pub status: BidStatus, + /// Number of bids returned. + pub bid_count: usize, + /// Unique bidder/seat names (e.g., "kargo", "pubmatic", "ix"). + pub bidders: Vec, + /// Response time in milliseconds. + pub time_ms: u64, + /// Provider-specific metadata (from [`AuctionResponse::metadata`]). + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +} + +impl From<&AuctionResponse> for ProviderSummary { + fn from(response: &AuctionResponse) -> Self { + let mut bidders: Vec = response.bids.iter().map(|b| b.bidder.clone()).collect(); + bidders.sort_unstable(); + bidders.dedup(); + + Self { + name: response.provider.clone(), + status: response.status.clone(), + bid_count: response.bids.len(), + bidders, + time_ms: response.response_time_ms, + metadata: response.metadata.clone(), + } + } +} + /// `OpenRTB` response metadata for the orchestrator. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrchestratorExt { @@ -153,6 +188,8 @@ pub struct OrchestratorExt { pub providers: usize, pub total_bids: usize, pub time_ms: u64, + /// Per-provider breakdown of the auction. + pub provider_details: Vec, } /// Status of bid response. diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index 3b405209..4b493f13 100644 --- a/crates/common/src/openrtb.rs +++ b/crates/common/src/openrtb.rs @@ -199,6 +199,7 @@ mod tests { providers: 2, total_bids: 3, time_ms: 12, + provider_details: vec![], }, }), }; @@ -224,7 +225,8 @@ mod tests { "strategy": "parallel_only", "providers": 2, "total_bids": 3, - "time_ms": 12 + "time_ms": 12, + "provider_details": [] } } }); From fc7b00295b00493fb5be88ce2bfcf8a907fcb8b5 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 24 Feb 2026 15:13:41 -0600 Subject: [PATCH 08/11] add responsetimemillies to auction metadata response --- crates/common/src/integrations/prebid.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index ef4a6196..5b732d4d 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -837,7 +837,19 @@ impl AuctionProvider for PrebidAuctionProvider { transform_prebid_response(&mut response_json, &request_host, &request_scheme)?; } - let auction_response = self.parse_openrtb_response(&response_json, response_time_ms); + let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); + + // Attach per-bidder timing and errors from the Prebid Server response. + // `responsetimemillis` contains an entry for every invited bidder, even + // those that returned no bids, making it the canonical source for + // "who was in the auction." + let ext = response_json.get("ext"); + if let Some(rtm) = ext.and_then(|e| e.get("responsetimemillis")) { + auction_response = auction_response.with_metadata("responsetimemillis", rtm.clone()); + } + if let Some(errors) = ext.and_then(|e| e.get("errors")) { + auction_response = auction_response.with_metadata("errors", errors.clone()); + } log::info!( "Prebid returned {} bids in {}ms", From 1c8d06809e96d48ced792014af97f17123947d73 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 24 Feb 2026 15:35:34 -0600 Subject: [PATCH 09/11] add tests and update trusted-server.toml --- crates/common/src/auction/types.rs | 130 +++++++++++++++++++++++++++++ trusted-server.toml | 9 +- 2 files changed, 134 insertions(+), 5 deletions(-) diff --git a/crates/common/src/auction/types.rs b/crates/common/src/auction/types.rs index a9c97454..372dd259 100644 --- a/crates/common/src/auction/types.rs +++ b/crates/common/src/auction/types.rs @@ -246,3 +246,133 @@ impl AuctionResponse { self } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_bid(bidder: &str) -> Bid { + Bid { + slot_id: "slot-1".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + metadata: HashMap::new(), + } + } + + #[test] + fn provider_summary_from_successful_response() { + let response = AuctionResponse::success( + "prebid", + vec![make_bid("kargo"), make_bid("pubmatic"), make_bid("ix")], + 95, + ); + + let summary = ProviderSummary::from(&response); + + assert_eq!(summary.name, "prebid", "should use provider name"); + assert_eq!(summary.status, BidStatus::Success, "should preserve status"); + assert_eq!(summary.bid_count, 3, "should count all bids"); + assert_eq!( + summary.bidders, + vec!["ix", "kargo", "pubmatic"], + "should list unique bidders sorted" + ); + assert_eq!(summary.time_ms, 95, "should preserve response time"); + assert!(summary.metadata.is_empty(), "should have no metadata"); + } + + #[test] + fn provider_summary_deduplicates_bidder_names() { + let response = AuctionResponse::success( + "prebid", + vec![make_bid("kargo"), make_bid("kargo"), make_bid("pubmatic")], + 50, + ); + + let summary = ProviderSummary::from(&response); + + assert_eq!( + summary.bid_count, 3, + "should count all bids including dupes" + ); + assert_eq!( + summary.bidders, + vec!["kargo", "pubmatic"], + "should deduplicate bidder names" + ); + } + + #[test] + fn provider_summary_from_no_bid_response() { + let response = AuctionResponse::no_bid("aps", 110); + + let summary = ProviderSummary::from(&response); + + assert_eq!(summary.name, "aps", "should use provider name"); + assert_eq!( + summary.status, + BidStatus::NoBid, + "should preserve no-bid status" + ); + assert_eq!(summary.bid_count, 0, "should have zero bids"); + assert!(summary.bidders.is_empty(), "should have no bidders"); + } + + #[test] + fn provider_summary_from_error_response() { + let response = AuctionResponse::error("prebid", 200); + + let summary = ProviderSummary::from(&response); + + assert_eq!( + summary.status, + BidStatus::Error, + "should preserve error status" + ); + assert_eq!(summary.bid_count, 0, "should have zero bids"); + assert!(summary.bidders.is_empty(), "should have no bidders"); + } + + #[test] + fn provider_summary_passes_through_metadata() { + let response = AuctionResponse::success("prebid", vec![make_bid("kargo")], 80) + .with_metadata("responsetimemillis", json!({"kargo": 70, "pubmatic": 90})) + .with_metadata("errors", json!({"pubmatic": [{"code": 1}]})); + + let summary = ProviderSummary::from(&response); + + assert_eq!(summary.metadata.len(), 2, "should forward all metadata"); + assert_eq!( + summary.metadata["responsetimemillis"], + json!({"kargo": 70, "pubmatic": 90}), + "should preserve responsetimemillis" + ); + assert_eq!( + summary.metadata["errors"], + json!({"pubmatic": [{"code": 1}]}), + "should preserve errors" + ); + } + + #[test] + fn provider_summary_skips_metadata_in_serialization_when_empty() { + let response = AuctionResponse::no_bid("aps", 100); + let summary = ProviderSummary::from(&response); + + let json = serde_json::to_value(&summary).expect("should serialize"); + + assert!( + json.get("metadata").is_none(), + "should omit metadata field when empty" + ); + } +} diff --git a/trusted-server.toml b/trusted-server.toml index 45455e8f..79c0f31c 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -42,15 +42,14 @@ server_url = "http://68.183.113.79:8000" timeout_ms = 1000 bidders = ["kargo", "rubicon", "appnexus", "openx"] debug = false +# debug_query_params = "" +# script_patterns = ["/prebid.js"] + # Zone-specific bid param overrides for Kargo s2s placement IDs. # The JS adapter reads the zone from mediaTypes.banner.name on each ad unit # and includes it in the request. The server maps zone → s2s placementId here. [integrations.prebid.bid_param_zone_overrides.kargo] -header = {placementId = "_kn1KdG0VJY"} -in_content = {placementId = "_kn1KdG0VJY"} -fixed_bottom = {placementId = "_kn1KdG0VJY"} -# debug_query_params = "" -# script_patterns = ["/prebid.js"] +# header = {placementId = "_abc"} [integrations.nextjs] enabled = false From 3dcf363adde0b1c884cb4f073234c1adc7424b9d Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 25 Feb 2026 11:55:58 -0600 Subject: [PATCH 10/11] Fix serde round-trip for ProviderSummary and add metadata extraction test Address PR #347 review comments: - Add #[serde(default)] to ProviderSummary.metadata and OrchestratorExt.provider_details for backward-compatible deserialization - Add regression test for responsetimemillis and errors metadata extraction from Prebid Server response ext --- crates/common/src/auction/types.rs | 3 +- crates/common/src/integrations/prebid.rs | 58 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/common/src/auction/types.rs b/crates/common/src/auction/types.rs index 372dd259..31a2c645 100644 --- a/crates/common/src/auction/types.rs +++ b/crates/common/src/auction/types.rs @@ -160,7 +160,7 @@ pub struct ProviderSummary { /// Response time in milliseconds. pub time_ms: u64, /// Provider-specific metadata (from [`AuctionResponse::metadata`]). - #[serde(skip_serializing_if = "HashMap::is_empty")] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub metadata: HashMap, } @@ -189,6 +189,7 @@ pub struct OrchestratorExt { pub total_bids: usize, pub time_ms: u64, /// Per-provider breakdown of the auction. + #[serde(default)] pub provider_details: Vec, } diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 5b732d4d..7d1e44e8 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -1700,4 +1700,62 @@ fixed_bottom = {placementId = "_s2sBottom"} "should parse fixed_bottom zone" ); } + + #[test] + fn parse_response_preserves_responsetimemillis_and_errors_metadata() { + let provider = PrebidAuctionProvider::new(base_config()); + + // Minimal valid OpenRTB response with ext containing diagnostics. + let response_json = json!({ + "seatbid": [{ + "seat": "kargo", + "bid": [{ + "impid": "slot-1", + "price": 2.50, + "adm": "
ad
" + }] + }], + "ext": { + "responsetimemillis": { + "kargo": 98, + "appnexus": 0, + "ix": 120 + }, + "errors": { + "openx": [{"code": 1, "message": "timeout"}] + } + } + }); + + // Replicate the metadata extraction logic from `run_auction`. + let mut auction_response = provider.parse_openrtb_response(&response_json, 150); + + let ext = response_json.get("ext"); + if let Some(rtm) = ext.and_then(|e| e.get("responsetimemillis")) { + auction_response = auction_response.with_metadata("responsetimemillis", rtm.clone()); + } + if let Some(errors) = ext.and_then(|e| e.get("errors")) { + auction_response = auction_response.with_metadata("errors", errors.clone()); + } + + // Verify bids were parsed. + assert_eq!(auction_response.bids.len(), 1, "should parse one bid"); + + // Verify responsetimemillis is preserved. + let rtm = auction_response + .metadata + .get("responsetimemillis") + .expect("should have responsetimemillis in metadata"); + assert_eq!(rtm["kargo"], 98); + assert_eq!(rtm["appnexus"], 0); + assert_eq!(rtm["ix"], 120); + + // Verify errors are preserved. + let errors = auction_response + .metadata + .get("errors") + .expect("should have errors in metadata"); + assert_eq!(errors["openx"][0]["code"], 1); + assert_eq!(errors["openx"][0]["message"], "timeout"); + } } From a746adf507de5473a4ea6b0f816077c009863ac2 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 26 Feb 2026 11:52:24 -0600 Subject: [PATCH 11/11] Guard debug JSON serialization with log_enabled check --- crates/common/src/integrations/prebid.rs | 28 +++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 7d1e44e8..6fb9f935 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -754,13 +754,17 @@ impl AuctionProvider for PrebidAuctionProvider { ); // Log the outgoing OpenRTB request for debugging - match serde_json::to_string_pretty(&openrtb) { - Ok(json) => log::debug!( - "Prebid OpenRTB request to {}/openrtb2/auction:\n{}", - self.config.server_url, - json - ), - Err(e) => log::warn!("Prebid: failed to serialize OpenRTB request for logging: {e}"), + if log::log_enabled!(log::Level::Debug) { + match serde_json::to_string_pretty(&openrtb) { + Ok(json) => log::debug!( + "Prebid OpenRTB request to {}/openrtb2/auction:\n{}", + self.config.server_url, + json + ), + Err(e) => { + log::warn!("Prebid: failed to serialize OpenRTB request for logging: {e}") + } + } } // Create HTTP request @@ -811,9 +815,13 @@ impl AuctionProvider for PrebidAuctionProvider { message: "Failed to parse Prebid response".to_string(), })?; - match serde_json::to_string_pretty(&response_json) { - Ok(json) => log::debug!("Prebid OpenRTB response:\n{}", json), - Err(e) => log::warn!("Prebid: failed to serialize OpenRTB response for logging: {e}"), + if log::log_enabled!(log::Level::Debug) { + match serde_json::to_string_pretty(&response_json) { + Ok(json) => log::debug!("Prebid OpenRTB response:\n{}", json), + Err(e) => { + log::warn!("Prebid: failed to serialize OpenRTB response for logging: {e}") + } + } } let request_host = response_json