diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f4329574..279a7467e 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 c931360f6..1686ebbb9 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 955ff235b..c2d07000c 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 2291fce74..4ebab07c2 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 441828a18..ec3afa4ec 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 986beb984..ea39d8739 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 4ef73e581..83ab3dbbf 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -157,6 +157,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, @@ -247,6 +270,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 @@ -1142,6 +1167,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, @@ -1260,6 +1286,8 @@ impl AuctionOrchestrator { (None, self.select_winning_bids(&responses, &floor_prices)) }; + log_winning_bids(&request.id, &winning_bids); + OrchestrationResult { provider_responses: responses, mediator_response, @@ -1417,6 +1445,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, @@ -1756,6 +1786,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -1776,6 +1808,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -2167,6 +2201,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -2217,6 +2253,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -2253,6 +2291,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 4819cef6c..ddda5bc91 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -960,6 +960,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 14c7713f8..fdee6ef1a 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 ffcf4f034..6a220498d 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 8fd3f9ddb..5a060c29f 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 60c22265d..fe0f08df1 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 09f94737d..86af9398c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1910,6 +1910,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()) @@ -1974,6 +1986,8 @@ impl PrebidAuctionProvider { nurl, burl, ad_id, + bid_id, + crid, cache_id, cache_host, cache_path, @@ -6059,6 +6073,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 70a4d6cfd..edc9a3c0a 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 c810bb21b..f670f380d 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); } @@ -1244,6 +1249,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 { @@ -1935,10 +1941,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), +/// 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() @@ -1951,10 +1964,36 @@ 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()), + ); + } + 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(), @@ -2003,6 +2042,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, @@ -2281,8 +2322,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 { let observation = AuctionObservationContext::from_parts( AuctionSource::SpaNavigation, @@ -2345,7 +2386,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:?}"); @@ -2362,7 +2403,7 @@ pub async fn handle_page_bids( ) }) .await; - std::collections::HashMap::new() + (std::collections::HashMap::new(), None) } } } else { @@ -2388,7 +2429,7 @@ pub async fn handle_page_bids( ) }) .await; - std::collections::HashMap::new() + (std::collections::HashMap::new(), None) } }; @@ -2396,6 +2437,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 @@ -3991,6 +4033,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, @@ -4045,7 +4089,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!( @@ -4075,6 +4119,86 @@ 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.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_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(); @@ -4089,7 +4213,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") @@ -4120,7 +4244,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") @@ -4156,7 +4280,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") @@ -4211,13 +4335,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") @@ -4226,7 +4354,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()), @@ -4257,13 +4385,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") @@ -4272,7 +4404,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(), @@ -4285,7 +4417,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(), @@ -4301,13 +4474,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") @@ -4315,7 +4490,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" ); } @@ -4336,13 +4511,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 03cc535c8..59b3f6f18 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1916,6 +1916,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 000000000..583a96238 --- /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 40f54367a..737f0e31a 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -55,6 +55,10 @@ export interface AuctionBid { creativeId: string; /** Advertiser domains. */ adomain: string[]; + /** Server-side auction ID (response top-level `id` / `ext.ts.auction_id`). */ + auctionId?: string; + /** Trace hash of the delivered adm (`ext.ts.adm_hash`, 16 hex chars of SHA-256). */ + admHash?: string; } // --------------------------------------------------------------------------- @@ -126,12 +130,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 +156,12 @@ 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, + 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 e39300a14..9dd755cfb 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,8 @@ type RenderCreativeInlineOptions = { creativeHeight?: number; seat: string; creativeId: string; + auctionId?: string; + admHash?: string; }; // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. @@ -60,6 +63,8 @@ export function requestAds( creativeHeight: bid.height, seat: bid.seat, creativeId: bid.creativeId, + auctionId: bid.auctionId, + admHash: bid.admHash, }); } log.info('requestAds: rendered creatives from response'); @@ -87,10 +92,22 @@ function renderCreativeInline({ creativeHeight, seat, creativeId, + auctionId, + admHash, }: RenderCreativeInlineOptions): void { + const trace = { + slotId, + path: 'auction' as const, + auctionId, + 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 +121,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 +162,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 000000000..e52a21fff --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -0,0 +1,485 @@ +// 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; + +/** + * Page-global render counter backing [`RenderRecord.seq`]. Module-scoped + * rather than stored on `window.tsjs` so a re-executed bundle cannot resume + * mid-sequence and hand two different renders the same number. + */ +let renderSeq = 0; + +/** 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)]; + el.querySelectorAll(`:scope > .${TRACE_BADGE_CLASS}`).forEach((n) => n.remove()); + + 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 ?? '—'}`, + `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); +} + +/** 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. + */ +function isCurrentRender(record: RenderRecord): boolean { + try { + return window.tsjs?.renders?.[record.slotId]?.seq === record.seq; + } 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 ?? '—'}`, + `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: ++renderSeq, 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; +} + +/** + * 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-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. + const status = panelStatus(record); + if ( + el instanceof HTMLElement && + el.tagName !== 'IFRAME' && + 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 360e2aa49..1ada7f109 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,12 @@ export interface AuctionBidData { hb_adid?: string; hb_cache_host?: string; hb_cache_path?: 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 +71,79 @@ 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 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 +173,29 @@ 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; + /** + * Per-slot flag: TS applied server-side bid targeting and no GAM render has + * consumed it yet. Set by `adInit()`, cleared by the first `slotRenderEnded` + * for that slot, so only that render is attributed to the SSAT auction (see + * [`RenderRecord.path`]). Publisher-driven refreshes afterwards find it false + * and are recorded as `gam-refresh` without the stale auction tuple. + */ + ssatTargetingFresh?: Record; /** * 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 ca4689684..86b0cea59 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,6 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; +import { recordRender, stampCreativeTrace, isEffectivelyVisible } from '../../core/trace'; +import type { AuctionSlot, AuctionBidData, RenderServedFrom, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -34,6 +35,24 @@ 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; + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -289,13 +308,19 @@ 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. + */ +function injectAdmIntoSlot(divId: string, adm: string): 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,6 +332,7 @@ 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; @@ -323,11 +349,14 @@ 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.src = innerSrc; slotEl!.appendChild(f); log.debug(`[tsjs-gpt] gam-intercept: inserted src iframe for '${divId}'`); } }); + // Placement deferred to the animation frame — not confirmed yet. + return false; } else { // No extractable safe src — replace slot content with a sandboxed srcdoc iframe. slotEl.innerHTML = ''; @@ -336,12 +365,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 +477,73 @@ 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. + */ +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; + + orphanObserver = new MutationObserver(() => { + clearTimeout(orphanDebounceTimer); + orphanDebounceTimer = setTimeout(() => { + 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); @@ -541,6 +640,13 @@ 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. + (ts.ssatTargetingFresh ??= {})[slot.id] = TS_BID_TARGETING_KEYS.some((key) => + Boolean(bid[key]) + ); // 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. @@ -596,9 +702,46 @@ export function installTsAdInit(): void { // 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) : false; + + // 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); + // 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 the + // stale bid tuple is dropped rather than re-stamped. + const fresh = (ts.ssatTargetingFresh ??= {})[slotId] === true; + ts.ssatTargetingFresh[slotId] = false; + const attributed = fresh + ? { + path: 'ssat' as const, + auctionId: bid.hb_auction_id, + bidder: bid.hb_bidder, + adId: bid.hb_adid, + creativeId: bid.hb_crid, + admHash: bid.hb_adm_hash, + } + : { path: 'gam-refresh' as const }; + const record = recordRender({ + slotId, + rendered: !event.isEmpty, + gamEmpty: event.isEmpty, + injected, + visible: isEffectivelyVisible(slotEl), + elementId: divId, + servedFrom: 'gam', + ...attributed, + }); + if (slotEl) stampCreativeTrace(slotEl, record); }); } @@ -635,6 +778,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); + } }); }; } @@ -836,6 +985,43 @@ 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( + 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 record = recordRender({ + slotId, + path: 'ssat', + rendered: true, + injected: true, + visible: isEffectivelyVisible(el), + elementId: el?.id, + auctionId: bid.hb_auction_id, + bidder: bid.hb_bidder, + adId: bid.hb_adid, + creativeId: bid.hb_crid, + admHash: bid.hb_adm_hash, + servedFrom, + }); + if (el && el.isConnected) stampCreativeTrace(el, record); +} + export function installTsRenderBridge(): void { if (typeof window === 'undefined') return; @@ -888,6 +1074,10 @@ export function installTsRenderBridge(): void { const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); const [width, height] = slot?.formats?.[0] ?? [728, 90]; + // 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; if (matchedBid.adm) { e.stopImmediatePropagation(); @@ -902,6 +1092,7 @@ export function installTsRenderBridge(): void { }) ); fireWinBillingBeacons(slotId, matchedBid); + recordBridgeRender(slotId, matchedBid, 'debug-adm', slotEl); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from debug adm`); return; } @@ -933,6 +1124,7 @@ export function installTsRenderBridge(): void { }) ); fireWinBillingBeacons(slotId, matchedBid); + recordBridgeRender(slotId, matchedBid, 'pbs-cache', slotEl); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`); }) .catch((err) => { 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 be839d8fc..2b4dca91e 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -30,7 +30,8 @@ import './_user_ids.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 { DEFAULT_PREBID_USER_ID_MODULES, PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; @@ -209,6 +210,11 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: bidderCode: bid.seat, meta: { advertiserDomains: bid.adomain, + // Carry the server-side trace tuple through Prebid so the bidWon + // render-trace hook can attribute the render to its /auction (see + // installPrebidRenderTrace). Prebid passes `meta` through unchanged. + tsAuctionId: bid.auctionId, + tsAdmHash: bid.admHash, }, }; }); @@ -921,10 +927,97 @@ function syncPrebidEidsCookie(): void { } } +// --------------------------------------------------------------------------- +// Render trace (client-side /auction path) +// --------------------------------------------------------------------------- + +/** Minimal shape of the bid object Prebid.js passes to a `bidWon` handler. */ +interface PrebidWonBid { + adUnitCode?: string; + bidderCode?: string; + bidder?: string; + creativeId?: string; + meta?: { tsAuctionId?: unknown; tsAdmHash?: unknown; [key: string]: unknown }; +} + +/** + * 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 Prebid `bidWon` — the authoritative signal + * that the client-side `/auction` creative actually rendered, 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. + * + * Exported for unit testing; the render path itself is unaffected (this only + * observes and stamps the DOM). + */ +export function recordPrebidBidWon(bid: PrebidWonBid | undefined): 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 el = findAuctionSlotElement(bid.adUnitCode); + const record = recordRender({ + slotId: bid.adUnitCode, + path: 'auction', + rendered: true, + // Prebid rendered the `ad` markup our adapter returned — a confirmed TS + // placement for this path. + injected: true, + visible: isEffectivelyVisible(el), + elementId: el?.id, + auctionId: meta.tsAuctionId, + 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 `bidWon` render-trace hook (idempotent). + * + * `bidWon` is the render-confirmation event for the client-side `/auction` + * path; without this hook only the one-time SSAT auction is traced and the + * ongoing Prebid renders are invisible to the panel. Read-only: the listener + * records and stamps but never alters Prebid's rendering. + */ +export function installPrebidRenderTrace(): void { + if (typeof window === 'undefined') return; + const p = pbjs as unknown as { + onEvent?: (event: string, handler: (bid: PrebidWonBid) => void) => void; + __tsRenderTraceInstalled?: boolean; + }; + if (typeof p.onEvent !== 'function' || p.__tsRenderTraceInstalled) return; + p.__tsRenderTraceInstalled = true; + p.onEvent('bidWon', (bid) => { + try { + recordPrebidBidWon(bid); + } catch (err) { + log.warn('[tsjs-prebid] render-trace bidWon failed', err); + } + }); +} + // 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 31e020eff..cf89aff17 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,40 @@ 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: [ + { + 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].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 2c56361dc..075592065 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 000000000..199aa499e --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -0,0 +1,405 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + recordRender, + 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); + }); +}); + +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('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('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 4a6368768..83e2bca60 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,163 @@ 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_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-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', + 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('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; @@ -973,6 +1130,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' }), @@ -1110,6 +1281,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 +1487,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/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9ad7945c4..e68aa3100 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 @@ -63,8 +63,10 @@ import { auctionBidsToPrebidBids, installPrebidNpm, installRefreshHandler, + recordPrebidBidWon, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; +import type { TsjsApi } from '../../../src/core/types'; describe('prebid/collectBidders', () => { it('returns empty array for empty ad units', () => { @@ -197,6 +199,82 @@ 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', + admHash: 'a1b2c3d4e5f60718', + }, + ]; + + const result = auctionBidsToPrebidBids(auctionBids, []); + + expect(result[0].meta.tsAuctionId).toBe('ts-auction-xyz'); + expect(result[0].meta.tsAdmHash).toBe('a1b2c3d4e5f60718'); + }); +}); + +describe('prebid/recordPrebidBidWon', () => { + 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 = recordPrebidBidWon({ + adUnitCode: 'ad-header-0-_R_x_', + bidderCode: 'kargo', + creativeId: 'KM-CREA-1', + meta: { tsAuctionId: '265dcedd-aa0a', tsAdmHash: 'f68044ca9f68c88c' }, + }); + + expect(record).toBeDefined(); + expect(record).toEqual( + expect.objectContaining({ + slotId: 'ad-header-0-_R_x_', + path: 'auction', + rendered: true, + injected: true, + auctionId: '265dcedd-aa0a', + 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(); + }); + + it('skips a bid without the server-side trace tuple (client-side bidder)', () => { + const record = recordPrebidBidWon({ + adUnitCode: 'ad-header-0', + bidderCode: 'appnexus', + meta: { advertiserDomains: ['x.com'] }, + }); + expect(record).toBeUndefined(); + expect((window as { tsjs?: TsjsApi }).tsjs?.renders).toBeUndefined(); + }); + + it('skips a bid with no adUnitCode', () => { + expect(recordPrebidBidWon({ meta: { tsAuctionId: 'x' } })).toBeUndefined(); + expect(recordPrebidBidWon(undefined)).toBeUndefined(); + }); }); describe('prebid/installPrebidNpm', () => { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0951b781e..65b78aba0 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -137,6 +137,15 @@ example_segments = "segments" [debug] ja4_endpoint_enabled = 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