From 397a4d6d063abfa0a8364af8b2d2be0e528fcab0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 16 Jul 2026 23:04:55 +0530 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] =?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/10] 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/10] 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" ); }