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..31a2c645 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(default, 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,9 @@ pub struct OrchestratorExt { pub providers: usize, pub total_bids: usize, pub time_ms: u64, + /// Per-provider breakdown of the auction. + #[serde(default)] + pub provider_details: Vec, } /// Status of bid response. @@ -209,3 +247,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/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 1ad84f9e..7d1e44e8 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -32,6 +32,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 { @@ -62,6 +63,20 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + /// 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 `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. + /// + /// Example in TOML: + /// ```toml + /// [integrations.prebid.bid_param_zone_overrides.kargo] + /// header = {placementId = "_s2sHeaderId"} + /// in_content = {placementId = "_s2sContentId"} + /// fixed_bottom = {placementId = "_s2sBottomId"} + /// ``` + #[serde(default)] + pub bid_param_zone_overrides: HashMap>, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -482,6 +497,14 @@ impl PrebidAuctionProvider { }) .collect(); + // Extract zone from trustedServer params (sent by the JS + // adapter from `mediaTypes.banner.name`, 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. @@ -502,6 +525,28 @@ impl PrebidAuctionProvider { } } + // Apply zone-specific bid param overrides when configured. + for (name, params) in &mut bidder { + 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()))); + } + } + } + Imp { id: slot.id.clone(), banner: Some(Banner { format: formats }), @@ -766,6 +811,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")) @@ -787,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", @@ -849,6 +911,9 @@ pub fn register_auction_provider(settings: &Settings) -> Vec 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 { @@ -1092,30 +1184,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())); @@ -1126,31 +1202,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 @@ -1385,4 +1444,318 @@ server_url = "https://prebid.example" truncation_index ); } + + fn make_auction_request(slots: 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 + } + + // ======================================================================== + // 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_noop_for_unknown_zone() { + 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": "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"], + "client_123", + "unrecognised zone should pass through original params" + ); + } + + #[test] + fn zone_override_noop_when_no_zone() { + 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": "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"], + "client_123", + "missing zone should pass through original params" + ); + } + + #[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 config = parse_prebid_toml( + r#" +[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 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" + ); + } + + #[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"); + } } 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": [] } } }); diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 43c369f0..a4a37003 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -22,6 +22,7 @@ import type { AuctionBid } from '../../core/auction'; const ADAPTER_CODE = 'trustedServer'; const BIDDER_PARAMS_KEY = 'bidderParams'; +const ZONE_KEY = 'zone'; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -118,7 +119,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,11 +219,27 @@ export function installPrebidNpm(config?: Partial): typeof pbjs bidderParams[bid.bidder] = bid.params ?? {}; } - const tsParams = { [BIDDER_PARAMS_KEY]: bidderParams }; + // 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). + // 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 77353bb2..4806e257 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -448,6 +448,84 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); + 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' } }], + }, + ]; + 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 mediaTypes.banner.name is not set', () => { + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + code: 'ad-header-0', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + 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 mediaTypes', () => { + 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('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 d3ff2801..cad1f8b7 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -24,19 +24,25 @@ debug = false # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] + +# 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 | -| `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 @@ -91,6 +97,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 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 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**: + +- 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**: + +```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 incoming params are left unchanged. + ## Endpoints ### GET /first-party/ad @@ -157,6 +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_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 5ad18888..79c0f31c 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -45,6 +45,12 @@ 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 = "_abc"} + [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"]