From 397a4d6d063abfa0a8364af8b2d2be0e528fcab0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 16 Jul 2026 23:04:55 +0530 Subject: [PATCH 01/11] Trace winning auction bids to rendered creatives on the page Mint a trace tuple (auction_id, slot_id, bidder, bid/creative IDs, adm_hash) server-side and carry it end-to-end on both render paths so a creative in the DOM can be joined back to the winning bid in server logs. Server: Bid gains crid and bid_id (extracted from PBS, restored through the mock mediator); adm_trace_hash() computes a 16-hex SHA-256 prefix of the creative markup; build_bid_map emits hb_auction_id/hb_crid/ hb_adm_hash for SSAT and SPA page-bids; /auction responses carry bid.ext.ts = {auction_id, adm_hash} and pass through upstream crid and bid id with the legacy synthetic fallback; the orchestrator logs one "auction winner:" line per winning bid on both the sync and dispatched collect paths. Client: new core/trace.ts writes a per-slot render registry to window.tsjs.renders, stamps slot elements and iframes with data-ts-* attributes (removing stale ones on re-render), and fires a tsjs:adRendered CustomEvent. renderCreativeInline, the GPT slotRenderEnded handler, and the pbRender bridge all record and stamp; the bridge captures the slot element at message-receipt time so an SPA navigation during a PBS Cache fetch cannot stamp the new route's slot with stale data. --- .../src/auction/formats.rs | 124 ++++++++++++++- crates/trusted-server-core/src/auction/mod.rs | 1 + .../src/auction/orchestrator.rs | 40 +++++ .../src/auction/telemetry.rs | 2 + .../trusted-server-core/src/auction/types.rs | 100 ++++++++++++ .../src/integrations/adserver_mock.rs | 10 ++ .../src/integrations/aps.rs | 2 + .../src/integrations/prebid.rs | 57 +++++++ crates/trusted-server-core/src/publisher.rs | 149 ++++++++++++++++-- .../trusted-server-js/lib/src/core/auction.ts | 16 ++ .../trusted-server-js/lib/src/core/request.ts | 37 +++++ .../trusted-server-js/lib/src/core/trace.ts | 71 +++++++++ .../trusted-server-js/lib/src/core/types.ts | 45 ++++++ .../lib/src/integrations/gpt/index.ts | 60 ++++++- .../lib/test/core/auction.test.ts | 34 ++++ .../lib/test/core/request.test.ts | 107 +++++++++++++ .../lib/test/core/trace.test.ts | 114 ++++++++++++++ .../lib/test/integrations/gpt/ad_init.test.ts | 86 ++++++++++ 18 files changed, 1038 insertions(+), 17 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/trace.ts create mode 100644 crates/trusted-server-js/lib/test/core/trace.test.ts diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 441828a1..ec3afa4e 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use uuid::Uuid; use crate::auction::context::ContextValue; +use crate::auction::types::adm_trace_hash; use crate::consent::ConsentContext; use crate::constants::{HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED}; use crate::creative; @@ -275,15 +276,57 @@ pub fn convert_to_openrtb_response( String::new() }; + // Trace hash over the exact markup delivered to the client (post + // sanitize/rewrite) so the client can stamp the rendered creative with + // a value that matches this response byte-for-byte. Logged at info so + // server logs join against the DOM markers without debug logging. + let adm_hash = (!creative_html.is_empty()).then(|| adm_trace_hash(&creative_html)); + if let Some(ref hash) = adm_hash { + log::info!( + "auction delivered creative: auction_id={} slot_id={} bidder={} crid={:?} adm_hash={}", + auction_request.id, + slot_id, + bid.bidder, + bid.crid, + hash, + ); + } + let mut ts_ext = serde_json::Map::new(); + ts_ext.insert( + "auction_id".to_string(), + serde_json::Value::String(auction_request.id.clone()), + ); + if let Some(ref hash) = adm_hash { + ts_ext.insert( + "adm_hash".to_string(), + serde_json::Value::String(hash.clone()), + ); + } + let mut bid_ext = serde_json::Map::new(); + bid_ext.insert("ts".to_string(), serde_json::Value::Object(ts_ext)); + let openrtb_bid = OpenRtbBid { - id: Some(format!("{}-{}", bid.bidder, slot_id)), + // Prefer the bidder's real bid ID; fall back to the legacy + // synthetic value so consumers of the old shape keep working. + id: Some( + bid.bid_id + .clone() + .unwrap_or_else(|| format!("{}-{}", bid.bidder, slot_id)), + ), impid: Some(slot_id.to_string()), price: Some(price), adm: Some(creative_html), - crid: Some(format!("{}-creative", bid.bidder)), + // Prefer the bidder's real creative ID; fall back to the legacy + // synthetic value so consumers of the old shape keep working. + crid: Some( + bid.crid + .clone() + .unwrap_or_else(|| format!("{}-creative", bid.bidder)), + ), w: width, h: height, adomain: bid.adomain.clone().unwrap_or_default(), + ext: Some(bid_ext), ..Default::default() }; @@ -438,6 +481,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -932,6 +977,81 @@ mod tests { ); } + #[test] + fn convert_to_openrtb_response_includes_trace_ext_with_delivered_adm_hash() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert auction result to OpenRTB response"); + + let json = response_json(response); + let bid = &json["seatbid"][0]["bid"][0]; + assert_eq!( + bid["ext"]["ts"]["auction_id"], + json!("auction-1"), + "should carry the auction ID in the trace ext" + ); + let delivered_adm = bid["adm"].as_str().expect("should serialize adm as string"); + assert_eq!( + bid["ext"]["ts"]["adm_hash"], + json!(adm_trace_hash(delivered_adm)), + "should hash the exact adm delivered to the client" + ); + } + + #[test] + fn convert_to_openrtb_response_omits_adm_hash_without_creative() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = None; + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert bid without creative HTML"); + + let json = response_json(response); + let ts_ext = json["seatbid"][0]["bid"][0]["ext"]["ts"] + .as_object() + .expect("should serialize trace ext as object"); + assert_eq!( + ts_ext.get("auction_id"), + Some(&json!("auction-1")), + "should still carry the auction ID" + ); + assert!( + !ts_ext.contains_key("adm_hash"), + "should omit adm_hash when there is no creative" + ); + } + + #[test] + fn convert_to_openrtb_response_prefers_upstream_crid_and_bid_id() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.crid = Some("cr-12345".to_string()); + bid.bid_id = Some("bid-abc-1".to_string()); + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert bid with upstream crid and bid ID"); + + let json = response_json(response); + assert_eq!( + json["seatbid"][0]["bid"][0]["crid"], + json!("cr-12345"), + "should pass through the bidder's creative ID instead of the synthetic fallback" + ); + assert_eq!( + json["seatbid"][0]["bid"][0]["id"], + json!("bid-abc-1"), + "should pass through the bidder's bid ID instead of the synthetic fallback" + ); + } + #[test] fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() { let settings = make_settings(); diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 986beb98..ea39d873 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -35,6 +35,7 @@ pub use telemetry::{ }; pub use types::{ AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, + adm_trace_hash, }; /// Type alias for provider builder functions. diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 4ef73e58..83ab3dbb 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 4819cef6..ddda5bc9 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 14c7713f..2f522398 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -198,6 +198,21 @@ 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. + #[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 +269,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 +397,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 +406,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 +564,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 +613,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/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 8fd3f9dd..5a060c29 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -319,6 +319,8 @@ impl AdServerMockProvider { nurl: original.and_then(|b| b.nurl.clone()), burl: original.and_then(|b| b.burl.clone()), ad_id: original.and_then(|b| b.ad_id.clone()), + bid_id: original.and_then(|b| b.bid_id.clone()), + crid: original.and_then(|b| b.crid.clone()), cache_id: original.and_then(|b| b.cache_id.clone()), cache_host: original.and_then(|b| b.cache_host.clone()), cache_path: original.and_then(|b| b.cache_path.clone()), @@ -643,6 +645,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -666,6 +670,8 @@ mod tests { nurl: Some("https://ssp.example/win?id=mock-bid-001".to_string()), burl: Some("https://ssp.example/bill?id=mock-bid-001".to_string()), ad_id: Some("mock-bid-001".to_string()), + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, @@ -785,6 +791,8 @@ mod tests { nurl: Some("https://ssp.example/win".to_string()), burl: Some("https://ssp.example/bill".to_string()), ad_id: Some("bid-impression-id".to_string()), + bid_id: None, + crid: None, cache_id: Some("cache-uuid".to_string()), cache_host: Some("cache.example".to_string()), cache_path: Some("/cache".to_string()), @@ -904,6 +912,8 @@ mod tests { nurl: None, burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 60c22265..fe0f08df 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -482,6 +482,8 @@ impl ApsAuctionProvider { nurl: None, // Real APS uses client-side event tracking burl: None, ad_id: None, + bid_id: None, + crid: None, cache_id: None, cache_host: None, cache_path: None, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 09f94737..86af9398 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/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c810bb21..e4ac8656 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,6 +1964,21 @@ 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. @@ -2003,6 +2031,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 +2311,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 +2375,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 +2392,7 @@ pub async fn handle_page_bids( ) }) .await; - std::collections::HashMap::new() + (std::collections::HashMap::new(), None) } } } else { @@ -2388,7 +2418,7 @@ pub async fn handle_page_bids( ) }) .await; - std::collections::HashMap::new() + (std::collections::HashMap::new(), None) } }; @@ -2396,6 +2426,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 +4022,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 +4078,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 +4108,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 +4202,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 +4233,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 +4269,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 +4324,15 @@ mod tests { nurl: None, burl: None, ad_id: Some("bid-impression-id".to_string()), + bid_id: None, + 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") @@ -4257,13 +4372,15 @@ mod tests { nurl: None, burl: None, ad_id: Some("aps-bid-token".to_string()), + 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") @@ -4301,13 +4418,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") @@ -4336,13 +4455,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-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index 40f54367..737f0e31 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 e39300a1..f0259a98 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 } 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 }); return; } @@ -104,6 +121,14 @@ 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, + elementId: container.id || undefined, + }); + stampCreativeTrace(container, rejectedRecord); return; } @@ -135,10 +160,22 @@ 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). + const record = recordRender({ + ...trace, + rendered: true, + elementId: container.id || undefined, + }); + stampCreativeTrace(container, record); + stampCreativeTrace(iframe, record); + log.info('renderCreativeInline: rendered', { slotId, seat, creativeId, + auctionId, + admHash, width, height, originalLength: sanitization.originalLength, diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts new file mode 100644 index 00000000..94edd9fc --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -0,0 +1,71 @@ +// Render-trace registry and DOM markers: 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 so tests and tooling can await renders. +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'; + +/** + * 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. + */ +export function recordRender(record: Omit): RenderRecord { + const full: RenderRecord = { ...record, count: 1, 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; + } 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 }); + } + 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. + */ +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], + ]; + try { + for (const [name, value] of attrs) { + if (value !== undefined && value !== '') { + el.setAttribute(name, value); + } else { + el.removeAttribute(name); + } + } + } catch (err) { + log.warn('trace: failed to stamp element', { slotId: record.slotId, err }); + } +} diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 360e2aa4..37f43f13 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,41 @@ export interface AuctionBidData { debug_bid?: AuctionDebugBidData; } +/** How a creative reached the page for a [`RenderRecord`]. */ +export type RenderServedFrom = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache'; + +/** + * 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. */ + path: 'auction' | 'ssat'; + /** 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; + /** How many renders this slot has seen (SPA navigations, refreshes). */ + count: number; + /** Epoch ms when the record was written. */ + at: number; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -92,6 +135,8 @@ 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; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ prevGptSlots?: unknown[]; /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ca468968..2dc333cf 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 } from '../../core/trace'; +import type { AuctionSlot, AuctionBidData, RenderServedFrom, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -336,6 +337,7 @@ 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}'`); @@ -593,6 +595,23 @@ export function installTsAdInit(): void { // Read ts.bids live (not the snapshot above) so post-navigation bid data is used. const bid = (ts.bids ?? {})[slotId] ?? {}; + // Trace: registry entry + DOM markers joining the GAM-rendered + // creative back to the server-side auction bid for this slot. + const record = recordRender({ + slotId, + path: 'ssat', + rendered: !event.isEmpty, + elementId: divId, + auctionId: bid.hb_auction_id, + bidder: bid.hb_bidder, + adId: bid.hb_adid, + creativeId: bid.hb_crid, + admHash: bid.hb_adm_hash, + servedFrom: 'gam', + }); + const slotEl = document.getElementById(divId); + if (slotEl) stampCreativeTrace(slotEl, record); + // 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. @@ -836,6 +855,39 @@ 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 { + const record = recordRender({ + slotId, + path: 'ssat', + rendered: true, + 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 +940,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 +958,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 +990,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/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 31e020ef..cf89aff1 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 2c56361d..07559206 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -296,6 +296,113 @@ describe('request.requestAds', () => { ); }); + it('stamps trace markers and records the render in window.tsjs.renders', async () => { + const creativeHtml = '
Traced Creative
'; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + id: 'auction-trace-1', + seatbid: [ + { + seat: 'kargo', + bid: [ + { + impid: 'slot1', + adm: creativeHtml, + crid: 'cr-777', + ext: { ts: { auction_id: 'auction-trace-1', adm_hash: 'a1b2c3d4e5f60718' } }, + }, + ], + }, + ], + }), + }); + + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + const { RENDER_EVENT_NAME } = await import('../../src/core/trace'); + const eventListener = vi.fn(); + window.addEventListener(RENDER_EVENT_NAME, eventListener); + + document.body.innerHTML = '
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + await flushRequestAds(); + + const container = document.querySelector('#slot1') as HTMLElement; + expect(container.getAttribute('data-ts-slot-id')).toBe('slot1'); + expect(container.getAttribute('data-ts-render-path')).toBe('auction'); + expect(container.getAttribute('data-ts-rendered')).toBe('true'); + expect(container.getAttribute('data-ts-auction-id')).toBe('auction-trace-1'); + expect(container.getAttribute('data-ts-bidder')).toBe('kargo'); + expect(container.getAttribute('data-ts-creative-id')).toBe('cr-777'); + expect(container.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); + + const iframe = container.querySelector('iframe') as HTMLIFrameElement; + expect(iframe.getAttribute('data-ts-slot-id')).toBe('slot1'); + expect(iframe.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); + + const record = (window as any).tsjs?.renders?.['slot1']; + expect(record).toEqual( + expect.objectContaining({ + slotId: 'slot1', + path: 'auction', + rendered: true, + elementId: 'slot1', + auctionId: 'auction-trace-1', + bidder: 'kargo', + creativeId: 'cr-777', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'inline', + }) + ); + expect(eventListener).toHaveBeenCalledTimes(1); + + window.removeEventListener(RENDER_EVENT_NAME, eventListener); + }); + + it('records a rendered:false trace entry when the creative is rejected', async () => { + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + id: 'auction-trace-2', + seatbid: [ + { + seat: 'appnexus', + bid: [{ impid: 'slot1', adm: ' ', crid: 'creative-empty' }], + }, + ], + }), + }); + + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + + document.body.innerHTML = '
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + await flushRequestAds(); + + const record = (window as any).tsjs?.renders?.['slot1']; + expect(record).toEqual( + expect.objectContaining({ + slotId: 'slot1', + path: 'auction', + rendered: false, + auctionId: 'auction-trace-2', + }) + ); + // Rejected creative must stamp an explicit rendered:false marker, + // matching the SSAT path's empty-render semantics. + expect(document.querySelector('#slot1')?.getAttribute('data-ts-rendered')).toBe('false'); + }); + it('skips iframe insertion when slot is missing', async () => { // mock fetch for unified auction endpoint - returns inline HTML (globalThis as any).fetch = vi.fn().mockResolvedValue({ diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts new file mode 100644 index 00000000..ba7a640e --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { recordRender, stampCreativeTrace, RENDER_EVENT_NAME } from '../../src/core/trace'; +import type { RenderRecord, TsjsApi } from '../../src/core/types'; + +describe('trace/recordRender', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + }); + + 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, + 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, + at: 1, + }; + stampCreativeTrace(el, first); + + const second: RenderRecord = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + auctionId: 'auction-new', + count: 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); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 4a636876..a304952f 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,92 @@ 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 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; From 966c8569cc07906a9dab2ec81dd5163079d81229 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 18 Jul 2026 09:51:24 +0530 Subject: [PATCH 02/11] Add /_ts/trace overlay to confirm creatives render on the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an operator-armed render-trace overlay so the winning auction recorded in window.tsjs.renders can be confirmed visually on the page, on both the SSAT/GAM and /auction paths. Server: GET /_ts/trace toggles a host-only ts-trace cookie and redirects to /, gated by the new [debug] trace_route_enabled flag (404 when off). Registered on all four adapters (Fastly/Cloudflare/Axum/Spin) and documented in trusted-server.example.toml. Client: while the cookie is armed, TSJS draws a floating Google-Publisher-Console-style panel summarising every traced slot and a confirmation badge on each genuinely-rendered creative. The panel reports honest per-slot status — ok / hidden / gam-only / empty — derived from separate signals (gamEmpty from GAM's slotRenderEnded, injected = whether TS actually placed the creative, visible = ancestor-aware visibility) so it never overclaims a render GAM merely fired. Clicking a row copies the full record; the badge is shown only on ok slots. --- crates/trusted-server-adapter-axum/src/app.rs | 12 +- .../src/app.rs | 10 + .../trusted-server-adapter-fastly/src/app.rs | 58 +++ crates/trusted-server-adapter-spin/src/app.rs | 20 +- crates/trusted-server-core/src/constants.rs | 1 + crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/settings.rs | 12 + .../trusted-server-core/src/trace_cookie.rs | 218 +++++++++++ .../trusted-server-js/lib/src/core/request.ts | 10 +- .../trusted-server-js/lib/src/core/trace.ts | 359 +++++++++++++++++- .../trusted-server-js/lib/src/core/types.ts | 23 ++ .../lib/src/integrations/gpt/index.ts | 47 ++- .../lib/test/core/trace.test.ts | 207 +++++++++- trusted-server.example.toml | 9 + 14 files changed, 962 insertions(+), 25 deletions(-) create mode 100644 crates/trusted-server-core/src/trace_cookie.rs diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f432957..279a7467 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -29,6 +29,7 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; +use trusted_server_core::trace_cookie::handle_trace_mode; use trusted_server_core::platform::RuntimeServices; @@ -255,6 +256,7 @@ enum NamedRouteHandler { /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -279,7 +281,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 12] { +fn named_routes() -> [NamedRoute; 13] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -320,6 +322,11 @@ fn named_routes() -> [NamedRoute; 12] { primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::LegacyAdminDenied, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -389,6 +396,9 @@ fn named_route_handler( Ok(resp) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), + NamedRouteHandler::TraceMode => { + handle_trace_mode(&state.settings, req.uri().query()) + } NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f..1686ebbb 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -28,6 +28,7 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::build_runtime_services; @@ -461,6 +462,15 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async { Ok::(admin_key_management_not_supported()) }) + // Render-trace toggle: arms/disarms the ts-trace cookie and + // redirects to `/`. Gated by [debug] trace_route_enabled (404 when + // off). + .get( + "/_ts/trace", + make_handler(Arc::clone(&state), |s, _services, req| async move { + handle_trace_mode(&s.settings, req.uri().query()) + }), + ) .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 955ff235..c2d07000 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -26,6 +26,7 @@ //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | //! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] | +//! | GET | `/_ts/trace` | [`handle_trace_mode`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | //! | POST | `/auction` | [`handle_auction`] | //! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | @@ -128,6 +129,7 @@ use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ @@ -587,6 +589,7 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::TraceMode => handle_trace_mode(&state.settings, req.uri().query()), NamedRouteHandler::Auction => { // The auction reads consent data, so the consent KV store must be // available — fail closed with 503 when it is configured but @@ -994,6 +997,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -1075,6 +1079,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -1746,6 +1755,55 @@ mod tests { ); } + #[test] + fn dispatch_trace_route_is_disabled_by_default() { + let router = test_router(); + let response = route(&router, empty_request(Method::GET, "/_ts/trace")); + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "disabled trace route should return 404" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "disabled trace route should not set a cookie" + ); + } + + #[test] + fn dispatch_trace_route_arms_cookie_and_redirects() { + let mut settings = test_settings(); + settings.debug.trace_route_enabled = true; + let state = app_state_for_settings(settings); + let router = TrustedServerApp::routes_for_state(&state); + let response = route(&router, empty_request(Method::GET, "/_ts/trace")); + + assert_eq!( + response.status(), + StatusCode::FOUND, + "enabled trace route should redirect to root" + ); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()), + Some("/"), + "trace route should redirect to /" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert!( + set_cookie.starts_with("ts-trace=1;"), + "trace route should arm the ts-trace cookie" + ); + } + #[test] fn dispatch_set_tester_sets_cookie_on_configured_domain() { let mut settings = test_settings(); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce7..4ebab07c 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -27,6 +27,7 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware}; use crate::platform::build_runtime_services; @@ -141,7 +142,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -149,6 +150,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { ("/_ts/admin/keys/deactivate", &[Method::POST]), ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), + ("/_ts/trace", &[Method::GET]), ("/auction", &[Method::POST]), ("/__ts/page-bids", &[Method::GET, Method::OPTIONS]), ("/first-party/proxy", &[Method::GET]), @@ -541,6 +543,21 @@ fn build_router(state: &Arc) -> RouterService { } }; + // GET /_ts/trace — render-trace toggle: arms/disarms the ts-trace + // cookie and redirects to `/`. Gated by [debug] trace_route_enabled + // (404 when off). + let s = Arc::clone(&state); + let trace_mode_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let req = ctx.into_request(); + Ok::( + handle_trace_mode(&s.settings, req.uri().query()) + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + // GET /__ts/page-bids — SPA re-auction endpoint. let s = Arc::clone(&state); let page_bids_handler = move |ctx: RequestContext| { @@ -730,6 +747,7 @@ fn build_router(state: &Arc) -> RouterService { // credentials and key-management payloads to the origin. .post("/_ts/admin/keys/rotate", admin_not_supported_handler) .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) + .get("/_ts/trace", trace_mode_handler) .post("/auction", auction_handler) .get("/__ts/page-bids", page_bids_handler) .route( diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index ffcf4f03..6a220498 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -5,6 +5,7 @@ pub const COOKIE_TS_EC: &str = "ts-ec"; /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; +pub const COOKIE_TS_TRACE: &str = "ts-trace"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 70a4d6cf..edc9a3c0 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -70,6 +70,7 @@ pub mod streaming_processor; pub mod streaming_replacer; pub mod test_support; pub mod tester_cookie; +pub mod trace_cookie; pub mod tsjs; #[cfg(test)] diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 03cc535c..59b3f6f1 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 00000000..583a9623 --- /dev/null +++ b/crates/trusted-server-core/src/trace_cookie.rs @@ -0,0 +1,218 @@ +//! Render-trace toggle endpoint helpers. +//! +//! `GET /_ts/trace` arms (or with `?enabled=false` disarms) the first-party +//! `ts-trace` cookie and redirects to `/`. While the cookie is present, the +//! TSJS render-trace layer draws a visible badge on every traced creative so +//! an operator can see on the page itself that a creative was delivered by +//! Trusted Server — and via which render path (SSAT/GAM or `/auction`). +//! +//! The route is gated behind `[debug] trace_route_enabled` and returns +//! `404 Not Found` while disabled, mirroring the tester-cookie endpoints. The +//! badge only surfaces data already exposed on `window.tsjs`, so the cookie +//! gates visibility, not access. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt}; +use http::{HeaderValue, Response, StatusCode, header}; + +use crate::constants::COOKIE_TS_TRACE; +use crate::error::TrustedServerError; +use crate::settings::Settings; + +/// How long an armed trace cookie lives, in seconds. +/// +/// One hour: long enough for a debugging session across reloads and SPA +/// navigations, short enough that a forgotten toggle expires on its own. +const TRACE_COOKIE_MAX_AGE_SECS: u32 = 3600; + +/// Formats the trace cookie `Set-Cookie` header value. +/// +/// Deliberately host-only (no `Domain` attribute): a `Domain` scoped to +/// `publisher.cookie_domain` would be rejected by the browser during local +/// development against `127.0.0.1`/`localhost`, and the overlay only needs to +/// work on the exact host being debugged. Also neither `HttpOnly` (the TSJS +/// overlay must read it from `document.cookie`) nor `Secure` (the badge is a +/// debug aid that must work through plain-HTTP local dev proxies, and the +/// cookie carries no data worth protecting). +fn format_trace_cookie() -> String { + format!( + "{}=1; Path=/; SameSite=Lax; Max-Age={}", + COOKIE_TS_TRACE, TRACE_COOKIE_MAX_AGE_SECS, + ) +} + +/// Formats the trace cookie clearing `Set-Cookie` header value. +fn format_clear_trace_cookie() -> String { + format!("{}=; Path=/; SameSite=Lax; Max-Age=0", COOKIE_TS_TRACE) +} + +/// Whether the request's query string asks to disarm the trace cookie. +/// +/// Only an explicit `enabled=false` (or `enabled=0`) disarms; any other query +/// — including none at all — arms it, so `GET /_ts/trace` alone switches the +/// overlay on. +fn query_disables(query: Option<&str>) -> bool { + query.is_some_and(|q| { + q.split('&') + .any(|pair| pair == "enabled=false" || pair == "enabled=0") + }) +} + +/// Handles `GET /_ts/trace`. +/// +/// Returns `404 Not Found` while `[debug] trace_route_enabled` is false. When +/// enabled, sets (or with `?enabled=false` clears) the `ts-trace` cookie +/// scoped to `publisher.cookie_domain` and returns `302 Found` redirecting to +/// `/` — landing back on the homepage confirms the toggle round-trip worked. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::InvalidHeaderValue`] if the configured cookie +/// domain cannot be rendered as an HTTP header value. +pub fn handle_trace_mode( + settings: &Settings, + query: Option<&str>, +) -> Result, Report> { + if !settings.debug.trace_route_enabled { + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = StatusCode::NOT_FOUND; + return Ok(response); + } + + let cookie_value = if query_disables(query) { + format_clear_trace_cookie() + } else { + format_trace_cookie() + }; + let set_cookie = HeaderValue::from_str(&cookie_value).change_context( + TrustedServerError::InvalidHeaderValue { + message: "trace cookie contains invalid header value".to_string(), + }, + )?; + + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = StatusCode::FOUND; + response + .headers_mut() + .insert(header::LOCATION, HeaderValue::from_static("/")); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, private"), + ); + response + .headers_mut() + .insert(header::SET_COOKIE, set_cookie); + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::tests::create_test_settings; + + fn trace_enabled_settings() -> Settings { + let mut settings = create_test_settings(); + settings.debug.trace_route_enabled = true; + settings + } + + #[test] + fn trace_route_arms_cookie_and_redirects_to_root() { + let settings = trace_enabled_settings(); + + let response = handle_trace_mode(&settings, None).expect("should build trace response"); + + assert_eq!( + response.status(), + StatusCode::FOUND, + "enabled trace route should redirect" + ); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()), + Some("/"), + "should redirect to the site root" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store, private"), + "trace route should not be cacheable" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert_eq!( + set_cookie, "ts-trace=1; Path=/; SameSite=Lax; Max-Age=3600", + "trace cookie should be host-only with a bounded lifetime" + ); + } + + #[test] + fn trace_route_clears_cookie_when_disabled_by_query() { + let settings = trace_enabled_settings(); + + let response = handle_trace_mode(&settings, Some("enabled=false")) + .expect("should build trace clear response"); + + assert_eq!( + response.status(), + StatusCode::FOUND, + "clearing should still redirect" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should clear trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert_eq!( + set_cookie, "ts-trace=; Path=/; SameSite=Lax; Max-Age=0", + "trace cookie clear should expire the cookie" + ); + } + + #[test] + fn trace_route_arms_cookie_for_unrelated_query() { + let settings = trace_enabled_settings(); + + let response = handle_trace_mode(&settings, Some("enabled=true&foo=bar")) + .expect("should build trace response"); + + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert!( + set_cookie.starts_with("ts-trace=1;"), + "non-disabling query should arm the cookie" + ); + } + + #[test] + fn trace_route_is_disabled_by_default() { + let settings = create_test_settings(); + + let response = + handle_trace_mode(&settings, None).expect("should build disabled trace response"); + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "disabled trace route should return not found" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "disabled trace route should not set a cookie" + ); + } +} diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index f0259a98..9dd755cf 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -4,7 +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 } from './trace'; +import { recordRender, stampCreativeTrace, isEffectivelyVisible } from './trace'; export type RequestAdsCallback = () => void; export interface RequestAdsOptions { @@ -107,7 +107,7 @@ function renderCreativeInline({ const container = findSlot(slotId) as HTMLElement | null; if (!container) { log.warn('renderCreativeInline: slot not found; skipping render', { slotId, seat, creativeId }); - recordRender({ ...trace, rendered: false }); + recordRender({ ...trace, rendered: false, injected: false, visible: false }); return; } @@ -126,6 +126,8 @@ function renderCreativeInline({ const rejectedRecord = recordRender({ ...trace, rendered: false, + injected: false, + visible: false, elementId: container.id || undefined, }); stampCreativeTrace(container, rejectedRecord); @@ -162,9 +164,13 @@ function renderCreativeInline({ // 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); diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 94edd9fc..a7634bfb 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -1,20 +1,350 @@ -// Render-trace registry and DOM markers: 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 so tests and tooling can await renders. +// 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'; + +/** 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'; + // injected===false means TS applied targeting only; the creative is GAM's and + // unreadable, so we must not claim it as a confirmed TS render. + if (record.injected === false) 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 { + 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; + badge.textContent = `TS ✓ ${record.bidder ?? '?'}`; + badge.title = [ + `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', 'rgba(0,128,0,0.9)'); + 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; +} + +/** GAM/injection state summary for the panel's detail line. */ +function stateSummary(record: RenderRecord): string { + const parts: string[] = []; + if (record.path === 'ssat') { + 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 = [ + `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'); + line1.textContent = `${style.mark} ${record.slotId} · ${style.label}`; + line1.style.setProperty('font-weight', '600'); + line1.style.setProperty('color', style.color); + + const line2 = document.createElement('div'); + line2.style.setProperty('color', '#bbb'); + line2.textContent = `${record.path}${mechanismSuffix(record)} · ${record.bidder ?? '?'} · ${short(record.admHash)}`; + + const line3 = document.createElement('div'); + line3.style.setProperty('color', '#777'); + line3.textContent = `${stateSummary(record)} · auction ${short(record.auctionId)}${record.count > 1 ? ` · ×${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 records = Object.values(renders).sort((a, b) => a.slotId.localeCompare(b.slotId)); + // 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 = records.filter((r) => panelStatus(r) === 'ok').length; + + 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}/${records.length} ok`; + + 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 = 'click a row to copy its full record · hover for detail'; + panel.appendChild(hint); + + if (records.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 records) { + 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, at: Date.now() }; @@ -33,6 +363,7 @@ export function recordRender(record: Omit): Render // CustomEvent unavailable — registry entry above is still written. log.debug('trace: failed to dispatch render event', { slotId: record.slotId, err }); } + renderTracePanel(); return full; } @@ -43,7 +374,9 @@ export function recordRender(record: Omit): Render * * 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. + * 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]> = [ @@ -56,6 +389,9 @@ export function stampCreativeTrace(el: Element, record: RenderRecord): void { ['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) { @@ -65,6 +401,17 @@ export function stampCreativeTrace(el: Element, record: RenderRecord): void { el.removeAttribute(name); } } + // Confirmation badge only on an honestly-ok slot (TS creative placed and + // visible), never on the iframe itself — ties a physical banner to its + // panel row. Hidden / gam-only / empty slots stay unbadged on purpose. + if ( + el instanceof HTMLElement && + el.tagName !== 'IFRAME' && + traceOverlayEnabled() && + panelStatus(record) === 'ok' + ) { + 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 37f43f13..933cf362 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -100,6 +100,29 @@ export interface RenderRecord { 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; /** Epoch ms when the record was written. */ 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 2dc333cf..dc124374 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,5 @@ import { log } from '../../core/log'; -import { recordRender, stampCreativeTrace } from '../../core/trace'; +import { recordRender, stampCreativeTrace, isEffectivelyVisible } from '../../core/trace'; import type { AuctionSlot, AuctionBidData, RenderServedFrom, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -290,13 +290,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. @@ -308,6 +314,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; @@ -324,11 +331,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 = ''; @@ -341,9 +351,11 @@ function injectAdmIntoSlot(divId: string, adm: string): void { 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; } } @@ -595,12 +607,25 @@ export function installTsAdInit(): void { // Read ts.bids live (not the snapshot above) so post-navigation bid data is used. const bid = (ts.bids ?? {})[slotId] ?? {}; - // Trace: registry entry + DOM markers joining the GAM-rendered - // creative back to the server-side auction bid for this slot. + // 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. + // Run before recording so the trace reflects the post-injection state. + const injected = bid.adm ? injectAdmIntoSlot(divId, bid.adm) : undefined; + + // Trace: registry entry + DOM markers joining the GAM render to the + // server-side auction bid. `rendered` is GAM's own non-empty signal; + // `injected`/`visible` carry the honest "is the TS creative actually + // on screen" state so the panel does not overclaim (a non-empty GAM + // slot is not proof the TS creative rendered). + const slotEl = document.getElementById(divId); const record = recordRender({ slotId, path: 'ssat', rendered: !event.isEmpty, + gamEmpty: event.isEmpty, + injected, + visible: isEffectivelyVisible(slotEl), elementId: divId, auctionId: bid.hb_auction_id, bidder: bid.hb_bidder, @@ -609,15 +634,7 @@ export function installTsAdInit(): void { admHash: bid.hb_adm_hash, servedFrom: 'gam', }); - const slotEl = document.getElementById(divId); if (slotEl) stampCreativeTrace(slotEl, record); - - // 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); - } }); } @@ -873,10 +890,14 @@ function recordBridgeRender( 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, diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts index ba7a640e..9a5f5a1c 100644 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -1,10 +1,28 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { recordRender, stampCreativeTrace, RENDER_EVENT_NAME } from '../../src/core/trace'; +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', () => { @@ -112,3 +130,188 @@ describe('trace/stampCreativeTrace', () => { 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 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 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 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, still one row. + expect(panels[0].textContent).toContain('TS Render Trace · 1/1 ok'); + expect(panels[0].textContent).toContain('×2'); + }); + + 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, 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, + 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 ✓ 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/trusted-server.example.toml b/trusted-server.example.toml index 0951b781..65b78aba 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 From e1a8b81b6eff5230cfe7690a88d42b898bfc3aa1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 18 Jul 2026 13:25:44 +0530 Subject: [PATCH 03/11] Trace client-side /auction renders and stop overclaiming GAM renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prod page runs two independent auctions against the same placements: the one-time SSAT auction on navigation, and an ongoing client-side /auction driven by GAM refresh through the Prebid.js trustedServer adapter. Only the SSAT path was traced, so every Prebid render was invisible to the overlay. Instrument the /auction path at its authoritative render signal. The adapter now forwards the server-side trace tuple to Prebid as meta.tsAuctionId / meta.tsAdmHash, and a bidWon listener records an `auction`-path entry (servedFrom: prebid) keyed by the ad-unit code with that auction's own ID, hash and visibility. Only bids carrying meta.tsAuctionId — i.e. the trustedServer seat — are traced, so a client-side bidder's render is never attributed to Trusted Server. The listener only observes and stamps; it does not touch Prebid's rendering. Also fix an overclaim in the SSAT path: with inject_adm_for_testing off there is no adm to inject, which left `injected` unset and let panelStatus fall through to `ok` — claiming a confirmed TS render for a slot TS had merely targeted. Targeting-only renders now report `injected: false`, and `ok` requires an explicit confirmed placement so any future path that omits the signal degrades to gam-only rather than silently claiming success. --- .../trusted-server-js/lib/src/core/trace.ts | 9 +- .../trusted-server-js/lib/src/core/types.ts | 2 +- .../lib/src/integrations/gpt/index.ts | 5 +- .../lib/src/integrations/prebid/index.ts | 95 ++++++++++++++++++- .../lib/test/core/trace.test.ts | 12 +++ .../test/integrations/prebid/index.test.ts | 78 +++++++++++++++ 6 files changed, 195 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index a7634bfb..faa3c1b7 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -89,9 +89,12 @@ 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'; - // injected===false means TS applied targeting only; the creative is GAM's and - // unreadable, so we must not claim it as a confirmed TS render. - if (record.injected === false) return 'gam-only'; + // `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'; } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 933cf362..db2a9233 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -72,7 +72,7 @@ export interface AuctionBidData { } /** How a creative reached the page for a [`RenderRecord`]. */ -export type RenderServedFrom = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache'; +export type RenderServedFrom = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid'; /** * One entry in `window.tsjs.renders` — the client-side half of the render 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 dc124374..45e7ff25 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -611,7 +611,10 @@ export function installTsAdInit(): void { // 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. // Run before recording so the trace reflects the post-injection state. - const injected = bid.adm ? injectAdmIntoSlot(divId, bid.adm) : undefined; + // 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; 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 be839d8f..2b4dca91 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/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts index 9a5f5a1c..05f5d174 100644 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -208,6 +208,18 @@ describe('trace/floating panel', () => { 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 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); 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 9ad7945c..e68aa310 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', () => { From 4f45974e5b5227c4dffea49bfec211814576bb22 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 18 Jul 2026 16:15:09 +0530 Subject: [PATCH 04/11] Recover GPT slots orphaned by a client re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client framework can replace the ad divs after GPT slots were bound to them: the publisher's React app serves ids like `ad-header-0-_R_ssr_` and swaps them for client ids (`ad-header-0-_r_1_`) during hydration. GPT is left holding slots whose element no longer exists — GAM reports "defineSlot was called without a corresponding DIV", still fetches a creative for them, and the bid is silently wasted with nowhere to render. Waiting for the divs to merely exist cannot help, because at adInit() time the server-rendered divs are present and are only later replaced. So rather than delaying the initial ad request, detect the swap after the fact: a debounced MutationObserver armed after adInit() looks for TS-defined slots whose element left the document and re-runs adInit(), which destroys the orphans and re-binds against the live DOM — reusing the publisher's own slot for that div when they have since defined one. Bounded deliberately, since each re-bind re-requests the affected slots: a 250ms quiet period, a 5s watch window (measured: the swap lands ~2-3s in, so the existing 2s SPA wait would miss it), and at most two re-binds per page load, with the attempt budget shared across the adInit() the watcher itself triggers so it cannot loop. Verified against the live page through the dev proxy: two orphaned slots were detected and re-bound, leaving zero orphans, with all three slots tracing to live client-id elements. (cherry picked from commit e1badbb92af80d6544a351b3fcd654e09e4191c0) --- .../lib/src/integrations/gpt/index.ts | 91 ++++++++++++++++ .../lib/test/integrations/gpt/ad_init.test.ts | 103 ++++++++++++++++++ 2 files changed, 194 insertions(+) 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 45e7ff25..29393926 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -35,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) // ------------------------------------------------------------------ @@ -459,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); @@ -674,6 +759,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); + } }); }; } 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 a304952f..b4197653 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 @@ -1387,3 +1387,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_'); + }); +}); From 9e46233b2929d92cd2f7586213d61773a2e84ffb Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 18 Jul 2026 16:48:05 +0530 Subject: [PATCH 05/11] Show the render trace as a timeline and badge every rendered slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel collapsed each slot to a single row with a ×N counter, so a publisher page that refreshes its slots on every render (autoblog's ad-service refreshes from its own slotRenderEnded handler) showed a climbing number instead of what actually happened. Keep an append-only `window.tsjs.renderLog` alongside the per-slot `renders` registry and render it newest-first, one entry per render, with a wall-clock time and a `#N` sequence. The log is trimmed to the most recent entries so a page that refreshes indefinitely cannot grow it without bound; `renders` still collapses per slot for "did this ever render" checks. Also badge every slot that actually shows a creative, not just confirmed TS renders. Gating the badge on `ok` meant production — where inject_adm_for_testing is off and TS only applies GAM targeting — never displayed one at all, since every slot is honestly `gam-only`. The badge now carries its status colour and mark: green ✓ for a confirmed TS render, blue ◐ for gam-only. Slots with nothing on screen (`empty`) or nothing visible (`hidden`) stay unbadged, as there is no creative to label. (cherry picked from commit 013f5e64646d80f101789e73e3786fcb37558f62) --- .../trusted-server-js/lib/src/core/trace.ts | 50 +++++++++++++------ .../trusted-server-js/lib/src/core/types.ts | 7 +++ .../lib/test/core/trace.test.ts | 14 +++--- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index faa3c1b7..e85e3939 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -23,6 +23,13 @@ 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; + /** CSS class of the per-slot confirmation badge (only on honestly-ok slots). */ export const TRACE_BADGE_CLASS = 'ts-render-badge'; @@ -116,6 +123,7 @@ const STATUS_STYLE: Record .${TRACE_BADGE_CLASS}`).forEach((n) => n.remove()); const position = getComputedStyle(el).position; @@ -125,7 +133,7 @@ function attachTraceBadge(el: HTMLElement, record: RenderRecord): void { const badge = document.createElement('div'); badge.className = TRACE_BADGE_CLASS; - badge.textContent = `TS ✓ ${record.bidder ?? '?'}`; + badge.textContent = `TS ${style.mark} ${record.bidder ?? '?'}${style.label === 'ok' ? '' : ` · ${style.label}`}`; badge.title = [ `slot: ${record.slotId}`, `auction: ${record.auctionId ?? '?'}`, @@ -143,7 +151,7 @@ function attachTraceBadge(el: HTMLElement, record: RenderRecord): void { s.setProperty('font', '10px/1.5 ui-monospace, Menlo, Consolas, monospace'); s.setProperty('padding', '1px 5px'); s.setProperty('color', '#fff'); - s.setProperty('background', 'rgba(0,128,0,0.9)'); + s.setProperty('background', style.color); s.setProperty('border-radius', '3px'); el.appendChild(badge); } @@ -250,7 +258,8 @@ function buildPanelRow(record: RenderRecord): HTMLElement { ].join('\n'); const line1 = document.createElement('div'); - line1.textContent = `${style.mark} ${record.slotId} · ${style.label}`; + const clock = new Date(record.at).toLocaleTimeString('en-GB', { hour12: false }); + line1.textContent = `${clock} ${style.mark} ${record.slotId} · ${style.label}`; line1.style.setProperty('font-weight', '600'); line1.style.setProperty('color', style.color); @@ -260,7 +269,7 @@ function buildPanelRow(record: RenderRecord): HTMLElement { const line3 = document.createElement('div'); line3.style.setProperty('color', '#777'); - line3.textContent = `${stateSummary(record)} · auction ${short(record.auctionId)}${record.count > 1 ? ` · ×${record.count}` : ''}`; + line3.textContent = `${stateSummary(record)} · auction ${short(record.auctionId)} · #${record.count}`; row.append(line1, line2, line3); return row; @@ -279,10 +288,13 @@ export function renderTracePanel(): void { if (!panel) return; const renders = window.tsjs?.renders ?? {}; - const records = Object.values(renders).sort((a, b) => a.slotId.localeCompare(b.slotId)); + 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 = records.filter((r) => panelStatus(r) === 'ok').length; + 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(); @@ -299,7 +311,7 @@ export function renderTracePanel(): void { hs.setProperty('font-weight', '700'); const title = document.createElement('span'); - title.textContent = `TS Render Trace · ${ok}/${records.length} ok`; + title.textContent = `TS Render Trace · ${ok}/${slots.length} slots ok · ${history.length} renders`; const close = document.createElement('button'); close.textContent = '×'; @@ -320,10 +332,10 @@ export function renderTracePanel(): void { hint.style.setProperty('padding', '2px 10px 4px'); hint.style.setProperty('color', '#777'); hint.style.setProperty('font-size', '9px'); - hint.textContent = 'click a row to copy its full record · hover for detail'; + hint.textContent = 'newest first · click a row to copy its full record · hover for detail'; panel.appendChild(hint); - if (records.length === 0) { + if (history.length === 0) { const empty = document.createElement('div'); empty.style.setProperty('padding', '6px 10px'); empty.style.setProperty('color', '#bbb'); @@ -332,7 +344,7 @@ export function renderTracePanel(): void { return; } - for (const record of records) { + for (const record of history) { panel.appendChild(buildPanelRow(record)); } } catch (err) { @@ -357,6 +369,13 @@ export function recordRender(record: Omit): Render 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 }); } @@ -404,14 +423,17 @@ export function stampCreativeTrace(el: Element, record: RenderRecord): void { el.removeAttribute(name); } } - // Confirmation badge only on an honestly-ok slot (TS creative placed and - // visible), never on the iframe itself — ties a physical banner to its - // panel row. Hidden / gam-only / empty slots stay unbadged on purpose. + // 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() && - panelStatus(record) === 'ok' + (status === 'ok' || status === 'gam-only') ) { attachTraceBadge(el, record); } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index db2a9233..ad9e8fb7 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -160,6 +160,13 @@ export interface TsjsApi { 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. */ diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts index 05f5d174..86ecbcae 100644 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -181,7 +181,7 @@ describe('trace/floating panel', () => { 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 ok'); + 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'); @@ -194,7 +194,7 @@ describe('trace/floating panel', () => { recordRender({ ...record, visible: false }); const panel = document.getElementById(TRACE_PANEL_ID); - expect(panel!.textContent).toContain('0/1 ok'); + expect(panel!.textContent).toContain('0/1 slots ok'); expect(panel!.textContent).toContain('⚠ slot-1 · hidden'); }); @@ -204,7 +204,7 @@ describe('trace/floating panel', () => { recordRender({ ...record, injected: false, gamEmpty: false, visible: true }); const panel = document.getElementById(TRACE_PANEL_ID); - expect(panel!.textContent).toContain('0/1 ok'); + expect(panel!.textContent).toContain('0/1 slots ok'); expect(panel!.textContent).toContain('◐ slot-1 · gam-only'); }); @@ -216,7 +216,7 @@ describe('trace/floating panel', () => { recordRender({ ...withoutInjected, gamEmpty: false, visible: true }); const panel = document.getElementById(TRACE_PANEL_ID); - expect(panel!.textContent).toContain('0/1 ok'); + expect(panel!.textContent).toContain('0/1 slots ok'); expect(panel!.textContent).toContain('◐ slot-1 · gam-only'); }); @@ -227,9 +227,9 @@ describe('trace/floating panel', () => { const panels = document.querySelectorAll(`#${TRACE_PANEL_ID}`); expect(panels).toHaveLength(1); - // Second render of the same slot bumps the count, still one row. - expect(panels[0].textContent).toContain('TS Render Trace · 1/1 ok'); - expect(panels[0].textContent).toContain('×2'); + // 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('close button removes the panel', () => { From 27a47279a12541312b03ad56b66f09e47125adb9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 20 Jul 2026 10:26:52 +0530 Subject: [PATCH 06/11] Stop attributing GAM refreshes to the finished server-side auction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-side auction runs once per navigation, but the slotRenderEnded handler read the winning bid out of window.tsjs.bids on every render. That map never changes, so each publisher-driven GAM refresh re-stamped the page-load auction's id, bidder and adm hash and labelled itself `ssat` — claiming a render the server-side auction never produced. A slot refreshed six times over a minute showed six `ssat` rows all pointing at one auction. Scope the claim to the render that actually consumes it: adInit arms a per-slot flag when it applies bid targeting, and the first slotRenderEnded clears it. Later renders are recorded as `gam-refresh` with the stale attribution dropped from both the record and the DOM markers, since GAM re-requested the slot on its own and the returned creative cannot be traced to any Trusted Server auction. These rows are where the client-side /auction path will report real attribution via Prebid's bidWon once that bundle ships; labelling them `ssat` hid that gap instead of showing it. The /auction recording path is unchanged. (cherry picked from commit c4999f7e9418bd48f9a476d9aab46a05e1be7166) --- .../trusted-server-js/lib/src/core/types.ts | 21 +++++- .../lib/src/integrations/gpt/index.ts | 31 ++++++-- .../lib/test/integrations/gpt/ad_init.test.ts | 71 +++++++++++++++++++ 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index ad9e8fb7..2af87e70 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -82,8 +82,17 @@ export type RenderServedFrom = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'p export interface RenderRecord { /** Slot the creative was rendered for. */ slotId: string; - /** Which render path produced this record. */ - path: 'auction' | 'ssat'; + /** + * 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). */ @@ -173,6 +182,14 @@ export interface TsjsApi { 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 29393926..86b0cea5 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -640,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. @@ -707,20 +714,32 @@ export function installTsAdInit(): void { // 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, - path: 'ssat', rendered: !event.isEmpty, gamEmpty: event.isEmpty, injected, visible: isEffectivelyVisible(slotEl), elementId: divId, - auctionId: bid.hb_auction_id, - bidder: bid.hb_bidder, - adId: bid.hb_adid, - creativeId: bid.hb_crid, - admHash: bid.hb_adm_hash, servedFrom: 'gam', + ...attributed, }); if (slotEl) stampCreativeTrace(slotEl, record); }); 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 b4197653..c6f2b292 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 @@ -574,6 +574,77 @@ describe('installTsAdInit', () => { 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; From 7b7c4664a2255c540dec99a446e4fb74cf17bf39 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 20 Jul 2026 12:14:01 +0530 Subject: [PATCH 07/11] Number every render and keep GAM's fill signal on refresh rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three trace-panel fixes, all surfaced by the gam-refresh rows the previous commit introduced: - Restore the `gam:filled`/`gam:empty` marker on gam-refresh rows. It was gated on `path === 'ssat'`, which hid GAM's own fill signal on exactly the rows where "did GAM fill it this time" is the whole question. - Stop rendering absent attribution as `? · ?` and `auction ?`. An unattributed GAM refresh carries no bidder/hash/auction id by design, so the row now says `no TS attribution` and drops the auction segment rather than looking like a failed lookup. - Give each render a page-global `seq`, shown as `#N` on both the panel row and the on-creative badge so the two point at each other, and mark the row still live for its slot as `◂ current`. The per-slot render count keeps its own `×N`. seq is module-scoped, not stored on window.tsjs, so a re-executed bundle restarts the sequence instead of handing two renders one number. (cherry picked from commit c0811bcb34086cda1a3d4ceeb35e72f7ba0cf04f) --- .../trusted-server-js/lib/src/core/trace.ts | 56 +++++++++++-- .../trusted-server-js/lib/src/core/types.ts | 6 ++ .../lib/test/core/trace.test.ts | 84 ++++++++++++++++++- 3 files changed, 135 insertions(+), 11 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index e85e3939..fabd4552 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -30,6 +30,13 @@ export const TRACE_PANEL_ID = 'ts-render-trace-panel'; */ 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'; @@ -133,8 +140,13 @@ function attachTraceBadge(el: HTMLElement, record: RenderRecord): void { const badge = document.createElement('div'); badge.className = TRACE_BADGE_CLASS; - badge.textContent = `TS ${style.mark} ${record.bidder ?? '?'}${style.label === 'ok' ? '' : ` · ${style.label}`}`; + // 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 ?? '?'}`, @@ -197,10 +209,26 @@ function ensureTracePanel(): HTMLElement | null { 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[] = []; - if (record.path === 'ssat') { + // 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) { @@ -240,6 +268,7 @@ function buildPanelRow(record: RenderRecord): HTMLElement { // 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}`, @@ -259,17 +288,30 @@ function buildPanelRow(record: RenderRecord): HTMLElement { const line1 = document.createElement('div'); const clock = new Date(record.at).toLocaleTimeString('en-GB', { hour12: false }); - line1.textContent = `${clock} ${style.mark} ${record.slotId} · ${style.label}`; + // `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'); - line2.textContent = `${record.path}${mechanismSuffix(record)} · ${record.bidder ?? '?'} · ${short(record.admHash)}`; + // 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'); - line3.textContent = `${stateSummary(record)} · auction ${short(record.auctionId)} · #${record.count}`; + 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; @@ -361,8 +403,8 @@ export function renderTracePanel(): void { * 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, at: Date.now() }; +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 ??= {}); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 2af87e70..1ada7f10 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -134,6 +134,12 @@ export interface RenderRecord { 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; } diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts index 86ecbcae..199aa499 100644 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -84,6 +84,7 @@ describe('trace/stampCreativeTrace', () => { adId: 'cache-uuid-1', admHash: 'a1b2c3d4e5f60718', count: 1, + seq: 1, at: 1, }; @@ -110,6 +111,7 @@ describe('trace/stampCreativeTrace', () => { admHash: 'a1b2c3d4e5f60718', servedFrom: 'gam', count: 1, + seq: 1, at: 1, }; stampCreativeTrace(el, first); @@ -120,6 +122,7 @@ describe('trace/stampCreativeTrace', () => { rendered: true, auctionId: 'auction-new', count: 2, + seq: 2, at: 2, }; stampCreativeTrace(el, second); @@ -132,7 +135,7 @@ describe('trace/stampCreativeTrace', () => { }); describe('trace/floating panel', () => { - const record: Omit = { + const record: Omit = { slotId: 'slot-1', path: 'ssat', rendered: true, @@ -229,7 +232,79 @@ describe('trace/floating panel', () => { 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'); + 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', () => { @@ -243,7 +318,7 @@ describe('trace/floating panel', () => { it('renderTracePanel is a no-op while disarmed even if renders exist', () => { (window as { tsjs?: TsjsApi }).tsjs = { - renders: { 'slot-1': { ...record, count: 1, at: 1 } }, + renders: { 'slot-1': { ...record, count: 1, seq: 1, at: 1 } }, } as unknown as TsjsApi; renderTracePanel(); expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); @@ -288,6 +363,7 @@ describe('trace/confirmation badge', () => { admHash: 'a1b2c3d4e5f60718', servedFrom: 'gam', count: 1, + seq: 1, at: 1, }; @@ -298,7 +374,7 @@ describe('trace/confirmation badge', () => { stampCreativeTrace(el, okRecord); const badge = el.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement; expect(badge).toBeTruthy(); - expect(badge.textContent).toBe('TS ✓ mocktioneer'); + expect(badge.textContent).toBe('TS ✓ #1 · mocktioneer'); }); it('does not badge a hidden slot', () => { From 2c482d74480bf626eff685f48a776f90798cefda Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 20 Jul 2026 14:54:02 +0530 Subject: [PATCH 08/11] =?UTF-8?q?Show=20absent=20attribution=20as=20?= =?UTF-8?q?=E2=80=94=20instead=20of=20=3F=20in=20trace=20tooltips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visible row text already says "no TS attribution" for a gam-refresh, but the badge title and row hover tooltip still rendered the same absent fields as `?`, which reads like a failed lookup rather than "there is nothing to attribute here by design". Switch both to `—`, matching the `gam_empty ?? '—'` convention the row tooltip already used elsewhere in the same list. (cherry picked from commit b4908cd2229c8f13f6614cc71819ca408ac2d454) --- .../trusted-server-js/lib/src/core/trace.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index fabd4552..e52a21ff 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -148,11 +148,11 @@ function attachTraceBadge(el: HTMLElement, record: RenderRecord): void { badge.title = [ `render: #${record.seq}`, `slot: ${record.slotId}`, - `auction: ${record.auctionId ?? '?'}`, - `bidder: ${record.bidder ?? '?'}`, - `creative: ${record.creativeId ?? '?'}`, - `adm_hash: ${record.admHash ?? '?'}`, - `served: ${record.servedFrom ?? '?'}`, + `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'); @@ -276,13 +276,13 @@ function buildPanelRow(record: RenderRecord): HTMLElement { `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 ?? '?'}`, + `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'); From 50ce3684c1a4604f7cb2cde689ed72c9739aa8a6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 20 Jul 2026 14:54:20 +0530 Subject: [PATCH 09/11] Trace SSAT creatives the Universal Creative bridge renders inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pbRender bridge has two branches for serving a winning SSAT bid into GAM's Universal Creative: fetch from PBS Cache, or use `bid.adm` directly when present (added by the SSAT inline-creative work, and the only path production actually exercises for bidders that carry markup inline). The PBS Cache branch calls recordBridgeRender after replying; the inline-adm branch replied, fired win/billing beacons, and logged success, but never called it — so this render path, the strongest confirmation signal SSAT has (TS supplies the exact bytes GAM's own creative asked for by name), was invisible to the trace panel. Every SSAT row capped at gam-only even when this branch was serving real creative. Add the missing call, matching the PBS Cache branch's placement. Add regression coverage on both branches — neither had a trace assertion before, so this gap could recur silently on either one. (cherry picked from commit 25316dd1583e365ed843c50c64960c5c7fcc7e9a) --- .../lib/test/integrations/gpt/ad_init.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 c6f2b292..83e2bca6 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 @@ -1130,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' }), @@ -1267,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 () => { From b0f31efbf673e9f4a36a52325d0778cb5d6fae33 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 20 Jul 2026 15:25:02 +0530 Subject: [PATCH 10/11] Fall back to the OpenRTB bid id for hb_adid when cache_id and ad_id are absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live on autoblog: Kargo's response carries neither a Prebid Cache UUID nor an `adid`, so hb_adid was omitted for every SSAT bid from that bidder. That broke the pbRender bridge's reverse lookup (adId -> hb_adid) for GAM's Universal Creative postMessage protocol — verified in the browser that GAM sends real 'Prebid Request' messages with real adIds on this account, but window.tsjs.bids[slot].hb_adid was undefined for all three winning bids, so the bridge could never match any of them. Confirmed rendering (injected: true) was structurally unreachable for this bidder regardless of what GAM did. bid.bid_id (the OpenRTB bid's own `id`, always present per spec) already flows through the pipeline unused for this purpose. It is unique per bid instance rather than a creative identifier, but that is exactly what hb_adid needs here: a stable value GAM's Universal Creative echoes back so the bridge can find the winning bid. Add it as the last-resort fallback, after cache_id, the APS renderer's bid id, and ad_id — all three still take priority where present, locked in by test. (cherry picked from commit 281df3871c6c25240af68bca51f2f39171baf2d4) --- .../trusted-server-core/src/auction/types.rs | 5 +- crates/trusted-server-core/src/publisher.rs | 70 +++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 2f522398..fdee6ef1 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -203,7 +203,10 @@ pub struct Bid { /// 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. + /// 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`). diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index e4ac8656..f670f380 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1982,7 +1982,18 @@ pub(crate) fn build_bid_map( // 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(), @@ -4324,7 +4335,9 @@ mod tests { nurl: None, burl: None, ad_id: Some("bid-impression-id".to_string()), - bid_id: None, + // 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()), @@ -4341,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()), @@ -4372,7 +4385,9 @@ mod tests { nurl: None, burl: None, ad_id: Some("aps-bid-token".to_string()), - bid_id: None, + // 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, @@ -4389,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(), @@ -4402,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(), @@ -4434,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" ); } From 03aa5ec06fd156fcdcbc49142a5e1cf55665d551 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 17:17:49 +0530 Subject: [PATCH 11/11] Correct render tracing across auction paths --- crates/trusted-server-core/src/publisher.rs | 23 +- .../trusted-server-js/lib/src/core/auction.ts | 8 + .../trusted-server-js/lib/src/core/request.ts | 4 + .../trusted-server-js/lib/src/core/trace.ts | 127 ++++- .../trusted-server-js/lib/src/core/types.ts | 36 +- .../lib/src/integrations/gpt/index.ts | 445 +++++++++++++++--- .../lib/src/integrations/prebid/index.ts | 97 ++-- .../lib/test/core/auction.test.ts | 2 + .../lib/test/core/trace.test.ts | 108 +++++ .../lib/test/integrations/gpt/ad_init.test.ts | 352 +++++++++++++- .../test/integrations/gpt/spa_hook.test.ts | 54 +++ .../test/integrations/prebid/index.test.ts | 137 +++++- 12 files changed, 1262 insertions(+), 131 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c073aae1..7969a57c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2100,9 +2100,9 @@ fn html_escape_for_script(s: &str) -> String { /// /// 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. +/// `hb_bid_id` (the bid's own `OpenRTB` `id`), and `hb_adm_hash` (when the bid +/// has creative markup) so the client can stamp rendered creatives with a tuple +/// that joins back to the server-side `auction winner:` log lines. pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, @@ -2132,6 +2132,17 @@ pub(crate) fn build_bid_map( serde_json::Value::String(crid.clone()), ); } + // The bid's own OpenRTB `id`, carried separately from `hb_adid` + // so the client can stamp the exact bid this render came from. + // `hb_adid` cannot serve that purpose: it holds the cache UUID + // or `adid` whenever one exists, and only falls back to the bid + // ID when neither does. Trace-only — never set as GAM targeting. + if let Some(ref bid_id) = bid.bid_id { + obj.insert( + "hb_bid_id".to_string(), + serde_json::Value::String(bid_id.clone()), + ); + } if let Some(hash) = bid.creative_trace_hash() { obj.insert("hb_adm_hash".to_string(), serde_json::Value::String(hash)); } @@ -4460,6 +4471,7 @@ mod tests { "https://ssp/bill", ); bid.creative = Some("
Creative
".to_string()); + bid.bid_id = Some("bid-abc123".to_string()); bid.crid = Some("cr-98765".to_string()); winning_bids.insert("atf_sidebar_ad".to_string(), bid); @@ -4485,6 +4497,11 @@ mod tests { Some("cr-98765"), "should carry the upstream creative ID" ); + assert_eq!( + obj.get("hb_bid_id").and_then(|v| v.as_str()), + Some("bid-abc123"), + "should carry the upstream bid ID separately" + ); assert_eq!( obj.get("hb_adm_hash").and_then(|v| v.as_str()), Some(crate::auction::adm_trace_hash("
Creative
").as_str()), diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index 737f0e31..32ca4325 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -57,6 +57,13 @@ export interface AuctionBid { adomain: string[]; /** Server-side auction ID (response top-level `id` / `ext.ts.auction_id`). */ auctionId?: string; + /** + * The bid's own OpenRTB `id` — the trace key identifying this exact bid in + * the server-side `auction winner:` log line. Kept distinct from + * [`creativeId`], which is the advertiser's creative (`crid`) and is reused + * across bids and slots. + */ + bidId?: string; /** Trace hash of the delivered adm (`ext.ts.adm_hash`, 16 hex chars of SHA-256). */ admHash?: string; } @@ -160,6 +167,7 @@ export function parseAuctionResponse(body: any): AuctionBid[] { typeof tsExt?.auction_id === 'string' && tsExt.auction_id !== '' ? tsExt.auction_id : responseAuctionId, + bidId: typeof b?.id === 'string' && b.id !== '' ? b.id : undefined, admHash: typeof tsExt?.adm_hash === 'string' && tsExt.adm_hash !== '' ? tsExt.adm_hash : undefined, }); diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index 9dd755cf..69aadada 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -21,6 +21,7 @@ type RenderCreativeInlineOptions = { seat: string; creativeId: string; auctionId?: string; + bidId?: string; admHash?: string; }; @@ -64,6 +65,7 @@ export function requestAds( seat: bid.seat, creativeId: bid.creativeId, auctionId: bid.auctionId, + bidId: bid.bidId, admHash: bid.admHash, }); } @@ -93,12 +95,14 @@ function renderCreativeInline({ seat, creativeId, auctionId, + bidId, admHash, }: RenderCreativeInlineOptions): void { const trace = { slotId, path: 'auction' as const, auctionId, + bidId, bidder: seat, creativeId, admHash, diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index e52a21ff..2acc5090 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -31,11 +31,32 @@ export const TRACE_PANEL_ID = 'ts-render-trace-panel'; 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. + * Fallback for [`nextRenderSeq`] when `window.tsjs` is unreachable (no DOM, or + * a throwing property access). Never the primary counter — see below. */ -let renderSeq = 0; +let fallbackRenderSeq = 0; + +/** + * Allocate the next value for [`RenderRecord.seq`]. + * + * The counter lives on the shared `window.tsjs` object, not in module scope: + * `build-all.mjs` emits core, GPT and every integration as separate + * self-contained IIFEs, each with its own inlined copy of this module. A + * module-scoped counter would therefore restart at 1 in each bundle and hand + * two different renders the same number — duplicate `#1` panel rows and + * badges across the SSAT and `/auction` paths. + */ +function nextRenderSeq(): number { + try { + const ts = (window.tsjs ??= {} as TsjsApi); + const next = Math.max(ts.renderSeq ?? 0, fallbackRenderSeq) + 1; + ts.renderSeq = next; + fallbackRenderSeq = next; + return next; + } catch { + return ++fallbackRenderSeq; + } +} /** CSS class of the per-slot confirmation badge (only on honestly-ok slots). */ export const TRACE_BADGE_CLASS = 'ts-render-badge'; @@ -131,7 +152,6 @@ const STATUS_STYLE: Record .${TRACE_BADGE_CLASS}`).forEach((n) => n.remove()); const position = getComputedStyle(el).position; if (position === 'static' || position === '') { @@ -150,6 +170,7 @@ function attachTraceBadge(el: HTMLElement, record: RenderRecord): void { `slot: ${record.slotId}`, `auction: ${record.auctionId ?? '—'}`, `bidder: ${record.bidder ?? '—'}`, + `bid_id: ${record.bidId ?? '—'}`, `creative: ${record.creativeId ?? '—'}`, `adm_hash: ${record.admHash ?? '—'}`, `served: ${record.servedFrom ?? '—'}`, @@ -168,6 +189,19 @@ function attachTraceBadge(el: HTMLElement, record: RenderRecord): void { el.appendChild(badge); } +/** + * Remove this element's own trace badge, if it has one. + * + * Must run on *every* stamp, not only the ones that go on to attach a new + * badge: a slot that re-renders into `empty` or `hidden` gets no replacement + * badge, so without an unconditional removal it would keep displaying the green + * or blue badge from its previous render — contradicting the status the panel + * shows for the same slot. + */ +function removeTraceBadge(el: HTMLElement): void { + el.querySelectorAll(`:scope > .${TRACE_BADGE_CLASS}`).forEach((n) => n.remove()); +} + /** Truncate a long id for the compact panel row while keeping the tail. */ function short(value: string | undefined, keep = 10): string { if (!value) return '?'; @@ -213,10 +247,14 @@ function ensureTracePanel(): HTMLElement | null { * Whether this record is still the live render for its slot — i.e. the entry * `window.tsjs.renders` currently holds. Every other row in the log has been * superseded by a later render of the same slot. + * + * Compares by object identity, not by `seq`: the registry and the history hold + * the same record objects, so identity is exact regardless of how sequence + * numbers were allocated. */ function isCurrentRender(record: RenderRecord): boolean { try { - return window.tsjs?.renders?.[record.slotId]?.seq === record.seq; + return window.tsjs?.renders?.[record.slotId] === record; } catch { return false; } @@ -280,6 +318,7 @@ function buildPanelRow(record: RenderRecord): HTMLElement { `bidder: ${record.bidder ?? '—'}`, `creative: ${record.creativeId ?? '—'}`, `ad_id: ${record.adId ?? '—'}`, + `bid_id: ${record.bidId ?? '—'}`, `adm_hash: ${record.admHash ?? '—'}`, `served: ${record.servedFrom ?? '—'}`, `element: ${record.elementId ?? '—'}`, @@ -404,7 +443,7 @@ export function renderTracePanel(): void { * single choke point every render passes through. */ export function recordRender(record: Omit): RenderRecord { - const full: RenderRecord = { ...record, count: 1, seq: ++renderSeq, at: Date.now() }; + const full: RenderRecord = { ...record, count: 1, seq: nextRenderSeq(), at: Date.now() }; try { const ts = (window.tsjs ??= {} as TsjsApi); const renders = (ts.renders ??= {}); @@ -431,6 +470,61 @@ export function recordRender(record: Omit) return full; } +/** + * Fields a later signal about an already-recorded render may contribute. + * Identity (`slotId`) and bookkeeping (`seq`, `count`, `at`) are fixed at + * [`recordRender`] time and are never revised. + */ +export type RenderUpdate = Partial>; + +/** Confirmation flags that a later, weaker signal must never clear. */ +const CONFIRMATION_FIELDS = ['rendered', 'injected'] as const; + +/** + * Merge a later signal into an existing render record, **in place**. + * + * One impression can be observed twice: GAM's `slotRenderEnded` and the Prebid + * Universal Creative bridge both describe the same GAM ad request, and a + * deferred ADM placement resolves an animation frame after the render was first + * recorded. Appending a second [`recordRender`] for those would inflate the + * slot's `count`, the history length, the page-global sequence numbers and the + * panel totals — one impression must stay one row. + * + * So the later signal enriches the record instead: `seq`, `count` and `at` are + * left untouched and no new history entry is appended. Because the registry and + * the history hold the *same* object, mutating it updates both. + * + * Confirmations only ever strengthen. A `false` in `patch` cannot clear a + * `true` already on the record, so the weaker GAM-only signal arriving after + * the bridge's confirmed placement does not erase it. + */ +export function updateRender(record: RenderRecord, patch: RenderUpdate): RenderRecord { + try { + const fields = record as unknown as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if ( + value === false && + fields[key] === true && + (CONFIRMATION_FIELDS as readonly string[]).includes(key) + ) { + continue; + } + fields[key] = value; + } + } catch (err) { + log.warn('trace: failed to update render record', { slotId: record.slotId, err }); + } + try { + window.dispatchEvent(new CustomEvent(RENDER_EVENT_NAME, { detail: record })); + } catch (err) { + // CustomEvent unavailable — the mutated record above still stands. + log.debug('trace: failed to dispatch render update event', { slotId: record.slotId, err }); + } + renderTracePanel(); + return record; +} + /** * Stamp an element with `data-ts-*` attributes carrying the trace tuple, so * a creative in the DOM can be joined to the server-side `auction winner:` / @@ -450,6 +544,7 @@ export function stampCreativeTrace(el: Element, record: RenderRecord): void { ['data-ts-auction-id', record.auctionId], ['data-ts-bidder', record.bidder], ['data-ts-ad-id', record.adId], + ['data-ts-bid-id', record.bidId], ['data-ts-creative-id', record.creativeId], ['data-ts-adm-hash', record.admHash], ['data-ts-served-from', record.servedFrom], @@ -470,14 +565,16 @@ export function stampCreativeTrace(el: Element, record: RenderRecord): void { // 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); + // + // Any previous badge is dropped first, unconditionally, so a slot that + // re-renders into `empty` or `hidden` sheds the badge from its last render + // instead of contradicting the panel. + if (el instanceof HTMLElement && el.tagName !== 'IFRAME') { + removeTraceBadge(el); + const status = panelStatus(record); + if (traceOverlayEnabled() && (status === 'ok' || status === 'gam-only')) { + attachTraceBadge(el, record); + } } } catch (err) { log.warn('trace: failed to stamp element', { slotId: record.slotId, err }); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 1ada7f10..767a111a 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -57,6 +57,17 @@ export interface AuctionBidData { hb_adid?: string; hb_cache_host?: string; hb_cache_path?: string; + /** + * Upstream OpenRTB `bid.id` — the trace key that identifies this exact bid in + * the server-side `auction winner:` log line. + * + * Distinct from `hb_adid`, which is whatever value GAM's Universal Creative + * must echo back for the render bridge to find the bid (a PBS cache UUID, an + * `adid`, or — only when neither exists — the bid ID). Carried for tracing + * only: unlike the `hb_*` keys in `TS_BID_TARGETING_KEYS` this is never set as + * GAM key-value targeting. + */ + hb_bid_id?: string; /** Server-side auction ID — trace key joining this bid to server logs. */ hb_auction_id?: string; /** Upstream creative ID (OpenRTB `crid`), when the bidder returned one. */ @@ -103,6 +114,14 @@ export interface RenderRecord { bidder?: string; /** hb_adid (PBS cache UUID or OpenRTB adid). */ adId?: string; + /** + * Upstream OpenRTB `bid.id`, completing the + * (auctionId, slotId, bidder, bidId, creativeId, admHash) tuple that joins + * this render to exactly one server-side `auction winner:` log line. Never + * overloaded onto [`adId`], which carries a different value whenever the bid + * has a PBS cache UUID or an `adid`. + */ + bidId?: string; /** Upstream creative ID (OpenRTB crid). */ creativeId?: string; /** Trace hash of the creative markup (16 hex chars of SHA-256). */ @@ -189,13 +208,18 @@ export interface TsjsApi { /** 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. + * Monotonic render generation, bumped by every `adInit()` and by the start of + * every SPA navigation. Async work captures it and re-checks it on completion + * so a result belonging to a superseded route is discarded rather than + * applied to the current one. + */ + renderGeneration?: number; + /** + * Page-global render counter backing [`RenderRecord.seq`]. Lives here, on the + * object every bundle shares, because each generated IIFE inlines its own + * copy of `core/trace` — a module-scoped counter would restart per bundle. */ - ssatTargetingFresh?: Record; + renderSeq?: number; /** * Win/billing beacons already fired, keyed by `slotId|bidIdentity|kind|url`. * Used by the GPT render bridge so a bid's nurl/burl fire at most once even diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 86b0cea5..5dd06b60 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,6 +1,17 @@ import { log } from '../../core/log'; -import { recordRender, stampCreativeTrace, isEffectivelyVisible } from '../../core/trace'; -import type { AuctionSlot, AuctionBidData, RenderServedFrom, TsjsApi } from '../../core/types'; +import { + recordRender, + updateRender, + stampCreativeTrace, + isEffectivelyVisible, +} from '../../core/trace'; +import type { + AuctionSlot, + AuctionBidData, + RenderRecord, + RenderServedFrom, + TsjsApi, +} from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -53,6 +64,141 @@ const ORPHAN_RECONCILE_WINDOW_MS = 5000; */ const MAX_ORPHAN_RECONCILE_ATTEMPTS = 2; +// ---- Render generation ----------------------------------------------------- +// Every `adInit()` and every SPA navigation opens a new generation. Async work +// (a PBS Cache fetch, a debounced orphan re-bind, a GAM render event) captures +// the generation it started in and re-checks it before acting, so a result that +// belongs to a route the page has already left is discarded instead of being +// applied to the route now on screen. + +function currentGeneration(ts: TsjsApi): number { + return ts.renderGeneration ?? 0; +} + +function bumpGeneration(ts: TsjsApi): number { + const next = currentGeneration(ts) + 1; + ts.renderGeneration = next; + return next; +} + +/** Immutable context for one GPT request TS initiated for a slot. */ +interface PendingGptRender { + generation: number; + slotId: string; + bid: AuctionBidData; + attributed: boolean; +} + +/** + * Per-slot FIFO of requests awaiting `slotRenderEnded`. + * + * Publisher-owned GPT slots are reused across SPA routes, so stamping a single + * generation on the slot object is insufficient: route B overwrites that stamp + * before route A's late event arrives. GPT emits one render event per request in + * request order, so retaining each immutable request context lets the old event + * retire only route A's entry while route B's attribution remains queued. + */ +const pendingGptRenders = new WeakMap(); + +function enqueueGptRender(slot: GoogleTagSlot, pending: PendingGptRender): void { + const queue = pendingGptRenders.get(slot) ?? []; + queue.push(pending); + pendingGptRenders.set(slot, queue); +} + +function takeGptRender(slot: GoogleTagSlot): PendingGptRender | undefined { + const queue = pendingGptRenders.get(slot); + const pending = queue?.shift(); + if (queue?.length === 0) pendingGptRenders.delete(slot); + return pending; +} + +/** + * The render record for the GAM impression currently open on a slot. + * + * One GAM ad request is observed twice: `slotRenderEnded` carries GAM's own + * fill signal, and the Prebid Universal Creative bridge carries the proof that + * TS served the markup. They describe the same impression, so whichever arrives + * second enriches the first one's record rather than appending its own — + * otherwise a single creative counts as two renders, inflating the slot's + * `count`, the history length, the page-global sequence numbers and the panel + * totals. + */ +interface OpenImpression { + record: RenderRecord; + /** Generation the impression was opened in. */ + generation: number; + /** `hb_adid` both signals must agree on to be the same impression. */ + adId?: string; + /** Whether `slotRenderEnded` has reported on this impression. */ + gamSeen: boolean; + /** Whether the Universal Creative bridge has reported on this impression. */ + bridgeSeen: boolean; +} + +const openImpressions = new Map(); + +/** PBS Cache fetches still in flight, so navigation can abort them. */ +const inflightCacheFetches = new Set(); + +/** + * Retire everything armed for the route being left. + * + * Runs at the *start* of a navigation, not after `/__ts/page-bids` returns: a + * route whose markup commits faster than that request can otherwise trip the + * orphan watch or land a cache fetch on the new DOM while `ts.adSlots` and + * `ts.bids` still hold the previous route's auction. + */ +function beginNavigation(ts: TsjsApi): void { + bumpGeneration(ts); + stopOrphanWatch(); + for (const controller of inflightCacheFetches) { + controller.abort(); + } + inflightCacheFetches.clear(); + openImpressions.clear(); +} + +/** + * The impression `side` may still enrich, or `undefined` if it must open a new + * one. + * + * Requires the impression to be from the current route, for the same bid, not + * already reported on by this side, and still the slot's live render — the last + * check is what stops a signal that arrives after a *newer* render of the same + * slot from rewriting an impression the page has moved past. + */ +function enrichableImpression( + ts: TsjsApi, + slotId: string, + adId: string | undefined, + side: 'gam' | 'bridge' +): OpenImpression | undefined { + const open = openImpressions.get(slotId); + if (!open) return undefined; + if (open.generation !== currentGeneration(ts)) return undefined; + if (open.adId !== adId) return undefined; + if (side === 'gam' ? open.gamSeen : open.bridgeSeen) return undefined; + if (ts.renders?.[slotId] !== open.record) return undefined; + return open; +} + +function openImpression( + ts: TsjsApi, + slotId: string, + adId: string | undefined, + record: RenderRecord, + side: 'gam' | 'bridge' +): void { + openImpressions.set(slotId, { + record, + generation: currentGeneration(ts), + adId, + gamSeen: side === 'gam', + bridgeSeen: side === 'bridge', + }); +} + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -313,8 +459,18 @@ export function safeAdmIframeSrc(src: string): string | undefined { * animation-frame retry branch resolves after this returns, so it reports * `false` (placement deferred/unconfirmed) — the render trace must not claim a * placement that has not happened yet. + * + * That deferred branch reports its real outcome through `onDeferredPlacement` + * once the animation frame runs. Without it a placement that ultimately + * succeeded would stay recorded as unconfirmed and the panel would report + * `gam-only` forever, even though Trusted Server did place the creative. */ -function injectAdmIntoSlot(divId: string, adm: string): boolean { +function injectAdmIntoSlot( + divId: string, + adm: string, + onDeferredPlacement?: (placed: boolean) => void, + mayPlaceDeferred?: () => boolean +): boolean { try { // divId may be the container div (used by GPT slot) or the inner div. // Resolve it the same way the rest of adInit does (exact then prefix) so @@ -338,24 +494,41 @@ function injectAdmIntoSlot(divId: string, adm: string): boolean { // Retry on next animation frame so APS has a tick to insert its iframe; // if it still isn't there, replace slot content directly. requestAnimationFrame(() => { - const retryIframe = slotEl!.querySelector('iframe') as HTMLIFrameElement | null; - if (retryIframe) { - retryIframe.src = innerSrc; - log.debug(`[tsjs-gpt] gam-intercept: set iframe src (retry) for '${divId}'`); - } else { - slotEl!.innerHTML = ''; - const f = document.createElement('iframe'); - f.style.cssText = 'border:none'; - f.width = String(slotEl!.offsetWidth || 728); - f.height = String(slotEl!.offsetHeight || 90); - f.setAttribute('sandbox', ADM_IFRAME_SANDBOX); - f.setAttribute('data-ts-injected-adm', 'true'); - f.src = innerSrc; - slotEl!.appendChild(f); - log.debug(`[tsjs-gpt] gam-intercept: inserted src iframe for '${divId}'`); + let placed = false; + try { + // Validate before touching the DOM. A newer SPA route may reuse the + // same connected publisher slot element, so suppressing only the + // later trace update would still let the old callback overwrite the + // new route's creative. + if (mayPlaceDeferred && !mayPlaceDeferred()) { + onDeferredPlacement?.(false); + return; + } + const retryIframe = slotEl!.querySelector('iframe') as HTMLIFrameElement | null; + if (retryIframe) { + retryIframe.src = innerSrc; + placed = true; + log.debug(`[tsjs-gpt] gam-intercept: set iframe src (retry) for '${divId}'`); + } else { + slotEl!.innerHTML = ''; + const f = document.createElement('iframe'); + f.style.cssText = 'border:none'; + f.width = String(slotEl!.offsetWidth || 728); + f.height = String(slotEl!.offsetHeight || 90); + f.setAttribute('sandbox', ADM_IFRAME_SANDBOX); + f.setAttribute('data-ts-injected-adm', 'true'); + f.src = innerSrc; + slotEl!.appendChild(f); + placed = true; + log.debug(`[tsjs-gpt] gam-intercept: inserted src iframe for '${divId}'`); + } + } catch (err) { + log.warn('[tsjs-gpt] gam-intercept: deferred placement failed', err); } + onDeferredPlacement?.(placed); }); - // Placement deferred to the animation frame — not confirmed yet. + // Placement deferred to the animation frame — not confirmed yet. The + // callback above corrects the record once it resolves. return false; } else { // No extractable safe src — replace slot content with a sandboxed srcdoc iframe. @@ -518,6 +691,14 @@ export function orphanedTsSlots(ts: TsjsApi): GoogleTagSlot[] { * Bounded by {@link MAX_ORPHAN_RECONCILE_ATTEMPTS} and * {@link ORPHAN_RECONCILE_WINDOW_MS} so a page whose DOM never settles cannot * spin on ad requests. + * + * Scoped to the generation it was armed in. SPA navigation is exactly the kind + * of DOM churn this observer reacts to, so without that scope a route change + * whose markup commits faster than `/__ts/page-bids` responds would trip the + * debounce while `ts.adSlots`/`ts.bids` still describe the *previous* route — + * re-binding the finished auction onto the new route's divs, issuing another + * billable GAM request, and burning the recovery budget on an ordinary + * navigation. `beginNavigation()` also disconnects it outright. */ function watchForOrphanedSlots(ts: TsjsApi): void { if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return; @@ -525,9 +706,16 @@ function watchForOrphanedSlots(ts: TsjsApi): void { stopOrphanWatch(); if (orphanReconcileAttempts >= MAX_ORPHAN_RECONCILE_ATTEMPTS) return; + const generation = currentGeneration(ts); orphanObserver = new MutationObserver(() => { clearTimeout(orphanDebounceTimer); orphanDebounceTimer = setTimeout(() => { + // A navigation (or a newer adInit) has superseded the state this watch + // was armed for — recovering against it would apply the old route. + if (currentGeneration(ts) !== generation) { + stopOrphanWatch(); + return; + } const orphans = orphanedTsSlots(ts); if (orphans.length === 0) return; orphanReconcileAttempts += 1; @@ -557,6 +745,10 @@ export function installTsAdInit(): void { if (!g) return; g.cmd?.push(() => { + // A new ad-init opens a new generation: everything armed by the previous + // one (SSAT attribution, orphan watch, in-flight bridge fetches) belongs + // to state this call is about to replace. + const generation = bumpGeneration(ts); // Destroy previously defined TS slots before redefining for the new page. if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { g.destroySlots?.(ts.prevGptSlots as GoogleTagSlot[]); @@ -644,9 +836,21 @@ export function installTsAdInit(): void { // 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]) - ); + // + // The tuple is snapshotted here rather than re-read from `ts.bids` when + // the render event arrives: by then a newer navigation may have + // replaced `bids`, and stamping this render with that route's auction + // would both mislabel it and leave the real render of that auction + // demoted to `gam-refresh`. + const armed = TS_BID_TARGETING_KEYS.some((key) => Boolean(bid[key])); + enqueueGptRender(gptSlot, { + generation, + slotId: slot.id, + // Snapshot request data instead of reading a newer route's `ts.bids` + // when this request's render event eventually arrives. + bid: { ...bid }, + attributed: armed, + }); // Map both inner div and container div → slot ID so slotRenderEnded // (which reports the GPT slot's div, i.e. slotDivId/container) can look up // the slot, while adm injection (which targets the inner div) also works. @@ -694,19 +898,15 @@ export function installTsAdInit(): void { g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { const divId: string = event.slot?.getSlotElementId?.() ?? ''; - const slotId = (ts.divToSlotId ?? {})[divId]; - if (!slotId) return; - // Read ts.bids live (not the snapshot above) so post-navigation bid data is used. - const bid = (ts.bids ?? {})[slotId] ?? {}; + const pending = takeGptRender(event.slot); + if (pending && pending.generation !== currentGeneration(ts)) { + log.debug('[tsjs-gpt] ignoring slotRenderEnded from a superseded route', { divId }); + return; + } - // 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. - // 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; + const slotId = pending?.slotId ?? (ts.divToSlotId ?? {})[divId]; + if (!slotId) return; + const bid = pending?.bid ?? (ts.bids ?? {})[slotId] ?? {}; // Trace: registry entry + DOM markers joining the GAM render to the // server-side auction bid. `rendered` is GAM's own non-empty signal; @@ -714,33 +914,74 @@ export function installTsAdInit(): void { // on screen" state so the panel does not overclaim (a non-empty GAM // slot is not proof the TS creative rendered). const slotEl = document.getElementById(divId); + const eventGeneration = currentGeneration(ts); + // The server-side auction runs once per navigation. Only the render // that consumes the targeting adInit just applied may be attributed // to it; a later publisher refresh re-requests GAM on its own and the - // creative it returns has no traceable link to any TS auction, so the - // stale bid tuple is dropped rather than re-stamped. - const fresh = (ts.ssatTargetingFresh ??= {})[slotId] === true; - ts.ssatTargetingFresh[slotId] = false; - const attributed = fresh + // creative it returns has no traceable link to any TS auction, so no + // bid tuple is stamped. Single-shot: the armed tuple is consumed here. + const attributed = pending?.attributed ? { path: 'ssat' as const, auctionId: bid.hb_auction_id, bidder: bid.hb_bidder, adId: bid.hb_adid, + bidId: bid.hb_bid_id, creativeId: bid.hb_crid, admHash: bid.hb_adm_hash, } : { path: 'gam-refresh' as const }; - const record = recordRender({ - slotId, + + // The record this event belongs to, resolved below. Captured by the + // deferred-placement callback, which fires an animation frame later. + let record: RenderRecord | undefined; + + // GAM interceptor (testing): when adm is present, replace the GAM creative. + // Adapted from PR #241 — uses window.tsjs.bids[slotId].adm instead of pbjs. + // Only active when inject_adm_for_testing injects adm into bids server-side. + // Run before recording so the trace reflects the post-injection state. + // No adm to inject means TS only applied GAM targeting: whatever GAM + // rendered lives in a cross-origin iframe TS cannot read, so this is + // explicitly *not* a confirmed TS placement (status: gam-only). + const injected = bid.adm + ? injectAdmIntoSlot( + divId, + bid.adm, + (placed) => { + // The animation-frame retry has resolved. Promote the record + // unless a newer render already replaced this impression. + if (!placed || !record || ts.renders?.[slotId] !== record || !slotEl) return; + updateRender(record, { injected: true, visible: isEffectivelyVisible(slotEl) }); + stampCreativeTrace(slotEl, record); + }, + () => + currentGeneration(ts) === eventGeneration && + !!slotEl?.isConnected && + document.getElementById(divId) === slotEl + ) + : false; + + const gamSignal = { rendered: !event.isEmpty, gamEmpty: event.isEmpty, injected, visible: isEffectivelyVisible(slotEl), elementId: divId, - servedFrom: 'gam', - ...attributed, - }); + }; + // The bridge may already have opened this impression's record (it + // serves the creative the same GAM request asked for). Enrich that + // record rather than appending a second one for the same impression. + const open = event.isEmpty + ? undefined + : enrichableImpression(ts, slotId, bid.hb_adid, 'gam'); + if (open) { + open.gamSeen = true; + record = updateRender(open.record, gamSignal); + } else { + record = recordRender({ slotId, servedFrom: 'gam', ...gamSignal, ...attributed }); + openImpression(ts, slotId, bid.hb_adid, record, 'gam'); + } if (slotEl) stampCreativeTrace(slotEl, record); }); } @@ -873,6 +1114,10 @@ export function installSpaAuctionHook(): void { if (path === currentPath) return; currentPath = path; inflight?.abort(); + // Retire the route being left before awaiting anything. The new DOM can + // commit long before page-bids answers, and until then every watcher and + // in-flight render still refers to the previous route's auction. + beginNavigation(ts); const controller = new AbortController(); inflight = controller; @@ -998,6 +1243,7 @@ const TS_DISPLAY_RENDERER = * below drops the stamp when the captured element has left the document. */ function recordBridgeRender( + ts: TsjsApi, slotId: string, bid: AuctionBidData, servedFrom: RenderServedFrom, @@ -1005,23 +1251,79 @@ function recordBridgeRender( ): 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', + const bridgeSignal = { 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, - }); + }; + // `slotRenderEnded` may already have opened this impression's record for the + // same GAM request. Enrich it — appending here would count one creative as + // two renders, and this confirmed placement must not be a separate row from + // the GAM fill signal describing it. + const open = enrichableImpression(ts, slotId, bid.hb_adid, 'bridge'); + let record: RenderRecord; + if (open) { + open.bridgeSeen = true; + record = updateRender(open.record, bridgeSignal); + } else { + record = recordRender({ + slotId, + path: 'ssat', + auctionId: bid.hb_auction_id, + bidder: bid.hb_bidder, + adId: bid.hb_adid, + bidId: bid.hb_bid_id, + creativeId: bid.hb_crid, + admHash: bid.hb_adm_hash, + ...bridgeSignal, + }); + openImpression(ts, slotId, bid.hb_adid, record, 'bridge'); + } if (el && el.isConnected) stampCreativeTrace(el, record); } +/** + * Whether a PBS Cache result may still be applied when its fetch resolves. + * + * The fetch is started for one specific impression on one specific route, but + * settles arbitrarily later. By then an SPA navigation may have rebound the + * slot to a new auction — writing the record anyway would make a finished + * auction the slot's current render, fire a render event for a creative nobody + * can see, and stamp the new route's element with the old route's tuple. + * + * So all four anchors of that impression must still hold: the route it started + * in, the element it resolved to, the message source still living under that + * same slot, and the slot still showing the very bid it was fetched for. + */ +function bridgeResultStillCurrent( + ts: TsjsApi, + generation: number, + slotId: string, + bid: AuctionBidData, + el: HTMLElement | null, + source: MessageEventSource | null +): boolean { + if (currentGeneration(ts) !== generation) return false; + if (!el?.isConnected) return false; + if (slotIdForMessageSource(source) !== slotId) return false; + const live = (ts.bids ?? {})[slotId]; + if (!live) return false; + return ( + live.hb_adid === bid.hb_adid && + live.hb_auction_id === bid.hb_auction_id && + live.hb_bid_id === bid.hb_bid_id && + live.hb_bidder === bid.hb_bidder && + live.hb_crid === bid.hb_crid && + live.hb_adm_hash === bid.hb_adm_hash && + live.hb_cache_host === bid.hb_cache_host && + live.hb_cache_path === bid.hb_cache_path && + live.nurl === bid.nurl && + live.burl === bid.burl + ); +} + export function installTsRenderBridge(): void { if (typeof window === 'undefined') return; @@ -1072,33 +1374,39 @@ export function installTsRenderBridge(): void { // creative/dimensions while firing slot B's win/billing beacons. if (slotId !== sourceSlotId) return; - const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); + const ts = (window.tsjs ??= {} as TsjsApi); + // Snapshot every field the asynchronous cache completion will consume. + const capturedBid: AuctionBidData = { ...matchedBid }; + const slot = ts.adSlots?.find((s) => s.id === slotId); const [width, height] = slot?.formats?.[0] ?? [728, 90]; // Resolve the slot element now, at message-receipt time: the PBS Cache // branch stamps after an async fetch, and by then an SPA navigation may // have swapped tsjs.adSlots/DOM for a new route with the same slot IDs. const slotEl = slot ? findSlotElementByDivId(slot.div_id) : null; + // The route this render belongs to, re-checked when the async fetch below + // resolves. + const generation = currentGeneration(ts); - if (matchedBid.adm) { + if (capturedBid.adm) { e.stopImmediatePropagation(); port.postMessage( JSON.stringify({ message: 'Prebid Response', adId, - ad: matchedBid.adm, + ad: capturedBid.adm, renderer: TS_DISPLAY_RENDERER, width, height, }) ); - fireWinBillingBeacons(slotId, matchedBid); - recordBridgeRender(slotId, matchedBid, 'debug-adm', slotEl); + fireWinBillingBeacons(slotId, capturedBid); + recordBridgeRender(ts, slotId, capturedBid, 'debug-adm', slotEl); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from debug adm`); return; } // No TS render source — let Prebid.js handle it. - if (!matchedBid.hb_cache_host || !matchedBid.hb_cache_path) return; + if (!capturedBid.hb_cache_host || !capturedBid.hb_cache_path) return; // TS owns this adId — stop Prebid from also processing it. e.stopImmediatePropagation(); @@ -1108,11 +1416,20 @@ export function installTsRenderBridge(): void { if (renderingAdIds.has(adId)) return; renderingAdIds.add(adId); - const cacheUrl = `https://${matchedBid.hb_cache_host}${matchedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`; + const cacheUrl = `https://${capturedBid.hb_cache_host}${capturedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`; - fetch(cacheUrl, { mode: 'cors' }) + // Abortable so a navigation can cancel a render belonging to the route it + // is leaving instead of letting it land on the new one. + const controller = new AbortController(); + inflightCacheFetches.add(controller); + + fetch(cacheUrl, { mode: 'cors', signal: controller.signal }) .then((res) => (res.ok ? res.text() : Promise.reject(res.status))) .then((ad) => { + if (!bridgeResultStillCurrent(ts, generation, slotId, capturedBid, slotEl, e.source)) { + log.debug(`[tsjs-gpt] pbRender bridge: dropping stale PBS Cache result for '${slotId}'`); + return; + } port.postMessage( JSON.stringify({ message: 'Prebid Response', @@ -1123,14 +1440,16 @@ export function installTsRenderBridge(): void { height, }) ); - fireWinBillingBeacons(slotId, matchedBid); - recordBridgeRender(slotId, matchedBid, 'pbs-cache', slotEl); + fireWinBillingBeacons(slotId, capturedBid); + recordBridgeRender(ts, slotId, capturedBid, 'pbs-cache', slotEl); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`); }) .catch((err) => { + if (err instanceof DOMException && err.name === 'AbortError') return; log.warn(`[tsjs-gpt] pbRender bridge: PBS Cache fetch failed for '${slotId}'`, err); }) .finally(() => { + inflightCacheFetches.delete(controller); renderingAdIds.delete(adId); }); }); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index b35708d8..5db7ff80 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -220,10 +220,13 @@ 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 + // Carry the server-side trace tuple through Prebid so the render-trace + // hook can attribute the render to its /auction (see // installPrebidRenderTrace). Prebid passes `meta` through unchanged. + // `tsBidId` is the bid's own OpenRTB `id`, kept separate from + // `creativeId` (the advertiser's `crid`, reused across bids). tsAuctionId: bid.auctionId, + tsBidId: bid.bidId, tsAdmHash: bid.admHash, }, }; @@ -930,13 +933,29 @@ function syncPrebidEidsCookie(): void { // Render trace (client-side /auction path) // --------------------------------------------------------------------------- -/** Minimal shape of the bid object Prebid.js passes to a `bidWon` handler. */ -interface PrebidWonBid { +/** Minimal shape of the bid object Prebid.js attaches to a render event. */ +interface PrebidRenderedBid { adUnitCode?: string; bidderCode?: string; bidder?: string; creativeId?: string; - meta?: { tsAuctionId?: unknown; tsAdmHash?: unknown; [key: string]: unknown }; + meta?: { + tsAuctionId?: unknown; + tsBidId?: unknown; + tsAdmHash?: unknown; + [key: string]: unknown; + }; +} + +/** + * Payload of Prebid's `adRenderSucceeded` (`{ doc, bid, adId }`) and + * `adRenderFailed` (`{ reason, message, bid?, adId? }`) events. + */ +interface PrebidRenderEvent { + bid?: PrebidRenderedBid; + adId?: string; + reason?: string; + message?: string; } /** @@ -951,33 +970,44 @@ function findAuctionSlotElement(adUnitCode: string): 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. + * Record a render-trace entry for a completed Prebid render attempt on the + * client-side `/auction` path, distinct from the SSAT/GAM path. + * + * Only server-side (`trustedServer`) bids carry `meta.tsAuctionId` (set in + * {@link auctionBidsToPrebidBids}); client-side bidders lack it and are skipped + * so the panel never attributes a non-TS render to Trusted Server. + * + * `outcome` comes from which event fired. A failed attempt is still recorded — + * silence would read as "never won" — but as `rendered: false, injected: false`, + * which [`panelStatus`] reports as `empty`, so the panel shows a red row rather + * than a confirmed green one. * * Exported for unit testing; the render path itself is unaffected (this only * observes and stamps the DOM). */ -export function recordPrebidBidWon(bid: PrebidWonBid | undefined): RenderRecord | undefined { +export function recordPrebidAdRender( + bid: PrebidRenderedBid | undefined, + outcome: 'succeeded' | 'failed' +): RenderRecord | undefined { if (!bid || typeof bid.adUnitCode !== 'string' || bid.adUnitCode === '') return undefined; const meta = bid.meta ?? {}; // Only trace our server-side bids: the trustedServer adapter is the only // source of meta.tsAuctionId. if (typeof meta.tsAuctionId !== 'string') return undefined; + const succeeded = outcome === 'succeeded'; const el = findAuctionSlotElement(bid.adUnitCode); const record = recordRender({ slotId: bid.adUnitCode, path: 'auction', - rendered: true, + rendered: succeeded, // Prebid rendered the `ad` markup our adapter returned — a confirmed TS - // placement for this path. - injected: true, - visible: isEffectivelyVisible(el), + // placement for this path, but only once the render actually succeeded. + injected: succeeded, + visible: succeeded ? isEffectivelyVisible(el) : false, elementId: el?.id, auctionId: meta.tsAuctionId, + bidId: typeof meta.tsBidId === 'string' ? meta.tsBidId : undefined, admHash: typeof meta.tsAdmHash === 'string' ? meta.tsAdmHash : undefined, bidder: bid.bidderCode ?? bid.bidder, creativeId: bid.creativeId, @@ -988,28 +1018,39 @@ export function recordPrebidBidWon(bid: PrebidWonBid | undefined): RenderRecord } /** - * Install the Prebid `bidWon` render-trace hook (idempotent). + * Install the Prebid render-trace hooks (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. + * Listens on `adRenderSucceeded` / `adRenderFailed`, **not** `bidWon`. `bidWon` + * fires when Prebid marks a bid as the auction winner — before it hands the bid + * to a renderer that can still fail asynchronously (`emitAdRenderFail`), so a + * `bidWon` listener would stamp the DOM and light up a confirmed green render + * for a creative that never reached the page. `adRenderSucceeded` is emitted + * only after the render function returned without error, and carries the + * rendered bid on `event.bid`. + * + * Without these hooks only the one-time SSAT auction is traced and the ongoing + * Prebid renders are invisible to the panel. Read-only: the listeners record and + * stamp but never alter Prebid's rendering. */ export function installPrebidRenderTrace(): void { if (typeof window === 'undefined') return; const p = pbjs as unknown as { - onEvent?: (event: string, handler: (bid: PrebidWonBid) => void) => void; + onEvent?: (event: string, handler: (event: PrebidRenderEvent) => 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); - } - }); + const listen = (name: string, outcome: 'succeeded' | 'failed'): void => { + p.onEvent!(name, (event) => { + try { + recordPrebidAdRender(event?.bid, outcome); + } catch (err) { + log.warn(`[tsjs-prebid] render-trace ${name} failed`, err); + } + }); + }; + listen('adRenderSucceeded', 'succeeded'); + listen('adRenderFailed', 'failed'); } // Self-initialize when loaded in a browser (same pattern as other integrations). 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 cf89aff1..29555653 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -238,6 +238,7 @@ describe('auction/parseAuctionResponse', () => { seat: 'kargo', bid: [ { + id: 'bid-uuid-7', impid: 'slot-1', price: 1.0, adm: '
A
', @@ -250,6 +251,7 @@ describe('auction/parseAuctionResponse', () => { const bids = parseAuctionResponse(body); expect(bids[0].auctionId).toBe('auction-uuid-2'); + expect(bids[0].bidId).toBe('bid-uuid-7'); expect(bids[0].admHash).toBe('a1b2c3d4e5f60718'); }); }); diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts index 199aa499..2c737099 100644 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; import { recordRender, + updateRender, stampCreativeTrace, traceOverlayEnabled, renderTracePanel, @@ -70,6 +72,68 @@ describe('trace/recordRender', () => { window.removeEventListener(RENDER_EVENT_NAME, listener); }); + + it('allocates one sequence across separately bundled IIFEs', async () => { + const buildTraceBundle = (): string => + execFileSync( + './node_modules/.bin/esbuild', + ['--bundle', '--format=iife', '--platform=browser', '--loader=ts'], + { + cwd: process.cwd(), + encoding: 'utf8', + input: + 'import { recordRender } from "./src/core/trace.ts";' + + 'window.__recordFromTraceBundle = recordRender;', + } + ); + + const firstBundle = buildTraceBundle(); + const secondBundle = buildTraceBundle(); + const testWindow = window as typeof window & { + __recordFromTraceBundle?: typeof recordRender; + }; + + Function(firstBundle)(); + const firstRecord = testWindow.__recordFromTraceBundle!; + const first = firstRecord({ slotId: 'iife-a', path: 'auction', rendered: true }); + + Function(secondBundle)(); + const secondRecord = testWindow.__recordFromTraceBundle!; + const second = secondRecord({ slotId: 'iife-b', path: 'ssat', rendered: true }); + + expect(second.seq).toBe(first.seq + 1); + expect(window.tsjs?.renderSeq).toBe(second.seq); + delete testWindow.__recordFromTraceBundle; + }); + + it('enriches an existing impression without changing its bookkeeping', () => { + const original = recordRender({ + slotId: 'slot-enrich', + path: 'ssat', + rendered: true, + injected: false, + servedFrom: 'gam', + }); + const bookkeeping = { + seq: original.seq, + count: original.count, + at: original.at, + historyLength: window.tsjs?.renderLog?.length, + }; + + const updated = updateRender(original, { injected: true, servedFrom: 'pbs-cache' }); + + expect(updated).toBe(original); + expect(window.tsjs?.renders?.['slot-enrich']).toBe(original); + expect(window.tsjs?.renderLog?.[0]).toBe(original); + expect(updated).toEqual(expect.objectContaining({ injected: true, servedFrom: 'pbs-cache' })); + expect({ + seq: updated.seq, + count: updated.count, + at: updated.at, + historyLength: window.tsjs?.renderLog?.length, + }).toEqual(bookkeeping); + }); }); describe('trace/stampCreativeTrace', () => { @@ -307,6 +371,26 @@ describe('trace/floating panel', () => { expect(panel.textContent!.match(/◂ current/g)).toHaveLength(1); }); + it('uses record identity when duplicate sequence values exist', () => { + document.cookie = 'ts-trace=1; Path=/'; + const oldRecord = { ...record, auctionId: 'auction-old', count: 1, seq: 7, at: 1 }; + const liveRecord = { ...record, auctionId: 'auction-live', count: 2, seq: 7, at: 2 }; + (window as { tsjs?: TsjsApi }).tsjs = { + renders: { 'slot-1': liveRecord }, + renderLog: [oldRecord, liveRecord], + } as unknown as TsjsApi; + + renderTracePanel(); + + const rows = [...document.querySelectorAll(`#${TRACE_PANEL_ID} div[style*="cursor"]`)]; + const oldRow = rows.find((row) => row.getAttribute('title')?.includes('auction: auction-old')); + const liveRow = rows.find((row) => + row.getAttribute('title')?.includes('auction: auction-live') + ); + expect(oldRow?.textContent).not.toContain('◂ current'); + expect(liveRow?.textContent).toContain('◂ current'); + }); + it('close button removes the panel', () => { document.cookie = 'ts-trace=1; Path=/'; recordRender(record); @@ -385,6 +469,30 @@ describe('trace/confirmation badge', () => { expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); }); + it('removes the previous badge when a filled slot becomes empty', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeTruthy(); + + stampCreativeTrace(el, { ...okRecord, rendered: false, injected: false, gamEmpty: true }); + + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('removes the previous badge when a visible slot becomes hidden', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeTruthy(); + + stampCreativeTrace(el, { ...okRecord, visible: false }); + + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + it('does not badge when the overlay is disarmed', () => { clearTraceCookie(); const el = document.createElement('div'); 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 83e2bca6..7980cdba 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -526,6 +526,7 @@ describe('installTsAdInit', () => { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'cache-uuid-9', + hb_bid_id: 'bid-uuid-9', hb_auction_id: 'ts-req-trace9', hb_crid: 'cr-98765', hb_adm_hash: 'a1b2c3d4e5f60718', @@ -547,6 +548,7 @@ describe('installTsAdInit', () => { expect(el.getAttribute('data-ts-auction-id')).toBe('ts-req-trace9'); expect(el.getAttribute('data-ts-bidder')).toBe('kargo'); expect(el.getAttribute('data-ts-ad-id')).toBe('cache-uuid-9'); + expect(el.getAttribute('data-ts-bid-id')).toBe('bid-uuid-9'); expect(el.getAttribute('data-ts-creative-id')).toBe('cr-98765'); expect(el.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); @@ -560,6 +562,7 @@ describe('installTsAdInit', () => { auctionId: 'ts-req-trace9', bidder: 'kargo', adId: 'cache-uuid-9', + bidId: 'bid-uuid-9', creativeId: 'cr-98765', admHash: 'a1b2c3d4e5f60718', servedFrom: 'gam', @@ -574,6 +577,236 @@ describe('installTsAdInit', () => { expect(el.getAttribute('data-ts-rendered')).toBe('false'); }); + it('drops a late render from a reused publisher slot without consuming the new route', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_bidder: 'route-a', + hb_adid: 'ad-a', + hb_bid_id: 'bid-a', + hb_auction_id: 'auction-a', + }, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + ts.adInit!(); + + ts.bids = { + atf_sidebar_ad: { + hb_bidder: 'route-b', + hb_adid: 'ad-b', + hb_bid_id: 'bid-b', + hb_auction_id: 'auction-b', + }, + }; + ts.adInit!(); + + // Route A's event arrives only after route B has reused and refreshed the + // same publisher-owned GPT slot object. It must be rejected, not stamped + // with route B's tuple or allowed to consume route B's pending request. + capturedListener!({ isEmpty: false, slot: mockSlot }); + expect(ts.renders?.['atf_sidebar_ad']).toBeUndefined(); + + capturedListener!({ isEmpty: false, slot: mockSlot }); + expect(ts.renders?.['atf_sidebar_ad']).toEqual( + expect.objectContaining({ + path: 'ssat', + auctionId: 'auction-b', + bidId: 'bid-b', + bidder: 'route-b', + }) + ); + }); + + it('does not confirm a deferred ADM placement after navigation supersedes it', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + let runDeferredPlacement: FrameRequestCallback | undefined; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + runDeferredPlacement = callback; + return 1; + }) + ); + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_bidder: 'mocktioneer', + hb_adid: 'deferred-ad', + hb_auction_id: 'auction-a', + adm: '', + }, + }, + }; + + try { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + ts.adInit!(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + + const oldRecord = ts.renders?.['atf_sidebar_ad']; + expect(oldRecord?.injected).toBe(false); + expect(runDeferredPlacement).toBeDefined(); + + // A newer route/adInit starts before the animation-frame retry runs while + // retaining the same publisher-owned slot element. The old callback must + // not mutate that shared element before its trace guard runs. + ts.adInit!(); + const reusedSlot = document.getElementById('div-atf-sidebar')!; + runDeferredPlacement!(0); + + expect(oldRecord?.injected).toBe(false); + expect(reusedSlot.querySelector('iframe')).toBeNull(); + expect(reusedSlot.getAttribute('data-ts-injected')).toBe('false'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('confirms a successful deferred ADM placement on the original record', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + let runDeferredPlacement: FrameRequestCallback | undefined; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + runDeferredPlacement = callback; + return 1; + }) + ); + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_adid: 'deferred-ad', + adm: '', + }, + }, + }; + + try { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + ts.adInit!(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + const record = ts.renders?.atf_sidebar_ad; + const originalBookkeeping = { + seq: record?.seq, + count: record?.count, + at: record?.at, + historyLength: ts.renderLog?.length, + }; + + runDeferredPlacement!(0); + + expect(ts.renders?.atf_sidebar_ad).toBe(record); + expect(record?.injected).toBe(true); + expect({ + seq: record?.seq, + count: record?.count, + at: record?.at, + historyLength: ts.renderLog?.length, + }).toEqual(originalBookkeeping); + } finally { + vi.unstubAllGlobals(); + } + }); + it('does not attribute a later GAM refresh to the finished server-side auction', async () => { let capturedListener: ((e: SlotRenderEvent) => void) | undefined; @@ -1117,7 +1350,9 @@ describe('installTsRenderBridge', () => { expect(fetchStub).toHaveBeenCalledWith( 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } + // Carries an abort signal so a navigation can cancel a render belonging + // to the route it is leaving. + { mode: 'cors', signal: expect.any(AbortSignal) } ); expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); @@ -1157,6 +1392,80 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('keeps GAM and bridge signals for both arrival orders on one record per impression', async () => { + const source = createTrustedSlotIframe(); + let slotRenderListener: ((event: SlotRenderEvent) => void) | undefined; + const gptSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-header'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([gptSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, listener: (event: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') slotRenderListener = listener; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(gptSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + const ts = (window as TestWindow).tsjs!; + ts.bids.homepage_header = { + hb_adid: 'debug-first', + hb_bidder: 'mocktioneer', + }; + const bridgeListener = await captureBridgeListener(); + ts.adInit!(); + + const sendBridgeRequest = (adId: string): void => { + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId }), + ports: [{ postMessage: vi.fn() }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + }; + + // GAM first, bridge second. + slotRenderListener!({ isEmpty: false, slot: gptSlot }); + const firstRecord = ts.renders?.homepage_header; + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + adm: '
First creative
', + }; + sendBridgeRequest('debug-first'); + expect(ts.renders?.homepage_header).toBe(firstRecord); + expect(firstRecord).toEqual( + expect.objectContaining({ count: 1, injected: true, servedFrom: 'debug-adm' }) + ); + expect(ts.renderLog).toHaveLength(1); + + // Bridge first, GAM second for the next impression. + ts.bids.homepage_header = { + hb_adid: 'debug-second', + hb_bidder: 'mocktioneer', + adm: '', + }; + ts.adInit!(); + sendBridgeRequest('debug-second'); + const secondRecord = ts.renders?.homepage_header; + slotRenderListener!({ isEmpty: false, slot: gptSlot }); + expect(ts.renders?.homepage_header).toBe(secondRecord); + expect(secondRecord).toEqual( + expect.objectContaining({ count: 2, injected: true, gamEmpty: false }) + ); + expect(ts.renderLog).toHaveLength(2); + }); + it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { // Concurrent render double-fire guard: two 'Prebid Request' messages for the // same adId can arrive before the first cache fetch settles. The in-flight @@ -1210,6 +1519,47 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('drops a PBS Cache result when the live bid changed before fetch completion', async () => { + let resolveFetch: (value: Response) => void = () => {}; + fetchStub.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const ts = (window as TestWindow).tsjs!; + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + hb_auction_id: 'auction-1', + hb_bid_id: 'bid-1', + }; + const bridgeListener = await captureBridgeListener(); + const portMessages: string[] = []; + const source = createTrustedSlotIframe(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage: (message: string) => portMessages.push(message) }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + // Same bridge ad ID and auction ID, but a different bid object/trace ID. + // Comparing only hb_adid + hb_auction_id would incorrectly accept the old + // creative and stamp it with the captured route's data. + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + hb_bid_id: 'bid-2', + hb_adm_hash: 'new-creative-hash', + }; + resolveFetch({ ok: true, text: () => Promise.resolve('
Old creative
') } as Response); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(portMessages).toHaveLength(0); + expect(ts.renders?.homepage_header).toBeUndefined(); + }); + it('responds with adm without fetching PBS Cache when debug adm is available', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const debugAdm = '
Debug Creative
'; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 9a08defc..58291329 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -51,6 +51,7 @@ describe('installSpaAuctionHook', () => { originalReplaceState({}, '', '/'); // Drop any ad containers inserted by a test so DOM state does not leak. document.body.innerHTML = ''; + delete (window as TestWindow).googletag; // Remove this test's popstate listener(s) so they do not fire in later tests. popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); popstateHandlers = []; @@ -304,6 +305,59 @@ describe('installSpaAuctionHook', () => { expect(adInit).toHaveBeenCalledTimes(1); }); + it('stops orphan recovery before a fast route DOM swap can replay old bids', async () => { + document.body.innerHTML = '
'; + const definedDivs: string[] = []; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + addEventListener: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn((_path: string, _sizes: unknown, divId: string) => { + definedDivs.push(divId); + return { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(divId), + getTargeting: vi.fn().mockReturnValue([]), + }; + }), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display: vi.fn(), + destroySlots: vi.fn(), + }; + // Keep page-bids slower than the orphan observer's 250 ms debounce. + fetchStub.mockReturnValue(new Promise(() => {})); + + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + ts.adSlots = [ + { + id: 'ad-header-0', + gam_unit_path: '/123/header', + div_id: 'ad-header-0', + formats: [[728, 90]], + targeting: {}, + }, + ]; + ts.bids = { 'ad-header-0': { hb_adid: 'old-route-ad' } }; + ts.adInit!(); + expect(definedDivs).toEqual(['ad-header-0-_R_old_']); + + history.pushState({}, '', '/new-route'); + document.body.innerHTML = '
'; + await new Promise((resolve) => setTimeout(resolve, 350)); + + // The pending old-route watcher was disconnected synchronously when + // navigation began, so it never rebound or re-requested the old auction. + expect(definedDivs).toEqual(['ad-header-0-_R_old_']); + }); + it('leaves slots and bids untouched on a non-OK response', async () => { fetchStub.mockResolvedValue({ ok: false, status: 500 }); const { installSpaAuctionHook } = await importGptModule(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index c6d47eb4..4b5fffe5 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -8,6 +8,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -21,6 +22,7 @@ const { () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); const mockGetConfig = vi.fn(); + const mockOnEvent = vi.fn(); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, @@ -28,6 +30,7 @@ const { registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, + onEvent: mockOnEvent, adUnits: [] as any[], }; const mockAdapterManager = { @@ -40,6 +43,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -69,7 +73,8 @@ import { auctionBidsToPrebidBids, installPrebidNpm, installRefreshHandler, - recordPrebidBidWon, + installPrebidRenderTrace, + recordPrebidAdRender, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; import type { TsjsApi } from '../../../src/core/types'; @@ -219,6 +224,7 @@ describe('prebid/auctionBidsToPrebidBids', () => { creativeId: 'KM-CREA-1', adomain: ['kargo.com'], auctionId: 'ts-auction-xyz', + bidId: 'bid-abc-1', admHash: 'a1b2c3d4e5f60718', }, ]; @@ -227,10 +233,13 @@ describe('prebid/auctionBidsToPrebidBids', () => { expect(result[0].meta.tsAuctionId).toBe('ts-auction-xyz'); expect(result[0].meta.tsAdmHash).toBe('a1b2c3d4e5f60718'); + // The bid's own OpenRTB id, distinct from the advertiser creative id. + expect(result[0].meta.tsBidId).toBe('bid-abc-1'); + expect(result[0].creativeId).toBe('KM-CREA-1'); }); }); -describe('prebid/recordPrebidBidWon', () => { +describe('prebid/recordPrebidAdRender', () => { beforeEach(() => { delete (window as { tsjs?: TsjsApi }).tsjs; document.body.innerHTML = ''; @@ -242,12 +251,19 @@ describe('prebid/recordPrebidBidWon', () => { 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' }, - }); + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0-_R_x_', + bidderCode: 'kargo', + creativeId: 'KM-CREA-1', + meta: { + tsAuctionId: '265dcedd-aa0a', + tsBidId: 'bid-abc-1', + tsAdmHash: 'f68044ca9f68c88c', + }, + }, + 'succeeded' + ); expect(record).toBeDefined(); expect(record).toEqual( @@ -257,6 +273,7 @@ describe('prebid/recordPrebidBidWon', () => { rendered: true, injected: true, auctionId: '265dcedd-aa0a', + bidId: 'bid-abc-1', admHash: 'f68044ca9f68c88c', bidder: 'kargo', creativeId: 'KM-CREA-1', @@ -266,21 +283,111 @@ describe('prebid/recordPrebidBidWon', () => { ); // Written into the shared registry the panel reads. expect((window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0-_R_x_']).toBeDefined(); + // The bid id must reach the DOM as its own attribute, never folded into + // data-ts-ad-id. + const el = document.getElementById('ad-header-0-_R_x_')!; + expect(el.getAttribute('data-ts-bid-id')).toBe('bid-abc-1'); + }); + + it('records a failed render as unconfirmed, not as a green render', () => { + document.body.innerHTML = '
'; + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: '265dcedd-aa0a' }, + }, + 'failed' + ); + + expect(record).toEqual( + expect.objectContaining({ rendered: false, injected: false, visible: false }) + ); }); it('skips a bid without the server-side trace tuple (client-side bidder)', () => { - const record = recordPrebidBidWon({ - adUnitCode: 'ad-header-0', - bidderCode: 'appnexus', - meta: { advertiserDomains: ['x.com'] }, - }); + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0', + bidderCode: 'appnexus', + meta: { advertiserDomains: ['x.com'] }, + }, + 'succeeded' + ); expect(record).toBeUndefined(); expect((window as { tsjs?: TsjsApi }).tsjs?.renders).toBeUndefined(); }); it('skips a bid with no adUnitCode', () => { - expect(recordPrebidBidWon({ meta: { tsAuctionId: 'x' } })).toBeUndefined(); - expect(recordPrebidBidWon(undefined)).toBeUndefined(); + expect(recordPrebidAdRender({ meta: { tsAuctionId: 'x' } }, 'succeeded')).toBeUndefined(); + expect(recordPrebidAdRender(undefined, 'succeeded')).toBeUndefined(); + }); +}); + +describe('prebid/installPrebidRenderTrace', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + document.body.innerHTML = ''; + mockOnEvent.mockReset(); + delete (mockPbjs as { __tsRenderTraceInstalled?: boolean }).__tsRenderTraceInstalled; + }); + afterEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + document.body.innerHTML = ''; + }); + + it('confirms renders from adRenderSucceeded, never from bidWon', () => { + document.body.innerHTML = '
'; + installPrebidRenderTrace(); + + const events = mockOnEvent.mock.calls.map(([name]) => name); + expect(events).toEqual(['adRenderSucceeded', 'adRenderFailed']); + // bidWon fires when a bid is marked the winner — before the renderer runs, + // and so before the render can fail. Confirming on it would show a green + // render for a creative that never reached the page. + expect(events).not.toContain('bidWon'); + + const handlers = Object.fromEntries(mockOnEvent.mock.calls) as Record< + string, + (event: unknown) => void + >; + handlers['adRenderSucceeded']({ + bid: { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: 'auction-success', tsBidId: 'bid-success' }, + }, + }); + expect((window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0']).toEqual( + expect.objectContaining({ rendered: true, injected: true, bidId: 'bid-success' }) + ); + }); + + it('does not produce a confirmed record when the render fails after the win', () => { + document.body.innerHTML = '
'; + installPrebidRenderTrace(); + + const handlers = Object.fromEntries(mockOnEvent.mock.calls) as Record< + string, + (event: unknown) => void + >; + const bid = { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: '265dcedd-aa0a' }, + }; + + // Prebid marks the bid as won, then its renderer fails asynchronously. + handlers['adRenderFailed']({ reason: 'exception', message: 'boom', bid }); + + const record = (window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0']; + expect(record).toEqual( + expect.objectContaining({ rendered: false, injected: false, visible: false }) + ); + // No green badge and no confirmed-render attributes on the slot. + const el = document.getElementById('ad-header-0')!; + expect(el.getAttribute('data-ts-rendered')).toBe('false'); + expect(el.getAttribute('data-ts-injected')).toBe('false'); }); });