diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 84c052c..bf1ce3e 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -45,6 +45,8 @@ pub struct PrebidIntegrationConfig { pub auto_configure: bool, #[serde(default)] pub debug: bool, + #[serde(default)] + pub debug_query_params: Option, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -150,6 +152,7 @@ impl PrebidIntegration { &preview.chars().take(512).collect::() ); } + log::info!("openrtb: {:#?}", openrtb); req.set_body_json(&openrtb) .change_context(TrustedServerError::Prebid { @@ -187,6 +190,16 @@ impl PrebidIntegration { })); } + // Parse bids from query parameter if present + let bids = qp.get("bids").and_then(|json_str| { + serde_json::from_str::>(json_str) + .map_err(|e| { + log::warn!("Failed to parse bids parameter: {}", e); + e + }) + .ok() + }); + let ad_req = AdRequest { ad_units: vec![AdUnit { code: slot.clone(), @@ -195,7 +208,7 @@ impl PrebidIntegration { sizes: vec![vec![w, h]], }), }), - bids: None, + bids, }], config: None, }; @@ -311,18 +324,18 @@ fn build_openrtb_from_ts( .ad_units .iter() .map(|unit| { - let formats: Vec = unit - .media_types - .as_ref() - .and_then(|mt| mt.banner.as_ref()) - .map(|b| { - b.sizes - .iter() - .filter(|s| s.len() >= 2) - .map(|s| Format { w: s[0], h: s[1] }) - .collect::>() - }) - .unwrap_or_else(|| vec![Format { w: 300, h: 250 }]); + let formats: Vec = vec![ + Format { w: 300, h: 250 }, + Format { w: 320, h: 50 }, + Format { w: 300, h: 50 }, + Format { w: 300, h: 300 }, + Format { w: 200, h: 200 }, + Format { w: 250, h: 250 }, + Format { w: 336, h: 280 }, + Format { w: 320, h: 100 }, + Format { w: 320, h: 250 }, + Format { w: 840, h: 280 }, + ]; let mut bidder: HashMap = HashMap::new(); if let Some(bids) = &unit.bids { @@ -376,6 +389,8 @@ async fn pbs_auction_for_get( fn extract_adm_for_slot(json: &Json, slot: &str) -> Option { let seatbids = json.get("seatbid")?.as_array()?; + + // First pass: look for exact slot match for sb in seatbids { if let Some(bids) = sb.get("bid").and_then(|b| b.as_array()) { for bid in bids { @@ -388,6 +403,8 @@ fn extract_adm_for_slot(json: &Json, slot: &str) -> Option { } } } + + // Second pass: return any bid's adm as fallback for sb in seatbids { if let Some(bids) = sb.get("bid").and_then(|b| b.as_array()) { for bid in bids { @@ -397,6 +414,7 @@ fn extract_adm_for_slot(json: &Json, slot: &str) -> Option { } } } + None } @@ -427,6 +445,7 @@ async fn handle_prebid_auction( &fresh_id, settings, &req, + config, )?; let mut pbs_req = Request::new( @@ -441,6 +460,8 @@ async fn handle_prebid_auction( })?; log::info!("Sending request to Prebid Server"); + log::info!("request: {:#?}", openrtb_request); + let backend_name = ensure_backend_from_url(&config.server_url)?; let mut pbs_response = pbs_req @@ -449,6 +470,8 @@ async fn handle_prebid_auction( message: "Failed to send request to Prebid Server".to_string(), })?; + log::info!("pbs_response {}: OK", pbs_response.get_status()); + if pbs_response.get_status().is_success() { let response_body = pbs_response.take_body_bytes(); match serde_json::from_slice::(&response_body) { @@ -485,6 +508,7 @@ fn enhance_openrtb_request( fresh_id: &str, settings: &Settings, req: &Request, + config: &PrebidIntegrationConfig, ) -> Result<(), Report> { if !request["user"].is_object() { request["user"] = json!({}); @@ -521,10 +545,31 @@ fn enhance_openrtb_request( } if !request["site"].is_object() { + let mut page_url = format!("https://{}", settings.publisher.domain); + + // Append debug query params if configured + if let Some(ref params) = config.debug_query_params { + page_url = format!("{}?{}", page_url, params); + } + request["site"] = json!({ "domain": settings.publisher.domain, - "page": format!("https://{}", settings.publisher.domain), + "page": page_url, }); + } else if config.debug_query_params.is_some() { + // If site already exists, append debug params to existing page URL + if let Some(page_url) = request["site"]["page"].as_str() { + let params = config.debug_query_params.as_ref().unwrap(); + // Only append if params aren't already present + if !page_url.contains(params.as_str()) { + let updated_url = if page_url.contains('?') { + format!("{}&{}", page_url, params) + } else { + format!("{}?{}", page_url, params) + }; + request["site"]["page"] = json!(updated_url); + } + } } if let Some(request_signing_config) = &settings.request_signing { @@ -545,6 +590,16 @@ fn enhance_openrtb_request( } } + if config.debug { + if !request["ext"].is_object() { + request["ext"] = json!({}); + } + if !request["ext"]["prebid"].is_object() { + request["ext"]["prebid"] = json!({}); + } + request["ext"]["prebid"]["debug"] = json!(true); + } + Ok(()) } @@ -691,6 +746,7 @@ mod tests { bidders: vec!["exampleBidder".to_string()], auto_configure: true, debug: false, + debug_query_params: None, } } @@ -855,7 +911,9 @@ mod tests { let mut req = Request::new(Method::POST, "https://edge.example/third-party/ad"); req.set_header("Sec-GPC", "1"); - enhance_openrtb_request(&mut request_json, synthetic_id, fresh_id, &settings, &req) + let config = base_config(); + + enhance_openrtb_request(&mut request_json, synthetic_id, fresh_id, &settings, &req, &config) .expect("should enhance request"); assert_eq!(request_json["user"]["id"], synthetic_id); @@ -877,6 +935,52 @@ mod tests { ); } + #[test] + fn enhance_openrtb_request_adds_debug_flag_when_enabled() { + let settings = make_settings(); + let mut request_json = json!({ + "id": "openrtb-request-id" + }); + + let synthetic_id = "synthetic-123"; + let fresh_id = "fresh-456"; + let req = Request::new(Method::POST, "https://edge.example/third-party/ad"); + + let mut config = base_config(); + config.debug = true; + + enhance_openrtb_request(&mut request_json, synthetic_id, fresh_id, &settings, &req, &config) + .expect("should enhance request"); + + assert_eq!( + request_json["ext"]["prebid"]["debug"], true, + "debug flag should be set to true when config.debug is enabled" + ); + } + + #[test] + fn enhance_openrtb_request_does_not_add_debug_flag_when_disabled() { + let settings = make_settings(); + let mut request_json = json!({ + "id": "openrtb-request-id" + }); + + let synthetic_id = "synthetic-123"; + let fresh_id = "fresh-456"; + let req = Request::new(Method::POST, "https://edge.example/third-party/ad"); + + let mut config = base_config(); + config.debug = false; + + enhance_openrtb_request(&mut request_json, synthetic_id, fresh_id, &settings, &req, &config) + .expect("should enhance request"); + + assert!( + request_json["ext"]["prebid"]["debug"].is_null(), + "debug flag should not be set when config.debug is disabled" + ); + } + #[test] fn transform_prebid_response_rewrites_creatives_and_tracking() { let mut response = json!({ @@ -957,4 +1061,132 @@ mod tests { )); assert!(!is_prebid_script_url("https://cdn.com/app.js")); } + + #[test] + fn debug_query_params_added_in_enhance_not_build() { + // Verify that build_openrtb_from_ts doesn't add debug params + // They should only be added in enhance_openrtb_request + let settings = make_settings(); + let mut config = base_config(); + config.debug_query_params = Some("kargo_debug=true&force_bid=1".to_string()); + + let ad_request = AdRequest { + ad_units: vec![], + config: None, + }; + + let openrtb = build_openrtb_from_ts(&ad_request, &settings, &config); + + assert!(openrtb.site.is_some()); + let site = openrtb.site.unwrap(); + assert!(site.page.is_some()); + let page = site.page.unwrap(); + + // Debug params should NOT be in the URL from build_openrtb_from_ts + assert!(!page.contains("?")); + assert!(page.starts_with("https://")); + } + + #[test] + fn debug_query_params_appended_to_existing_site_page_in_enhance() { + let settings = make_settings(); + let mut config = base_config(); + config.debug_query_params = Some("kargo_debug=true".to_string()); + + let req = Request::new(Method::GET, "https://example.com/test"); + let synthetic_id = "test-synthetic-id"; + let fresh_id = "test-fresh-id"; + + // Test with existing site.page + let mut request = json!({ + "id": "test-id", + "site": { + "domain": "example.com", + "page": "https://example.com/page" + } + }); + + enhance_openrtb_request(&mut request, synthetic_id, fresh_id, &settings, &req, &config) + .expect("should enhance request"); + + let page = request["site"]["page"].as_str().unwrap(); + assert_eq!(page, "https://example.com/page?kargo_debug=true"); + } + + #[test] + fn debug_query_params_appended_to_url_with_existing_query() { + let settings = make_settings(); + let mut config = base_config(); + config.debug_query_params = Some("kargo_debug=true".to_string()); + + let req = Request::new(Method::GET, "https://example.com/test"); + let synthetic_id = "test-synthetic-id"; + let fresh_id = "test-fresh-id"; + + // Test with existing query params in site.page + let mut request = json!({ + "id": "test-id", + "site": { + "domain": "example.com", + "page": "https://example.com/page?existing=param" + } + }); + + enhance_openrtb_request(&mut request, synthetic_id, fresh_id, &settings, &req, &config) + .expect("should enhance request"); + + let page = request["site"]["page"].as_str().unwrap(); + assert_eq!(page, "https://example.com/page?existing=param&kargo_debug=true"); + } + + #[test] + fn no_debug_query_params_when_none_configured() { + let settings = make_settings(); + let config = base_config(); + + let ad_request = AdRequest { + ad_units: vec![], + config: None, + }; + + let openrtb = build_openrtb_from_ts(&ad_request, &settings, &config); + + assert!(openrtb.site.is_some()); + let site = openrtb.site.unwrap(); + assert!(site.page.is_some()); + let page = site.page.unwrap(); + + assert!(!page.contains("?")); + assert!(page.starts_with("https://")); + } + + #[test] + fn debug_query_params_not_duplicated() { + // Verify that if params are already in the URL, they aren't added again + let settings = make_settings(); + let mut config = base_config(); + config.debug_query_params = Some("kargo_debug=true".to_string()); + + let req = Request::new(Method::GET, "https://example.com/test"); + let synthetic_id = "test-synthetic-id"; + let fresh_id = "test-fresh-id"; + + // Test with URL that already has the debug params + let mut request = json!({ + "id": "test-id", + "site": { + "domain": "example.com", + "page": "https://example.com/page?kargo_debug=true" + } + }); + + enhance_openrtb_request(&mut request, synthetic_id, fresh_id, &settings, &req, &config) + .expect("should enhance request"); + + let page = request["site"]["page"].as_str().unwrap(); + // Should still only have params once + assert_eq!(page, "https://example.com/page?kargo_debug=true"); + // Verify params appear exactly once + assert_eq!(page.matches("kargo_debug=true").count(), 1); + } } diff --git a/crates/js/lib/src/core/request.ts b/crates/js/lib/src/core/request.ts index 98e04a3..765b8d3 100644 --- a/crates/js/lib/src/core/request.ts +++ b/crates/js/lib/src/core/request.ts @@ -46,7 +46,7 @@ export function requestAds( } // Create per-slot first-party iframe requests served directly from the edge. -async function requestAdsFirstParty(adUnits: ReadonlyArray<{ code: string }>) { +async function requestAdsFirstParty(adUnits: ReadonlyArray<{ code: string; bids?: unknown }>) { for (const unit of adUnits) { const size = (firstSize(unit) ?? [300, 250]) as readonly [number, number]; const slotId = unit.code; @@ -60,7 +60,9 @@ async function requestAdsFirstParty(adUnits: ReadonlyArray<{ code: string }>) { width: size[0], height: size[1], }); - iframe.src = `/first-party/ad?slot=${encodeURIComponent(slotId)}&w=${encodeURIComponent(String(size[0]))}&h=${encodeURIComponent(String(size[1]))}`; + // Serialize bids to JSON and add to URL if present + const bidsParam = unit.bids ? `&bids=${encodeURIComponent(JSON.stringify(unit.bids))}` : ''; + iframe.src = `/first-party/ad?slot=${encodeURIComponent(slotId)}&w=${encodeURIComponent(String(size[0]))}&h=${encodeURIComponent(String(size[1]))}${bidsParam}`; return; } diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 3bf1b49..72365fb 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -9,9 +9,15 @@ export interface MediaTypes { banner?: Banner; } +export interface Bid { + bidder: string; + params?: Record; +} + export interface AdUnit { code: string; mediaTypes?: MediaTypes; + bids?: Bid[]; } export interface TsjsApi { diff --git a/trusted-server.toml b/trusted-server.toml index 864dbc8..cad055d 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -6,7 +6,7 @@ password = "pass" [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" -origin_url = "https://origin.test-publisher.com" +origin_url = "http://tauritutorials.test" proxy_secret = "change-me-proxy-secret" [synthetic] @@ -38,8 +38,8 @@ enabled = true server_url = "http://68.183.113.79:8000" timeout_ms = 1000 bidders = ["kargo", "rubicon", "appnexus", "openx"] -auto_configure = false -debug = false +auto_configure = true +debug = true [integrations.nextjs] enabled = false