diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f432957..279a7467 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -29,6 +29,7 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; +use trusted_server_core::trace_cookie::handle_trace_mode; use trusted_server_core::platform::RuntimeServices; @@ -255,6 +256,7 @@ enum NamedRouteHandler { /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -279,7 +281,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 12] { +fn named_routes() -> [NamedRoute; 13] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -320,6 +322,11 @@ fn named_routes() -> [NamedRoute; 12] { primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::LegacyAdminDenied, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -389,6 +396,9 @@ fn named_route_handler( Ok(resp) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), + NamedRouteHandler::TraceMode => { + handle_trace_mode(&state.settings, req.uri().query()) + } NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f..1686ebbb 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -28,6 +28,7 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::build_runtime_services; @@ -461,6 +462,15 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async { Ok::(admin_key_management_not_supported()) }) + // Render-trace toggle: arms/disarms the ts-trace cookie and + // redirects to `/`. Gated by [debug] trace_route_enabled (404 when + // off). + .get( + "/_ts/trace", + make_handler(Arc::clone(&state), |s, _services, req| async move { + handle_trace_mode(&s.settings, req.uri().query()) + }), + ) .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 955ff235..c2d07000 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -26,6 +26,7 @@ //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | //! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] | +//! | GET | `/_ts/trace` | [`handle_trace_mode`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | //! | POST | `/auction` | [`handle_auction`] | //! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | @@ -128,6 +129,7 @@ use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ @@ -587,6 +589,7 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::TraceMode => handle_trace_mode(&state.settings, req.uri().query()), NamedRouteHandler::Auction => { // The auction reads consent data, so the consent KV store must be // available — fail closed with 503 when it is configured but @@ -994,6 +997,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -1075,6 +1079,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -1746,6 +1755,55 @@ mod tests { ); } + #[test] + fn dispatch_trace_route_is_disabled_by_default() { + let router = test_router(); + let response = route(&router, empty_request(Method::GET, "/_ts/trace")); + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "disabled trace route should return 404" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "disabled trace route should not set a cookie" + ); + } + + #[test] + fn dispatch_trace_route_arms_cookie_and_redirects() { + let mut settings = test_settings(); + settings.debug.trace_route_enabled = true; + let state = app_state_for_settings(settings); + let router = TrustedServerApp::routes_for_state(&state); + let response = route(&router, empty_request(Method::GET, "/_ts/trace")); + + assert_eq!( + response.status(), + StatusCode::FOUND, + "enabled trace route should redirect to root" + ); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()), + Some("/"), + "trace route should redirect to /" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert!( + set_cookie.starts_with("ts-trace=1;"), + "trace route should arm the ts-trace cookie" + ); + } + #[test] fn dispatch_set_tester_sets_cookie_on_configured_domain() { let mut settings = test_settings(); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce7..4ebab07c 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -27,6 +27,7 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware}; use crate::platform::build_runtime_services; @@ -141,7 +142,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -149,6 +150,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { ("/_ts/admin/keys/deactivate", &[Method::POST]), ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), + ("/_ts/trace", &[Method::GET]), ("/auction", &[Method::POST]), ("/__ts/page-bids", &[Method::GET, Method::OPTIONS]), ("/first-party/proxy", &[Method::GET]), @@ -541,6 +543,21 @@ fn build_router(state: &Arc) -> RouterService { } }; + // GET /_ts/trace — render-trace toggle: arms/disarms the ts-trace + // cookie and redirects to `/`. Gated by [debug] trace_route_enabled + // (404 when off). + let s = Arc::clone(&state); + let trace_mode_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let req = ctx.into_request(); + Ok::( + handle_trace_mode(&s.settings, req.uri().query()) + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + // GET /__ts/page-bids — SPA re-auction endpoint. let s = Arc::clone(&state); let page_bids_handler = move |ctx: RequestContext| { @@ -730,6 +747,7 @@ fn build_router(state: &Arc) -> RouterService { // credentials and key-management payloads to the origin. .post("/_ts/admin/keys/rotate", admin_not_supported_handler) .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) + .get("/_ts/trace", trace_mode_handler) .post("/auction", auction_handler) .get("/__ts/page-bids", page_bids_handler) .route( diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 441828a1..ec3afa4e 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use uuid::Uuid; use crate::auction::context::ContextValue; +use crate::auction::types::adm_trace_hash; use crate::consent::ConsentContext; use crate::constants::{HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED}; use crate::creative; @@ -275,15 +276,57 @@ pub fn convert_to_openrtb_response( String::new() }; + // Trace hash over the exact markup delivered to the client (post + // sanitize/rewrite) so the client can stamp the rendered creative with + // a value that matches this response byte-for-byte. Logged at info so + // server logs join against the DOM markers without debug logging. + let adm_hash = (!creative_html.is_empty()).then(|| adm_trace_hash(&creative_html)); + if let Some(ref hash) = adm_hash { + log::info!( + "auction delivered creative: auction_id={} slot_id={} bidder={} crid={:?} adm_hash={}", + auction_request.id, + slot_id, + bid.bidder, + bid.crid, + hash, + ); + } + let mut ts_ext = serde_json::Map::new(); + ts_ext.insert( + "auction_id".to_string(), + serde_json::Value::String(auction_request.id.clone()), + ); + if let Some(ref hash) = adm_hash { + ts_ext.insert( + "adm_hash".to_string(), + serde_json::Value::String(hash.clone()), + ); + } + let mut bid_ext = serde_json::Map::new(); + bid_ext.insert("ts".to_string(), serde_json::Value::Object(ts_ext)); + let openrtb_bid = OpenRtbBid { - id: Some(format!("{}-{}", bid.bidder, slot_id)), + // Prefer the bidder's real bid ID; fall back to the legacy + // synthetic value so consumers of the old shape keep working. + id: Some( + bid.bid_id + .clone() + .unwrap_or_else(|| format!("{}-{}", bid.bidder, slot_id)), + ), impid: Some(slot_id.to_string()), price: Some(price), adm: Some(creative_html), - crid: Some(format!("{}-creative", bid.bidder)), + // Prefer the bidder's real creative ID; fall back to the legacy + // synthetic value so consumers of the old shape keep working. + crid: Some( + bid.crid + .clone() + .unwrap_or_else(|| format!("{}-creative", bid.bidder)), + ), w: width, h: height, adomain: bid.adomain.clone().unwrap_or_default(), + ext: Some(bid_ext), ..Default::default() }; @@ -438,6 +481,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -932,6 +977,81 @@ mod tests { ); } + #[test] + fn convert_to_openrtb_response_includes_trace_ext_with_delivered_adm_hash() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert auction result to OpenRTB response"); + + let json = response_json(response); + let bid = &json["seatbid"][0]["bid"][0]; + assert_eq!( + bid["ext"]["ts"]["auction_id"], + json!("auction-1"), + "should carry the auction ID in the trace ext" + ); + let delivered_adm = bid["adm"].as_str().expect("should serialize adm as string"); + assert_eq!( + bid["ext"]["ts"]["adm_hash"], + json!(adm_trace_hash(delivered_adm)), + "should hash the exact adm delivered to the client" + ); + } + + #[test] + fn convert_to_openrtb_response_omits_adm_hash_without_creative() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = None; + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert bid without creative HTML"); + + let json = response_json(response); + let ts_ext = json["seatbid"][0]["bid"][0]["ext"]["ts"] + .as_object() + .expect("should serialize trace ext as object"); + assert_eq!( + ts_ext.get("auction_id"), + Some(&json!("auction-1")), + "should still carry the auction ID" + ); + assert!( + !ts_ext.contains_key("adm_hash"), + "should omit adm_hash when there is no creative" + ); + } + + #[test] + fn convert_to_openrtb_response_prefers_upstream_crid_and_bid_id() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.crid = Some("cr-12345".to_string()); + bid.bid_id = Some("bid-abc-1".to_string()); + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert bid with upstream crid and bid ID"); + + let json = response_json(response); + assert_eq!( + json["seatbid"][0]["bid"][0]["crid"], + json!("cr-12345"), + "should pass through the bidder's creative ID instead of the synthetic fallback" + ); + assert_eq!( + json["seatbid"][0]["bid"][0]["id"], + json!("bid-abc-1"), + "should pass through the bidder's bid ID instead of the synthetic fallback" + ); + } + #[test] fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() { let settings = make_settings(); diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 986beb98..ea39d873 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -35,6 +35,7 @@ pub use telemetry::{ }; pub use types::{ AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, + adm_trace_hash, }; /// Type alias for provider builder functions. diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index bee63856..434d0193 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -162,6 +162,29 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } +/// Log one structured trace line per winning bid. +/// +/// Emits the full trace tuple — auction ID, slot, bidder, ad/cache/creative +/// IDs, and the creative trace hash — so a rendered creative on the page +/// (carrying the same tuple in its DOM markers) can be joined back to this +/// auction in server logs. +fn log_winning_bids(auction_id: &str, winning_bids: &HashMap) { + for (slot_id, bid) in winning_bids { + log::info!( + "auction winner: auction_id={} slot_id={} bidder={} price={:?} bid_id={:?} ad_id={:?} cache_id={:?} crid={:?} adm_hash={:?}", + auction_id, + slot_id, + bid.bidder, + bid.price, + bid.bid_id, + bid.ad_id, + bid.cache_id, + bid.crid, + bid.creative_trace_hash(), + ); + } +} + /// Manages auction execution across multiple providers. pub struct AuctionOrchestrator { config: AuctionConfig, @@ -252,6 +275,8 @@ impl AuctionOrchestrator { strategy_name ); + log_winning_bids(&request.id, &result.winning_bids); + Ok(OrchestrationResult { total_time_ms: start_time.elapsed().as_millis() as u64, ..result @@ -1147,6 +1172,7 @@ impl AuctionOrchestrator { responses.len(), ); let winning = self.select_winning_bids(&responses, &floor_prices); + log_winning_bids(&request.id, &winning); return OrchestrationResult { provider_responses: responses, mediator_response: None, @@ -1265,6 +1291,8 @@ impl AuctionOrchestrator { (None, self.select_winning_bids(&responses, &floor_prices)) }; + log_winning_bids(&request.id, &winning_bids); + OrchestrationResult { provider_responses: responses, mediator_response, @@ -1422,6 +1450,8 @@ mod tests { nurl: nurl.clone(), burl: nurl, ad_id: Some("creative-123".to_string()), + bid_id: None, + crid: None, cache_id: Some("cache-abc".to_string()), cache_host: None, cache_path: None, @@ -1761,6 +1791,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -1781,6 +1813,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -2172,6 +2206,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -2222,6 +2258,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -2258,6 +2296,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index d6344536..86198087 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -961,6 +961,8 @@ mod tests { nurl: None, burl: None, ad_id: ad_id.map(str::to_owned), + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 14c7713f..fdee6ef1 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -198,6 +198,24 @@ pub struct Bid { pub burl: Option, /// Ad ID from the bidder pub ad_id: Option, + /// Bid ID from the bidder (`OpenRTB` `bid.id`). + /// + /// Distinct from [`Bid::ad_id`] (the `adid` creative/ad identifier): + /// this is the bidder's identifier for the bid itself. Carried so the + /// `/auction` response can echo the upstream bid ID instead of a + /// synthesized one. Unique per bid instance, not a creative identifier — + /// always present per spec. Also used as the last-resort `hb_adid` + /// fallback in `build_bid_map` for bidders that return neither a Prebid + /// Cache UUID nor `adid`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bid_id: Option, + /// Creative ID from the bidder (`OpenRTB` `crid`). + /// + /// Carried end-to-end so a rendered creative on the page can be traced + /// back to the upstream creative, not just the bidder. `None` when the + /// provider response does not include one (e.g. APS pre-decode). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub crid: Option, /// Prebid Cache UUID for this bid. /// /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. @@ -254,6 +272,49 @@ impl From<&AuctionResponse> for ProviderSummary { } } +/// Length of the hex-encoded creative trace hash. +/// +/// 16 hex chars (64 bits of SHA-256) — short enough for a DOM attribute and a +/// log field, long enough that collisions across a page's creatives are not a +/// practical concern for tracing. +const ADM_TRACE_HASH_LEN: usize = 16; + +/// Compute the trace hash for a creative markup string. +/// +/// The hash is the first [`ADM_TRACE_HASH_LEN`] hex characters of the SHA-256 +/// of the exact bytes handed to the client. It is a correlation key for +/// tracing a winning bid to the creative rendered on the page — server logs, +/// the injected bid payload, and DOM markers all carry the same value — not an +/// integrity mechanism. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::auction::adm_trace_hash; +/// +/// let hash = adm_trace_hash("
example creative
"); +/// assert_eq!(hash.len(), 16); +/// ``` +#[must_use] +pub fn adm_trace_hash(adm: &str) -> String { + use sha2::{Digest as _, Sha256}; + + let digest = Sha256::digest(adm.as_bytes()); + let mut hex = hex::encode(digest); + hex.truncate(ADM_TRACE_HASH_LEN); + hex +} + +impl Bid { + /// Trace hash of this bid's creative markup, when present. + /// + /// See [`adm_trace_hash`] for the hash definition. + #[must_use] + pub fn creative_trace_hash(&self) -> Option { + self.creative.as_deref().map(adm_trace_hash) + } +} + /// `OpenRTB` response metadata for the orchestrator. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrchestratorExt { @@ -339,6 +400,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -346,6 +409,42 @@ mod tests { } } + #[test] + fn adm_trace_hash_is_sha256_prefix() { + // SHA-256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + assert_eq!( + adm_trace_hash("abc"), + "ba7816bf8f01cfea", + "should be the first 16 hex chars of the SHA-256 digest" + ); + } + + #[test] + fn adm_trace_hash_distinguishes_creatives() { + assert_ne!( + adm_trace_hash("
creative a
"), + adm_trace_hash("
creative b
"), + "should produce different hashes for different markup" + ); + } + + #[test] + fn creative_trace_hash_follows_creative_presence() { + let mut bid = make_bid("kargo"); + assert_eq!( + bid.creative_trace_hash(), + None, + "should be None without creative markup" + ); + + bid.creative = Some("
example creative
".to_owned()); + assert_eq!( + bid.creative_trace_hash(), + Some(adm_trace_hash("
example creative
")), + "should hash the creative markup when present" + ); + } + #[test] fn provider_summary_from_successful_response() { let response = AuctionResponse::success( @@ -468,6 +567,8 @@ mod tests { nurl: None, burl: None, ad_id: Some("bid-id".to_string()), + bid_id: None, + crid: None, cache_id: Some("cache-uuid".to_string()), cache_host: Some("cache.example.com".to_string()), cache_path: Some("/pbc/v1/cache".to_string()), @@ -515,6 +616,8 @@ mod tests { nurl: None, burl: None, ad_id: Some("prebid-ad-id-abc".to_string()), + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index ffcf4f03..6a220498 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -5,6 +5,7 @@ pub const COOKIE_TS_EC: &str = "ts-ec"; /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; +pub const COOKIE_TS_TRACE: &str = "ts-trace"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 8fd3f9dd..5a060c29 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -319,6 +319,8 @@ impl AdServerMockProvider { nurl: original.and_then(|b| b.nurl.clone()), burl: original.and_then(|b| b.burl.clone()), ad_id: original.and_then(|b| b.ad_id.clone()), + bid_id: original.and_then(|b| b.bid_id.clone()), + crid: original.and_then(|b| b.crid.clone()), cache_id: original.and_then(|b| b.cache_id.clone()), cache_host: original.and_then(|b| b.cache_host.clone()), cache_path: original.and_then(|b| b.cache_path.clone()), @@ -643,6 +645,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -666,6 +670,8 @@ mod tests { nurl: Some("https://ssp.example/win?id=mock-bid-001".to_string()), burl: Some("https://ssp.example/bill?id=mock-bid-001".to_string()), ad_id: Some("mock-bid-001".to_string()), + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -785,6 +791,8 @@ mod tests { nurl: Some("https://ssp.example/win".to_string()), burl: Some("https://ssp.example/bill".to_string()), ad_id: Some("bid-impression-id".to_string()), + bid_id: None, + crid: None, cache_id: Some("cache-uuid".to_string()), cache_host: Some("cache.example".to_string()), cache_path: Some("/cache".to_string()), @@ -904,6 +912,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 60c22265..fe0f08df 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -482,6 +482,8 @@ impl ApsAuctionProvider { nurl: None, // Real APS uses client-side event tracking burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index a1b40281..0106ae5e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2144,6 +2144,18 @@ impl PrebidAuctionProvider { .and_then(|v| v.as_str()) .map(String::from); + // `crid` is the OpenRTB creative ID — carried so a rendered creative + // on the page can be traced back to the upstream creative. + let crid = bid_obj + .get("crid") + .and_then(|v| v.as_str()) + .map(String::from); + + // OpenRTB `id` is the bidder's identifier for the bid itself — carried + // separately from `ad_id` (see the comment above) so the `/auction` + // response can echo the upstream bid ID. + let bid_id = bid_obj.get("id").and_then(|v| v.as_str()).map(String::from); + let adomain = bid_obj .get("adomain") .and_then(|v| v.as_array()) @@ -2208,6 +2220,8 @@ impl PrebidAuctionProvider { nurl, burl, ad_id, + bid_id, + crid, cache_id, cache_host, cache_path, @@ -6659,6 +6673,49 @@ set = { networkId = 42 } ); } + #[test] + fn parse_bid_extracts_crid() { + let bid_json = serde_json::json!({ + "id": "bid-id-321", + "impid": "atf_sidebar_ad", + "price": 1.25, + "adm": "
ad
", + "crid": "cr-98765", + "w": 300, + "h": 250 + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "kargo") + .expect("should parse bid"); + assert_eq!( + bid.crid.as_deref(), + Some("cr-98765"), + "should extract the OpenRTB creative ID" + ); + assert_eq!( + bid.bid_id.as_deref(), + Some("bid-id-321"), + "should extract the OpenRTB bid ID" + ); + } + + #[test] + fn parse_bid_sets_crid_to_none_when_absent() { + let bid_json = serde_json::json!({ + "id": "bid-id-322", + "impid": "atf_sidebar_ad", + "price": 1.25, + "w": 300, + "h": 250 + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "kargo") + .expect("should parse bid"); + assert!(bid.crid.is_none(), "should be None when crid absent"); + } + #[test] fn parse_bid_sets_cache_fields_to_none_when_no_cache_entry() { let bid_json = serde_json::json!({ diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 70a4d6cf..edc9a3c0 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -70,6 +70,7 @@ pub mod streaming_processor; pub mod streaming_replacer; pub mod test_support; pub mod tester_cookie; +pub mod trace_cookie; pub mod tsjs; #[cfg(test)] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 34909efe..7969a57c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -698,6 +698,7 @@ pub async fn stream_publisher_body_async( params.price_granularity, ¶ms.ad_bids_state, settings.debug.inject_adm_for_testing, + telemetry.auction_request.as_ref().map(|r| r.id.as_str()), ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -844,18 +845,22 @@ pub(crate) fn should_run_server_side_ad_stack( } /// Write winning bids from an auction result into the shared `ad_bids_state` lock. +/// +/// `auction_id` propagates into each bid entry as `hb_auction_id` so the +/// injected `tsjs.bids` payload can be traced back to the server-side auction. pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, inject_adm: bool, + auction_id: Option<&str>, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); + let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm, auction_id); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -1389,6 +1394,7 @@ async fn collect_stream_auction( price_granularity, ad_bids_state, settings.debug.inject_adm_for_testing, + telemetry.auction_request.as_ref().map(|r| r.id.as_str()), ); if settings.debug.auction_html_comment { @@ -2091,10 +2097,17 @@ fn html_escape_for_script(s: &str) -> String { /// /// Returns a JSON object map of slot ID → bid metadata including the bucketed /// CPM (`hb_pb`), bidder (`hb_bidder`), and optional ad ID, nurl, and burl. +/// +/// Every entry also carries the trace fields `hb_auction_id` (when +/// `auction_id` is known), `hb_crid` (when the bidder returned a creative ID), +/// `hb_bid_id` (the bid's own `OpenRTB` `id`), and `hb_adm_hash` (when the bid +/// has creative markup) so the client can stamp rendered creatives with a tuple +/// that joins back to the server-side `auction winner:` log lines. pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, include_adm: bool, + auction_id: Option<&str>, ) -> serde_json::Map { winning_bids .iter() @@ -2107,10 +2120,47 @@ pub(crate) fn build_bid_map( "hb_bidder".to_string(), serde_json::Value::String(bid.bidder.clone()), ); + if let Some(id) = auction_id { + obj.insert( + "hb_auction_id".to_string(), + serde_json::Value::String(id.to_string()), + ); + } + if let Some(ref crid) = bid.crid { + obj.insert( + "hb_crid".to_string(), + serde_json::Value::String(crid.clone()), + ); + } + // The bid's own OpenRTB `id`, carried separately from `hb_adid` + // so the client can stamp the exact bid this render came from. + // `hb_adid` cannot serve that purpose: it holds the cache UUID + // or `adid` whenever one exists, and only falls back to the bid + // ID when neither does. Trace-only — never set as GAM targeting. + if let Some(ref bid_id) = bid.bid_id { + obj.insert( + "hb_bid_id".to_string(), + serde_json::Value::String(bid_id.clone()), + ); + } + if let Some(hash) = bid.creative_trace_hash() { + obj.insert("hb_adm_hash".to_string(), serde_json::Value::String(hash)); + } // hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses // this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to // bid.ad_id for APS and other non-PBS providers. - let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); + // + // `bid.bid_id` (the OpenRTB bid's own `id`) is the last resort: it is + // always present per spec but only unique per bid instance, not a + // creative identifier. It still satisfies what hb_adid needs here — + // a stable value GAM's Universal Creative echoes back verbatim so + // the render bridge can find this exact winning bid — for bidders + // (e.g. Kargo) that return neither a cache UUID nor `adid`. + let hb_adid = bid + .cache_id + .as_deref() + .or(bid.ad_id.as_deref()) + .or(bid.bid_id.as_deref()); if let Some(id) = hb_adid { obj.insert( "hb_adid".to_string(), @@ -2159,6 +2209,8 @@ pub(crate) fn build_bid_map( "nurl": bid.nurl, "burl": bid.burl, "ad_id": bid.ad_id, + "bid_id": bid.bid_id, + "crid": bid.crid, "cache_id": bid.cache_id, "cache_host": bid.cache_host, "cache_path": bid.cache_path, @@ -2437,8 +2489,8 @@ pub async fn handle_page_bids( // skip the live auction, matching the existing bot/prefetch behaviour. let ad_stack_enabled = auction_enabled && consent_allows_auction; - let winning_bids = if matched_slots.is_empty() { - std::collections::HashMap::new() + let (winning_bids, page_auction_id) = if matched_slots.is_empty() { + (std::collections::HashMap::new(), None) } else { // Same publisher identity as the outbound bid request — see the // matching note on the initial-navigation observation above. @@ -2504,7 +2556,7 @@ pub async fn handle_page_bids( ) }) .await; - winning_bids + (winning_bids, Some(auction_request.id.clone())) } Err(e) => { log::warn!("page-bids auction failed: {e:?}"); @@ -2521,7 +2573,7 @@ pub async fn handle_page_bids( ) }) .await; - std::collections::HashMap::new() + (std::collections::HashMap::new(), None) } } } else { @@ -2547,7 +2599,7 @@ pub async fn handle_page_bids( ) }) .await; - std::collections::HashMap::new() + (std::collections::HashMap::new(), None) } }; @@ -2555,6 +2607,7 @@ pub async fn handle_page_bids( &winning_bids, co_config.price_granularity, settings.debug.inject_adm_for_testing, + page_auction_id.as_deref(), ); // Gate slots on the ad-stack kill switch / consent: when disabled, return no @@ -2626,6 +2679,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -4318,6 +4373,8 @@ mod tests { nurl: Some(nurl.to_string()), burl: Some(burl.to_string()), ad_id: Some(ad_id.to_string()), + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -4372,7 +4429,7 @@ mod tests { "https://ssp/bill", ), ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false, None); let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); let obj = entry.as_object().expect("should be object"); assert_eq!( @@ -4402,6 +4459,92 @@ mod tests { ); } + #[test] + fn bid_map_includes_trace_fields() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + bid.bid_id = Some("bid-abc123".to_string()); + bid.crid = Some("cr-98765".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map( + &winning_bids, + PriceGranularity::Dense, + false, + Some("ts-req-trace1"), + ); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("hb_auction_id").and_then(|v| v.as_str()), + Some("ts-req-trace1"), + "should carry the auction ID" + ); + assert_eq!( + obj.get("hb_crid").and_then(|v| v.as_str()), + Some("cr-98765"), + "should carry the upstream creative ID" + ); + assert_eq!( + obj.get("hb_bid_id").and_then(|v| v.as_str()), + Some("bid-abc123"), + "should carry the upstream bid ID separately" + ); + assert_eq!( + obj.get("hb_adm_hash").and_then(|v| v.as_str()), + Some(crate::auction::adm_trace_hash("
Creative
").as_str()), + "should hash the raw creative markup" + ); + } + + #[test] + fn bid_map_omits_trace_fields_without_sources() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ), + ); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false, None); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert!( + obj.get("hb_auction_id").is_none(), + "should omit hb_auction_id when the auction ID is unknown" + ); + assert!( + obj.get("hb_crid").is_none(), + "should omit hb_crid when the bidder returned none" + ); + assert!( + obj.get("hb_adm_hash").is_none(), + "should omit hb_adm_hash without creative markup" + ); + } + #[test] fn client_bid_map_omits_adm_by_default() { let mut winning_bids = HashMap::new(); @@ -4416,7 +4559,7 @@ mod tests { bid.creative = Some("
Creative
".to_string()); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false, None); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -4447,7 +4590,7 @@ mod tests { bid.creative = Some("
Creative
".to_string()); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true, None); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -4483,7 +4626,7 @@ mod tests { ); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true, None); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -4538,13 +4681,17 @@ mod tests { nurl: None, burl: None, ad_id: Some("bid-impression-id".to_string()), + // Present alongside cache_id/ad_id to prove cache_id still wins + // — bid_id is the last resort, not a co-equal fallback. + bid_id: Some("should-be-ignored-bid-id".to_string()), + crid: None, cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), cache_host: Some("openads.adsrvr.org".to_string()), cache_path: Some("/cache".to_string()), metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false, None); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -4553,7 +4700,7 @@ mod tests { assert_eq!( obj.get("hb_adid").and_then(|v| v.as_str()), Some("f47447a0-b759-4f2f-9887-af458b79b570"), - "should use cache_id for hb_adid, not ad_id" + "should use cache_id for hb_adid, not ad_id or bid_id" ); assert_eq!( obj.get("hb_cache_host").and_then(|v| v.as_str()), @@ -4584,13 +4731,17 @@ mod tests { nurl: None, burl: None, ad_id: Some("aps-bid-token".to_string()), + // Present alongside ad_id to prove ad_id still wins — bid_id + // is the last resort, not a co-equal fallback. + bid_id: Some("should-be-ignored-bid-id".to_string()), + crid: None, cache_id: None, cache_host: None, cache_path: None, metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false, None); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -4599,7 +4750,7 @@ mod tests { assert_eq!( obj.get("hb_adid").and_then(|v| v.as_str()), Some("aps-bid-token"), - "should fall back to ad_id when cache_id absent" + "should fall back to ad_id when cache_id absent, ignoring bid_id" ); assert!( obj.get("hb_cache_host").is_none(), @@ -4612,7 +4763,48 @@ mod tests { } #[test] - fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { + fn bid_map_falls_back_to_bid_id_when_cache_id_and_ad_id_absent() { + // Real shape for bidders like Kargo: no Prebid Cache UUID, no `adid` + // in the OpenRTB response, but `id` (the bid's own identifier) is + // always present per spec. + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(1.00), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()), + ad_id: None, + crid: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false, None); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"), + "should fall back to bid_id when cache_id and ad_id are both absent" + ); + } + + #[test] + fn bid_map_omits_hb_adid_when_cache_id_ad_id_and_bid_id_all_absent() { let mut winning_bids = HashMap::new(); winning_bids.insert( "atf_sidebar_ad".to_string(), @@ -4628,13 +4820,15 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false, None); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -4642,7 +4836,7 @@ mod tests { .expect("should be object"); assert!( obj.get("hb_adid").is_none(), - "should omit hb_adid when no cache_id and no ad_id" + "should omit hb_adid when no cache_id, ad_id, or bid_id" ); } @@ -4663,13 +4857,15 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false, None); assert!( map.is_empty(), "slot with no price should be excluded from bid map" diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c49e9968..70b01887 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1921,6 +1921,18 @@ pub struct DebugConfig { /// production — injects raw HTML from SSPs. #[serde(default)] pub inject_adm_for_testing: bool, + + /// Expose `GET /_ts/trace`, which toggles the `ts-trace` cookie and + /// redirects to `/`. + /// + /// The cookie makes the TSJS render-trace overlay draw a floating panel + /// summarising every traced slot (render path, bidder, GAM/injected/visible + /// state) plus a confirmation badge on each genuinely-rendered creative. + /// The overlay only surfaces data already exposed on `window.tsjs`, so + /// enabling this leaks nothing new — it is off by default to avoid shipping + /// a live toggle route on deployments that never asked for it. + #[serde(default)] + pub trace_route_enabled: bool, } /// Tester-cookie endpoint configuration. diff --git a/crates/trusted-server-core/src/trace_cookie.rs b/crates/trusted-server-core/src/trace_cookie.rs new file mode 100644 index 00000000..583a9623 --- /dev/null +++ b/crates/trusted-server-core/src/trace_cookie.rs @@ -0,0 +1,218 @@ +//! Render-trace toggle endpoint helpers. +//! +//! `GET /_ts/trace` arms (or with `?enabled=false` disarms) the first-party +//! `ts-trace` cookie and redirects to `/`. While the cookie is present, the +//! TSJS render-trace layer draws a visible badge on every traced creative so +//! an operator can see on the page itself that a creative was delivered by +//! Trusted Server — and via which render path (SSAT/GAM or `/auction`). +//! +//! The route is gated behind `[debug] trace_route_enabled` and returns +//! `404 Not Found` while disabled, mirroring the tester-cookie endpoints. The +//! badge only surfaces data already exposed on `window.tsjs`, so the cookie +//! gates visibility, not access. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt}; +use http::{HeaderValue, Response, StatusCode, header}; + +use crate::constants::COOKIE_TS_TRACE; +use crate::error::TrustedServerError; +use crate::settings::Settings; + +/// How long an armed trace cookie lives, in seconds. +/// +/// One hour: long enough for a debugging session across reloads and SPA +/// navigations, short enough that a forgotten toggle expires on its own. +const TRACE_COOKIE_MAX_AGE_SECS: u32 = 3600; + +/// Formats the trace cookie `Set-Cookie` header value. +/// +/// Deliberately host-only (no `Domain` attribute): a `Domain` scoped to +/// `publisher.cookie_domain` would be rejected by the browser during local +/// development against `127.0.0.1`/`localhost`, and the overlay only needs to +/// work on the exact host being debugged. Also neither `HttpOnly` (the TSJS +/// overlay must read it from `document.cookie`) nor `Secure` (the badge is a +/// debug aid that must work through plain-HTTP local dev proxies, and the +/// cookie carries no data worth protecting). +fn format_trace_cookie() -> String { + format!( + "{}=1; Path=/; SameSite=Lax; Max-Age={}", + COOKIE_TS_TRACE, TRACE_COOKIE_MAX_AGE_SECS, + ) +} + +/// Formats the trace cookie clearing `Set-Cookie` header value. +fn format_clear_trace_cookie() -> String { + format!("{}=; Path=/; SameSite=Lax; Max-Age=0", COOKIE_TS_TRACE) +} + +/// Whether the request's query string asks to disarm the trace cookie. +/// +/// Only an explicit `enabled=false` (or `enabled=0`) disarms; any other query +/// — including none at all — arms it, so `GET /_ts/trace` alone switches the +/// overlay on. +fn query_disables(query: Option<&str>) -> bool { + query.is_some_and(|q| { + q.split('&') + .any(|pair| pair == "enabled=false" || pair == "enabled=0") + }) +} + +/// Handles `GET /_ts/trace`. +/// +/// Returns `404 Not Found` while `[debug] trace_route_enabled` is false. When +/// enabled, sets (or with `?enabled=false` clears) the `ts-trace` cookie +/// scoped to `publisher.cookie_domain` and returns `302 Found` redirecting to +/// `/` — landing back on the homepage confirms the toggle round-trip worked. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::InvalidHeaderValue`] if the configured cookie +/// domain cannot be rendered as an HTTP header value. +pub fn handle_trace_mode( + settings: &Settings, + query: Option<&str>, +) -> Result, Report> { + if !settings.debug.trace_route_enabled { + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = StatusCode::NOT_FOUND; + return Ok(response); + } + + let cookie_value = if query_disables(query) { + format_clear_trace_cookie() + } else { + format_trace_cookie() + }; + let set_cookie = HeaderValue::from_str(&cookie_value).change_context( + TrustedServerError::InvalidHeaderValue { + message: "trace cookie contains invalid header value".to_string(), + }, + )?; + + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = StatusCode::FOUND; + response + .headers_mut() + .insert(header::LOCATION, HeaderValue::from_static("/")); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, private"), + ); + response + .headers_mut() + .insert(header::SET_COOKIE, set_cookie); + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::tests::create_test_settings; + + fn trace_enabled_settings() -> Settings { + let mut settings = create_test_settings(); + settings.debug.trace_route_enabled = true; + settings + } + + #[test] + fn trace_route_arms_cookie_and_redirects_to_root() { + let settings = trace_enabled_settings(); + + let response = handle_trace_mode(&settings, None).expect("should build trace response"); + + assert_eq!( + response.status(), + StatusCode::FOUND, + "enabled trace route should redirect" + ); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()), + Some("/"), + "should redirect to the site root" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store, private"), + "trace route should not be cacheable" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert_eq!( + set_cookie, "ts-trace=1; Path=/; SameSite=Lax; Max-Age=3600", + "trace cookie should be host-only with a bounded lifetime" + ); + } + + #[test] + fn trace_route_clears_cookie_when_disabled_by_query() { + let settings = trace_enabled_settings(); + + let response = handle_trace_mode(&settings, Some("enabled=false")) + .expect("should build trace clear response"); + + assert_eq!( + response.status(), + StatusCode::FOUND, + "clearing should still redirect" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should clear trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert_eq!( + set_cookie, "ts-trace=; Path=/; SameSite=Lax; Max-Age=0", + "trace cookie clear should expire the cookie" + ); + } + + #[test] + fn trace_route_arms_cookie_for_unrelated_query() { + let settings = trace_enabled_settings(); + + let response = handle_trace_mode(&settings, Some("enabled=true&foo=bar")) + .expect("should build trace response"); + + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert!( + set_cookie.starts_with("ts-trace=1;"), + "non-disabling query should arm the cookie" + ); + } + + #[test] + fn trace_route_is_disabled_by_default() { + let settings = create_test_settings(); + + let response = + handle_trace_mode(&settings, None).expect("should build disabled trace response"); + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "disabled trace route should return not found" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "disabled trace route should not set a cookie" + ); + } +} diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index 40f54367..32ca4325 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -55,6 +55,17 @@ export interface AuctionBid { creativeId: string; /** Advertiser domains. */ adomain: string[]; + /** Server-side auction ID (response top-level `id` / `ext.ts.auction_id`). */ + auctionId?: string; + /** + * The bid's own OpenRTB `id` — the trace key identifying this exact bid in + * the server-side `auction winner:` log line. Kept distinct from + * [`creativeId`], which is the advertiser's creative (`crid`) and is reused + * across bids and slots. + */ + bidId?: string; + /** Trace hash of the delivered adm (`ext.ts.adm_hash`, 16 hex chars of SHA-256). */ + admHash?: string; } // --------------------------------------------------------------------------- @@ -126,12 +137,18 @@ export function parseAuctionResponse(body: any): AuctionBid[] { const seatbids = body?.seatbid; if (!Array.isArray(seatbids)) return bids; + // Server-side auction ID: response top-level `id`, kept per-bid so callers + // that only see individual bids retain the trace key. + const responseAuctionId: string | undefined = + typeof body?.id === 'string' && body.id !== '' ? body.id : undefined; + for (const sb of seatbids) { const seat: string = sb.seat ?? 'unknown'; const sbBids = sb.bid; if (!Array.isArray(sbBids)) continue; for (const b of sbBids) { + const tsExt = b?.ext?.ts; // Coerce missing/null adm to '' so AuctionBid.adm is always a string. // The empty-string case is filtered in renderCreativeInline via the // `if (!bid.adm)` guard. The client-side `typeof !== 'string'` check in @@ -146,6 +163,13 @@ export function parseAuctionResponse(body: any): AuctionBid[] { seat, creativeId: b.crid ?? `${seat}-${b.impid ?? ''}`, adomain: Array.isArray(b.adomain) ? b.adomain : [], + auctionId: + typeof tsExt?.auction_id === 'string' && tsExt.auction_id !== '' + ? tsExt.auction_id + : responseAuctionId, + bidId: typeof b?.id === 'string' && b.id !== '' ? b.id : undefined, + admHash: + typeof tsExt?.adm_hash === 'string' && tsExt.adm_hash !== '' ? tsExt.adm_hash : undefined, }); } } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index e39300a1..69aadada 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -4,6 +4,7 @@ import { collectContext } from './context'; import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; import { buildAdRequest, sendAuction } from './auction'; +import { recordRender, stampCreativeTrace, isEffectivelyVisible } from './trace'; export type RequestAdsCallback = () => void; export interface RequestAdsOptions { @@ -19,6 +20,9 @@ type RenderCreativeInlineOptions = { creativeHeight?: number; seat: string; creativeId: string; + auctionId?: string; + bidId?: string; + admHash?: string; }; // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. @@ -60,6 +64,9 @@ export function requestAds( creativeHeight: bid.height, seat: bid.seat, creativeId: bid.creativeId, + auctionId: bid.auctionId, + bidId: bid.bidId, + admHash: bid.admHash, }); } log.info('requestAds: rendered creatives from response'); @@ -87,10 +94,24 @@ function renderCreativeInline({ creativeHeight, seat, creativeId, + auctionId, + bidId, + admHash, }: RenderCreativeInlineOptions): void { + const trace = { + slotId, + path: 'auction' as const, + auctionId, + bidId, + bidder: seat, + creativeId, + admHash, + servedFrom: 'inline' as const, + }; const container = findSlot(slotId) as HTMLElement | null; if (!container) { log.warn('renderCreativeInline: slot not found; skipping render', { slotId, seat, creativeId }); + recordRender({ ...trace, rendered: false, injected: false, visible: false }); return; } @@ -104,6 +125,16 @@ function renderCreativeInline({ originalLength: sanitization.originalLength, rejectionReason: sanitization.rejectionReason, }); + // Stamp rendered:false so the DOM marker semantics match the SSAT path + // (explicit false on a failed render, not just an absent attribute). + const rejectedRecord = recordRender({ + ...trace, + rendered: false, + injected: false, + visible: false, + elementId: container.id || undefined, + }); + stampCreativeTrace(container, rejectedRecord); return; } @@ -135,10 +166,26 @@ function renderCreativeInline({ iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml); + // Trace: registry entry + DOM markers joining this creative back to the + // server-side auction (matches the `auction delivered creative:` log line). + // The /auction path writes the srcdoc itself, so this is a confirmed TS + // placement (injected: true). + const record = recordRender({ + ...trace, + rendered: true, + injected: true, + visible: isEffectivelyVisible(container), + elementId: container.id || undefined, + }); + stampCreativeTrace(container, record); + stampCreativeTrace(iframe, record); + log.info('renderCreativeInline: rendered', { slotId, seat, creativeId, + auctionId, + admHash, width, height, originalLength: sanitization.originalLength, diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts new file mode 100644 index 00000000..2acc5090 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -0,0 +1,582 @@ +// Render-trace registry, DOM markers, and a floating debug panel: joins a +// creative rendered on the page back to the winning server-side auction bid. +// Every render writes a RenderRecord to window.tsjs.renders (keyed by slot ID), +// stamps the slot element with data-ts-* attributes carrying the same trace +// tuple, and fires a 'tsjs:adRendered' CustomEvent. When the ts-trace cookie is +// armed (via GET /_ts/trace), a Google-Publisher-Console-style overlay panel +// summarises every traced slot so an operator can confirm on the page itself +// that creatives came through Trusted Server — on both the SSAT/GAM and +// /auction render paths. +import { log } from './log'; +import type { RenderRecord, TsjsApi } from './types'; + +/** CustomEvent fired on window after each render-trace record is written. */ +export const RENDER_EVENT_NAME = 'tsjs:adRendered'; + +/** + * Cookie armed by `GET /_ts/trace` (server-side, `ts-trace=1`). While present, + * the floating trace panel is shown so an operator can see on the page itself + * that creatives were delivered by Trusted Server. + */ +const TRACE_COOKIE_NAME = 'ts-trace'; + +/** DOM id of the floating trace panel (body-level overlay). */ +export const TRACE_PANEL_ID = 'ts-render-trace-panel'; + +/** + * Upper bound on `window.tsjs.renderLog`. A publisher page that refreshes its + * slots on every render can produce hundreds of entries in a session, so the + * history is trimmed from the front rather than growing without limit. + */ +const MAX_RENDER_LOG_ENTRIES = 200; + +/** + * Fallback for [`nextRenderSeq`] when `window.tsjs` is unreachable (no DOM, or + * a throwing property access). Never the primary counter — see below. + */ +let fallbackRenderSeq = 0; + +/** + * Allocate the next value for [`RenderRecord.seq`]. + * + * The counter lives on the shared `window.tsjs` object, not in module scope: + * `build-all.mjs` emits core, GPT and every integration as separate + * self-contained IIFEs, each with its own inlined copy of this module. A + * module-scoped counter would therefore restart at 1 in each bundle and hand + * two different renders the same number — duplicate `#1` panel rows and + * badges across the SSAT and `/auction` paths. + */ +function nextRenderSeq(): number { + try { + const ts = (window.tsjs ??= {} as TsjsApi); + const next = Math.max(ts.renderSeq ?? 0, fallbackRenderSeq) + 1; + ts.renderSeq = next; + fallbackRenderSeq = next; + return next; + } catch { + return ++fallbackRenderSeq; + } +} + +/** CSS class of the per-slot confirmation badge (only on honestly-ok slots). */ +export const TRACE_BADGE_CLASS = 'ts-render-badge'; + +/** + * Whether the visible trace overlay is armed (`ts-trace=1` cookie present — + * set via `GET /_ts/trace`, cleared via `GET /_ts/trace?enabled=false`). + */ +export function traceOverlayEnabled(): boolean { + try { + return new RegExp(`(?:^|;\\s*)${TRACE_COOKIE_NAME}=1(?:;|$)`).test(document.cookie); + } catch { + return false; + } +} + +/** Short-form mechanism suffix — only the bridge mechanisms add information. */ +function mechanismSuffix(record: RenderRecord): string { + return record.servedFrom === 'debug-adm' || record.servedFrom === 'pbs-cache' + ? ` (${record.servedFrom})` + : ''; +} + +/** + * Whether an element is effectively visible: connected, non-zero box, and no + * ancestor hiding it via `display:none`, `visibility:hidden`, or `opacity:0`. + * + * The ancestor walk is what catches a slot the publisher holds at `opacity:0` + * on a wrapper until its own ad code reveals it — the slot's own computed + * opacity is `1`, so only walking up exposes the gate. + */ +export function isEffectivelyVisible(el: Element | null): boolean { + try { + if (!el || !(el instanceof HTMLElement) || !el.isConnected) return false; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + let node: HTMLElement | null = el; + while (node) { + const cs = getComputedStyle(node); + if ( + cs.display === 'none' || + cs.visibility === 'hidden' || + parseFloat(cs.opacity || '1') === 0 + ) { + return false; + } + node = node.parentElement; + } + return true; + } catch { + return false; + } +} + +/** + * Honest per-slot status for the panel, derived from the separate signals: + * - `empty` — GAM reported the slot empty, or nothing was placed. + * - `hidden` — a creative rendered but the slot is not visible (reveal gate). + * - `gam-only`— GAM rendered something, but TS did not place it (can't confirm + * it is the TS creative — cross-origin). + * - `ok` — TS placed a creative and the slot is visible. + */ +type PanelStatus = 'ok' | 'hidden' | 'gam-only' | 'empty'; + +function panelStatus(record: RenderRecord): PanelStatus { + if (!record.rendered || record.gamEmpty === true) return 'empty'; + if (record.visible === false) return 'hidden'; + // `ok` requires a *confirmed* TS placement. Anything else — TS applied + // targeting only (injected false, creative is GAM's and cross-origin + // unreadable), or a path that never reported placement (undefined) — must not + // be claimed as a TS render. Defaulting to gam-only keeps the panel honest + // even if a future render path forgets to set `injected`. + if (record.injected !== true) return 'gam-only'; + return 'ok'; +} + +const STATUS_STYLE: Record = { + ok: { color: '#3fb950', mark: '✓', label: 'ok' }, + hidden: { color: '#d29922', mark: '⚠', label: 'hidden' }, + 'gam-only': { color: '#58a6ff', mark: '◐', label: 'gam-only' }, + empty: { color: '#f85149', mark: '✗', label: 'empty' }, +}; + +/** + * Attach (or replace) the per-slot confirmation badge on a slot element. + * + * Only called for `ok` slots — a TS creative that actually placed and is + * visible — so the green badge on a physical banner is a truthful "this banner + * is the render in the trace panel" marker, not the overclaiming badge the + * first cut shipped. Hidden / gam-only / empty slots deliberately get none. + * + * `pointer-events: none` keeps the badge from intercepting clicks on the ad. + */ +function attachTraceBadge(el: HTMLElement, record: RenderRecord): void { + const style = STATUS_STYLE[panelStatus(record)]; + + const position = getComputedStyle(el).position; + if (position === 'static' || position === '') { + el.style.position = 'relative'; + } + + const badge = document.createElement('div'); + badge.className = TRACE_BADGE_CLASS; + // Lead with the sequence number: it is what ties this badge to a panel row. + badge.textContent = + `TS ${style.mark} #${record.seq}` + + `${record.bidder ? ` · ${record.bidder}` : ''}` + + `${style.label === 'ok' ? '' : ` · ${style.label}`}`; + badge.title = [ + `render: #${record.seq}`, + `slot: ${record.slotId}`, + `auction: ${record.auctionId ?? '—'}`, + `bidder: ${record.bidder ?? '—'}`, + `bid_id: ${record.bidId ?? '—'}`, + `creative: ${record.creativeId ?? '—'}`, + `adm_hash: ${record.admHash ?? '—'}`, + `served: ${record.servedFrom ?? '—'}`, + ].join('\n'); + const s = badge.style; + s.setProperty('position', 'absolute'); + s.setProperty('top', '4px'); + s.setProperty('left', '4px'); + s.setProperty('z-index', '2147483646'); + s.setProperty('pointer-events', 'none'); + s.setProperty('font', '10px/1.5 ui-monospace, Menlo, Consolas, monospace'); + s.setProperty('padding', '1px 5px'); + s.setProperty('color', '#fff'); + s.setProperty('background', style.color); + s.setProperty('border-radius', '3px'); + el.appendChild(badge); +} + +/** + * Remove this element's own trace badge, if it has one. + * + * Must run on *every* stamp, not only the ones that go on to attach a new + * badge: a slot that re-renders into `empty` or `hidden` gets no replacement + * badge, so without an unconditional removal it would keep displaying the green + * or blue badge from its previous render — contradicting the status the panel + * shows for the same slot. + */ +function removeTraceBadge(el: HTMLElement): void { + el.querySelectorAll(`:scope > .${TRACE_BADGE_CLASS}`).forEach((n) => n.remove()); +} + +/** Truncate a long id for the compact panel row while keeping the tail. */ +function short(value: string | undefined, keep = 10): string { + if (!value) return '?'; + return value.length > keep ? `…${value.slice(-keep)}` : value; +} + +/** + * Create (or return) the floating trace panel appended to `document.body`. + * + * A body-level fixed overlay is used deliberately instead of per-slot badges: + * it survives GAM/APS clearing a slot's `innerHTML`, publisher reveal gates + * that hold a slot wrapper at `opacity: 0`, and cross-origin creative iframes — + * none of which a child-of-slot badge can survive. + */ +function ensureTracePanel(): HTMLElement | null { + if (typeof document === 'undefined' || !document.body) return null; + + const existing = document.getElementById(TRACE_PANEL_ID); + if (existing) return existing; + + const panel = document.createElement('div'); + panel.id = TRACE_PANEL_ID; + const s = panel.style; + s.setProperty('position', 'fixed'); + s.setProperty('bottom', '12px'); + s.setProperty('right', '12px'); + s.setProperty('z-index', '2147483647'); + s.setProperty('max-width', '360px'); + s.setProperty('max-height', '45vh'); + s.setProperty('overflow', 'auto'); + s.setProperty('background', 'rgba(17,17,17,0.94)'); + s.setProperty('color', '#eee'); + s.setProperty('font', '11px/1.5 ui-monospace, Menlo, Consolas, monospace'); + s.setProperty('border', '1px solid #333'); + s.setProperty('border-radius', '6px'); + s.setProperty('box-shadow', '0 4px 16px rgba(0,0,0,0.4)'); + s.setProperty('padding', '0'); + document.body.appendChild(panel); + return panel; +} + +/** + * Whether this record is still the live render for its slot — i.e. the entry + * `window.tsjs.renders` currently holds. Every other row in the log has been + * superseded by a later render of the same slot. + * + * Compares by object identity, not by `seq`: the registry and the history hold + * the same record objects, so identity is exact regardless of how sequence + * numbers were allocated. + */ +function isCurrentRender(record: RenderRecord): boolean { + try { + return window.tsjs?.renders?.[record.slotId] === record; + } catch { + return false; + } +} + +/** GAM/injection state summary for the panel's detail line. */ +function stateSummary(record: RenderRecord): string { + const parts: string[] = []; + // GAM's own fill signal, on every render path that has one. Gating this on + // `ssat` would hide it for `gam-refresh`, where "did GAM fill it this time" + // is the whole question. + if (record.gamEmpty !== undefined) { + parts.push(`gam:${record.gamEmpty ? 'empty' : 'filled'}`); + } + if (record.injected !== undefined) { + parts.push(`inj:${record.injected ? 'y' : 'n'}`); + } + parts.push(`vis:${record.visible === false ? 'n' : record.visible ? 'y' : '?'}`); + return parts.join(' · '); +} + +/** + * Copy a record's full JSON to the clipboard and log it — used by the panel's + * click-to-copy so full (untruncated) auction IDs and hashes are debuggable + * without hovering the title or digging in `window.tsjs.renders`. + */ +function copyRecord(record: RenderRecord): void { + const json = JSON.stringify(record, null, 2); + log.info('trace: render record', record); + try { + void navigator.clipboard?.writeText(json); + } catch { + // Clipboard unavailable (insecure context / permissions) — the console + // log above is the fallback. + } +} + +/** Build one slot row for the panel. */ +function buildPanelRow(record: RenderRecord): HTMLElement { + const status = panelStatus(record); + const style = STATUS_STYLE[status]; + + const row = document.createElement('div'); + const rs = row.style; + rs.setProperty('padding', '6px 10px'); + rs.setProperty('border-top', '1px solid #2a2a2a'); + rs.setProperty('border-left', `3px solid ${style.color}`); + rs.setProperty('cursor', 'pointer'); + // Click a row to copy its full record (untruncated IDs/hash) + log it. + row.addEventListener('click', () => copyRecord(record)); + row.title = [ + `render: #${record.seq}`, + `slot: ${record.slotId}`, + `status: ${style.label}`, + `path: ${record.path}`, + `rendered (gam non-empty): ${record.rendered}`, + `gam_empty: ${record.gamEmpty ?? '—'}`, + `injected (ts placed): ${record.injected ?? '—'}`, + `visible: ${record.visible ?? '—'}`, + `auction: ${record.auctionId ?? '—'}`, + `bidder: ${record.bidder ?? '—'}`, + `creative: ${record.creativeId ?? '—'}`, + `ad_id: ${record.adId ?? '—'}`, + `bid_id: ${record.bidId ?? '—'}`, + `adm_hash: ${record.admHash ?? '—'}`, + `served: ${record.servedFrom ?? '—'}`, + `element: ${record.elementId ?? '—'}`, + `renders: ${record.count}`, + ].join('\n'); + + const line1 = document.createElement('div'); + const clock = new Date(record.at).toLocaleTimeString('en-GB', { hour12: false }); + // `current` marks the row still on screen for its slot — the one whose badge, + // if any, is the badge you are looking at. Older rows are history. + const current = isCurrentRender(record) ? ' ◂ current' : ''; + line1.textContent = `#${record.seq} ${clock} ${style.mark} ${record.slotId} · ${style.label}${current}`; + line1.style.setProperty('font-weight', '600'); + line1.style.setProperty('color', style.color); + + const line2 = document.createElement('div'); + line2.style.setProperty('color', '#bbb'); + // An unattributed render (a GAM refresh TS ran no auction for) carries no + // bidder or hash by design. Say that, rather than rendering `? · ?` as if a + // lookup had failed. + const attribution = + record.bidder || record.admHash + ? `${record.bidder ?? '?'} · ${short(record.admHash)}` + : 'no TS attribution'; + line2.textContent = `${record.path}${mechanismSuffix(record)} · ${attribution}`; + + const line3 = document.createElement('div'); + line3.style.setProperty('color', '#777'); + const auction = record.auctionId ? ` · auction ${short(record.auctionId)}` : ''; + // `×N` is this slot's own render count — distinct from the page-global `#seq` + // on line 1, which is what the on-creative badge shows. + line3.textContent = `${stateSummary(record)}${auction} · ×${record.count}`; + + row.append(line1, line2, line3); + return row; +} + +/** + * Rebuild the floating trace panel from `window.tsjs.renders`. + * + * Reads the whole registry each call so the panel always reflects the current + * state; safe to call on every render event. + */ +export function renderTracePanel(): void { + try { + if (!traceOverlayEnabled()) return; + const panel = ensureTracePanel(); + if (!panel) return; + + const renders = window.tsjs?.renders ?? {}; + const slots = Object.values(renders); + // Count only slots that are honestly OK (TS creative placed and visible), + // not merely "GAM said something rendered" — the whole point of the fix. + const ok = slots.filter((r) => panelStatus(r) === 'ok').length; + // Newest render first: on a page that refreshes its slots this reads as a + // timeline rather than a set of counters. + const history = [...(window.tsjs?.renderLog ?? [])].reverse(); + + panel.replaceChildren(); + + const header = document.createElement('div'); + const hs = header.style; + hs.setProperty('display', 'flex'); + hs.setProperty('justify-content', 'space-between'); + hs.setProperty('align-items', 'center'); + hs.setProperty('gap', '8px'); + hs.setProperty('padding', '6px 10px'); + hs.setProperty('position', 'sticky'); + hs.setProperty('top', '0'); + hs.setProperty('background', '#000'); + hs.setProperty('font-weight', '700'); + + const title = document.createElement('span'); + title.textContent = `TS Render Trace · ${ok}/${slots.length} slots ok · ${history.length} renders`; + + const close = document.createElement('button'); + close.textContent = '×'; + close.setAttribute('aria-label', 'Close trace panel'); + const cs = close.style; + cs.setProperty('background', 'transparent'); + cs.setProperty('color', '#eee'); + cs.setProperty('border', '0'); + cs.setProperty('font-size', '14px'); + cs.setProperty('cursor', 'pointer'); + cs.setProperty('line-height', '1'); + close.addEventListener('click', () => panel.remove()); + + header.append(title, close); + panel.appendChild(header); + + const hint = document.createElement('div'); + hint.style.setProperty('padding', '2px 10px 4px'); + hint.style.setProperty('color', '#777'); + hint.style.setProperty('font-size', '9px'); + hint.textContent = 'newest first · click a row to copy its full record · hover for detail'; + panel.appendChild(hint); + + if (history.length === 0) { + const empty = document.createElement('div'); + empty.style.setProperty('padding', '6px 10px'); + empty.style.setProperty('color', '#bbb'); + empty.textContent = 'No creatives traced yet.'; + panel.appendChild(empty); + return; + } + + for (const record of history) { + panel.appendChild(buildPanelRow(record)); + } + } catch (err) { + log.warn('trace: failed to render panel', err); + } +} + +/** + * Write a render record into `window.tsjs.renders` and fire the render event. + * + * Repeated records for the same slot (SPA navigation, GPT refresh) overwrite + * the previous entry and increment `count`, so the registry always reflects + * the latest render while preserving how many renders the slot has seen. + * When the trace overlay is armed, the floating panel is refreshed here — the + * single choke point every render passes through. + */ +export function recordRender(record: Omit): RenderRecord { + const full: RenderRecord = { ...record, count: 1, seq: nextRenderSeq(), at: Date.now() }; + try { + const ts = (window.tsjs ??= {} as TsjsApi); + const renders = (ts.renders ??= {}); + const prev = renders[record.slotId]; + if (prev) full.count = prev.count + 1; + renders[record.slotId] = full; + + // Keep each render as its own history entry, trimmed from the front. + const history = (ts.renderLog ??= []); + history.push(full); + if (history.length > MAX_RENDER_LOG_ENTRIES) { + history.splice(0, history.length - MAX_RENDER_LOG_ENTRIES); + } + } catch (err) { + log.warn('trace: failed to write render record', { slotId: record.slotId, err }); + } + try { + window.dispatchEvent(new CustomEvent(RENDER_EVENT_NAME, { detail: full })); + } catch (err) { + // CustomEvent unavailable — registry entry above is still written. + log.debug('trace: failed to dispatch render event', { slotId: record.slotId, err }); + } + renderTracePanel(); + return full; +} + +/** + * Fields a later signal about an already-recorded render may contribute. + * Identity (`slotId`) and bookkeeping (`seq`, `count`, `at`) are fixed at + * [`recordRender`] time and are never revised. + */ +export type RenderUpdate = Partial>; + +/** Confirmation flags that a later, weaker signal must never clear. */ +const CONFIRMATION_FIELDS = ['rendered', 'injected'] as const; + +/** + * Merge a later signal into an existing render record, **in place**. + * + * One impression can be observed twice: GAM's `slotRenderEnded` and the Prebid + * Universal Creative bridge both describe the same GAM ad request, and a + * deferred ADM placement resolves an animation frame after the render was first + * recorded. Appending a second [`recordRender`] for those would inflate the + * slot's `count`, the history length, the page-global sequence numbers and the + * panel totals — one impression must stay one row. + * + * So the later signal enriches the record instead: `seq`, `count` and `at` are + * left untouched and no new history entry is appended. Because the registry and + * the history hold the *same* object, mutating it updates both. + * + * Confirmations only ever strengthen. A `false` in `patch` cannot clear a + * `true` already on the record, so the weaker GAM-only signal arriving after + * the bridge's confirmed placement does not erase it. + */ +export function updateRender(record: RenderRecord, patch: RenderUpdate): RenderRecord { + try { + const fields = record as unknown as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if ( + value === false && + fields[key] === true && + (CONFIRMATION_FIELDS as readonly string[]).includes(key) + ) { + continue; + } + fields[key] = value; + } + } catch (err) { + log.warn('trace: failed to update render record', { slotId: record.slotId, err }); + } + try { + window.dispatchEvent(new CustomEvent(RENDER_EVENT_NAME, { detail: record })); + } catch (err) { + // CustomEvent unavailable — the mutated record above still stands. + log.debug('trace: failed to dispatch render update event', { slotId: record.slotId, err }); + } + renderTracePanel(); + return record; +} + +/** + * Stamp an element with `data-ts-*` attributes carrying the trace tuple, so + * a creative in the DOM can be joined to the server-side `auction winner:` / + * `auction delivered creative:` log lines by inspection alone. + * + * Attributes whose record field is absent are removed, so a re-render of the + * same element (SPA navigation, GPT refresh) never leaves stale values from a + * previous auction next to the new ones. These attributes live on the element + * itself, so they survive a later `innerHTML = ''` that clears the slot's + * children (e.g. the GAM adm interceptor) — unlike a child badge would. + */ +export function stampCreativeTrace(el: Element, record: RenderRecord): void { + const attrs: Array<[string, string | undefined]> = [ + ['data-ts-slot-id', record.slotId], + ['data-ts-render-path', record.path], + ['data-ts-rendered', String(record.rendered)], + ['data-ts-auction-id', record.auctionId], + ['data-ts-bidder', record.bidder], + ['data-ts-ad-id', record.adId], + ['data-ts-bid-id', record.bidId], + ['data-ts-creative-id', record.creativeId], + ['data-ts-adm-hash', record.admHash], + ['data-ts-served-from', record.servedFrom], + ['data-ts-gam-empty', record.gamEmpty === undefined ? undefined : String(record.gamEmpty)], + ['data-ts-injected', record.injected === undefined ? undefined : String(record.injected)], + ['data-ts-visible', record.visible === undefined ? undefined : String(record.visible)], + ]; + try { + for (const [name, value] of attrs) { + if (value !== undefined && value !== '') { + el.setAttribute(name, value); + } else { + el.removeAttribute(name); + } + } + // Badge any slot that actually shows something, carrying its honest status + // colour: green ✓ for a confirmed TS render, blue ◐ for `gam-only` (GAM + // rendered, TS cannot confirm it as its own). Slots with nothing on screen + // (`empty`) or nothing visible (`hidden`) stay unbadged — there is no + // creative there to label. Never badge the iframe itself. + // + // Any previous badge is dropped first, unconditionally, so a slot that + // re-renders into `empty` or `hidden` sheds the badge from its last render + // instead of contradicting the panel. + if (el instanceof HTMLElement && el.tagName !== 'IFRAME') { + removeTraceBadge(el); + const status = panelStatus(record); + if (traceOverlayEnabled() && (status === 'ok' || status === 'gam-only')) { + attachTraceBadge(el, record); + } + } + } catch (err) { + log.warn('trace: failed to stamp element', { slotId: record.slotId, err }); + } +} diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 360e2aa4..767a111a 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -42,6 +42,8 @@ export interface AuctionDebugBidData { nurl?: string | null; burl?: string | null; ad_id?: string | null; + bid_id?: string | null; + crid?: string | null; cache_id?: string | null; cache_host?: string | null; cache_path?: string | null; @@ -55,6 +57,23 @@ export interface AuctionBidData { hb_adid?: string; hb_cache_host?: string; hb_cache_path?: string; + /** + * Upstream OpenRTB `bid.id` — the trace key that identifies this exact bid in + * the server-side `auction winner:` log line. + * + * Distinct from `hb_adid`, which is whatever value GAM's Universal Creative + * must echo back for the render bridge to find the bid (a PBS cache UUID, an + * `adid`, or — only when neither exists — the bid ID). Carried for tracing + * only: unlike the `hb_*` keys in `TS_BID_TARGETING_KEYS` this is never set as + * GAM key-value targeting. + */ + hb_bid_id?: string; + /** Server-side auction ID — trace key joining this bid to server logs. */ + hb_auction_id?: string; + /** Upstream creative ID (OpenRTB `crid`), when the bidder returned one. */ + hb_crid?: string; + /** Trace hash of the bid's raw creative markup (16 hex chars of SHA-256). */ + hb_adm_hash?: string; nurl?: string; burl?: string; /** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */ @@ -63,6 +82,87 @@ export interface AuctionBidData { debug_bid?: AuctionDebugBidData; } +/** How a creative reached the page for a [`RenderRecord`]. */ +export type RenderServedFrom = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid'; + +/** + * One entry in `window.tsjs.renders` — the client-side half of the render + * trace. Field values mirror the server-side `auction winner:` log line so + * the two can be joined on (auctionId, slotId). + */ +export interface RenderRecord { + /** Slot the creative was rendered for. */ + slotId: string; + /** + * Which render path produced this record. + * + * `ssat` is claimed only for the render that consumes the server-side + * targeting TS just applied — the server-side auction runs once per + * navigation, so a later GAM refresh of the same slot is NOT an SSAT render + * even though `window.tsjs.bids` still holds that auction's data. + * `gam-refresh` is that later render: GAM re-requested the slot and TS cannot + * attribute the returned creative to any TS auction. + */ + path: 'auction' | 'ssat' | 'gam-refresh'; + /** Whether a creative actually rendered (false for empty/rejected). */ + rendered: boolean; + /** Actual DOM element ID the slot resolved to (div_id may be a prefix). */ + elementId?: string; + /** Server-side auction ID. */ + auctionId?: string; + /** Winning bidder / seat. */ + bidder?: string; + /** hb_adid (PBS cache UUID or OpenRTB adid). */ + adId?: string; + /** + * Upstream OpenRTB `bid.id`, completing the + * (auctionId, slotId, bidder, bidId, creativeId, admHash) tuple that joins + * this render to exactly one server-side `auction winner:` log line. Never + * overloaded onto [`adId`], which carries a different value whenever the bid + * has a PBS cache UUID or an `adid`. + */ + bidId?: string; + /** Upstream creative ID (OpenRTB crid). */ + creativeId?: string; + /** Trace hash of the creative markup (16 hex chars of SHA-256). */ + admHash?: string; + /** Mechanism that delivered the creative. */ + servedFrom?: RenderServedFrom; + /** + * GAM's own `slotRenderEnded.isEmpty` (SSAT/GAM path only). `true` means GAM + * itself reported the slot empty. Undefined on the `/auction` path, which + * never involves GAM. + */ + gamEmpty?: boolean; + /** + * Whether Trusted Server actually placed the creative markup itself: + * `true` for the `/auction` iframe render and for a synchronous + * `injectAdmIntoSlot` placement; `false` when TS only applied GAM targeting + * (prod GAM path — the creative, if any, is GAM's and lives in a cross-origin + * iframe TS cannot read); `undefined` when placement was deferred/unknown. + * + * This is the honest "is it TS's creative" signal — distinct from `rendered` + * (GAM said something rendered) and `visible` (the slot box is on-screen). + */ + injected?: boolean; + /** + * Whether the slot element was effectively visible at record time — non-zero + * box and no ancestor `display:none` / `visibility:hidden` / `opacity:0`. + * Catches slots that "rendered" but are hidden behind a publisher reveal gate. + */ + visible?: boolean; + /** How many renders this slot has seen (SPA navigations, refreshes). */ + count: number; + /** + * Page-global render sequence, starting at 1 and shared by the trace panel + * row and the on-creative badge. Unlike `count` (per-slot) this is unique + * across the page, so a badge reading `#12` identifies exactly one row. + */ + seq: number; + /** Epoch ms when the record was written. */ + at: number; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -92,12 +192,34 @@ export interface TsjsApi { bids?: Record; /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ adInit?: () => void; + /** Render-trace registry: latest render per slot (see [`RenderRecord`]). */ + renders?: Record; + /** + * Append-only history of every render, oldest first, bounded to the most + * recent entries. `renders` collapses to one row per slot (useful for + * "did this slot ever render" checks); this keeps each individual render so + * a refreshing page shows a timeline instead of a climbing counter. + */ + renderLog?: RenderRecord[]; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ prevGptSlots?: unknown[]; /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ servicesEnabled?: boolean; /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ divToSlotId?: Record; + /** + * Monotonic render generation, bumped by every `adInit()` and by the start of + * every SPA navigation. Async work captures it and re-checks it on completion + * so a result belonging to a superseded route is discarded rather than + * applied to the current one. + */ + renderGeneration?: number; + /** + * Page-global render counter backing [`RenderRecord.seq`]. Lives here, on the + * object every bundle shares, because each generated IIFE inlines its own + * copy of `core/trace` — a module-scoped counter would restart per bundle. + */ + renderSeq?: number; /** * Win/billing beacons already fired, keyed by `slotId|bidIdentity|kind|url`. * Used by the GPT render bridge so a bid's nurl/burl fire at most once even diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ca468968..5dd06b60 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,17 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; +import { + recordRender, + updateRender, + stampCreativeTrace, + isEffectivelyVisible, +} from '../../core/trace'; +import type { + AuctionSlot, + AuctionBidData, + RenderRecord, + RenderServedFrom, + TsjsApi, +} from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -34,6 +46,159 @@ const TS_BID_TARGETING_KEYS = [ ] as const; const TS_BASE_TARGETING_KEYS = [...TS_BID_TARGETING_KEYS, TS_INITIAL_TARGETING_KEY] as const; +// ---- Orphaned-slot recovery (client re-render / hydration race) ------------ +// A client framework can replace the ad divs *after* GPT slots were bound to +// them: server-rendered React ids (`…-_R_abc_`) are swapped for client ids +// (`…-_r_1_`) during hydration, leaving GPT holding slots whose element no +// longer exists ("defineSlot was called without a corresponding DIV"). GAM +// still fetches a creative for those slots, but it has nowhere to render, so +// the bid is silently wasted. These bound the recovery re-bind. +/** Quiet period after DOM mutations before checking for orphaned slots. */ +const ORPHAN_RECONCILE_DEBOUNCE_MS = 250; +/** How long after an adInit() to keep watching for a re-render. */ +const ORPHAN_RECONCILE_WINDOW_MS = 5000; +/** + * Maximum re-binds per page load. Each re-bind re-requests the affected slots, + * so this is deliberately small: it recovers a hydration swap without letting a + * continuously-mutating page loop on ad requests. + */ +const MAX_ORPHAN_RECONCILE_ATTEMPTS = 2; + +// ---- Render generation ----------------------------------------------------- +// Every `adInit()` and every SPA navigation opens a new generation. Async work +// (a PBS Cache fetch, a debounced orphan re-bind, a GAM render event) captures +// the generation it started in and re-checks it before acting, so a result that +// belongs to a route the page has already left is discarded instead of being +// applied to the route now on screen. + +function currentGeneration(ts: TsjsApi): number { + return ts.renderGeneration ?? 0; +} + +function bumpGeneration(ts: TsjsApi): number { + const next = currentGeneration(ts) + 1; + ts.renderGeneration = next; + return next; +} + +/** Immutable context for one GPT request TS initiated for a slot. */ +interface PendingGptRender { + generation: number; + slotId: string; + bid: AuctionBidData; + attributed: boolean; +} + +/** + * Per-slot FIFO of requests awaiting `slotRenderEnded`. + * + * Publisher-owned GPT slots are reused across SPA routes, so stamping a single + * generation on the slot object is insufficient: route B overwrites that stamp + * before route A's late event arrives. GPT emits one render event per request in + * request order, so retaining each immutable request context lets the old event + * retire only route A's entry while route B's attribution remains queued. + */ +const pendingGptRenders = new WeakMap(); + +function enqueueGptRender(slot: GoogleTagSlot, pending: PendingGptRender): void { + const queue = pendingGptRenders.get(slot) ?? []; + queue.push(pending); + pendingGptRenders.set(slot, queue); +} + +function takeGptRender(slot: GoogleTagSlot): PendingGptRender | undefined { + const queue = pendingGptRenders.get(slot); + const pending = queue?.shift(); + if (queue?.length === 0) pendingGptRenders.delete(slot); + return pending; +} + +/** + * The render record for the GAM impression currently open on a slot. + * + * One GAM ad request is observed twice: `slotRenderEnded` carries GAM's own + * fill signal, and the Prebid Universal Creative bridge carries the proof that + * TS served the markup. They describe the same impression, so whichever arrives + * second enriches the first one's record rather than appending its own — + * otherwise a single creative counts as two renders, inflating the slot's + * `count`, the history length, the page-global sequence numbers and the panel + * totals. + */ +interface OpenImpression { + record: RenderRecord; + /** Generation the impression was opened in. */ + generation: number; + /** `hb_adid` both signals must agree on to be the same impression. */ + adId?: string; + /** Whether `slotRenderEnded` has reported on this impression. */ + gamSeen: boolean; + /** Whether the Universal Creative bridge has reported on this impression. */ + bridgeSeen: boolean; +} + +const openImpressions = new Map(); + +/** PBS Cache fetches still in flight, so navigation can abort them. */ +const inflightCacheFetches = new Set(); + +/** + * Retire everything armed for the route being left. + * + * Runs at the *start* of a navigation, not after `/__ts/page-bids` returns: a + * route whose markup commits faster than that request can otherwise trip the + * orphan watch or land a cache fetch on the new DOM while `ts.adSlots` and + * `ts.bids` still hold the previous route's auction. + */ +function beginNavigation(ts: TsjsApi): void { + bumpGeneration(ts); + stopOrphanWatch(); + for (const controller of inflightCacheFetches) { + controller.abort(); + } + inflightCacheFetches.clear(); + openImpressions.clear(); +} + +/** + * The impression `side` may still enrich, or `undefined` if it must open a new + * one. + * + * Requires the impression to be from the current route, for the same bid, not + * already reported on by this side, and still the slot's live render — the last + * check is what stops a signal that arrives after a *newer* render of the same + * slot from rewriting an impression the page has moved past. + */ +function enrichableImpression( + ts: TsjsApi, + slotId: string, + adId: string | undefined, + side: 'gam' | 'bridge' +): OpenImpression | undefined { + const open = openImpressions.get(slotId); + if (!open) return undefined; + if (open.generation !== currentGeneration(ts)) return undefined; + if (open.adId !== adId) return undefined; + if (side === 'gam' ? open.gamSeen : open.bridgeSeen) return undefined; + if (ts.renders?.[slotId] !== open.record) return undefined; + return open; +} + +function openImpression( + ts: TsjsApi, + slotId: string, + adId: string | undefined, + record: RenderRecord, + side: 'gam' | 'bridge' +): void { + openImpressions.set(slotId, { + record, + generation: currentGeneration(ts), + adId, + gamSeen: side === 'gam', + bridgeSeen: side === 'bridge', + }); +} + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -289,13 +454,29 @@ export function safeAdmIframeSrc(src: string): string | undefined { * 2. Otherwise replace the slot element's content with a sandboxed srcdoc * iframe (no `allow-same-origin` — see [ADM_IFRAME_SANDBOX]). */ -function injectAdmIntoSlot(divId: string, adm: string): void { +/** + * Returns whether the TS creative was placed **synchronously**. The + * animation-frame retry branch resolves after this returns, so it reports + * `false` (placement deferred/unconfirmed) — the render trace must not claim a + * placement that has not happened yet. + * + * That deferred branch reports its real outcome through `onDeferredPlacement` + * once the animation frame runs. Without it a placement that ultimately + * succeeded would stay recorded as unconfirmed and the panel would report + * `gam-only` forever, even though Trusted Server did place the creative. + */ +function injectAdmIntoSlot( + divId: string, + adm: string, + onDeferredPlacement?: (placed: boolean) => void, + mayPlaceDeferred?: () => boolean +): boolean { try { // divId may be the container div (used by GPT slot) or the inner div. // Resolve it the same way the rest of adInit does (exact then prefix) so // a config div_id prefix with a render-time suffix still finds the element. const slotEl = findSlotElementByDivId(divId); - if (!slotEl) return; + if (!slotEl) return false; // Extract the first iframe src from the adm (e.g. mocktioneer creative // wraps a first-party proxy iframe in a div). Reject non-http(s) schemes. @@ -307,27 +488,48 @@ function injectAdmIntoSlot(divId: string, adm: string): void { // Set the GAM iframe src — works even cross-origin (no document access needed). gamIframe.src = innerSrc; log.debug(`[tsjs-gpt] gam-intercept: set iframe src for '${divId}'`); + return true; } else if (innerSrc) { // GAM iframe not yet in DOM (APS renders async after slotRenderEnded). // Retry on next animation frame so APS has a tick to insert its iframe; // if it still isn't there, replace slot content directly. requestAnimationFrame(() => { - const retryIframe = slotEl!.querySelector('iframe') as HTMLIFrameElement | null; - if (retryIframe) { - retryIframe.src = innerSrc; - log.debug(`[tsjs-gpt] gam-intercept: set iframe src (retry) for '${divId}'`); - } else { - slotEl!.innerHTML = ''; - const f = document.createElement('iframe'); - f.style.cssText = 'border:none'; - f.width = String(slotEl!.offsetWidth || 728); - f.height = String(slotEl!.offsetHeight || 90); - f.setAttribute('sandbox', ADM_IFRAME_SANDBOX); - f.src = innerSrc; - slotEl!.appendChild(f); - log.debug(`[tsjs-gpt] gam-intercept: inserted src iframe for '${divId}'`); + let placed = false; + try { + // Validate before touching the DOM. A newer SPA route may reuse the + // same connected publisher slot element, so suppressing only the + // later trace update would still let the old callback overwrite the + // new route's creative. + if (mayPlaceDeferred && !mayPlaceDeferred()) { + onDeferredPlacement?.(false); + return; + } + const retryIframe = slotEl!.querySelector('iframe') as HTMLIFrameElement | null; + if (retryIframe) { + retryIframe.src = innerSrc; + placed = true; + log.debug(`[tsjs-gpt] gam-intercept: set iframe src (retry) for '${divId}'`); + } else { + slotEl!.innerHTML = ''; + const f = document.createElement('iframe'); + f.style.cssText = 'border:none'; + f.width = String(slotEl!.offsetWidth || 728); + f.height = String(slotEl!.offsetHeight || 90); + f.setAttribute('sandbox', ADM_IFRAME_SANDBOX); + f.setAttribute('data-ts-injected-adm', 'true'); + f.src = innerSrc; + slotEl!.appendChild(f); + placed = true; + log.debug(`[tsjs-gpt] gam-intercept: inserted src iframe for '${divId}'`); + } + } catch (err) { + log.warn('[tsjs-gpt] gam-intercept: deferred placement failed', err); } + onDeferredPlacement?.(placed); }); + // Placement deferred to the animation frame — not confirmed yet. The + // callback above corrects the record once it resolves. + return false; } else { // No extractable safe src — replace slot content with a sandboxed srcdoc iframe. slotEl.innerHTML = ''; @@ -336,12 +538,15 @@ function injectAdmIntoSlot(divId: string, adm: string): void { f.width = String(slotEl.offsetWidth || 728); f.height = String(slotEl.offsetHeight || 90); f.setAttribute('sandbox', ADM_IFRAME_SANDBOX); + f.setAttribute('data-ts-injected-adm', 'true'); f.srcdoc = adm; slotEl.appendChild(f); log.debug(`[tsjs-gpt] gam-intercept: replaced slot content for '${divId}'`); + return true; } } catch (err) { log.warn('[tsjs-gpt] gam-intercept: error injecting adm', err); + return false; } } @@ -445,6 +650,88 @@ function installInitialLoadDetector(ts: TsjsApi): void { }); } +// Orphan-watch state. Module-scoped so a re-bind cannot stack observers, and +// so the attempt budget is shared across the page load rather than reset by the +// adInit() the watcher itself triggers. +let orphanObserver: MutationObserver | null = null; +let orphanDebounceTimer: ReturnType | undefined; +let orphanWindowTimer: ReturnType | undefined; +let orphanReconcileAttempts = 0; + +function stopOrphanWatch(): void { + orphanObserver?.disconnect(); + orphanObserver = null; + clearTimeout(orphanDebounceTimer); + clearTimeout(orphanWindowTimer); +} + +/** + * TS-defined GPT slots whose bound element is no longer in the document. + * + * Exported for testing. + */ +export function orphanedTsSlots(ts: TsjsApi): GoogleTagSlot[] { + return ((ts.prevGptSlots ?? []) as GoogleTagSlot[]).filter((slot) => { + const elementId = slot?.getSlotElementId?.(); + return !!elementId && !document.getElementById(elementId); + }); +} + +/** + * Watch for a client re-render that orphans TS's GPT slots and re-bind once it + * happens. + * + * Waiting for the divs to merely *exist* (as the SPA hook does) cannot help + * here: at `adInit()` time the server-rendered divs are present — they are + * later *replaced*. So instead of delaying the initial ad request, this detects + * the swap after the fact and re-runs `adInit()`, which destroys the orphaned + * slots and re-binds against the live DOM (reusing the publisher's own slot for + * that div when they have since defined one). + * + * Bounded by {@link MAX_ORPHAN_RECONCILE_ATTEMPTS} and + * {@link ORPHAN_RECONCILE_WINDOW_MS} so a page whose DOM never settles cannot + * spin on ad requests. + * + * Scoped to the generation it was armed in. SPA navigation is exactly the kind + * of DOM churn this observer reacts to, so without that scope a route change + * whose markup commits faster than `/__ts/page-bids` responds would trip the + * debounce while `ts.adSlots`/`ts.bids` still describe the *previous* route — + * re-binding the finished auction onto the new route's divs, issuing another + * billable GAM request, and burning the recovery budget on an ordinary + * navigation. `beginNavigation()` also disconnects it outright. + */ +function watchForOrphanedSlots(ts: TsjsApi): void { + if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return; + // Re-arm: a previous window may still be open from an earlier adInit(). + stopOrphanWatch(); + if (orphanReconcileAttempts >= MAX_ORPHAN_RECONCILE_ATTEMPTS) return; + + const generation = currentGeneration(ts); + orphanObserver = new MutationObserver(() => { + clearTimeout(orphanDebounceTimer); + orphanDebounceTimer = setTimeout(() => { + // A navigation (or a newer adInit) has superseded the state this watch + // was armed for — recovering against it would apply the old route. + if (currentGeneration(ts) !== generation) { + stopOrphanWatch(); + return; + } + const orphans = orphanedTsSlots(ts); + if (orphans.length === 0) return; + orphanReconcileAttempts += 1; + log.warn( + `[tsjs-gpt] ${orphans.length} TS slot(s) orphaned by a DOM re-render; re-binding`, + orphans.map((slot) => slot.getSlotElementId?.()) + ); + // Stop before re-running: adInit() re-arms the watch itself. + stopOrphanWatch(); + ts.adInit?.(); + }, ORPHAN_RECONCILE_DEBOUNCE_MS); + }); + orphanObserver.observe(document.documentElement, { childList: true, subtree: true }); + orphanWindowTimer = setTimeout(stopOrphanWatch, ORPHAN_RECONCILE_WINDOW_MS); +} + export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); installInitialLoadDetector(ts); @@ -458,6 +745,10 @@ export function installTsAdInit(): void { if (!g) return; g.cmd?.push(() => { + // A new ad-init opens a new generation: everything armed by the previous + // one (SSAT attribution, orphan watch, in-flight bridge fetches) belongs + // to state this call is about to replace. + const generation = bumpGeneration(ts); // Destroy previously defined TS slots before redefining for the new page. if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { g.destroySlots?.(ts.prevGptSlots as GoogleTagSlot[]); @@ -541,6 +832,25 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); + // Arm SSAT attribution for the next render of this slot only. Without + // this, every later publisher refresh would still read the page-load + // bid out of ts.bids and claim the (long finished) server-side auction + // rendered it. + // + // The tuple is snapshotted here rather than re-read from `ts.bids` when + // the render event arrives: by then a newer navigation may have + // replaced `bids`, and stamping this render with that route's auction + // would both mislabel it and leave the real render of that auction + // demoted to `gam-refresh`. + const armed = TS_BID_TARGETING_KEYS.some((key) => Boolean(bid[key])); + enqueueGptRender(gptSlot, { + generation, + slotId: slot.id, + // Snapshot request data instead of reading a newer route's `ts.bids` + // when this request's render event eventually arrives. + bid: { ...bid }, + attributed: armed, + }); // Map both inner div and container div → slot ID so slotRenderEnded // (which reports the GPT slot's div, i.e. slotDivId/container) can look up // the slot, while adm injection (which targets the inner div) also works. @@ -588,17 +898,91 @@ export function installTsAdInit(): void { g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { const divId: string = event.slot?.getSlotElementId?.() ?? ''; - const slotId = (ts.divToSlotId ?? {})[divId]; + const pending = takeGptRender(event.slot); + if (pending && pending.generation !== currentGeneration(ts)) { + log.debug('[tsjs-gpt] ignoring slotRenderEnded from a superseded route', { divId }); + return; + } + + const slotId = pending?.slotId ?? (ts.divToSlotId ?? {})[divId]; if (!slotId) return; - // Read ts.bids live (not the snapshot above) so post-navigation bid data is used. - const bid = (ts.bids ?? {})[slotId] ?? {}; + const bid = pending?.bid ?? (ts.bids ?? {})[slotId] ?? {}; + + // Trace: registry entry + DOM markers joining the GAM render to the + // server-side auction bid. `rendered` is GAM's own non-empty signal; + // `injected`/`visible` carry the honest "is the TS creative actually + // on screen" state so the panel does not overclaim (a non-empty GAM + // slot is not proof the TS creative rendered). + const slotEl = document.getElementById(divId); + const eventGeneration = currentGeneration(ts); + + // The server-side auction runs once per navigation. Only the render + // that consumes the targeting adInit just applied may be attributed + // to it; a later publisher refresh re-requests GAM on its own and the + // creative it returns has no traceable link to any TS auction, so no + // bid tuple is stamped. Single-shot: the armed tuple is consumed here. + const attributed = pending?.attributed + ? { + path: 'ssat' as const, + auctionId: bid.hb_auction_id, + bidder: bid.hb_bidder, + adId: bid.hb_adid, + bidId: bid.hb_bid_id, + creativeId: bid.hb_crid, + admHash: bid.hb_adm_hash, + } + : { path: 'gam-refresh' as const }; + + // The record this event belongs to, resolved below. Captured by the + // deferred-placement callback, which fires an animation frame later. + let record: RenderRecord | undefined; // GAM interceptor (testing): when adm is present, replace the GAM creative. // Adapted from PR #241 — uses window.tsjs.bids[slotId].adm instead of pbjs. // Only active when inject_adm_for_testing injects adm into bids server-side. - if (bid.adm) { - injectAdmIntoSlot(divId, bid.adm); + // Run before recording so the trace reflects the post-injection state. + // No adm to inject means TS only applied GAM targeting: whatever GAM + // rendered lives in a cross-origin iframe TS cannot read, so this is + // explicitly *not* a confirmed TS placement (status: gam-only). + const injected = bid.adm + ? injectAdmIntoSlot( + divId, + bid.adm, + (placed) => { + // The animation-frame retry has resolved. Promote the record + // unless a newer render already replaced this impression. + if (!placed || !record || ts.renders?.[slotId] !== record || !slotEl) return; + updateRender(record, { injected: true, visible: isEffectivelyVisible(slotEl) }); + stampCreativeTrace(slotEl, record); + }, + () => + currentGeneration(ts) === eventGeneration && + !!slotEl?.isConnected && + document.getElementById(divId) === slotEl + ) + : false; + + const gamSignal = { + rendered: !event.isEmpty, + gamEmpty: event.isEmpty, + injected, + visible: isEffectivelyVisible(slotEl), + elementId: divId, + }; + // The bridge may already have opened this impression's record (it + // serves the creative the same GAM request asked for). Enrich that + // record rather than appending a second one for the same impression. + const open = event.isEmpty + ? undefined + : enrichableImpression(ts, slotId, bid.hb_adid, 'gam'); + if (open) { + open.gamSeen = true; + record = updateRender(open.record, gamSignal); + } else { + record = recordRender({ slotId, servedFrom: 'gam', ...gamSignal, ...attributed }); + openImpression(ts, slotId, bid.hb_adid, record, 'gam'); } + if (slotEl) stampCreativeTrace(slotEl, record); }); } @@ -635,6 +1019,12 @@ export function installTsAdInit(): void { ts.adInitRefreshInProgress = false; } } + + // Only TS-defined slots can be orphaned by a re-render — publisher-owned + // slots are theirs to manage, and TS never destroys them. + if (newSlots.length > 0) { + watchForOrphanedSlots(ts); + } }); }; } @@ -724,6 +1114,10 @@ export function installSpaAuctionHook(): void { if (path === currentPath) return; currentPath = path; inflight?.abort(); + // Retire the route being left before awaiting anything. The new DOM can + // commit long before page-bids answers, and until then every watcher and + // in-flight render still refers to the previous route's auction. + beginNavigation(ts); const controller = new AbortController(); inflight = controller; @@ -836,6 +1230,100 @@ const TS_DISPLAY_RENDERER = * Lives in gpt/index.ts (not prebid/index.ts) to avoid pulling the full * Prebid bundle into tsjs-gpt.js via inlineDynamicImports. */ +/** + * Trace a creative served by the pbRender bridge: registry entry + DOM markers + * on the slot element. `servedFrom` distinguishes debug adm injection from a + * PBS Cache fetch so verification tooling knows which mechanism delivered the + * markup into the GAM iframe. + * + * `el` must be resolved by the caller at message-receipt time: the PBS Cache + * path stamps only after an async fetch, and re-resolving from live + * `tsjs.adSlots`/DOM at that point could stamp a *new* route's slot with the + * previous page's trace data after an SPA navigation. The connectivity check + * below drops the stamp when the captured element has left the document. + */ +function recordBridgeRender( + ts: TsjsApi, + slotId: string, + bid: AuctionBidData, + servedFrom: RenderServedFrom, + el: HTMLElement | null +): void { + // The bridge serves TS's own markup into the Prebid Universal Creative, so + // this is a confirmed TS placement (injected: true). + const bridgeSignal = { + rendered: true, + injected: true, + visible: isEffectivelyVisible(el), + elementId: el?.id, + servedFrom, + }; + // `slotRenderEnded` may already have opened this impression's record for the + // same GAM request. Enrich it — appending here would count one creative as + // two renders, and this confirmed placement must not be a separate row from + // the GAM fill signal describing it. + const open = enrichableImpression(ts, slotId, bid.hb_adid, 'bridge'); + let record: RenderRecord; + if (open) { + open.bridgeSeen = true; + record = updateRender(open.record, bridgeSignal); + } else { + record = recordRender({ + slotId, + path: 'ssat', + auctionId: bid.hb_auction_id, + bidder: bid.hb_bidder, + adId: bid.hb_adid, + bidId: bid.hb_bid_id, + creativeId: bid.hb_crid, + admHash: bid.hb_adm_hash, + ...bridgeSignal, + }); + openImpression(ts, slotId, bid.hb_adid, record, 'bridge'); + } + if (el && el.isConnected) stampCreativeTrace(el, record); +} + +/** + * Whether a PBS Cache result may still be applied when its fetch resolves. + * + * The fetch is started for one specific impression on one specific route, but + * settles arbitrarily later. By then an SPA navigation may have rebound the + * slot to a new auction — writing the record anyway would make a finished + * auction the slot's current render, fire a render event for a creative nobody + * can see, and stamp the new route's element with the old route's tuple. + * + * So all four anchors of that impression must still hold: the route it started + * in, the element it resolved to, the message source still living under that + * same slot, and the slot still showing the very bid it was fetched for. + */ +function bridgeResultStillCurrent( + ts: TsjsApi, + generation: number, + slotId: string, + bid: AuctionBidData, + el: HTMLElement | null, + source: MessageEventSource | null +): boolean { + if (currentGeneration(ts) !== generation) return false; + if (!el?.isConnected) return false; + if (slotIdForMessageSource(source) !== slotId) return false; + const live = (ts.bids ?? {})[slotId]; + if (!live) return false; + return ( + live.hb_adid === bid.hb_adid && + live.hb_auction_id === bid.hb_auction_id && + live.hb_bid_id === bid.hb_bid_id && + live.hb_bidder === bid.hb_bidder && + live.hb_crid === bid.hb_crid && + live.hb_adm_hash === bid.hb_adm_hash && + live.hb_cache_host === bid.hb_cache_host && + live.hb_cache_path === bid.hb_cache_path && + live.nurl === bid.nurl && + live.burl === bid.burl + ); +} + export function installTsRenderBridge(): void { if (typeof window === 'undefined') return; @@ -886,28 +1374,39 @@ export function installTsRenderBridge(): void { // creative/dimensions while firing slot B's win/billing beacons. if (slotId !== sourceSlotId) return; - const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); + const ts = (window.tsjs ??= {} as TsjsApi); + // Snapshot every field the asynchronous cache completion will consume. + const capturedBid: AuctionBidData = { ...matchedBid }; + const slot = ts.adSlots?.find((s) => s.id === slotId); const [width, height] = slot?.formats?.[0] ?? [728, 90]; - - if (matchedBid.adm) { + // Resolve the slot element now, at message-receipt time: the PBS Cache + // branch stamps after an async fetch, and by then an SPA navigation may + // have swapped tsjs.adSlots/DOM for a new route with the same slot IDs. + const slotEl = slot ? findSlotElementByDivId(slot.div_id) : null; + // The route this render belongs to, re-checked when the async fetch below + // resolves. + const generation = currentGeneration(ts); + + if (capturedBid.adm) { e.stopImmediatePropagation(); port.postMessage( JSON.stringify({ message: 'Prebid Response', adId, - ad: matchedBid.adm, + ad: capturedBid.adm, renderer: TS_DISPLAY_RENDERER, width, height, }) ); - fireWinBillingBeacons(slotId, matchedBid); + fireWinBillingBeacons(slotId, capturedBid); + recordBridgeRender(ts, slotId, capturedBid, 'debug-adm', slotEl); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from debug adm`); return; } // No TS render source — let Prebid.js handle it. - if (!matchedBid.hb_cache_host || !matchedBid.hb_cache_path) return; + if (!capturedBid.hb_cache_host || !capturedBid.hb_cache_path) return; // TS owns this adId — stop Prebid from also processing it. e.stopImmediatePropagation(); @@ -917,11 +1416,20 @@ export function installTsRenderBridge(): void { if (renderingAdIds.has(adId)) return; renderingAdIds.add(adId); - const cacheUrl = `https://${matchedBid.hb_cache_host}${matchedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`; + const cacheUrl = `https://${capturedBid.hb_cache_host}${capturedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`; - fetch(cacheUrl, { mode: 'cors' }) + // Abortable so a navigation can cancel a render belonging to the route it + // is leaving instead of letting it land on the new one. + const controller = new AbortController(); + inflightCacheFetches.add(controller); + + fetch(cacheUrl, { mode: 'cors', signal: controller.signal }) .then((res) => (res.ok ? res.text() : Promise.reject(res.status))) .then((ad) => { + if (!bridgeResultStillCurrent(ts, generation, slotId, capturedBid, slotEl, e.source)) { + log.debug(`[tsjs-gpt] pbRender bridge: dropping stale PBS Cache result for '${slotId}'`); + return; + } port.postMessage( JSON.stringify({ message: 'Prebid Response', @@ -932,13 +1440,16 @@ export function installTsRenderBridge(): void { height, }) ); - fireWinBillingBeacons(slotId, matchedBid); + fireWinBillingBeacons(slotId, capturedBid); + recordBridgeRender(ts, slotId, capturedBid, 'pbs-cache', slotEl); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`); }) .catch((err) => { + if (err instanceof DOMException && err.name === 'AbortError') return; log.warn(`[tsjs-gpt] pbRender bridge: PBS Cache fetch failed for '${slotId}'`, err); }) .finally(() => { + inflightCacheFetches.delete(controller); renderingAdIds.delete(adId); }); }); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 342e4038..5db7ff80 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -29,7 +29,8 @@ import './_adapters.generated'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; -import type { AuctionSlot } from '../../core/types'; +import type { AuctionSlot, RenderRecord } from '../../core/types'; +import { recordRender, stampCreativeTrace, isEffectivelyVisible } from '../../core/trace'; import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; @@ -219,6 +220,14 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: bidderCode: bid.seat, meta: { advertiserDomains: bid.adomain, + // Carry the server-side trace tuple through Prebid so the render-trace + // hook can attribute the render to its /auction (see + // installPrebidRenderTrace). Prebid passes `meta` through unchanged. + // `tsBidId` is the bid's own OpenRTB `id`, kept separate from + // `creativeId` (the advertiser's `crid`, reused across bids). + tsAuctionId: bid.auctionId, + tsBidId: bid.bidId, + tsAdmHash: bid.admHash, }, }; }); @@ -920,10 +929,135 @@ function syncPrebidEidsCookie(): void { } } +// --------------------------------------------------------------------------- +// Render trace (client-side /auction path) +// --------------------------------------------------------------------------- + +/** Minimal shape of the bid object Prebid.js attaches to a render event. */ +interface PrebidRenderedBid { + adUnitCode?: string; + bidderCode?: string; + bidder?: string; + creativeId?: string; + meta?: { + tsAuctionId?: unknown; + tsBidId?: unknown; + tsAdmHash?: unknown; + [key: string]: unknown; + }; +} + +/** + * Payload of Prebid's `adRenderSucceeded` (`{ doc, bid, adId }`) and + * `adRenderFailed` (`{ reason, message, bid?, adId? }`) events. + */ +interface PrebidRenderEvent { + bid?: PrebidRenderedBid; + adId?: string; + reason?: string; + message?: string; +} + +/** + * Resolve the on-page element for a `bidWon` ad-unit code. Prebid renders into + * the ad unit's own div; fall back to the `-container` wrapper used by the GPT + * integration when the inner div is not directly addressable. + */ +function findAuctionSlotElement(adUnitCode: string): HTMLElement | null { + if (typeof document === 'undefined') return null; + return (document.getElementById(adUnitCode) ?? + document.getElementById(`${adUnitCode}-container`)) as HTMLElement | null; +} + +/** + * Record a render-trace entry for a completed Prebid render attempt on the + * client-side `/auction` path, distinct from the SSAT/GAM path. + * + * Only server-side (`trustedServer`) bids carry `meta.tsAuctionId` (set in + * {@link auctionBidsToPrebidBids}); client-side bidders lack it and are skipped + * so the panel never attributes a non-TS render to Trusted Server. + * + * `outcome` comes from which event fired. A failed attempt is still recorded — + * silence would read as "never won" — but as `rendered: false, injected: false`, + * which [`panelStatus`] reports as `empty`, so the panel shows a red row rather + * than a confirmed green one. + * + * Exported for unit testing; the render path itself is unaffected (this only + * observes and stamps the DOM). + */ +export function recordPrebidAdRender( + bid: PrebidRenderedBid | undefined, + outcome: 'succeeded' | 'failed' +): RenderRecord | undefined { + if (!bid || typeof bid.adUnitCode !== 'string' || bid.adUnitCode === '') return undefined; + const meta = bid.meta ?? {}; + // Only trace our server-side bids: the trustedServer adapter is the only + // source of meta.tsAuctionId. + if (typeof meta.tsAuctionId !== 'string') return undefined; + + const succeeded = outcome === 'succeeded'; + const el = findAuctionSlotElement(bid.adUnitCode); + const record = recordRender({ + slotId: bid.adUnitCode, + path: 'auction', + rendered: succeeded, + // Prebid rendered the `ad` markup our adapter returned — a confirmed TS + // placement for this path, but only once the render actually succeeded. + injected: succeeded, + visible: succeeded ? isEffectivelyVisible(el) : false, + elementId: el?.id, + auctionId: meta.tsAuctionId, + bidId: typeof meta.tsBidId === 'string' ? meta.tsBidId : undefined, + admHash: typeof meta.tsAdmHash === 'string' ? meta.tsAdmHash : undefined, + bidder: bid.bidderCode ?? bid.bidder, + creativeId: bid.creativeId, + servedFrom: 'prebid', + }); + if (el) stampCreativeTrace(el, record); + return record; +} + +/** + * Install the Prebid render-trace hooks (idempotent). + * + * Listens on `adRenderSucceeded` / `adRenderFailed`, **not** `bidWon`. `bidWon` + * fires when Prebid marks a bid as the auction winner — before it hands the bid + * to a renderer that can still fail asynchronously (`emitAdRenderFail`), so a + * `bidWon` listener would stamp the DOM and light up a confirmed green render + * for a creative that never reached the page. `adRenderSucceeded` is emitted + * only after the render function returned without error, and carries the + * rendered bid on `event.bid`. + * + * Without these hooks only the one-time SSAT auction is traced and the ongoing + * Prebid renders are invisible to the panel. Read-only: the listeners record and + * stamp but never alter Prebid's rendering. + */ +export function installPrebidRenderTrace(): void { + if (typeof window === 'undefined') return; + const p = pbjs as unknown as { + onEvent?: (event: string, handler: (event: PrebidRenderEvent) => void) => void; + __tsRenderTraceInstalled?: boolean; + }; + if (typeof p.onEvent !== 'function' || p.__tsRenderTraceInstalled) return; + p.__tsRenderTraceInstalled = true; + const listen = (name: string, outcome: 'succeeded' | 'failed'): void => { + p.onEvent!(name, (event) => { + try { + recordPrebidAdRender(event?.bid, outcome); + } catch (err) { + log.warn(`[tsjs-prebid] render-trace ${name} failed`, err); + } + }); + }; + listen('adRenderSucceeded', 'succeeded'); + listen('adRenderFailed', 'failed'); +} + // Self-initialize when loaded in a browser (same pattern as other integrations). if (typeof window !== 'undefined') { installPrebidNpm(); installRefreshHandler(); + installPrebidRenderTrace(); // The slim-Prebid lazy loader appends this bundle from a window.load // handler, so `load` may already have fired by the time this code runs — // waiting for it again would skip user ID setup entirely on that path. diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 31e020ef..29555653 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -218,6 +218,42 @@ describe('auction/parseAuctionResponse', () => { expect(bids[0].height).toBe(250); expect(bids[0].adomain).toEqual([]); }); + + it('retains the auction id from the response top-level id', () => { + const body = { + id: 'auction-uuid-1', + seatbid: [{ seat: 'kargo', bid: [{ impid: 'slot-1', price: 1.0, adm: '
A
' }] }], + }; + + const bids = parseAuctionResponse(body); + expect(bids[0].auctionId).toBe('auction-uuid-1'); + expect(bids[0].admHash).toBeUndefined(); + }); + + it('prefers bid-level ext.ts trace fields over the top-level id', () => { + const body = { + id: 'auction-uuid-1', + seatbid: [ + { + seat: 'kargo', + bid: [ + { + id: 'bid-uuid-7', + impid: 'slot-1', + price: 1.0, + adm: '
A
', + ext: { ts: { auction_id: 'auction-uuid-2', adm_hash: 'a1b2c3d4e5f60718' } }, + }, + ], + }, + ], + }; + + const bids = parseAuctionResponse(body); + expect(bids[0].auctionId).toBe('auction-uuid-2'); + expect(bids[0].bidId).toBe('bid-uuid-7'); + expect(bids[0].admHash).toBe('a1b2c3d4e5f60718'); + }); }); describe('auction/sendAuction', () => { diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 2c56361d..07559206 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -296,6 +296,113 @@ describe('request.requestAds', () => { ); }); + it('stamps trace markers and records the render in window.tsjs.renders', async () => { + const creativeHtml = '
Traced Creative
'; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + id: 'auction-trace-1', + seatbid: [ + { + seat: 'kargo', + bid: [ + { + impid: 'slot1', + adm: creativeHtml, + crid: 'cr-777', + ext: { ts: { auction_id: 'auction-trace-1', adm_hash: 'a1b2c3d4e5f60718' } }, + }, + ], + }, + ], + }), + }); + + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + const { RENDER_EVENT_NAME } = await import('../../src/core/trace'); + const eventListener = vi.fn(); + window.addEventListener(RENDER_EVENT_NAME, eventListener); + + document.body.innerHTML = '
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + await flushRequestAds(); + + const container = document.querySelector('#slot1') as HTMLElement; + expect(container.getAttribute('data-ts-slot-id')).toBe('slot1'); + expect(container.getAttribute('data-ts-render-path')).toBe('auction'); + expect(container.getAttribute('data-ts-rendered')).toBe('true'); + expect(container.getAttribute('data-ts-auction-id')).toBe('auction-trace-1'); + expect(container.getAttribute('data-ts-bidder')).toBe('kargo'); + expect(container.getAttribute('data-ts-creative-id')).toBe('cr-777'); + expect(container.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); + + const iframe = container.querySelector('iframe') as HTMLIFrameElement; + expect(iframe.getAttribute('data-ts-slot-id')).toBe('slot1'); + expect(iframe.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); + + const record = (window as any).tsjs?.renders?.['slot1']; + expect(record).toEqual( + expect.objectContaining({ + slotId: 'slot1', + path: 'auction', + rendered: true, + elementId: 'slot1', + auctionId: 'auction-trace-1', + bidder: 'kargo', + creativeId: 'cr-777', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'inline', + }) + ); + expect(eventListener).toHaveBeenCalledTimes(1); + + window.removeEventListener(RENDER_EVENT_NAME, eventListener); + }); + + it('records a rendered:false trace entry when the creative is rejected', async () => { + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + id: 'auction-trace-2', + seatbid: [ + { + seat: 'appnexus', + bid: [{ impid: 'slot1', adm: ' ', crid: 'creative-empty' }], + }, + ], + }), + }); + + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + + document.body.innerHTML = '
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + await flushRequestAds(); + + const record = (window as any).tsjs?.renders?.['slot1']; + expect(record).toEqual( + expect.objectContaining({ + slotId: 'slot1', + path: 'auction', + rendered: false, + auctionId: 'auction-trace-2', + }) + ); + // Rejected creative must stamp an explicit rendered:false marker, + // matching the SSAT path's empty-render semantics. + expect(document.querySelector('#slot1')?.getAttribute('data-ts-rendered')).toBe('false'); + }); + it('skips iframe insertion when slot is missing', async () => { // mock fetch for unified auction endpoint - returns inline HTML (globalThis as any).fetch = vi.fn().mockResolvedValue({ diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts new file mode 100644 index 00000000..2c737099 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { + recordRender, + updateRender, + stampCreativeTrace, + traceOverlayEnabled, + renderTracePanel, + RENDER_EVENT_NAME, + TRACE_PANEL_ID, + TRACE_BADGE_CLASS, +} from '../../src/core/trace'; +import type { RenderRecord, TsjsApi } from '../../src/core/types'; + +function clearTraceCookie(): void { + document.cookie = 'ts-trace=; Max-Age=0; Path=/'; +} + +function removePanel(): void { + document.getElementById(TRACE_PANEL_ID)?.remove(); +} + +describe('trace/recordRender', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + clearTraceCookie(); + removePanel(); + }); + + it('writes a render record into window.tsjs.renders', () => { + const record = recordRender({ + slotId: 'slot-1', + path: 'auction', + rendered: true, + elementId: 'slot-1', + auctionId: 'auction-abc', + bidder: 'kargo', + creativeId: 'cr-1', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'inline', + }); + + expect(window.tsjs?.renders?.['slot-1']).toEqual(record); + expect(record.count).toBe(1); + expect(record.at).toBeGreaterThan(0); + }); + + it('overwrites the previous record and increments count on re-render', () => { + recordRender({ slotId: 'slot-1', path: 'ssat', rendered: true, auctionId: 'a-1' }); + const second = recordRender({ + slotId: 'slot-1', + path: 'ssat', + rendered: true, + auctionId: 'a-2', + }); + + const entry = window.tsjs?.renders?.['slot-1']; + expect(entry?.auctionId).toBe('a-2'); + expect(entry?.count).toBe(2); + expect(second.count).toBe(2); + }); + + it('fires a tsjs:adRendered CustomEvent with the record as detail', () => { + const listener = vi.fn(); + window.addEventListener(RENDER_EVENT_NAME, listener); + + const record = recordRender({ slotId: 'slot-ev', path: 'auction', rendered: true }); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail).toEqual(record); + + window.removeEventListener(RENDER_EVENT_NAME, listener); + }); + + it('allocates one sequence across separately bundled IIFEs', async () => { + const buildTraceBundle = (): string => + execFileSync( + './node_modules/.bin/esbuild', + ['--bundle', '--format=iife', '--platform=browser', '--loader=ts'], + { + cwd: process.cwd(), + encoding: 'utf8', + input: + 'import { recordRender } from "./src/core/trace.ts";' + + 'window.__recordFromTraceBundle = recordRender;', + } + ); + + const firstBundle = buildTraceBundle(); + const secondBundle = buildTraceBundle(); + const testWindow = window as typeof window & { + __recordFromTraceBundle?: typeof recordRender; + }; + + Function(firstBundle)(); + const firstRecord = testWindow.__recordFromTraceBundle!; + const first = firstRecord({ slotId: 'iife-a', path: 'auction', rendered: true }); + + Function(secondBundle)(); + const secondRecord = testWindow.__recordFromTraceBundle!; + const second = secondRecord({ slotId: 'iife-b', path: 'ssat', rendered: true }); + + expect(second.seq).toBe(first.seq + 1); + expect(window.tsjs?.renderSeq).toBe(second.seq); + delete testWindow.__recordFromTraceBundle; + }); + + it('enriches an existing impression without changing its bookkeeping', () => { + const original = recordRender({ + slotId: 'slot-enrich', + path: 'ssat', + rendered: true, + injected: false, + servedFrom: 'gam', + }); + const bookkeeping = { + seq: original.seq, + count: original.count, + at: original.at, + historyLength: window.tsjs?.renderLog?.length, + }; + + const updated = updateRender(original, { injected: true, servedFrom: 'pbs-cache' }); + + expect(updated).toBe(original); + expect(window.tsjs?.renders?.['slot-enrich']).toBe(original); + expect(window.tsjs?.renderLog?.[0]).toBe(original); + expect(updated).toEqual(expect.objectContaining({ injected: true, servedFrom: 'pbs-cache' })); + expect({ + seq: updated.seq, + count: updated.count, + at: updated.at, + historyLength: window.tsjs?.renderLog?.length, + }).toEqual(bookkeeping); + }); +}); + +describe('trace/stampCreativeTrace', () => { + it('stamps data-ts-* attributes for present fields only', () => { + const el = document.createElement('div'); + const record: RenderRecord = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + auctionId: 'ts-req-abc', + bidder: 'kargo', + adId: 'cache-uuid-1', + admHash: 'a1b2c3d4e5f60718', + count: 1, + seq: 1, + at: 1, + }; + + stampCreativeTrace(el, record); + + expect(el.getAttribute('data-ts-slot-id')).toBe('slot-1'); + expect(el.getAttribute('data-ts-render-path')).toBe('ssat'); + expect(el.getAttribute('data-ts-rendered')).toBe('true'); + expect(el.getAttribute('data-ts-auction-id')).toBe('ts-req-abc'); + expect(el.getAttribute('data-ts-bidder')).toBe('kargo'); + expect(el.getAttribute('data-ts-ad-id')).toBe('cache-uuid-1'); + expect(el.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); + // creativeId absent — attribute must not exist. + expect(el.hasAttribute('data-ts-creative-id')).toBe(false); + }); + + it('removes stale attributes when a re-render lacks a field', () => { + const el = document.createElement('div'); + const first: RenderRecord = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + auctionId: 'auction-old', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'gam', + count: 1, + seq: 1, + at: 1, + }; + stampCreativeTrace(el, first); + + const second: RenderRecord = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + auctionId: 'auction-new', + count: 2, + seq: 2, + at: 2, + }; + stampCreativeTrace(el, second); + + expect(el.getAttribute('data-ts-auction-id')).toBe('auction-new'); + // The previous auction's hash and mechanism must not survive the re-stamp. + expect(el.hasAttribute('data-ts-adm-hash')).toBe(false); + expect(el.hasAttribute('data-ts-served-from')).toBe(false); + }); +}); + +describe('trace/floating panel', () => { + const record: Omit = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + gamEmpty: false, + auctionId: 'ts-req-abcdef123456', + bidder: 'kargo', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'gam', + }; + + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + clearTraceCookie(); + removePanel(); + }); + + afterEach(() => { + clearTraceCookie(); + removePanel(); + }); + + it('reports the overlay disabled without the ts-trace cookie', () => { + expect(traceOverlayEnabled()).toBe(false); + }); + + it('does not create a panel when the overlay is disarmed', () => { + recordRender(record); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + }); + + it('renders a panel row per traced slot with honest status', () => { + document.cookie = 'ts-trace=1; Path=/'; + // slot-1: TS placed + visible → ok. slot-2: nothing rendered → empty. + recordRender(record); + recordRender({ + slotId: 'slot-2', + path: 'auction', + rendered: false, + injected: false, + visible: false, + bidder: 'appnexus', + }); + + const panel = document.getElementById(TRACE_PANEL_ID); + expect(panel).toBeTruthy(); + // Only slot-1 is honestly ok; slot-2 rendered nothing. + expect(panel!.textContent).toContain('TS Render Trace · 1/2 slots ok'); + expect(panel!.textContent).toContain('✓ slot-1 · ok'); + expect(panel!.textContent).toContain('✗ slot-2 · empty'); + expect(panel!.textContent).toContain('ssat · kargo'); + expect(panel!.textContent).toContain('auction · appnexus'); + }); + + it('marks a rendered-but-hidden slot as hidden, not ok', () => { + document.cookie = 'ts-trace=1; Path=/'; + // GAM rendered non-empty, TS injected, but a reveal gate keeps it hidden. + recordRender({ ...record, visible: false }); + + const panel = document.getElementById(TRACE_PANEL_ID); + expect(panel!.textContent).toContain('0/1 slots ok'); + expect(panel!.textContent).toContain('⚠ slot-1 · hidden'); + }); + + it('marks a targeting-only GAM slot as gam-only, not a confirmed TS render', () => { + document.cookie = 'ts-trace=1; Path=/'; + // GAM rendered something, but TS never placed it (prod targeting path). + recordRender({ ...record, injected: false, gamEmpty: false, visible: true }); + + const panel = document.getElementById(TRACE_PANEL_ID); + expect(panel!.textContent).toContain('0/1 slots ok'); + expect(panel!.textContent).toContain('◐ slot-1 · gam-only'); + }); + + it('never claims ok when a render path did not report placement', () => { + document.cookie = 'ts-trace=1; Path=/'; + // Regression: an unset `injected` must not fall through to ok — that would + // claim a confirmed TS render for a slot TS only targeted. + const { injected: _omitted, ...withoutInjected } = record; + recordRender({ ...withoutInjected, gamEmpty: false, visible: true }); + + const panel = document.getElementById(TRACE_PANEL_ID); + expect(panel!.textContent).toContain('0/1 slots ok'); + expect(panel!.textContent).toContain('◐ slot-1 · gam-only'); + }); + + it('reuses a single panel across renders and reflects the latest count', () => { + document.cookie = 'ts-trace=1; Path=/'; + recordRender(record); + recordRender(record); + + const panels = document.querySelectorAll(`#${TRACE_PANEL_ID}`); + expect(panels).toHaveLength(1); + // Second render of the same slot bumps the count and appends a history row. + expect(panels[0].textContent).toContain('TS Render Trace · 1/1 slots ok'); + expect(panels[0].textContent).toContain('×2'); + }); + + it("keeps GAM's fill signal and drops ? placeholders on an unattributed refresh", () => { + document.cookie = 'ts-trace=1; Path=/'; + // A publisher-driven GAM refresh: TS ran no auction for it, so there is no + // bidder/hash/auction id — but GAM still reported whether it filled, and + // that is the most useful field on the row. + recordRender({ + slotId: 'slot-1', + path: 'gam-refresh', + rendered: true, + gamEmpty: false, + injected: false, + visible: true, + servedFrom: 'gam', + }); + + const panel = document.getElementById(TRACE_PANEL_ID)!; + expect(panel.textContent).toContain('gam:filled'); + expect(panel.textContent).toContain('no TS attribution'); + // Absent attribution must not render as a failed lookup, and an auction + // segment must not appear at all when there is no auction to name. + expect(panel.textContent).not.toContain('· ? ·'); + expect(panel.textContent).not.toContain('auction ?'); + }); + + it('still reports gam:empty for a refresh GAM declined to fill', () => { + document.cookie = 'ts-trace=1; Path=/'; + recordRender({ + slotId: 'slot-1', + path: 'gam-refresh', + rendered: false, + gamEmpty: true, + injected: false, + visible: true, + }); + + const panel = document.getElementById(TRACE_PANEL_ID)!; + expect(panel.textContent).toContain('gam:empty'); + expect(panel.textContent).toContain('✗ slot-1 · empty'); + }); + + it('gives each render a page-global seq the badge and its panel row share', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + el.id = 'slot-el'; + document.body.appendChild(el); + + // Two slots interleaved: seq must be unique page-wide, not per-slot, so a + // badge reading #N identifies exactly one row. + const first = recordRender(record); + const other = recordRender({ ...record, slotId: 'slot-2' }); + expect(other.seq).toBe(first.seq + 1); + + stampCreativeTrace(el, other); + const badge = el.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement; + expect(badge.textContent).toContain(`#${other.seq}`); + // The same number appears on that render's row in the panel. + expect(document.getElementById(TRACE_PANEL_ID)!.textContent).toContain(`#${other.seq}`); + + el.remove(); + }); + + it('marks only the live render for a slot as current', () => { + document.cookie = 'ts-trace=1; Path=/'; + recordRender(record); + const latest = recordRender(record); + + const panel = document.getElementById(TRACE_PANEL_ID)!; + // Both renders are in the log, but only the newest is still on screen. + expect(panel.textContent).toContain(`#${latest.seq}`); + expect(panel.textContent!.match(/◂ current/g)).toHaveLength(1); + }); + + it('uses record identity when duplicate sequence values exist', () => { + document.cookie = 'ts-trace=1; Path=/'; + const oldRecord = { ...record, auctionId: 'auction-old', count: 1, seq: 7, at: 1 }; + const liveRecord = { ...record, auctionId: 'auction-live', count: 2, seq: 7, at: 2 }; + (window as { tsjs?: TsjsApi }).tsjs = { + renders: { 'slot-1': liveRecord }, + renderLog: [oldRecord, liveRecord], + } as unknown as TsjsApi; + + renderTracePanel(); + + const rows = [...document.querySelectorAll(`#${TRACE_PANEL_ID} div[style*="cursor"]`)]; + const oldRow = rows.find((row) => row.getAttribute('title')?.includes('auction: auction-old')); + const liveRow = rows.find((row) => + row.getAttribute('title')?.includes('auction: auction-live') + ); + expect(oldRow?.textContent).not.toContain('◂ current'); + expect(liveRow?.textContent).toContain('◂ current'); + }); + + it('close button removes the panel', () => { + document.cookie = 'ts-trace=1; Path=/'; + recordRender(record); + const panel = document.getElementById(TRACE_PANEL_ID)!; + const close = panel.querySelector('button') as HTMLButtonElement; + close.click(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + }); + + it('renderTracePanel is a no-op while disarmed even if renders exist', () => { + (window as { tsjs?: TsjsApi }).tsjs = { + renders: { 'slot-1': { ...record, count: 1, seq: 1, at: 1 } }, + } as unknown as TsjsApi; + renderTracePanel(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + }); + + it('clicking a row logs the full record', async () => { + document.cookie = 'ts-trace=1; Path=/'; + const { log } = await import('../../src/core/log'); + const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => undefined); + + recordRender(record); + const row = document.getElementById(TRACE_PANEL_ID)!.querySelector('div[style*="cursor"]'); + (row as HTMLElement).click(); + + const call = infoSpy.mock.calls.find(([m]) => m === 'trace: render record'); + expect(call?.[1]).toEqual( + expect.objectContaining({ slotId: 'slot-1', auctionId: record.auctionId }) + ); + infoSpy.mockRestore(); + }); +}); + +describe('trace/confirmation badge', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + clearTraceCookie(); + document.body.innerHTML = ''; + }); + afterEach(() => { + clearTraceCookie(); + document.body.innerHTML = ''; + }); + + const okRecord: RenderRecord = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + gamEmpty: false, + bidder: 'mocktioneer', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'gam', + count: 1, + seq: 1, + at: 1, + }; + + it('badges an ok slot when armed', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + const badge = el.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.textContent).toBe('TS ✓ #1 · mocktioneer'); + }); + + it('does not badge a hidden slot', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, { ...okRecord, visible: false }); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('removes the previous badge when a filled slot becomes empty', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeTruthy(); + + stampCreativeTrace(el, { ...okRecord, rendered: false, injected: false, gamEmpty: true }); + + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('removes the previous badge when a visible slot becomes hidden', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeTruthy(); + + stampCreativeTrace(el, { ...okRecord, visible: false }); + + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('does not badge when the overlay is disarmed', () => { + clearTraceCookie(); + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('never badges an iframe element', () => { + document.cookie = 'ts-trace=1; Path=/'; + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + stampCreativeTrace(iframe, okRecord); + expect(iframe.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + // Attributes still stamped on the iframe though. + expect(iframe.getAttribute('data-ts-slot-id')).toBe('slot-1'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 4a636876..7980cdba 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -488,6 +488,396 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); + it('stamps trace markers and records the render on slotRenderEnded', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'cache-uuid-9', + hb_bid_id: 'bid-uuid-9', + hb_auction_id: 'ts-req-trace9', + hb_crid: 'cr-98765', + hb_adm_hash: 'a1b2c3d4e5f60718', + }, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(capturedListener).toBeDefined(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + + const el = document.getElementById('div-atf-sidebar')!; + expect(el.getAttribute('data-ts-slot-id')).toBe('atf_sidebar_ad'); + expect(el.getAttribute('data-ts-render-path')).toBe('ssat'); + expect(el.getAttribute('data-ts-rendered')).toBe('true'); + expect(el.getAttribute('data-ts-auction-id')).toBe('ts-req-trace9'); + expect(el.getAttribute('data-ts-bidder')).toBe('kargo'); + expect(el.getAttribute('data-ts-ad-id')).toBe('cache-uuid-9'); + expect(el.getAttribute('data-ts-bid-id')).toBe('bid-uuid-9'); + expect(el.getAttribute('data-ts-creative-id')).toBe('cr-98765'); + expect(el.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); + + const record = (window as TestWindow).tsjs!.renders?.['atf_sidebar_ad']; + expect(record).toEqual( + expect.objectContaining({ + slotId: 'atf_sidebar_ad', + path: 'ssat', + rendered: true, + elementId: 'div-atf-sidebar', + auctionId: 'ts-req-trace9', + bidder: 'kargo', + adId: 'cache-uuid-9', + bidId: 'bid-uuid-9', + creativeId: 'cr-98765', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'gam', + }) + ); + + // An empty render must record rendered:false and bump the count. + capturedListener!({ isEmpty: true, slot: mockSlot }); + const second = (window as TestWindow).tsjs!.renders?.['atf_sidebar_ad']; + expect(second?.rendered).toBe(false); + expect(second?.count).toBe(2); + expect(el.getAttribute('data-ts-rendered')).toBe('false'); + }); + + it('drops a late render from a reused publisher slot without consuming the new route', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_bidder: 'route-a', + hb_adid: 'ad-a', + hb_bid_id: 'bid-a', + hb_auction_id: 'auction-a', + }, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + ts.adInit!(); + + ts.bids = { + atf_sidebar_ad: { + hb_bidder: 'route-b', + hb_adid: 'ad-b', + hb_bid_id: 'bid-b', + hb_auction_id: 'auction-b', + }, + }; + ts.adInit!(); + + // Route A's event arrives only after route B has reused and refreshed the + // same publisher-owned GPT slot object. It must be rejected, not stamped + // with route B's tuple or allowed to consume route B's pending request. + capturedListener!({ isEmpty: false, slot: mockSlot }); + expect(ts.renders?.['atf_sidebar_ad']).toBeUndefined(); + + capturedListener!({ isEmpty: false, slot: mockSlot }); + expect(ts.renders?.['atf_sidebar_ad']).toEqual( + expect.objectContaining({ + path: 'ssat', + auctionId: 'auction-b', + bidId: 'bid-b', + bidder: 'route-b', + }) + ); + }); + + it('does not confirm a deferred ADM placement after navigation supersedes it', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + let runDeferredPlacement: FrameRequestCallback | undefined; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + runDeferredPlacement = callback; + return 1; + }) + ); + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_bidder: 'mocktioneer', + hb_adid: 'deferred-ad', + hb_auction_id: 'auction-a', + adm: '', + }, + }, + }; + + try { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + ts.adInit!(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + + const oldRecord = ts.renders?.['atf_sidebar_ad']; + expect(oldRecord?.injected).toBe(false); + expect(runDeferredPlacement).toBeDefined(); + + // A newer route/adInit starts before the animation-frame retry runs while + // retaining the same publisher-owned slot element. The old callback must + // not mutate that shared element before its trace guard runs. + ts.adInit!(); + const reusedSlot = document.getElementById('div-atf-sidebar')!; + runDeferredPlacement!(0); + + expect(oldRecord?.injected).toBe(false); + expect(reusedSlot.querySelector('iframe')).toBeNull(); + expect(reusedSlot.getAttribute('data-ts-injected')).toBe('false'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('confirms a successful deferred ADM placement on the original record', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + let runDeferredPlacement: FrameRequestCallback | undefined; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + runDeferredPlacement = callback; + return 1; + }) + ); + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_adid: 'deferred-ad', + adm: '', + }, + }, + }; + + try { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + ts.adInit!(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + const record = ts.renders?.atf_sidebar_ad; + const originalBookkeeping = { + seq: record?.seq, + count: record?.count, + at: record?.at, + historyLength: ts.renderLog?.length, + }; + + runDeferredPlacement!(0); + + expect(ts.renders?.atf_sidebar_ad).toBe(record); + expect(record?.injected).toBe(true); + expect({ + seq: record?.seq, + count: record?.count, + at: record?.at, + historyLength: ts.renderLog?.length, + }).toEqual(originalBookkeeping); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('does not attribute a later GAM refresh to the finished server-side auction', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_bidder: 'kargo', + hb_adid: 'cache-uuid-9', + hb_auction_id: 'ts-req-trace9', + hb_adm_hash: 'a1b2c3d4e5f60718', + }, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + // First render consumes the targeting adInit applied → attributable. + capturedListener!({ isEmpty: false, slot: mockSlot }); + expect((window as TestWindow).tsjs!.renders?.['atf_sidebar_ad']?.path).toBe('ssat'); + + // A publisher-driven refresh fills the slot again, but the server-side + // auction ran once and is long finished. ts.bids still holds its data — + // re-stamping it would claim a render that auction never produced. + capturedListener!({ isEmpty: false, slot: mockSlot }); + + const refreshed = (window as TestWindow).tsjs!.renders?.['atf_sidebar_ad']; + expect(refreshed?.path).toBe('gam-refresh'); + expect(refreshed?.rendered).toBe(true); + expect(refreshed?.count).toBe(2); + expect(refreshed?.auctionId).toBeUndefined(); + expect(refreshed?.bidder).toBeUndefined(); + expect(refreshed?.admHash).toBeUndefined(); + + // Stale attribution must not survive on the DOM either. + const el = document.getElementById('div-atf-sidebar')!; + expect(el.getAttribute('data-ts-render-path')).toBe('gam-refresh'); + expect(el.hasAttribute('data-ts-auction-id')).toBe(false); + expect(el.hasAttribute('data-ts-adm-hash')).toBe(false); + }); + it('does not fire beacons for an APS-style bid that carries no hb_adid', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); let capturedListener: ((e: SlotRenderEvent) => void) | undefined; @@ -960,7 +1350,9 @@ describe('installTsRenderBridge', () => { expect(fetchStub).toHaveBeenCalledWith( 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } + // Carries an abort signal so a navigation can cancel a render belonging + // to the route it is leaving. + { mode: 'cors', signal: expect.any(AbortSignal) } ); expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); @@ -973,6 +1365,20 @@ describe('installTsRenderBridge', () => { expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); expect(beaconSpy).toHaveBeenCalledTimes(2); + // The PBS Cache branch must trace the same as the debug-adm branch — + // regression coverage for the two recordBridgeRender call sites staying + // in sync (this branch's stamp only lands after the async fetch settles). + const record = (window as TestWindow).tsjs!.renders?.['homepage_header']; + expect(record).toEqual( + expect.objectContaining({ + slotId: 'homepage_header', + path: 'ssat', + rendered: true, + injected: true, + servedFrom: 'pbs-cache', + }) + ); + bridgeListener!( Object.assign(new Event('message'), { data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), @@ -986,6 +1392,80 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('keeps GAM and bridge signals for both arrival orders on one record per impression', async () => { + const source = createTrustedSlotIframe(); + let slotRenderListener: ((event: SlotRenderEvent) => void) | undefined; + const gptSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-header'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([gptSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, listener: (event: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') slotRenderListener = listener; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(gptSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + const ts = (window as TestWindow).tsjs!; + ts.bids.homepage_header = { + hb_adid: 'debug-first', + hb_bidder: 'mocktioneer', + }; + const bridgeListener = await captureBridgeListener(); + ts.adInit!(); + + const sendBridgeRequest = (adId: string): void => { + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId }), + ports: [{ postMessage: vi.fn() }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + }; + + // GAM first, bridge second. + slotRenderListener!({ isEmpty: false, slot: gptSlot }); + const firstRecord = ts.renders?.homepage_header; + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + adm: '
First creative
', + }; + sendBridgeRequest('debug-first'); + expect(ts.renders?.homepage_header).toBe(firstRecord); + expect(firstRecord).toEqual( + expect.objectContaining({ count: 1, injected: true, servedFrom: 'debug-adm' }) + ); + expect(ts.renderLog).toHaveLength(1); + + // Bridge first, GAM second for the next impression. + ts.bids.homepage_header = { + hb_adid: 'debug-second', + hb_bidder: 'mocktioneer', + adm: '', + }; + ts.adInit!(); + sendBridgeRequest('debug-second'); + const secondRecord = ts.renders?.homepage_header; + slotRenderListener!({ isEmpty: false, slot: gptSlot }); + expect(ts.renders?.homepage_header).toBe(secondRecord); + expect(secondRecord).toEqual( + expect.objectContaining({ count: 2, injected: true, gamEmpty: false }) + ); + expect(ts.renderLog).toHaveLength(2); + }); + it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { // Concurrent render double-fire guard: two 'Prebid Request' messages for the // same adId can arrive before the first cache fetch settles. The in-flight @@ -1039,6 +1519,47 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('drops a PBS Cache result when the live bid changed before fetch completion', async () => { + let resolveFetch: (value: Response) => void = () => {}; + fetchStub.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const ts = (window as TestWindow).tsjs!; + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + hb_auction_id: 'auction-1', + hb_bid_id: 'bid-1', + }; + const bridgeListener = await captureBridgeListener(); + const portMessages: string[] = []; + const source = createTrustedSlotIframe(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage: (message: string) => portMessages.push(message) }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + // Same bridge ad ID and auction ID, but a different bid object/trace ID. + // Comparing only hb_adid + hb_auction_id would incorrectly accept the old + // creative and stamp it with the captured route's data. + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + hb_bid_id: 'bid-2', + hb_adm_hash: 'new-creative-hash', + }; + resolveFetch({ ok: true, text: () => Promise.resolve('
Old creative
') } as Response); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(portMessages).toHaveLength(0); + expect(ts.renders?.homepage_header).toBeUndefined(); + }); + it('responds with adm without fetching PBS Cache when debug adm is available', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const debugAdm = '
Debug Creative
'; @@ -1110,6 +1631,21 @@ describe('installTsRenderBridge', () => { expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/bill'); expect(beaconSpy).toHaveBeenCalledTimes(2); beaconSpy.mockRestore(); + + // Serving debug adm through the Universal Creative bridge is a confirmed + // TS placement — same as the PBS Cache branch — and must not be silently + // untraced just because no cache fetch was involved. + const record = (window as TestWindow).tsjs!.renders?.['homepage_header']; + expect(record).toEqual( + expect.objectContaining({ + slotId: 'homepage_header', + path: 'ssat', + rendered: true, + injected: true, + bidder: 'mocktioneer', + servedFrom: 'debug-adm', + }) + ); }); it('falls back to keepalive fetch when sendBeacon is unavailable', async () => { @@ -1301,3 +1837,106 @@ describe('installTsRenderBridge', () => { expect(fetchStub).not.toHaveBeenCalled(); }); }); + +describe('orphaned TS slot recovery', () => { + type TestWin = Window & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tsjs?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + googletag?: any; + }; + + beforeEach(() => { + vi.resetModules(); + const tw = window as TestWin; + delete tw.tsjs; + delete tw.googletag; + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + function slotStub(elementId: string) { + return { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }; + } + + it('reports slots whose bound element left the document', async () => { + const { orphanedTsSlots } = await import('../../../src/integrations/gpt/index'); + document.body.innerHTML = '
'; + + const live = slotStub('live-div'); + const orphan = slotStub('ad-header-0-_R_ssr_'); + const ts = { prevGptSlots: [live, orphan] }; + + const orphans = orphanedTsSlots(ts as never); + expect(orphans).toHaveLength(1); + expect(orphans[0].getSlotElementId()).toBe('ad-header-0-_R_ssr_'); + }); + + it('returns nothing when every TS slot still has its element', async () => { + const { orphanedTsSlots } = await import('../../../src/integrations/gpt/index'); + document.body.innerHTML = '
'; + const ts = { prevGptSlots: [slotStub('a'), slotStub('b')] }; + expect(orphanedTsSlots(ts as never)).toHaveLength(0); + }); + + it('re-runs adInit after a re-render swaps the ad div', async () => { + // SSR div that hydration will replace. + document.body.innerHTML = '
'; + + const definedSlots: string[] = []; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + addEventListener: vi.fn(), + }; + const tw = window as TestWin; + tw.googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn((_path: string, _sizes: unknown, divId: string) => { + definedSlots.push(divId); + return slotStub(divId); + }), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display: vi.fn(), + destroySlots: vi.fn(), + }; + tw.tsjs = { + adSlots: [ + { + id: 'ad-header-0', + gam_unit_path: '/123/header', + div_id: 'ad-header-0', + formats: [[728, 90]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + tw.tsjs.adInit(); + + expect(definedSlots).toEqual(['ad-header-0-_R_ssr_']); + + // Hydration: React replaces the SSR div with a client-id div. + document.body.innerHTML = '
'; + + // The MutationObserver is debounced; give it room to fire. + await new Promise((r) => setTimeout(r, 600)); + + // adInit re-ran and bound to the live div instead of the dead one. + expect(definedSlots).toContain('ad-header-0-_r_1_'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 9a08defc..58291329 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -51,6 +51,7 @@ describe('installSpaAuctionHook', () => { originalReplaceState({}, '', '/'); // Drop any ad containers inserted by a test so DOM state does not leak. document.body.innerHTML = ''; + delete (window as TestWindow).googletag; // Remove this test's popstate listener(s) so they do not fire in later tests. popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); popstateHandlers = []; @@ -304,6 +305,59 @@ describe('installSpaAuctionHook', () => { expect(adInit).toHaveBeenCalledTimes(1); }); + it('stops orphan recovery before a fast route DOM swap can replay old bids', async () => { + document.body.innerHTML = '
'; + const definedDivs: string[] = []; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + addEventListener: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn((_path: string, _sizes: unknown, divId: string) => { + definedDivs.push(divId); + return { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(divId), + getTargeting: vi.fn().mockReturnValue([]), + }; + }), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display: vi.fn(), + destroySlots: vi.fn(), + }; + // Keep page-bids slower than the orphan observer's 250 ms debounce. + fetchStub.mockReturnValue(new Promise(() => {})); + + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + ts.adSlots = [ + { + id: 'ad-header-0', + gam_unit_path: '/123/header', + div_id: 'ad-header-0', + formats: [[728, 90]], + targeting: {}, + }, + ]; + ts.bids = { 'ad-header-0': { hb_adid: 'old-route-ad' } }; + ts.adInit!(); + expect(definedDivs).toEqual(['ad-header-0-_R_old_']); + + history.pushState({}, '', '/new-route'); + document.body.innerHTML = '
'; + await new Promise((resolve) => setTimeout(resolve, 350)); + + // The pending old-route watcher was disconnected synchronously when + // navigation began, so it never rebound or re-requested the old auction. + expect(definedDivs).toEqual(['ad-header-0-_R_old_']); + }); + it('leaves slots and bids untouched on a non-OK response', async () => { fetchStub.mockResolvedValue({ ok: false, status: 500 }); const { installSpaAuctionHook } = await importGptModule(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 726f40b4..4b5fffe5 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -8,6 +8,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -21,6 +22,7 @@ const { () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); const mockGetConfig = vi.fn(); + const mockOnEvent = vi.fn(); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, @@ -28,6 +30,7 @@ const { registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, + onEvent: mockOnEvent, adUnits: [] as any[], }; const mockAdapterManager = { @@ -40,6 +43,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -69,8 +73,11 @@ import { auctionBidsToPrebidBids, installPrebidNpm, installRefreshHandler, + installPrebidRenderTrace, + recordPrebidAdRender, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; +import type { TsjsApi } from '../../../src/core/types'; import { log } from '../../../src/core/log'; describe('prebid/collectBidders', () => { @@ -204,6 +211,184 @@ describe('prebid/auctionBidsToPrebidBids', () => { expect(result[0].requestId).toBe('req-a'); expect(result[1].requestId).toBe('req-b'); }); + + it('forwards the server-side trace tuple into Prebid meta', () => { + const auctionBids: AuctionBid[] = [ + { + impid: 'div-gpt-1', + adm: '
Ad
', + price: 1.0, + width: 300, + height: 250, + seat: 'kargo', + creativeId: 'KM-CREA-1', + adomain: ['kargo.com'], + auctionId: 'ts-auction-xyz', + bidId: 'bid-abc-1', + admHash: 'a1b2c3d4e5f60718', + }, + ]; + + const result = auctionBidsToPrebidBids(auctionBids, []); + + expect(result[0].meta.tsAuctionId).toBe('ts-auction-xyz'); + expect(result[0].meta.tsAdmHash).toBe('a1b2c3d4e5f60718'); + // The bid's own OpenRTB id, distinct from the advertiser creative id. + expect(result[0].meta.tsBidId).toBe('bid-abc-1'); + expect(result[0].creativeId).toBe('KM-CREA-1'); + }); +}); + +describe('prebid/recordPrebidAdRender', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + document.body.innerHTML = ''; + }); + afterEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + document.body.innerHTML = ''; + }); + + it('records an auction-path render for a server-side bid', () => { + document.body.innerHTML = '
'; + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0-_R_x_', + bidderCode: 'kargo', + creativeId: 'KM-CREA-1', + meta: { + tsAuctionId: '265dcedd-aa0a', + tsBidId: 'bid-abc-1', + tsAdmHash: 'f68044ca9f68c88c', + }, + }, + 'succeeded' + ); + + expect(record).toBeDefined(); + expect(record).toEqual( + expect.objectContaining({ + slotId: 'ad-header-0-_R_x_', + path: 'auction', + rendered: true, + injected: true, + auctionId: '265dcedd-aa0a', + bidId: 'bid-abc-1', + admHash: 'f68044ca9f68c88c', + bidder: 'kargo', + creativeId: 'KM-CREA-1', + servedFrom: 'prebid', + elementId: 'ad-header-0-_R_x_', + }) + ); + // Written into the shared registry the panel reads. + expect((window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0-_R_x_']).toBeDefined(); + // The bid id must reach the DOM as its own attribute, never folded into + // data-ts-ad-id. + const el = document.getElementById('ad-header-0-_R_x_')!; + expect(el.getAttribute('data-ts-bid-id')).toBe('bid-abc-1'); + }); + + it('records a failed render as unconfirmed, not as a green render', () => { + document.body.innerHTML = '
'; + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: '265dcedd-aa0a' }, + }, + 'failed' + ); + + expect(record).toEqual( + expect.objectContaining({ rendered: false, injected: false, visible: false }) + ); + }); + + it('skips a bid without the server-side trace tuple (client-side bidder)', () => { + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0', + bidderCode: 'appnexus', + meta: { advertiserDomains: ['x.com'] }, + }, + 'succeeded' + ); + expect(record).toBeUndefined(); + expect((window as { tsjs?: TsjsApi }).tsjs?.renders).toBeUndefined(); + }); + + it('skips a bid with no adUnitCode', () => { + expect(recordPrebidAdRender({ meta: { tsAuctionId: 'x' } }, 'succeeded')).toBeUndefined(); + expect(recordPrebidAdRender(undefined, 'succeeded')).toBeUndefined(); + }); +}); + +describe('prebid/installPrebidRenderTrace', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + document.body.innerHTML = ''; + mockOnEvent.mockReset(); + delete (mockPbjs as { __tsRenderTraceInstalled?: boolean }).__tsRenderTraceInstalled; + }); + afterEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + document.body.innerHTML = ''; + }); + + it('confirms renders from adRenderSucceeded, never from bidWon', () => { + document.body.innerHTML = '
'; + installPrebidRenderTrace(); + + const events = mockOnEvent.mock.calls.map(([name]) => name); + expect(events).toEqual(['adRenderSucceeded', 'adRenderFailed']); + // bidWon fires when a bid is marked the winner — before the renderer runs, + // and so before the render can fail. Confirming on it would show a green + // render for a creative that never reached the page. + expect(events).not.toContain('bidWon'); + + const handlers = Object.fromEntries(mockOnEvent.mock.calls) as Record< + string, + (event: unknown) => void + >; + handlers['adRenderSucceeded']({ + bid: { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: 'auction-success', tsBidId: 'bid-success' }, + }, + }); + expect((window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0']).toEqual( + expect.objectContaining({ rendered: true, injected: true, bidId: 'bid-success' }) + ); + }); + + it('does not produce a confirmed record when the render fails after the win', () => { + document.body.innerHTML = '
'; + installPrebidRenderTrace(); + + const handlers = Object.fromEntries(mockOnEvent.mock.calls) as Record< + string, + (event: unknown) => void + >; + const bid = { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: '265dcedd-aa0a' }, + }; + + // Prebid marks the bid as won, then its renderer fails asynchronously. + handlers['adRenderFailed']({ reason: 'exception', message: 'boom', bid }); + + const record = (window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0']; + expect(record).toEqual( + expect.objectContaining({ rendered: false, injected: false, visible: false }) + ); + // No green badge and no confirmed-render attributes on the slot. + const el = document.getElementById('ad-header-0')!; + expect(el.getAttribute('data-ts-rendered')).toBe('false'); + expect(el.getAttribute('data-ts-injected')).toBe('false'); + }); }); describe('prebid/installPrebidNpm', () => { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 26d95d68..126efbf0 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -146,6 +146,15 @@ ja4_endpoint_enabled = false # in production. auction_html_comment = false +# Expose GET /_ts/trace, which toggles the `ts-trace` cookie and redirects to /. +# While the cookie is set, the TSJS overlay draws a floating panel summarising +# every traced ad slot (render path, bidder, and GAM/injected/visible state) +# plus a confirmation badge on each genuinely-rendered creative. It only +# surfaces data already present on window.tsjs, so it leaks nothing new — but +# it is off by default so the toggle route is not live on deployments that +# never asked for it. Enable only for render-verification debugging. +# trace_route_enabled = false + [creative_opportunities] gam_network_id = "123456789" # FCP is not affected by this value — body content above has already