From 628ce28f5f4482bf1e5875cff1c810709449caa7 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 8 Sep 2026 16:07:31 -0500 Subject: [PATCH 1/4] Clarify GPT auction diagnostics evidence --- .../tests/nextjs/gpt-diagnostics.spec.ts | 84 ++++ .../trusted-server-js/lib/src/core/types.ts | 30 +- .../lib/src/integrations/gpt/index.ts | 33 +- .../src/integrations/gpt_diagnostics/api.ts | 37 ++ .../integrations/gpt_diagnostics/badges.ts | 23 +- .../src/integrations/gpt_diagnostics/index.ts | 2 + .../integrations/gpt_diagnostics/overlay.ts | 442 ++++++++++++++---- .../gpt_diagnostics/presentation_helpers.ts | 18 +- .../src/integrations/gpt_diagnostics/store.ts | 73 ++- .../lib/src/integrations/prebid/index.ts | 144 +++++- .../lib/test/integrations/gpt/ad_init.test.ts | 5 +- .../integrations/gpt_diagnostics/api.test.ts | 2 + .../gpt_diagnostics/badges.test.ts | 36 ++ .../gpt_diagnostics/index.test.ts | 2 + .../gpt_diagnostics/overlay.test.ts | 137 ++++-- .../gpt_diagnostics/store.test.ts | 55 ++- .../gpt_diagnostics/types.test.ts | 2 + .../test/integrations/prebid/index.test.ts | 84 +++- docs/.vitepress/config.mts | 4 + .../gpt-diagnostics-dictionary.md | 156 +++++++ docs/guide/integrations/gpt-diagnostics.md | 51 +- 21 files changed, 1211 insertions(+), 209 deletions(-) create mode 100644 docs/guide/integrations/gpt-diagnostics-dictionary.md diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts index 0c65eaee2..93399fd95 100644 --- a/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts @@ -215,6 +215,19 @@ test.describe("GPT runtime diagnostics", () => { const pageErrors: string[] = []; const diagnosticNetworkRequests: string[] = []; await captureClosedShadowRoots(page); + await page.addInitScript(() => { + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function (init: ShadowRootInit) { + const root = originalAttachShadow.call(this, init); + if ( + (this as HTMLElement).id === + "trusted-server-gpt-diagnostics" + ) { + (window as any).__gptDiagnosticsTestRoot = root; + } + return root; + }; + }); page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("request", (request) => { if ( @@ -378,6 +391,77 @@ test.describe("GPT runtime diagnostics", () => { const hiddenPeriodSnapshot = await page.evaluate(() => (window as any).tsjs.gptDiagnostics.snapshot(), ); + await page.waitForFunction(() => + Boolean( + (window as any).__gptDiagnosticsTestRoot?.querySelector( + ".tsgd-badge", + ), + ), + ); + const badgeIdentity = await page.evaluate(() => { + const badge = ( + window as any + ).__gptDiagnosticsTestRoot.querySelector( + ".tsgd-badge", + ) as HTMLButtonElement; + badge.focus(); + return { + tagName: badge.tagName, + text: badge.textContent, + ariaLabel: badge.getAttribute("aria-label"), + runtimeSlotNumber: badge.dataset.runtimeSlot, + requestNumber: badge.dataset.requestNumber, + }; + }); + expect(badgeIdentity).toMatchObject({ + tagName: "BUTTON", + text: expect.stringMatching(/Ad #\d+ · Request #\d+/), + ariaLabel: expect.stringMatching(/Ad #\d+, Request #\d+/), + }); + await page.keyboard.press("Enter"); + await page.waitForFunction( + ({ runtimeSlotNumber, requestNumber }) => { + const root = (window as any) + .__gptDiagnosticsTestRoot as ShadowRoot; + const selected = root?.querySelector( + `[aria-current="true"][data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]`, + ); + return selected !== null && root.activeElement === selected; + }, + { + runtimeSlotNumber: badgeIdentity.runtimeSlotNumber, + requestNumber: badgeIdentity.requestNumber, + }, + ); + await page.evaluate( + ({ runtimeSlotNumber, requestNumber }) => { + const root = (window as any) + .__gptDiagnosticsTestRoot as ShadowRoot; + const selected = root.querySelector( + `[aria-current="true"][data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]`, + ); + const locate = Array.from( + selected + ?.closest(".tsgd-slot") + ?.querySelectorAll("button") ?? [], + ).find( + (candidate) => + candidate.textContent === "Locate on page", + ); + locate?.click(); + }, + { + runtimeSlotNumber: badgeIdentity.runtimeSlotNumber, + requestNumber: badgeIdentity.requestNumber, + }, + ); + await page.waitForFunction(() => + Boolean( + (window as any).__gptDiagnosticsTestRoot?.querySelector( + ".tsgd-highlight", + ), + ), + ); const secondaryRequests = hiddenPeriodSnapshot.slots.find( (slot: any) => slot.slotElementId === "gpt-diagnostics-slot-secondary", diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 8322d4e88..32d93c398 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -87,10 +87,6 @@ export interface AuctionBidData { hb_cache_path?: string; /** Opaque server-auction correlation ID used only by GPT diagnostics. */ hb_auction_id?: string; - /** Winning creative width; the bridge sizes the inline render from this. */ - w?: number; - /** Winning creative height; the bridge sizes the inline render from this. */ - h?: number; nurl?: string; burl?: string; /** Typed winning-bid renderer capability. */ @@ -123,10 +119,19 @@ export type GptDiagnosticsAuctionType = 'ssat' | 'trusted_server' | 'client_side /** Clock origin for server auction timings, independent of aggregate auction classification. */ export type GptDiagnosticsServerAuctionTimingOrigin = 'navigation' | 'spa_auction'; -/** Sanitized winning-bid facts already exposed in GPT targeting. */ +/** Sanitized bid facts already exposed as bucketed ad-server targeting. */ export interface GptDiagnosticsAuctionWinner { bidder: string; priceBucket: string; + /** ISO currency supplied by the evidence source; absent means not supplied. */ + currency?: string; +} + +/** A completed, exactly correlated client-side Prebid auction. */ +export interface GptDiagnosticsPrebidAuctionEvidence { + auctionId: string; + targetingCandidate?: GptDiagnosticsAuctionWinner; + win?: GptDiagnosticsAuctionWinner; } /** Internal Trusted Server auction evidence attached to the next GPT request. */ @@ -253,7 +258,10 @@ export interface GptDiagnosticsRequestCycle { requestIntentId?: number; trustedServerAuctionId?: string; auctionType?: GptDiagnosticsAuctionType; + /** Compatibility field: winner of the observed server auction, not necessarily the served creative. */ auctionWinner?: GptDiagnosticsAuctionWinner; + /** Completed Prebid facts, correlated to this exact slot, request, and auction attempt. */ + prebidAuction?: GptDiagnosticsPrebidAuctionEvidence; serverAuctionTimings?: AuctionDiagnosticsData; /** Retained separately because `auctionType` can become `competing`. */ serverAuctionTimingOrigin?: GptDiagnosticsServerAuctionTimingOrigin; @@ -370,6 +378,18 @@ export interface GptDiagnosticsRecorder { ): void; /** Mark slots whose next observed GPT request follows the Prebid refresh path. */ recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; + /** Record a completed Prebid attempt at its targeting boundary for one exact GPT slot. */ + recordPrebidAuction( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void; + /** Record Prebid's documented `bidWon` observation for that exact attempt. */ + recordPrebidWin( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void; /** Record a creative markup request and return its opaque attempt ID. */ recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; /** Record that a creative attempt successfully posted markup. */ 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 5b771f738..a4a575396 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -71,12 +71,13 @@ function diagnosticsAuctionFacts( generation: number, auctionDiagnostics: AuctionDiagnosticsData | undefined, bid: AuctionBidData -): GptDiagnosticsAuctionFacts { +): GptDiagnosticsAuctionFacts | undefined { const isSpaAuction = generation > 0; const winner = isNonEmptyString(bid.hb_bidder) && isNonEmptyString(bid.hb_pb) ? { bidder: bid.hb_bidder, priceBucket: bid.hb_pb } : undefined; + if (!winner && auctionDiagnostics === undefined) return undefined; return { auctionType: isSpaAuction ? 'trusted_server' : 'ssat', @@ -1110,14 +1111,25 @@ export function installTsAdInit(): void { const requestedSlotSizes = ts.gptSlotHandoffs?.[slotDivId2]?.formats; const opportunity = trustedServerOpportunity(bid); const auctionFacts = diagnosticsAuctionFacts(generation, auctionDiagnostics, bid); - ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( - gptSlot, - slot.id, - opportunity, - bid.hb_auction_id, - requestedSlotSizes, - auctionFacts - ); + const recorder = ts.gptDiagnosticsRecorder; + if (auctionFacts) { + recorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + opportunity, + bid.hb_auction_id, + requestedSlotSizes, + auctionFacts + ); + } else { + recorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + opportunity, + bid.hb_auction_id, + requestedSlotSizes + ); + } } catch { // Diagnostics must not alter ad delivery. } @@ -1426,9 +1438,6 @@ export function installSpaAuctionHook(): void { if (path === currentPath) return; currentPath = path; ts.navGeneration = (ts.navGeneration ?? 0) + 1; - // Server timings belong to the route that produced them. Clear them before - // page-bids starts so failure or supersession cannot relabel stale offsets. - ts.auctionDiagnostics = undefined; // A route change invalidates hydration aliases before the new route's // publisher can define a same-prefix slot while page-bids is in flight. for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs ?? {})) { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index cbd2b82e2..956824f84 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -1,6 +1,7 @@ import type { GptDiagnosticsApi, GptDiagnosticsAuctionFacts, + GptDiagnosticsAuctionWinner, GptDiagnosticsCreativeFailure, GptDiagnosticsExportV1, GptDiagnosticsRecorder, @@ -23,6 +24,16 @@ interface ApiStore { auctionFacts?: GptDiagnosticsAuctionFacts ): void; recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; + recordPrebidAuction( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void; + recordPrebidWin( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void; recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; recordTrustedServerCreativeResponse(attemptId: number): void; recordTrustedServerCreativeFailure( @@ -70,6 +81,17 @@ function cloneExportSnapshot(snapshot: GptDiagnosticsExportV1): GptDiagnosticsEx size: cycle.size ? [...cycle.size] : undefined, observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { + prebidAuction: { + ...cycle.prebidAuction, + ...(cycle.prebidAuction.targetingCandidate + ? { targetingCandidate: { ...cycle.prebidAuction.targetingCandidate } } + : {}), + ...(cycle.prebidAuction.win ? { win: { ...cycle.prebidAuction.win } } : {}), + }, + } + : {}), ...(cycle.serverAuctionTimings ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } : {}), @@ -177,6 +199,10 @@ export class GptDiagnosticsApiController { ); }), recordPrebidRefresh: (slots) => safelyRecord(() => this.store.recordPrebidRefresh(slots)), + recordPrebidAuction: (slot, auctionId, targetingCandidate) => + safelyRecord(() => this.store.recordPrebidAuction(slot, auctionId, targetingCandidate)), + recordPrebidWin: (slot, auctionId, winner) => + safelyRecord(() => this.store.recordPrebidWin(slot, auctionId, winner)), recordTrustedServerCreativeRequest: (auctionSlotId) => safelyCreateAttempt(() => this.store.recordTrustedServerCreativeRequest(auctionSlotId)), recordTrustedServerCreativeResponse: (attemptId) => @@ -209,6 +235,17 @@ export class GptDiagnosticsApiController { size: cycle.size ? [...cycle.size] : undefined, observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { + prebidAuction: { + ...cycle.prebidAuction, + ...(cycle.prebidAuction.targetingCandidate + ? { targetingCandidate: { ...cycle.prebidAuction.targetingCandidate } } + : {}), + ...(cycle.prebidAuction.win ? { win: { ...cycle.prebidAuction.win } } : {}), + }, + } + : {}), ...(cycle.serverAuctionTimings ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } : {}), diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts index 86dddd223..18f080695 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts @@ -3,7 +3,7 @@ import type { GptDiagnosticsRequestCycle } from '../../core/types'; import type { GptDiagnosticsBindingManager } from './binding'; import { unhandledCase } from './exhaustive'; import { - auctionTypeLabel, + auctionTypeBadgeLabel, displayableGptFillSize, formatSizes, scheduleFrame, @@ -38,6 +38,7 @@ interface BadgeOptions { window?: BadgeWindow; document?: Document; scheduleFrame?: (callback: () => void) => void; + onActivate?: (runtimeSlotNumber: number, requestNumber: number) => void; } function intersectsViewport(rectangle: DOMRect, window: Window): boolean { @@ -111,7 +112,7 @@ function badgeText(cycle: GptDiagnosticsRequestCycle): string { else if (cycle.isEmpty === false) firstLine.push('Filled'); else if (cycle.renderAtMs !== undefined) firstLine.push('Rendered (fill unknown)'); else firstLine.push('Pending'); - if (cycle.auctionType) firstLine.push(auctionTypeLabel(cycle.auctionType)); + if (cycle.auctionType) firstLine.push(auctionTypeBadgeLabel(cycle.auctionType)); const delivery = deliveryLabel(cycle); if (delivery) firstLine.push(delivery); if (cycle.requestPath === 'competing' && cycle.auctionType !== 'competing') { @@ -157,6 +158,7 @@ export class GptDiagnosticsBadgeManager { private readonly window: BadgeWindow; private readonly document: Document; private readonly scheduleFrame: (callback: () => void) => void; + private readonly onActivate: (runtimeSlotNumber: number, requestNumber: number) => void; private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; private readonly slotElementIds = new Set(); @@ -174,6 +176,7 @@ export class GptDiagnosticsBadgeManager { this.document = options.document ?? document; this.scheduleFrame = options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); + this.onActivate = options.onActivate ?? (() => undefined); this.refreshSlotElementIds(); this.unsubscribeStore = this.store.subscribe(() => { this.refreshSlotElementIds(); @@ -210,10 +213,19 @@ export class GptDiagnosticsBadgeManager { if (!intersectsViewport(rectangle, this.window)) continue; observedElements.push(element); - const badge = this.document.createElement('div'); + const badge = this.document.createElement('button'); + badge.type = 'button'; badge.className = 'tsgd-badge'; badge.dataset.runtimeSlot = String(slot.runtimeSlotNumber); - badge.textContent = `Ad #${slot.runtimeSlotNumber} · ${badgeText(cycle)}`; + badge.dataset.requestNumber = String(cycle.requestNumber); + badge.textContent = `Ad #${slot.runtimeSlotNumber} · Request #${cycle.requestNumber} · ${badgeText(cycle)}`; + badge.setAttribute( + 'aria-label', + `Open diagnostics for Ad #${slot.runtimeSlotNumber}, Request #${cycle.requestNumber}` + ); + badge.addEventListener('click', () => + this.onActivate(slot.runtimeSlotNumber, cycle.requestNumber) + ); badge.style.maxWidth = `${BADGE_MAX_WIDTH_PX}px`; badge.style.left = `${Math.max( BADGE_EDGE_GUTTER_PX, @@ -231,7 +243,8 @@ export class GptDiagnosticsBadgeManager { badges.push(badge); } - this.layer.replaceChildren(...badges); + for (const badge of this.layer.querySelectorAll('.tsgd-badge')) badge.remove(); + this.layer.append(...badges); this.resizeObserver?.disconnect(); for (const element of observedElements) this.resizeObserver?.observe(element); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index d7271710c..635ca51b9 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -60,6 +60,8 @@ export function installGptDiagnosticsRuntime( badges = new GptDiagnosticsBadgeManager(store, bindings, { window: target, document: target.document, + onActivate: (runtimeSlotNumber, requestNumber) => + overlay?.selectRequest(runtimeSlotNumber, requestNumber), }); slotSizeObserver = new GptDiagnosticsSlotSizeObserver(store, bindings, { window: target }); overlay = new GptDiagnosticsOverlay(store, bindings, { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index 127317035..a58ec1986 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -74,7 +74,7 @@ const PANEL_STYLES = ` font: inherit; } button { cursor: pointer; } - button:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } + button:focus-visible, select:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } .tsgd-toolbar { display: flex; gap: 8px; align-items: center; border-bottom: 1px solid #334155; } .tsgd-toolbar label { color: #cbd5e1; } .tsgd-summary { color: #cbd5e1; border-bottom: 1px solid #334155; } @@ -83,6 +83,13 @@ const PANEL_STYLES = ` .tsgd-empty { padding: 18px 12px; color: #94a3b8; } .tsgd-slot { border-bottom: 1px solid #334155; } .tsgd-slot:last-child { border-bottom: 0; } + .tsgd-slot[aria-current="true"], .tsgd-cycle[aria-current="true"] { outline: 2px solid #60a5fa; outline-offset: -2px; } + .tsgd-group { margin-top: 8px; } + .tsgd-group h3 { margin: 0; color: #e2e8f0; font-size: 12px; } + .tsgd-help { padding: 0 12px 8px; color: #cbd5e1; } + .tsgd-help p { margin: 6px 0 0; } + .tsgd-locate { margin-top: 8px; } + .tsgd-selection-note { padding: 8px 12px; color: #fde68a; border-bottom: 1px solid #334155; } .tsgd-slot-title { display: flex; gap: 8px; align-items: baseline; } .tsgd-slot-title strong { overflow-wrap: anywhere; } .tsgd-state { margin-left: auto; color: #fde68a; white-space: nowrap; } @@ -94,6 +101,7 @@ const PANEL_STYLES = ` .tsgd-badge-layer { position: fixed; z-index: 2147483646; inset: 0; pointer-events: none; } .tsgd-badge { position: fixed; + pointer-events: auto; padding: 5px 7px; color: #fff; background: rgb(15 23 42 / 94%); @@ -102,6 +110,15 @@ const PANEL_STYLES = ` box-shadow: 0 2px 8px rgb(0 0 0 / 35%); font: 11px/1.35 ui-sans-serif, system-ui, sans-serif; white-space: pre-line; + text-align: left; + cursor: pointer; + } + .tsgd-highlight { + position: fixed; + border: 3px solid #fbbf24; + background: rgb(251 191 36 / 18%); + box-shadow: 0 0 0 3px rgb(15 23 42 / 75%); + pointer-events: none; } `; @@ -125,28 +142,33 @@ function formatMilliseconds(value: number | undefined): string | undefined { return `${Math.round(value * 10) / 10} ms`; } -function deliveryFact(cycle: GptDiagnosticsRequestCycle): string | undefined { +function deliveryFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.delivery) { case 'trusted_server_response_sent': - return 'Trusted Server selected; markup response sent to PUC'; + return 'Creative markup sent; execution not confirmed'; case 'trusted_server_selected': - return 'Trusted Server selected; no markup response confirmed'; + return 'Server bid selected by the creative bridge; response not confirmed'; case 'candidate_unconfirmed': - return 'Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible'; + return 'Server bid available; selection not confirmed'; case 'no_candidate': - return 'adInit observed no direct Trusted Server candidate for this request'; + return 'No direct Trusted Server candidate'; case 'unknown': - return 'Delivery status unknown — required GPT or direct-candidate evidence was not observed'; + return 'Delivery status unknown — required evidence was not observed'; case 'pending': return 'Waiting for Trusted Server creative evidence'; case 'not_applicable': + return 'Delivery evidence: Not applicable'; case undefined: - return undefined; + return 'Delivery evidence: Not observed'; default: return unhandledCase(cycle.delivery); } } +function servedBidderFact(cycle: GptDiagnosticsRequestCycle): string | undefined { + return cycle.isEmpty === false ? 'Served bidder not confirmed' : undefined; +} + function requestPathFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.requestPath) { case 'trusted_server_direct': @@ -156,24 +178,24 @@ function requestPathFact(cycle: GptDiagnosticsRequestCycle): string { case 'publisher_refresh': return 'Request path: Publisher refresh'; case 'competing': - return 'Request path: Competing paths'; + return 'Request path: Multiple paths observed'; case 'unattributed': - return 'Request path: Unattributed'; + return 'Request path: Not observed'; case undefined: - return 'Request path: Unknown (not observed)'; + return 'Request path: Not observed'; } } function trustedServerOpportunityFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.trustedServerOpportunity) { case 'renderable_candidate': - return 'Direct opportunity: Renderable candidate'; + return 'Server bid available; creative source present'; case 'unrenderable_candidate': - return 'Direct opportunity: Unrenderable candidate'; + return 'Server bid available; creative source incomplete'; case 'no_candidate': return 'Direct opportunity: No candidate'; case undefined: - return 'Direct opportunity: Unknown (not observed)'; + return 'Direct opportunity: Not observed'; } } @@ -226,46 +248,124 @@ function responseClassFact(cycle: GptDiagnosticsRequestCycle): string | undefine } } -function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { - const facts: string[] = [requestPathFact(cycle), trustedServerOpportunityFact(cycle)]; +function auctionFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [ + requestPathFact(cycle), + `Auction evidence: ${cycle.auctionType ? auctionTypeLabel(cycle.auctionType) : 'Auction not observed'}`, + ]; + if (cycle.auctionWinner) { + facts.push(`Server auction winner: ${cycle.auctionWinner.bidder}`); + facts.push( + `Server bid price bucket: ${cycle.auctionWinner.priceBucket} ${cycle.auctionWinner.currency ?? '(currency not supplied)'}` + ); + } + if (cycle.prebidAuction?.targetingCandidate) { + const candidate = cycle.prebidAuction.targetingCandidate; + facts.push(`Prebid targeting candidate: ${candidate.bidder}`); + facts.push( + `Prebid candidate price bucket: ${candidate.priceBucket} ${candidate.currency ?? '(currency not supplied)'}` + ); + } + if (cycle.prebidAuction?.win) { + const win = cycle.prebidAuction.win; + facts.push(`Prebid bidWon observation: ${win.bidder}`); + facts.push( + `Prebid win price bucket: ${win.priceBucket} ${win.currency ?? '(currency not supplied)'}` + ); + } + const servedBidder = servedBidderFact(cycle); + if (servedBidder) facts.push(servedBidder); + return facts; +} + +function timingFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const serverTimingApplies = + cycle.auctionType === 'ssat' || + cycle.auctionType === 'trusted_server' || + cycle.auctionType === 'competing'; + const missingServerTiming = serverTimingApplies ? 'Unavailable' : 'Not applicable'; + const timingAnchor = + cycle.serverAuctionTimingOrigin === 'spa_auction' ? 'SPA page-bids T0' : 'Edge request T0'; + const serverTimings = [ + [`${timingAnchor} → auction dispatched`, cycle.serverAuctionTimings?.auctionDispatchedMs], + [`${timingAnchor} → auction collected`, cycle.serverAuctionTimings?.auctionResolvedMs], + [`${timingAnchor} → bids ready`, cycle.serverAuctionTimings?.auctionCommittedMs], + ] as const; + const facts = serverTimings.map( + ([label, timing]) => `${label} ${formatMilliseconds(timing) ?? missingServerTiming}` + ); + const wait = formatMilliseconds(cycle.serverAuctionTimings?.auctionWaitMs); + if (wait) { + const placement = + cycle.serverAuctionTimings?.auctionWaitPlacement === 'pre_header' + ? 'pre-header' + : cycle.serverAuctionTimings?.auctionWaitPlacement === 'in_stream' + ? 'in stream' + : 'placement unknown'; + facts.push(`Auction collection wait (${placement}) ${wait}`); + } else { + facts.push(`Auction collection wait ${missingServerTiming}`); + } + facts.push( + `Opportunity → request ${formatMilliseconds(cycle.opportunityToRequestMs) ?? 'Unavailable'}` + ); + const durations = [ + ['GAM request → response', cycle.durations.requestToResponseMs], + ['GAM response → render', cycle.durations.responseToRenderMs], + ['GAM request → render', cycle.durations.requestToRenderMs], + ['Render → load', cycle.durations.renderToLoadMs], + ['Render → viewable', cycle.durations.renderToViewableMs], + ] as const; + for (const [label, duration] of durations) { + facts.push(`${label} ${formatMilliseconds(duration) ?? 'Unavailable'}`); + } + return facts; +} + +function deliveryFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [ + deliveryFact(cycle), + responseClassFact(cycle) ?? 'Ad Manager response class: Not observed', + adManagerFact(cycle) ?? 'Ad Manager fields: Not observed', + ]; + if (cycle.loadAtMs !== undefined) facts.push('GPT slot onload observed'); + if (cycle.viewableAtMs !== undefined) facts.push('GPT impressionViewable observed'); + if (cycle.incompleteSequence) facts.push('Incomplete sequence'); + if (cycle.isBackfill !== undefined) facts.push(`Backfill ${cycle.isBackfill ? 'yes' : 'no'}`); + if (cycle.slotContentChanged !== undefined) { + facts.push(`Slot content changed ${cycle.slotContentChanged ? 'yes' : 'no'}`); + } + return facts; +} + +function sizeFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const fillSize = displayableGptFillSize(cycle.size); + return [ + cycle.requestedSlotSizes + ? `Requested sizes ${formatSizes(cycle.requestedSlotSizes)}` + : 'Requested sizes: Not observed', + fillSize + ? `GPT-reported size ${formatSizes([fillSize])}` + : cycle.size?.[0] === 1 && cycle.size[1] === 1 + ? 'GPT-reported size: 1×1 placeholder hidden' + : 'GPT-reported size: Not observed', + cycle.observedSlotSize + ? `Size filled ${formatSizes([cycle.observedSlotSize])} · Measured outer slot size` + : 'Size filled: Not observed · Measured outer slot size', + ]; +} + +function technicalCycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [trustedServerOpportunityFact(cycle)]; if (Number.isSafeInteger(cycle.requestIntentId) && cycle.requestIntentId! > 0) { facts.push(`Request intent: ${cycle.requestIntentId}`); } if (typeof cycle.trustedServerAuctionId === 'string' && cycle.trustedServerAuctionId.length > 0) { facts.push(`Trusted Server auction: ${cycle.trustedServerAuctionId}`); } - if (cycle.auctionType) facts.push(`Auction type: ${auctionTypeLabel(cycle.auctionType)}`); - if (cycle.auctionWinner) { - facts.push(`Winning bidder: ${cycle.auctionWinner.bidder}`); - facts.push(`Winning bid price bucket: ${cycle.auctionWinner.priceBucket}`); - } - if (cycle.serverAuctionTimings) { - const timingOrigin = - cycle.serverAuctionTimingOrigin ?? - (cycle.auctionType === 'trusted_server' ? 'spa_auction' : 'navigation'); - const timingAnchor = timingOrigin === 'spa_auction' ? 'SPA page-bids T0' : 'Edge request T0'; - const serverTimings = [ - [`${timingAnchor} → auction dispatched`, cycle.serverAuctionTimings.auctionDispatchedMs], - [`${timingAnchor} → auction resolved`, cycle.serverAuctionTimings.auctionResolvedMs], - [`${timingAnchor} → bids committed`, cycle.serverAuctionTimings.auctionCommittedMs], - ] as const; - for (const [label, timing] of serverTimings) { - const formatted = formatMilliseconds(timing); - if (formatted) facts.push(`${label} ${formatted}`); - } - const wait = formatMilliseconds(cycle.serverAuctionTimings.auctionWaitMs); - if (wait) { - const placement = - cycle.serverAuctionTimings.auctionWaitPlacement === 'pre_header' - ? 'pre-header' - : cycle.serverAuctionTimings.auctionWaitPlacement === 'in_stream' - ? 'in stream' - : 'placement unknown'; - facts.push(`Auction wait (${placement}) ${wait}`); - } + if (cycle.prebidAuction?.auctionId) { + facts.push(`Prebid auction: ${cycle.prebidAuction.auctionId}`); } - const opportunityToRequest = formatMilliseconds(cycle.opportunityToRequestMs); - if (opportunityToRequest) facts.push(`Opportunity → request ${opportunityToRequest}`); const previousRenderToRequest = formatMilliseconds(cycle.previousRenderToRequestMs); if (cycle.replacedRequestNumber !== undefined && previousRenderToRequest) { facts.push( @@ -296,42 +396,19 @@ function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { for (const failure of new Set(cycle.trustedServerCreativeFailures ?? [])) { facts.push(creativeFailureFact(failure)); } - const deliveryLine = deliveryFact(cycle); - if (deliveryLine) facts.push(deliveryLine); - const responseClassLine = responseClassFact(cycle); - if (responseClassLine) facts.push(responseClassLine); - const adManagerLine = adManagerFact(cycle); - if (adManagerLine) facts.push(adManagerLine); - if (cycle.loadAtMs !== undefined) facts.push('GPT slot onload observed'); - if (cycle.viewableAtMs !== undefined) facts.push('GPT impressionViewable observed'); - if (cycle.incompleteSequence) facts.push('Incomplete sequence'); - if (cycle.requestedSlotSizes) { - facts.push(`Requested slot sizes ${formatSizes(cycle.requestedSlotSizes)}`); - } - const fillSize = displayableGptFillSize(cycle.size); - if (fillSize) facts.push(`GPT-reported fill size ${fillSize[0]}×${fillSize[1]}`); - if (cycle.observedSlotSize) { - facts.push(`Size filled ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); - } - if (cycle.isBackfill !== undefined) facts.push(`Backfill ${cycle.isBackfill ? 'yes' : 'no'}`); - if (cycle.slotContentChanged !== undefined) { - facts.push(`Slot content changed ${cycle.slotContentChanged ? 'yes' : 'no'}`); - } - - const durations = [ - ['GAM request → response', cycle.durations.requestToResponseMs], - ['GAM response → render', cycle.durations.responseToRenderMs], - ['GAM request → render', cycle.durations.requestToRenderMs], - ['Render → load', cycle.durations.renderToLoadMs], - ['Render → viewable', cycle.durations.renderToViewableMs], - ] as const; - for (const [label, duration] of durations) { - const formatted = formatMilliseconds(duration); - if (formatted) facts.push(`${label} ${formatted}`); - } return facts; } +function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { + return [ + ...auctionFacts(cycle), + ...deliveryFacts(cycle), + ...timingFacts(cycle), + ...sizeFacts(cycle), + ...technicalCycleFacts(cycle), + ]; +} + function cycleLabel(cycle: GptDiagnosticsRequestCycle): string { return cycle.requestNumber === 1 ? 'Initial request' : `Refresh ${cycle.requestNumber - 1}`; } @@ -363,6 +440,21 @@ function appendFacts(document: Document, parent: HTMLElement, facts: string[]): parent.append(list); } +function appendGroup( + document: Document, + parent: HTMLElement, + heading: string, + facts: string[] +): void { + const section = document.createElement('section'); + section.className = 'tsgd-group'; + const title = document.createElement('h3'); + title.textContent = heading; + section.append(title); + appendFacts(document, section, facts); + parent.append(section); +} + /** Owns hydration-safe mounting and the closed-shadow diagnostics panel. */ export class GptDiagnosticsOverlay { private readonly store: OverlayStore; @@ -377,6 +469,7 @@ export class GptDiagnosticsOverlay { private readonly unsubscribeBindings: () => void; private host?: HTMLElement; private panel?: HTMLElement; + private badgeLayer?: HTMLElement; private lifecycleObserver?: MutationObserver; private visualReady = false; private mountWaitStarted = false; @@ -387,6 +480,8 @@ export class GptDiagnosticsOverlay { private dismissed = false; private destroyed = false; private filter: GptDiagnosticsFilter = 'all'; + private selectedRequest?: { runtimeSlotNumber: number; requestNumber: number }; + private selectedRequestHasFocus = false; constructor(store: OverlayStore, bindings: OverlayBindings, options: OverlayOptions = {}) { this.store = store; @@ -417,6 +512,23 @@ export class GptDiagnosticsOverlay { this.removeHost(); } + /** Open and focus the exact retained request selected from an on-page badge. */ + selectRequest(runtimeSlotNumber: number, requestNumber: number): void { + if (this.destroyed) return; + this.selectedRequest = { runtimeSlotNumber, requestNumber }; + this.filter = 'all'; + this.collapsed = false; + this.show(); + this.render(); + this.scheduleFrame(() => { + const selected = this.panel?.querySelector( + `[data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]` + ); + selected?.focus(); + selected?.scrollIntoView?.({ block: 'nearest' }); + }); + } + destroy(): void { if (this.destroyed) return; this.destroyed = true; @@ -475,11 +587,11 @@ export class GptDiagnosticsOverlay { panel.setAttribute('aria-label', 'GPT runtime diagnostics'); const badgeLayer = this.document.createElement('div'); badgeLayer.className = 'tsgd-badge-layer'; - badgeLayer.setAttribute('aria-hidden', 'true'); root.append(style, badgeLayer, panel); this.host = host; this.panel = panel; + this.badgeLayer = badgeLayer; (this.document.body ?? this.document.documentElement).append(host); this.onShadowRoot?.(root); this.onBadgeLayerChange?.(badgeLayer); @@ -491,6 +603,7 @@ export class GptDiagnosticsOverlay { const host = this.host; this.host = undefined; this.panel = undefined; + this.badgeLayer = undefined; host?.remove(); } @@ -538,8 +651,9 @@ export class GptDiagnosticsOverlay { const panel = this.panel; const previousContent = panel.querySelector('.tsgd-content'); const previousScrollTop = previousContent?.scrollTop ?? 0; + const selectedRequestWasFocused = this.selectedRequestHasFocus; const openHistorySlots = new Set( - Array.from(panel.querySelectorAll('.tsgd-slot details[open]')) + Array.from(panel.querySelectorAll('.tsgd-history[open]')) .map((details) => details.closest('.tsgd-slot')?.dataset.runtimeSlot) .filter((runtimeSlot): runtimeSlot is string => runtimeSlot !== undefined) ); @@ -590,10 +704,27 @@ export class GptDiagnosticsOverlay { this.render(); }); const exportButton = this.button('Export JSON', () => this.onExport()); + const dictionaryLink = this.document.createElement('a'); + dictionaryLink.href = + 'https://iabtechlab.github.io/trusted-server/guide/integrations/gpt-diagnostics-dictionary'; + dictionaryLink.target = '_blank'; + dictionaryLink.rel = 'noopener'; + dictionaryLink.textContent = 'Label dictionary'; + dictionaryLink.setAttribute('aria-label', 'Open GPT diagnostics label dictionary'); filterLabel.append(select); - toolbar.append(filterLabel, exportButton); + toolbar.append(filterLabel, exportButton, dictionaryLink); panel.append(toolbar); + const help = this.document.createElement('details'); + help.className = 'tsgd-help'; + const helpSummary = this.document.createElement('summary'); + helpSummary.textContent = 'How to read this evidence'; + const helpText = this.document.createElement('p'); + helpText.textContent = + 'Auction winners, Prebid candidates, and GPT render results are separate observations. “Filled” does not identify the served bidder. Browser and server timings use separate clocks.'; + help.append(helpSummary, helpText); + panel.append(help); + const summary = this.document.createElement('div'); summary.className = 'tsgd-summary'; summary.textContent = `${snapshot.slots.length} slots · ${snapshot.callbackIssues.length} callback issues · ${snapshot.attributionIssues?.length ?? 0} attribution issues`; @@ -608,6 +739,21 @@ export class GptDiagnosticsOverlay { summary.append(coverage); panel.append(summary); + if ( + this.selectedRequest && + !snapshot.slots.some( + (slot) => + slot.runtimeSlotNumber === this.selectedRequest?.runtimeSlotNumber && + slot.requests.some((cycle) => cycle.requestNumber === this.selectedRequest?.requestNumber) + ) + ) { + const note = this.document.createElement('div'); + note.className = 'tsgd-selection-note'; + note.setAttribute('role', 'status'); + note.textContent = `Ad #${this.selectedRequest.runtimeSlotNumber}, Request #${this.selectedRequest.requestNumber} is no longer retained.`; + panel.append(note); + } + const content = this.document.createElement('div'); content.className = 'tsgd-content'; const filteredSlots = snapshot.slots.filter((slot) => @@ -621,22 +767,44 @@ export class GptDiagnosticsOverlay { content.append(empty); } else { for (const slot of filteredSlots) { - content.append(this.renderSlot(slot, openHistorySlots.has(String(slot.runtimeSlotNumber)))); + const selectedPreviousRequest = + this.selectedRequest?.runtimeSlotNumber === slot.runtimeSlotNumber && + this.selectedRequest.requestNumber !== latestCycle(slot)?.requestNumber; + content.append( + this.renderSlot( + slot, + openHistorySlots.has(String(slot.runtimeSlotNumber)) || selectedPreviousRequest + ) + ); } } panel.append(content); content.scrollTop = previousScrollTop; + if (selectedRequestWasFocused) { + panel.querySelector('[aria-current="true"]')?.focus({ preventScroll: true }); + } } private renderSlot(slot: GptDiagnosticsStoreSlotSnapshot, historyOpen: boolean): HTMLElement { const container = this.document.createElement('article'); container.className = 'tsgd-slot'; + const latest = latestCycle(slot); container.dataset.runtimeSlot = String(slot.runtimeSlotNumber); + if (latest) container.dataset.requestNumber = String(latest.requestNumber); + const latestSelected = + latest !== undefined && + this.selectedRequest?.runtimeSlotNumber === slot.runtimeSlotNumber && + this.selectedRequest.requestNumber === latest.requestNumber; + if (latestSelected) { + container.tabIndex = -1; + container.setAttribute('aria-current', 'true'); + this.trackSelectedRequestFocus(container); + } + const title = this.document.createElement('div'); title.className = 'tsgd-slot-title'; const name = this.document.createElement('strong'); - name.textContent = `Ad #${slot.runtimeSlotNumber} · ${slot.slotElementId ?? 'Unbound GPT slot'}`; - const latest = latestCycle(slot); + name.textContent = `Ad #${slot.runtimeSlotNumber}${latest ? ` · Request #${latest.requestNumber}` : ''} · ${slot.slotElementId ?? 'Unbound GPT slot'}`; const state = this.document.createElement('span'); state.className = 'tsgd-state'; state.textContent = primaryState(latest); @@ -644,41 +812,111 @@ export class GptDiagnosticsOverlay { container.append(title); const binding = this.bindings.get(slot.runtimeSlotNumber); - const facts = [ - slot.adUnitPath ? `Ad unit ${slot.adUnitPath}` : undefined, - binding.binding.status === 'bound' - ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` - : binding.binding.status === 'ambiguous' - ? `Ambiguous binding · ${binding.binding.reason ?? 'unknown'}` - : `Unbound · ${binding.binding.reason ?? 'unknown'}`, - slot.currentVisibilityPercentage !== undefined - ? `GPT visibility ${slot.currentVisibilityPercentage}% (maximum ${slot.maximumVisibilityPercentage ?? slot.currentVisibilityPercentage}%)` - : undefined, - latest ? cycleLabel(latest) : undefined, - ...(latest ? cycleFacts(latest) : []), - ].filter((fact): fact is string => fact !== undefined); - appendFacts(this.document, container, facts); + if (binding.binding.status === 'bound' && binding.element?.isConnected) { + const locate = this.button('Locate on page', () => this.locateOnPage(slot.runtimeSlotNumber)); + locate.className = 'tsgd-locate'; + container.append(locate); + } + + if (latest) { + const summaryFacts = [ + `Ad #${slot.runtimeSlotNumber} · Request #${latest.requestNumber}`, + `GPT result: ${primaryState(latest)}`, + `Observed auction path: ${latest.auctionType ? auctionTypeLabel(latest.auctionType) : 'Auction not observed'}`, + deliveryFact(latest), + ]; + const servedBidder = servedBidderFact(latest); + if (servedBidder) summaryFacts.push(servedBidder); + appendGroup(this.document, container, 'Summary', summaryFacts); + appendGroup(this.document, container, 'Auction evidence', auctionFacts(latest)); + appendGroup(this.document, container, 'Delivery evidence', deliveryFacts(latest)); + appendGroup(this.document, container, 'Timing', timingFacts(latest)); + appendGroup(this.document, container, 'Size and visibility', [ + ...sizeFacts(latest), + binding.binding.status === 'bound' + ? `Binding: Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` + : `Binding: ${binding.binding.status} · ${binding.binding.reason ?? 'reason unavailable'}`, + slot.currentVisibilityPercentage !== undefined + ? `GPT visibility ${slot.currentVisibilityPercentage}% (maximum ${slot.maximumVisibilityPercentage ?? slot.currentVisibilityPercentage}%)` + : 'GPT visibility: Not observed', + ]); + } if (slot.requests.length > 1) { const history = this.document.createElement('details'); + history.className = 'tsgd-history'; history.open = historyOpen; const summary = this.document.createElement('summary'); - summary.textContent = `Previous requests (${slot.requests.length - 1})`; + summary.textContent = `Request history (${slot.requests.length - 1} previous)`; history.append(summary); for (const cycle of slot.requests.slice(0, -1).reverse()) { const previous = this.document.createElement('div'); previous.className = 'tsgd-cycle'; + previous.dataset.runtimeSlot = String(slot.runtimeSlotNumber); + previous.dataset.requestNumber = String(cycle.requestNumber); + const selected = + this.selectedRequest?.runtimeSlotNumber === slot.runtimeSlotNumber && + this.selectedRequest.requestNumber === cycle.requestNumber; + if (selected) { + previous.tabIndex = -1; + previous.setAttribute('aria-current', 'true'); + this.trackSelectedRequestFocus(previous); + } const heading = this.document.createElement('strong'); - heading.textContent = `${cycleLabel(cycle)} · ${primaryState(cycle)}`; + heading.textContent = `Request #${cycle.requestNumber} · ${cycleLabel(cycle)} · ${primaryState(cycle)}`; previous.append(heading); appendFacts(this.document, previous, cycleFacts(cycle)); history.append(previous); } container.append(history); } + + const technical = this.document.createElement('details'); + const technicalSummary = this.document.createElement('summary'); + technicalSummary.textContent = 'Technical details'; + technical.append(technicalSummary); + appendFacts(this.document, technical, [ + slot.adUnitPath ? `Ad unit ${slot.adUnitPath}` : 'Ad unit: Unavailable', + binding.binding.status === 'bound' + ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` + : binding.binding.status === 'ambiguous' + ? `Ambiguous binding · ${binding.binding.reason ?? 'reason unavailable'}` + : `Unbound · ${binding.binding.reason ?? 'reason unavailable'}`, + ...(latest ? technicalCycleFacts(latest) : []), + ]); + container.append(technical); return container; } + private trackSelectedRequestFocus(element: HTMLElement): void { + element.addEventListener('focus', () => { + this.selectedRequestHasFocus = true; + }); + element.addEventListener('blur', () => { + this.selectedRequestHasFocus = false; + }); + } + + private locateOnPage(runtimeSlotNumber: number): void { + const binding = this.bindings.get(runtimeSlotNumber); + const element = binding.element; + if (binding.binding.status !== 'bound' || !element?.isConnected) return; + element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }); + this.scheduleFrame(() => { + if (!this.badgeLayer?.isConnected || !element.isConnected) return; + const rectangle = element.getBoundingClientRect(); + const highlight = this.document.createElement('div'); + highlight.className = 'tsgd-highlight'; + highlight.setAttribute('aria-hidden', 'true'); + highlight.style.left = `${rectangle.left}px`; + highlight.style.top = `${rectangle.top}px`; + highlight.style.width = `${rectangle.width}px`; + highlight.style.height = `${rectangle.height}px`; + this.badgeLayer.append(highlight); + this.window.setTimeout(() => highlight.remove(), 1500); + }); + } + private button(label: string, action: () => void): HTMLButtonElement { const button = this.document.createElement('button'); button.type = 'button'; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts index 7e26778d1..42ce496c6 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts @@ -12,15 +12,29 @@ export function displayableGptFillSize(size: Size | undefined): Size | undefined /** Human-readable auction classification shared by the badge and side panel. */ export function auctionTypeLabel(type: GptDiagnosticsAuctionType): string { + switch (type) { + case 'ssat': + return 'SSAT: initial-page server auction'; + case 'trusted_server': + return 'TS auction: SPA server auction'; + case 'client_side': + return 'Client-side Prebid auction'; + case 'competing': + return 'Multiple auction paths observed'; + } +} + +/** Compact auction classification shared by on-page badges. */ +export function auctionTypeBadgeLabel(type: GptDiagnosticsAuctionType): string { switch (type) { case 'ssat': return 'SSAT'; case 'trusted_server': return 'TS auction'; case 'client_side': - return 'Client-side auction'; + return 'Prebid auction'; case 'competing': - return 'Competing auctions'; + return 'Multiple paths'; } } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index f0e006cac..6fcbacfb5 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -12,6 +12,7 @@ import type { GptDiagnosticsAuctionFacts, GptDiagnosticsAuctionType, GptDiagnosticsAuctionWinner, + GptDiagnosticsPrebidAuctionEvidence, GptDiagnosticsDurations, GptDiagnosticsRequestCycle, GptDiagnosticsRequestPath, @@ -115,6 +116,7 @@ interface PendingSourceEvidence { requestedSlotSizes?: ReadonlyArray; auctionType?: Extract; auctionWinner?: GptDiagnosticsAuctionWinner; + prebidAuction?: GptDiagnosticsPrebidAuctionEvidence; serverAuctionTimings?: AuctionDiagnosticsData; } @@ -251,7 +253,12 @@ function normalizedAuctionWinner(value: unknown): GptDiagnosticsAuctionWinner | const bidder = normalizedBoundedString(candidate.bidder, 128); const priceBucket = normalizedBoundedString(candidate.priceBucket, 64); if (!bidder || !priceBucket || !/^\d+(?:\.\d+)?$/.test(priceBucket)) return undefined; - return Object.freeze({ bidder, priceBucket }); + const currency = normalizedBoundedString(candidate.currency, 3)?.toUpperCase(); + return Object.freeze({ + bidder, + priceBucket, + ...(currency && /^[A-Z]{3}$/.test(currency) ? { currency } : {}), + }); } const MAX_SERVER_AUCTION_TIMING_MS = 0xffffffff; @@ -361,6 +368,17 @@ function copyCycle(cycle: MutableRequestCycle, nowMs: number): GptDiagnosticsReq } : undefined, ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { + prebidAuction: { + ...cycle.prebidAuction, + ...(cycle.prebidAuction.targetingCandidate + ? { targetingCandidate: { ...cycle.prebidAuction.targetingCandidate } } + : {}), + ...(cycle.prebidAuction.win ? { win: { ...cycle.prebidAuction.win } } : {}), + }, + } + : {}), ...(cycle.serverAuctionTimings ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } : {}), @@ -454,6 +472,47 @@ export class GptDiagnosticsStore { } } + /** Record a completed Prebid attempt for one exact slot's next request. */ + recordPrebidAuction( + slot: GptDiagnosticsSlotLike, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void { + if (!isSlotObject(slot)) return; + const normalizedId = normalizedAuctionId(auctionId); + if (!normalizedId) return; + const candidate = normalizedAuctionWinner(targetingCandidate); + this.recordRequestIntentSource(slot, 'prebid_refresh', { + prebidAuction: Object.freeze({ + auctionId: normalizedId, + ...(candidate ? { targetingCandidate: candidate } : {}), + }), + }); + } + + /** Attach a documented Prebid win only to its retained exact slot and attempt. */ + recordPrebidWin( + slot: GptDiagnosticsSlotLike, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void { + if (!isSlotObject(slot)) return; + const normalizedId = normalizedAuctionId(auctionId); + const normalizedWinner = normalizedAuctionWinner(winner); + if (!normalizedId || !normalizedWinner) return; + const runtimeSlotNumber = this.slotNumbers.get(slot); + const record = runtimeSlotNumber === undefined ? undefined : this.slots.get(runtimeSlotNumber); + const matches = record?.requests.filter( + (cycle) => cycle.prebidAuction?.auctionId === normalizedId + ); + if (!matches || matches.length !== 1 || !matches[0]?.prebidAuction) return; + matches[0].prebidAuction = Object.freeze({ + ...matches[0].prebidAuction, + win: normalizedWinner, + }); + this.notify(); + } + /** Record publisher refresh observation from the private GPT diagnostics observer. */ recordPublisherRefresh(slots: GptDiagnosticsSlotLike[]): void { if (!Array.isArray(slots)) return; @@ -699,6 +758,9 @@ export class GptDiagnosticsStore { ...(trustedServerEvidence?.auctionWinner !== undefined ? { auctionWinner: trustedServerEvidence.auctionWinner } : {}), + ...(intent?.sources.get('prebid_refresh')?.prebidAuction !== undefined + ? { prebidAuction: intent.sources.get('prebid_refresh')?.prebidAuction } + : {}), ...(trustedServerEvidence?.serverAuctionTimings !== undefined ? { serverAuctionTimings: trustedServerEvidence.serverAuctionTimings } : {}), @@ -1122,12 +1184,11 @@ export class GptDiagnosticsStore { intent: PendingRequestIntent | undefined, trustedServerEvidence: PendingSourceEvidence | undefined ): GptDiagnosticsAuctionType | undefined { - const hasTrustedServerAuction = intent?.sources.has('trusted_server_direct') === true; - const hasClientSideAuction = intent?.sources.has('prebid_refresh') === true; - if (hasTrustedServerAuction && hasClientSideAuction) return 'competing'; + const trustedServerAuction = trustedServerEvidence?.auctionType; + const hasClientSideAuction = intent?.sources.get('prebid_refresh')?.prebidAuction !== undefined; + if (trustedServerAuction && hasClientSideAuction) return 'competing'; if (hasClientSideAuction) return 'client_side'; - if (hasTrustedServerAuction) return trustedServerEvidence?.auctionType; - return undefined; + return trustedServerAuction; } private recordReplacement(record: MutableSlotRecord, cycle: MutableRequestCycle): void { 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 44b47f2da..ae4dbae0d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -17,7 +17,7 @@ import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import { registerApsPrebidRenderer, validateApsRenderer } from '../aps/render'; import type { AuctionBid, AuctionEid } from '../../core/auction'; -import type { AuctionSlot, TsjsApi } from '../../core/types'; +import type { AuctionSlot, GptDiagnosticsAuctionWinner, TsjsApi } from '../../core/types'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; @@ -426,6 +426,135 @@ function recordPrebidRefreshForDiagnostics(slots: RefreshGptSlot[]): void { } } +const MAX_PREBID_DIAGNOSTIC_ATTEMPTS = 128; +const PREBID_DIAGNOSTIC_WINDOW_MS = 30_000; + +interface PrebidDiagnosticAttempt { + slot: RefreshGptSlot; + generation: number; + expiresAtMs: number; +} + +const prebidDiagnosticAttempts = new Map(); + +function prebidDiagnosticKey(auctionId: string, adUnitCode: string): string { + return `${auctionId}\u0000${adUnitCode}`; +} + +function boundedTargetingValue( + slot: RefreshGptSlot, + key: string, + maxBytes: number +): string | undefined { + let values: string[] | undefined; + try { + values = slot.getTargeting?.(key); + } catch { + return undefined; + } + if (!Array.isArray(values) || values.length !== 1) return undefined; + const value = values[0]?.trim(); + if (!value || new TextEncoder().encode(value).length > maxBytes) return undefined; + return value; +} + +function targetingCandidate(slot: RefreshGptSlot): GptDiagnosticsAuctionWinner | undefined { + const bidder = boundedTargetingValue(slot, 'hb_bidder', 128); + const priceBucket = boundedTargetingValue(slot, 'hb_pb', 64); + if (!bidder || !priceBucket || !/^\d+(?:\.\d+)?$/.test(priceBucket)) return undefined; + const suppliedCurrency = boundedTargetingValue(slot, 'hb_cur', 3)?.toUpperCase(); + return { + bidder, + priceBucket, + ...(suppliedCurrency && /^[A-Z]{3}$/.test(suppliedCurrency) + ? { currency: suppliedCurrency } + : {}), + }; +} + +function recordCompletedPrebidAuction( + rawAuctionId: unknown, + auctionSlots: RefreshGptSlot[], + adUnitCodes: string[] +): void { + const recorder = window.tsjs?.gptDiagnosticsRecorder; + if (!recorder || typeof rawAuctionId !== 'string') return; + const auctionId = rawAuctionId; + if ( + !auctionId || + auctionId !== auctionId.trim() || + new TextEncoder().encode(auctionId).length > 256 + ) + return; + if (auctionSlots.length !== adUnitCodes.length) return; + installPrebidWinDiagnostics(); + const counts = new Map(); + for (const code of adUnitCodes) counts.set(code, (counts.get(code) ?? 0) + 1); + const nowMs = performance.now(); + const generation = window.tsjs?.navGeneration ?? 0; + for (let index = 0; index < auctionSlots.length; index += 1) { + const slot = auctionSlots[index]; + const code = adUnitCodes[index]; + if (!slot || !code || counts.get(code) !== 1) continue; + try { + recorder.recordPrebidAuction(slot, auctionId, targetingCandidate(slot)); + } catch { + // Diagnostics must not suppress the GAM request. + } + const key = prebidDiagnosticKey(auctionId, code); + prebidDiagnosticAttempts.delete(key); + prebidDiagnosticAttempts.set(key, { + slot, + generation, + expiresAtMs: nowMs + PREBID_DIAGNOSTIC_WINDOW_MS, + }); + while (prebidDiagnosticAttempts.size > MAX_PREBID_DIAGNOSTIC_ATTEMPTS) { + const oldest = prebidDiagnosticAttempts.keys().next().value; + if (oldest === undefined) break; + prebidDiagnosticAttempts.delete(oldest); + } + } +} + +function installPrebidWinDiagnostics(): void { + const diagnosticPbjs = pbjs as PbjsGlobal & { __tsDiagnosticsBidWonInstalled?: boolean }; + if (diagnosticPbjs.__tsDiagnosticsBidWonInstalled || typeof pbjs.onEvent !== 'function') return; + diagnosticPbjs.__tsDiagnosticsBidWonInstalled = true; + pbjs.onEvent('bidWon', (rawBid: unknown) => { + if (typeof rawBid !== 'object' || rawBid === null) return; + const bid = rawBid as Record; + const auctionId = typeof bid.auctionId === 'string' ? bid.auctionId : undefined; + const adUnitCode = typeof bid.adUnitCode === 'string' ? bid.adUnitCode : undefined; + if (!auctionId || !adUnitCode) return; + const key = prebidDiagnosticKey(auctionId, adUnitCode); + const attempt = prebidDiagnosticAttempts.get(key); + prebidDiagnosticAttempts.delete(key); + if ( + !attempt || + performance.now() > attempt.expiresAtMs || + (window.tsjs?.navGeneration ?? 0) !== attempt.generation + ) { + return; + } + const adserverTargeting = + typeof bid.adserverTargeting === 'object' && bid.adserverTargeting !== null + ? (bid.adserverTargeting as Record) + : {}; + const winner = targetingCandidate({ + getTargeting: (targetingKey) => { + const value = adserverTargeting[targetingKey]; + return typeof value === 'string' ? [value] : []; + }, + }); + if (!winner) return; + try { + window.tsjs?.gptDiagnosticsRecorder?.recordPrebidWin(attempt.slot, auctionId, winner); + } catch { + // Diagnostics must not alter delivery. + } + }); +} + function dispatchPrebidRefresh( refresh: (slots?: unknown[], opts?: unknown) => T, slots: unknown[] | undefined, @@ -1473,18 +1602,23 @@ export function installRefreshHandler(timeoutMs = 1500): void { // slots, and a late callback cannot issue a second GAM request. let completed = false; let fallbackTimer: ReturnType | undefined; - function completeRefresh(applyTargeting: boolean): void { + function completeRefresh(applyTargeting: boolean, completedAuctionId?: string): void { if (completed) return; completed = true; if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); - if (applyTargeting) { + let targetingApplied = false; + if (applyTargeting && typeof pbjs.setTargetingForGPTAsync === 'function') { try { - pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); + pbjs.setTargetingForGPTAsync(refreshAdUnitCodes); + targetingApplied = true; } catch (error) { log.error('[tsjs-prebid] refresh targeting failed', error); } } recordPrebidRefreshForDiagnostics(targetSlots); + if (targetingApplied) { + recordCompletedPrebidAuction(completedAuctionId, auctionSlots, refreshAdUnitCodes); + } // Preserve the publisher's original refresh form. In particular, a bare // GPT refresh remains bare so GPT resolves its registered slot set when // the auction completes; the dispatch wrapper only scopes the shared @@ -1495,7 +1629,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { try { pbjs.requestBids({ adUnits, - bidsBackHandler: () => completeRefresh(true), + bidsBackHandler: (_bids, _timedOut, auctionId) => completeRefresh(true, auctionId), timeout: timeoutMs, }); // A one-shot watchdog completes the GAM request even if Prebid never 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 fd318caee..5a8f97619 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 @@ -404,7 +404,7 @@ describe('installTsAdInit', () => { ); }); - it('forwards SPA auction classification without inventing a winner', async () => { + it('does not infer an SPA auction from navigation generation alone', async () => { const recordTrustedServerOpportunity = vi.fn(); const { mockSlot } = configureOpportunityDiagnostics(undefined, recordTrustedServerOpportunity); (window as TestWindow).tsjs!.navGeneration = 1; @@ -418,8 +418,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'no_candidate', undefined, - undefined, - { auctionType: 'trusted_server' } + undefined ); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts index 974f5d64e..71e71ddb8 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -92,7 +92,9 @@ describe('GptDiagnosticsApiController', () => { 'subscribe', ]); expect(Object.keys(controller.recorder).sort()).toEqual([ + 'recordPrebidAuction', 'recordPrebidRefresh', + 'recordPrebidWin', 'recordTrustedServerCreativeFailure', 'recordTrustedServerCreativeRequest', 'recordTrustedServerCreativeResponse', diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts index a26683d3a..fd635fd8b 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts @@ -116,6 +116,42 @@ describe('GptDiagnosticsBadgeManager', () => { manager.destroy(); }); + it('renders an accessible request-scoped control and activates its exact request', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const element = document.createElement('div'); + document.body.append(element); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rectangle(10, 100, 300, 250)); + const observedSlot = slot('accessible'); + store.recordSlotRequested(observedSlot); + store.recordSlotRequested(observedSlot); + bindings.set(1, { status: 'bound' }, element, true); + const activate = vi.fn(); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + onActivate: activate, + }); + manager.setLayer(layer); + runFrame(frames); + + const badge = layer.querySelector('.tsgd-badge'); + expect(badge).toBeInstanceOf(HTMLButtonElement); + expect(badge?.textContent).toContain('Ad #1 · Request #2'); + expect(badge?.getAttribute('aria-label')).toContain('Ad #1, Request #2'); + badge?.click(); + expect(activate).toHaveBeenCalledWith(1, 2); + + const highlight = document.createElement('div'); + highlight.className = 'tsgd-highlight'; + layer.append(highlight); + manager.update(); + expect(layer.querySelector('.tsgd-highlight')).toBe(highlight); + manager.destroy(); + }); + it('labels the delivery state the store derived rather than raw timestamps', () => { // The store owns the delivery ladder; a badge that re-derived it from these // timestamps could contradict the panel and the export. diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts index fa50d6366..6c5461815 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -124,7 +124,9 @@ describe('GPT diagnostics integration composition', () => { // Evidence writers live on their own channel; the operator API stays read-only. expect(Object.keys(first!).sort()).toEqual(['export', 'hide', 'show', 'snapshot', 'subscribe']); expect(Object.keys(target.tsjs!.gptDiagnosticsRecorder!).sort()).toEqual([ + 'recordPrebidAuction', 'recordPrebidRefresh', + 'recordPrebidWin', 'recordTrustedServerCreativeFailure', 'recordTrustedServerCreativeRequest', 'recordTrustedServerCreativeResponse', diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts index cdc4e4aef..598cdc00a 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts @@ -272,15 +272,15 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).toContain('Request path: Trusted Server direct'); expect(responseSentArticle).toContain('Request intent: 1'); expect(responseSentArticle).toContain('Trusted Server auction: auction-123'); - expect(responseSentArticle).toContain('Auction type: SSAT'); - expect(responseSentArticle).toContain('Winning bidder: example-bidder'); - expect(responseSentArticle).toContain('Winning bid price bucket: 1.20'); + expect(responseSentArticle).toContain('Auction evidence: SSAT: initial-page server auction'); + expect(responseSentArticle).toContain('Server auction winner: example-bidder'); + expect(responseSentArticle).toContain('Server bid price bucket: 1.20'); expect(responseSentArticle).toContain('Edge request T0 → auction dispatched 4 ms'); - expect(responseSentArticle).toContain('Edge request T0 → auction resolved 84 ms'); - expect(responseSentArticle).toContain('Edge request T0 → bids committed 85 ms'); - expect(responseSentArticle).toContain('Auction wait (in stream) 80 ms'); + expect(responseSentArticle).toContain('Edge request T0 → auction collected 84 ms'); + expect(responseSentArticle).toContain('Edge request T0 → bids ready 85 ms'); + expect(responseSentArticle).toContain('Auction collection wait (in stream) 80 ms'); expect(responseSentArticle).toContain('Opportunity → request 0 ms'); - expect(responseSentArticle).toContain('Direct opportunity: Renderable candidate'); + expect(responseSentArticle).toContain('Server bid available; creative source present'); expect(responseSentArticle).toContain('Trusted Server creative request observed at 13 ms'); expect(responseSentArticle).toContain('Trusted Server markup response sent at 14 ms'); expect( @@ -289,7 +289,7 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).toContain('Creative bridge failure: cache fetch failed'); expect(responseSentArticle).toContain('Creative bridge failure: invalid cache payload'); expect(responseSentArticle).toContain('Creative bridge failure: response post failed'); - expect(responseSentArticle).toContain('Trusted Server selected; markup response sent to PUC'); + expect(responseSentArticle).toContain('Creative markup sent; execution not confirmed'); expect(responseSentArticle).toContain( 'Ad Manager reported line item 6543210987 · order 2345678901' ); @@ -301,51 +301,47 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).not.toMatch(/creative rendered|ad visible|pixels confirmed/i); const selectedArticle = slotArticle(root!, 'selected-slot').textContent ?? ''; - expect(selectedArticle).toContain('Request path: Competing paths'); - expect(selectedArticle).toContain('Auction type: Competing auctions'); + expect(selectedArticle).toContain('Request path: Multiple paths observed'); + expect(selectedArticle).toContain('Auction evidence: TS auction: SPA server auction'); expect(selectedArticle).toContain('SPA page-bids T0 → auction dispatched 0 ms'); - expect(selectedArticle).toContain('SPA page-bids T0 → auction resolved 40 ms'); - expect(selectedArticle).toContain('Direct opportunity: Unrenderable candidate'); + expect(selectedArticle).toContain('SPA page-bids T0 → auction collected 40 ms'); + expect(selectedArticle).toContain('Server bid available; creative source incomplete'); expect(selectedArticle).toContain('Trusted Server creative request observed at 23 ms'); expect(selectedArticle).not.toContain('Trusted Server markup response sent'); - expect(selectedArticle).toContain('Trusted Server selected; no markup response confirmed'); + expect(selectedArticle).toContain( + 'Server bid selected by the creative bridge; response not confirmed' + ); const noCandidateArticle = slotArticle(root!, 'no-candidate-slot').textContent ?? ''; expect(noCandidateArticle).toContain('Request path: Trusted Server direct'); expect(noCandidateArticle).toContain('Direct opportunity: No candidate'); - expect(noCandidateArticle).toContain( - 'adInit observed no direct Trusted Server candidate for this request' - ); + expect(noCandidateArticle).toContain('No direct Trusted Server candidate'); const unattributedArticle = slotArticle(root!, 'unattributed-slot').textContent ?? ''; - expect(unattributedArticle).toContain('Request path: Unattributed'); - expect(unattributedArticle).toContain('Direct opportunity: Unknown (not observed)'); + expect(unattributedArticle).toContain('Request path: Not observed'); + expect(unattributedArticle).toContain('Direct opportunity: Not observed'); expect(unattributedArticle).toContain( - 'Delivery status unknown — required GPT or direct-candidate evidence was not observed' + 'Delivery status unknown — required evidence was not observed' ); const prebidArticle = slotArticle(root!, 'prebid-slot').textContent ?? ''; expect(prebidArticle).toContain('Request path: Prebid refresh'); - expect(prebidArticle).toContain('Direct opportunity: Unknown (not observed)'); - expect(prebidArticle).toContain( - 'Delivery status unknown — required GPT or direct-candidate evidence was not observed' - ); + expect(prebidArticle).toContain('Direct opportunity: Not observed'); + expect(prebidArticle).toContain('Delivery status unknown — required evidence was not observed'); const unconfirmedArticle = slotArticle(root!, 'unconfirmed-slot').textContent ?? ''; expect(unconfirmedArticle).toContain('Request path: Trusted Server direct'); - expect(unconfirmedArticle).toContain('Direct opportunity: Renderable candidate'); - expect(unconfirmedArticle).toContain( - 'Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible' - ); + expect(unconfirmedArticle).toContain('Server bid available; creative source present'); + expect(unconfirmedArticle).toContain('Server bid available; selection not confirmed'); const pendingArticle = slotArticle(root!, 'candidate-pending-slot').textContent ?? ''; expect(pendingArticle).toContain('Request path: Trusted Server direct'); - expect(pendingArticle).toContain('Direct opportunity: Renderable candidate'); + expect(pendingArticle).toContain('Server bid available; creative source present'); expect(pendingArticle).toContain('Waiting for Trusted Server creative evidence'); const notApplicableArticle = slotArticle(root!, 'not-applicable-slot').textContent ?? ''; expect(notApplicableArticle).toContain('Request path: Trusted Server direct'); - expect(notApplicableArticle).toContain('Direct opportunity: Renderable candidate'); + expect(notApplicableArticle).toContain('Server bid available; creative source present'); expect(notApplicableArticle).not.toMatch( /Trusted Server selected|candidate unconfirmed|no direct Trusted Server candidate|Delivery status unknown|Waiting for Trusted Server creative evidence/ ); @@ -500,12 +496,12 @@ describe('GptDiagnosticsOverlay', () => { expect(root!.textContent).toContain('GPT observed'); expect(root!.textContent).toContain('callback issues'); expect(root!.textContent).toContain('attribution issues'); - expect(root!.textContent).toContain('Ad #1 · filled-slot'); + expect(root!.textContent).toContain('Ad #1 · Request #2 · filled-slot'); expect(root!.textContent).toContain('/example/site/filled-slot'); expect(root!.textContent).toContain('Empty'); - expect(root!.textContent).toContain('Previous requests (1)'); - expect(root!.textContent).toContain('Requested slot sizes 300×250, 728×90, 320×50, 970×250'); - expect(root!.textContent).toContain('GPT-reported fill size 300×250'); + expect(root!.textContent).toContain('Request history (1 previous)'); + expect(root!.textContent).toContain('Requested sizes 300×250, 728×90, 320×50, 970×250'); + expect(root!.textContent).toContain('GPT-reported size 300×250'); expect(root!.textContent).toContain('Size filled 320×270'); expect(root!.textContent).toContain('Backfill yes'); expect(root!.textContent).toContain('GPT slot onload observed'); @@ -515,7 +511,20 @@ describe('GptDiagnosticsOverlay', () => { expect(root!.textContent).toContain('Requesting'); expect(root!.textContent).toContain('Ambiguous binding'); expect(root!.textContent).toContain('Incomplete sequence'); + expect(slotArticle(root!, 'pending-slot').textContent).toContain( + 'Delivery evidence: Not applicable' + ); + expect(slotArticle(root!, 'pending-slot').textContent).not.toContain( + 'Served bidder not confirmed' + ); + const emptySummary = slotArticle(root!, 'filled-slot').querySelector('.tsgd-group'); + expect(emptySummary?.textContent).toContain('Delivery evidence: Not applicable'); + expect(emptySummary?.textContent).not.toContain('Served bidder not confirmed'); + expect(root!.textContent).toContain('How to read this evidence'); + expect(root!.querySelector('a')?.href).toBe( + 'https://iabtechlab.github.io/trusted-server/guide/integrations/gpt-diagnostics-dictionary' + ); button(root!, 'Export JSON').click(); expect(exportSnapshot).toHaveBeenCalledTimes(1); @@ -556,6 +565,64 @@ describe('GptDiagnosticsOverlay', () => { overlay.destroy(); }); + it('reveals an exact request and locates it without mutating publisher markup', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const publisherSlot = document.createElement('div'); + publisherSlot.id = 'locatable-slot'; + publisherSlot.className = 'publisher-class'; + publisherSlot.style.minHeight = '250px'; + document.body.append(publisherSlot); + const originalMarkup = publisherSlot.outerHTML; + const scrollIntoView = vi.fn(); + publisherSlot.scrollIntoView = scrollIntoView; + vi.spyOn(publisherSlot, 'getBoundingClientRect').mockReturnValue({ + left: 10, + top: 20, + width: 300, + height: 250, + } as DOMRect); + const observedSlot = slot('locatable-slot'); + store.recordSlotRequested(observedSlot); + store.recordSlotRequested(observedSlot); + bindings.set(1, { status: 'bound' }, publisherSlot, true); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + overlay.selectRequest(1, 1); + runNextFrame(frames); + const selected = root?.querySelector( + '[data-runtime-slot="1"][data-request-number="1"]' + ); + expect(selected?.getAttribute('aria-current')).toBe('true'); + expect(root?.textContent).toContain('Request history (1 previous)'); + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + selected?.focus(); + const focusCallsBeforeUpdate = focus.mock.calls.length; + store.recordSlotResponseReceived(observedSlot); + runNextFrame(frames); + expect(focus.mock.calls.length).toBeGreaterThan(focusCallsBeforeUpdate); + + button(root!, 'Locate on page').click(); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: 'auto', + block: 'center', + inline: 'nearest', + }); + runNextFrame(frames); + expect(root?.querySelector('.tsgd-highlight')).not.toBeNull(); + expect(publisherSlot.outerHTML).toBe(originalMarkup); + overlay.destroy(); + }); + it('does not remove a publisher element that collides with the host ID', async () => { const frames: Array<() => void> = []; const publisherElement = document.createElement('div'); @@ -602,14 +669,14 @@ describe('GptDiagnosticsOverlay', () => { runNextFrame(frames); const content = root!.querySelector('.tsgd-content')!; - const history = root!.querySelector('details')!; + const history = root!.querySelector('.tsgd-slot details')!; history.open = true; content.scrollTop = 42; store.recordSlotResponseReceived(diagnosticSlot); runNextFrame(frames); expect(root!.textContent).toContain('Rendered (fill unknown)'); - expect(root!.querySelector('details')?.open).toBe(true); + expect(root!.querySelector('.tsgd-slot details')?.open).toBe(true); expect(root!.querySelector('.tsgd-content')?.scrollTop).toBe(42); overlay.destroy(); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index d92fcf23c..9872e33ff 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -572,7 +572,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'trusted_server_direct', expectedOpportunity: 'renderable_candidate', - expectedAuctionType: 'ssat', + expectedAuctionType: undefined, }, { name: 'a direct unrenderable candidate', @@ -581,7 +581,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'trusted_server_direct', expectedOpportunity: 'unrenderable_candidate', - expectedAuctionType: 'ssat', + expectedAuctionType: undefined, }, { name: 'a direct request without a candidate', @@ -590,7 +590,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'trusted_server_direct', expectedOpportunity: 'no_candidate', - expectedAuctionType: 'ssat', + expectedAuctionType: undefined, }, { name: 'a Prebid refresh', @@ -599,7 +599,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'prebid_refresh', expectedOpportunity: undefined, - expectedAuctionType: 'client_side', + expectedAuctionType: undefined, }, { name: 'competing direct and Prebid evidence', @@ -608,7 +608,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'competing', expectedOpportunity: 'renderable_candidate', - expectedAuctionType: 'competing', + expectedAuctionType: undefined, }, { name: 'an unattributed request', @@ -635,7 +635,7 @@ describe('GptDiagnosticsStore', () => { publisher: true, expectedPath: 'competing', expectedOpportunity: 'renderable_candidate', - expectedAuctionType: 'ssat', + expectedAuctionType: undefined, }, { name: 'client-side auction with publisher refresh evidence', @@ -644,7 +644,7 @@ describe('GptDiagnosticsStore', () => { publisher: true, expectedPath: 'competing', expectedOpportunity: undefined, - expectedAuctionType: 'client_side', + expectedAuctionType: undefined, }, { name: 'competing all source evidence', @@ -653,7 +653,7 @@ describe('GptDiagnosticsStore', () => { publisher: true, expectedPath: 'competing', expectedOpportunity: 'renderable_candidate', - expectedAuctionType: 'competing', + expectedAuctionType: undefined, }, ] as const)( 'attributes $name without inferring demand ownership', @@ -677,6 +677,41 @@ describe('GptDiagnosticsStore', () => { } ); + it('retains a completed Prebid candidate and only an exact correlated win', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('prebid-facts'); + + store.recordPrebidRefresh([slot]); + store.recordPrebidAuction(slot, 'auction-client-1', { + bidder: ' example-client ', + priceBucket: '2.40', + currency: 'eur', + }); + store.recordSlotRequested(slot); + store.recordPrebidWin(slot, 'other-auction', { + bidder: 'wrong', + priceBucket: '9.99', + }); + store.recordPrebidWin(slot, 'auction-client-1', { + bidder: ' example-client ', + priceBucket: '2.40', + currency: 'eur', + }); + + expect(store.snapshot().slots[0].requests[0]).toMatchObject({ + auctionType: 'client_side', + prebidAuction: { + auctionId: 'auction-client-1', + targetingCandidate: { + bidder: 'example-client', + priceBucket: '2.40', + currency: 'EUR', + }, + win: { bidder: 'example-client', priceBucket: '2.40', currency: 'EUR' }, + }, + }); + }); + it('retains bounded winner and server timing facts for a Trusted Server auction', () => { const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); const slot = fakeSlot('auction-facts'); @@ -714,7 +749,7 @@ describe('GptDiagnosticsStore', () => { }); }); - it('retains the SPA timing origin when direct and Prebid auctions compete', () => { + it('does not treat a Prebid refresh route as client-auction evidence', () => { const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); const slot = fakeSlot('competing-spa-auction'); @@ -733,7 +768,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(slot); expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - auctionType: 'competing', + auctionType: 'trusted_server', serverAuctionTimingOrigin: 'spa_auction', serverAuctionTimings: { auctionDispatchedMs: 0, auctionResolvedMs: 84 }, }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts index f4f4c7486..411acf1de 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts @@ -81,6 +81,8 @@ describe('GPT diagnostics public types', () => { expectTypeOf().toEqualTypeOf< | 'recordTrustedServerOpportunity' | 'recordPrebidRefresh' + | 'recordPrebidAuction' + | 'recordPrebidWin' | 'recordTrustedServerCreativeRequest' | 'recordTrustedServerCreativeResponse' | 'recordTrustedServerCreativeFailure' 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 8ead01aa8..00306dd61 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 @@ -2477,6 +2477,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockPbjs.requestBids = mockRequestBids; mockPbjs.removeAdUnit = mockRemoveAdUnit; delete (mockPbjs as unknown as Record).__tsRemoveAdUnitWrapped; + delete (mockPbjs as unknown as Record).__tsDiagnosticsBidWonInstalled; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); @@ -2560,6 +2561,80 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return recordPrebidRefresh; } + it('leaves Prebid auction identity and event listeners unchanged when diagnostics is inactive', () => { + const getTargeting = vi.fn(() => []); + const slot = { + getSlotElementId: () => 'example-inactive-slot', + getTargeting, + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-inactive-auction'); + }); + + pubads.refresh([slot]); + + const request = mockRequestBids.mock.calls[0][0]; + expect(request).not.toHaveProperty('auctionId'); + expect(mockOnEvent).not.toHaveBeenCalledWith('bidWon', expect.any(Function)); + expect(getTargeting).not.toHaveBeenCalledWith('hb_bidder'); + expect(getTargeting).not.toHaveBeenCalledWith('hb_pb'); + expect(getTargeting).not.toHaveBeenCalledWith('hb_cur'); + }); + + it('records completed client auction evidence only for the exact Prebid attempt', () => { + const slot = { + getSlotElementId: () => 'example-client-slot', + getTargeting: (key: string) => + key === 'hb_bidder' ? ['example-client'] : key === 'hb_pb' ? ['2.40'] : [], + clearTargeting: vi.fn(), + }; + const recordPrebidRefresh = vi.fn(); + const recordPrebidAuction = vi.fn(); + const recordPrebidWin = vi.fn(); + testWindow.tsjs = { + gptDiagnosticsRecorder: { + recordPrebidRefresh, + recordPrebidAuction, + recordPrebidWin, + }, + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-client-auction'); + }); + + pubads.refresh([slot]); + + expect(mockRequestBids.mock.calls[0][0]).not.toHaveProperty('auctionId'); + expect(mockOnEvent).toHaveBeenCalledWith('bidWon', expect.any(Function)); + expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); + expect(recordPrebidAuction).toHaveBeenCalledWith(slot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); + const bidWon = mockOnEvent.mock.calls.find(([event]) => event === 'bidWon')?.[1]; + expect(bidWon).toBeTypeOf('function'); + bidWon?.({ + auctionId: 'other-auction', + adUnitCode: 'example-client-slot', + adserverTargeting: { hb_bidder: 'wrong-client', hb_pb: '9.99' }, + }); + expect(recordPrebidWin).not.toHaveBeenCalled(); + bidWon?.({ + auctionId: 'example-client-auction', + adUnitCode: 'example-client-slot', + adserverTargeting: { hb_bidder: 'example-client', hb_pb: '2.40' }, + }); + expect(recordPrebidWin).toHaveBeenCalledWith(slot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); + }); + it('records a publisher delivery refresh immediately before its GPT request', () => { const slot = { getSlotElementId: () => 'example-delivery-marker', @@ -3955,12 +4030,17 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } }); - it('completes a synthetic refresh when targeting throws', () => { + it('completes a synthetic refresh without recording auction evidence when targeting throws', () => { const slot = { getSlotElementId: () => 'example-throwing-targeting', getTargeting: () => [], clearTargeting: vi.fn(), }; + const recordPrebidRefresh = vi.fn(); + const recordPrebidAuction = vi.fn(); + testWindow.tsjs = { + gptDiagnosticsRecorder: { recordPrebidRefresh, recordPrebidAuction }, + }; const { originalRefresh, pubads } = installGpt([slot]); mockPbjs.setTargetingForGPTAsync = vi.fn(() => { throw new Error('example targeting failure'); @@ -3970,6 +4050,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slot]); + expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); + expect(recordPrebidAuction).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 498e2e37a..a95222f11 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -161,6 +161,10 @@ export default withMermaid( text: 'GPT Runtime Diagnostics', link: '/guide/integrations/gpt-diagnostics', }, + { + text: 'GPT Diagnostics Label Dictionary', + link: '/guide/integrations/gpt-diagnostics-dictionary', + }, ], }, { diff --git a/docs/guide/integrations/gpt-diagnostics-dictionary.md b/docs/guide/integrations/gpt-diagnostics-dictionary.md new file mode 100644 index 000000000..605186887 --- /dev/null +++ b/docs/guide/integrations/gpt-diagnostics-dictionary.md @@ -0,0 +1,156 @@ +# GPT Diagnostics Label Dictionary + +This dictionary defines the exact fixed labels and dynamic label prefixes in the GPT Runtime Diagnostics panel and badges. Text after a prefix such as `Server auction winner:` is the bounded observed value. The console reports observations; it does not identify the creative ultimately served unless a listed evidence source explicitly establishes that fact. Browser times use `performance.now()` and server times use the server request's `RequestTimings` clock. The clocks are never subtracted. + +See [GPT Runtime Diagnostics](./gpt-diagnostics.md) for activation and operational details. + +## Identity and controls + +| Label | Badge | Source | Meaning and limits | +| ----------------------------- | ------------ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `Ad #N` | `Ad #N` | `runtimeSlotNumber` | Stable number assigned to a GPT slot object in this page. It is not a creative ID. | +| `Request #M` | `Request #M` | `requestNumber` | Monotonic request cycle for that slot, including refreshes. It does not imply fill. | +| `GPT-reported creative` | — | `adManager.creativeId` or `sourceAgnosticCreativeId` | GAM's callback identifier. It is separate from Ad/Request identity and does not identify a demand source. | +| `Filter` | — | panel state | Limits displayed rows. Activating a badge resets a hiding filter to `All`. | +| `Export JSON` | — | `gptDiagnostics.export()` | Downloads the allowlisted V1 snapshot. It issues no ad request. | +| `Label dictionary` | — | documentation link | Opens this keyboard-accessible help page. | +| `How to read this evidence` | — | inline help | Expands a concise explanation that winner, candidate, render, and timing observations have separate meanings and clocks. | +| `Locate on page` | — | exact unique DOM binding | Scrolls only on activation and briefly draws a diagnostics-layer highlight. It never changes publisher attributes, classes, or styles. | +| `Collapse`, `Expand`, `Close` | — | panel state | Presentation controls only. `show()` can reopen a closed panel. | +| `Request history` | — | retained cycles | Earlier retained requests. At most ten cycles are retained per slot. An evicted selection is reported as no longer retained. | +| `Technical details` | — | allowlisted snapshot fields | Expands correlation IDs, coverage, failures, and other non-summary evidence. | + +The per-request panel groups are `Summary`, `Auction evidence`, `Delivery evidence`, `Timing`, and `Size and visibility`. Summary prefixes are `GPT result:` and `Observed auction path:`. Technical prefixes include `Ad unit`, `Request intent:`, `Trusted Server auction:`, and `Prebid auction:`. A fact appears in one detailed group; Technical details does not repeat those grouped facts. + +Panel status is `GPT observed` or `Waiting for GPT`. Filter values are `All`, `Visible`, `Filled`, `Empty`, `Pending/Incomplete`, and `Unbound/Ambiguous`. Empty results are `No GPT slots observed yet.` or `No slots match.` The retained-cycle labels are `Initial request` and `Refresh N`; an evicted selection is announced as `Ad #N, Request #M is no longer retained.` The overview counts use the prefixes `slots`, `callback issues`, and `attribution issues`. Callback coverage uses the suffixes `observed`, `matched`, `unmatched`, and `ambiguous`. + +## GPT lifecycle and delivery + +| Label | Badge | Raw value / source | Meaning and what it does not prove | +| -------------------------------------------------------------------- | ------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Waiting for request` | — | no request cycle | GPT has not emitted `slotRequested` for the slot. | +| `Requesting` | `Pending` | `slotRequested` | A GPT request callback was observed. Elapsed time alone does not mean failure. | +| `Response received` | — | `slotResponseReceived` | GPT emitted its response callback. It does not prove fill. | +| `GPT result: Filled` / `Filled` | `Filled` | `slotRenderEnded.isEmpty === false` | GPT reported non-empty. It does not prove which bidder or pixels were served. | +| `GPT result: Empty` / `Empty` | `Empty` | `slotRenderEnded.isEmpty === true` | GPT explicitly reported empty. | +| `GPT result: Rendered (fill unknown)` / `Rendered (fill unknown)` | `Rendered (fill unknown)` | `slotRenderEnded` without `isEmpty` | Render callback observed; fill remains unavailable. | +| `Creative markup sent; execution not confirmed` | `TS response sent` | `delivery=trusted_server_response_sent` | The bridge posted markup to PUC. It does not prove execution, visibility, or final served source. | +| `Server bid selected by the creative bridge; response not confirmed` | `TS selected` | `delivery=trusted_server_selected` | A matched PUC request selected the server bid; no successful response post was observed. | +| `Server bid available; selection not confirmed` | `TS unconfirmed` | `delivery=candidate_unconfirmed` | A server candidate existed but no matched selection appeared in the observation window. | +| `Waiting for Trusted Server creative evidence` | `TS candidate (pending)` | `delivery=pending` | The five-second positive-evidence window is open. | +| `No direct Trusted Server candidate` | `No TS candidate` | `delivery=no_candidate` | `adInit` explicitly found no direct candidate for this request. | +| `Delivery status unknown — required evidence was not observed` | `Delivery unknown` | `delivery=unknown` | Evidence exists but cannot establish a more specific delivery state. | +| `Delivery evidence: Not applicable` | — | `delivery=not_applicable` | No positive bridge evidence exists and a delivery conclusion does not apply before render or for an explicitly empty result. | +| `Delivery evidence: Not observed` | — | missing delivery state | No delivery state was captured. | +| `Served bidder not confirmed` | — | a non-empty GPT render without served proof | Server winner, targeting candidate, `bidWon`, and GPT render facts remain separate; none alone confirms the final served creative. This label is not shown for pending or empty requests. | +| `GPT slot onload observed` | — | `slotOnload` | GPT emitted onload. It is not pixel-level or demand-source proof. | +| `GPT impressionViewable observed` | — | `impressionViewable` | GPT emitted its viewability callback. | +| `Incomplete sequence` | `Incomplete sequence` | `incompleteSequence` | An observed callback proves a missing or invalid predecessor. Time alone never sets it. | + +## Auction evidence + +| Label | Badge | Raw value / source | Meaning and limits | +| ----------------------------------- | ---------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SSAT: initial-page server auction` | `SSAT` | `auctionType=ssat` | Explicit completed server-auction evidence from the initial page request. | +| `TS auction: SPA server auction` | `TS auction` | `auctionType=trusted_server` | Explicit completed `/_ts/page-bids` server-auction evidence. | +| `Client-side Prebid auction` | `Prebid auction` | `auctionType=client_side`, exact Prebid callback correlation | A completed Prebid attempt was correlated to this slot and next GPT request. A refresh wrapper call alone does not establish it. | +| `Multiple auction paths observed` | `Multiple paths` | `auctionType=competing` | Completed server and Prebid auction evidence both exist for the request. This does not prove a race, overwrite, or winner. | +| `Auction not observed` | no auction badge | missing or malformed explicit facts | Diagnostics has no qualifying completed-auction evidence. It never defaults to SSAT. | +| `Server auction winner` | — | compatibility `auctionWinner`, server `hb_bidder` | Winner selected by the server auction. It is not the final served bidder. | +| `Server bid price bucket:` | — | server `hb_pb` | Already-bucketed price; raw CPM is never exported. Currency is shown only when supplied. | +| `Prebid targeting candidate:` | — | `prebidAuction.targetingCandidate` after exact `setTargetingForGPTAsync` boundary | Candidate targeting observed on the exact GPT slot for the exact Prebid callback auction ID. It is not a final win. | +| `Prebid candidate price bucket:` | — | candidate `hb_pb` | Bucketed targeting value for the candidate; it is not a winning-price claim. | +| `Prebid bidWon observation:` | — | `prebidAuction.win`, documented `bidWon` payload | A bounded event joined by exact auction ID, ad-unit code, slot object, navigation generation, and retention window. It remains distinct from GPT render and served-source proof. | +| `Prebid win price bucket:` | — | `bidWon.adserverTargeting.hb_pb` | Bucketed value observed on the correlated `bidWon` event; it is not proof of the creative GAM served. | +| `(currency not supplied)` | — | absent validated ISO currency | No verified currency was supplied. The console never assumes USD and does not compare currencies. | + +Bidder names are limited to 128 UTF-8 bytes, numeric bucket strings to 64 bytes, currencies to three ASCII letters, and auction IDs to 256 UTF-8 bytes. Prebid supplies its own auction ID through `bidsBackHandler`; diagnostics does not override Prebid auction identity. Duplicate, ambiguous, expired, late, prior-navigation, and malformed observations are rejected. Only an active diagnostics recorder installs the `bidWon` listener or retains candidate/win state, which is bounded to 128 pending attempts and 30 seconds. No raw CPM, creative markup, targeting dump, or losing bids are retained. + +## Request paths and opportunities + +| Label | Raw value | Meaning | +| -------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `Request path: Trusted Server direct` | `trusted_server_direct` | The direct `adInit` route preceded the request. Route evidence alone is not auction evidence. | +| `Request path: Prebid refresh` | `prebid_refresh` | The installed wrapper delegated the GPT refresh. Timeout and throw fallbacks retain this route label but do not claim a completed auction. | +| `Request path: Publisher refresh` | `publisher_refresh` | Publisher refresh boundary observed. | +| `Request path: Multiple paths observed` | `competing` | More than one route marker preceded the request; competition is possible but unproven. | +| `Request path: Not observed` | `unattributed` | No eligible route marker was consumed. | +| `Server bid available; creative source present` | `renderable_candidate` | Bid targeting plus ad ID and inline markup or complete cache coordinates were present. | +| `Server bid available; creative source incomplete` | `unrenderable_candidate` | Bid targeting existed but the bridge lacked a complete render source. | +| `Direct opportunity: No candidate` | `no_candidate` | `adInit` explicitly observed no direct bid targeting. | +| `Direct opportunity: Not observed` | missing opportunity | No bounded direct-opportunity evidence was captured. It is not negative demand-source evidence. | +| `Request intent:` | `requestIntentId` | Opaque local correlation sequence, not an auction or user ID. | +| `Trusted Server auction:` | `trustedServerAuctionId` | Opaque per-auction correlation token, not a GAM key or visitor identifier. | +| `Prebid auction:` | `prebidAuction.auctionId` | Opaque Prebid-supplied auction correlation token. Diagnostics does not create or replace it. | + +Path markers live for five seconds, are consumed once, and are keyed by GPT slot object identity. + +## Timing + +All values are milliseconds. Missing timing that should apply is `Unavailable`, never zero. Server timing is `Not applicable` when no completed server auction was observed. A displayed zero is a valid immediate observation. + +| Label | Origin and boundaries | Raw field | +| ------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------ | +| `Server request start → auction dispatched` | Server request `RequestTimings` T0 to successful `dispatch_auction` outcome | `serverAuctionTimings.auctionDispatchedMs` | +| `Server request start → auction collected` | Same T0 to completion of `collect_dispatched_auction` | `auctionResolvedMs` | +| `Server request start → bids ready` | Same T0 to winning-bid map commit | `auctionCommittedMs` | +| `Auction collection wait` | Actual duration blocked in collect; placement is `pre-header` or `in stream` | `auctionWaitMs`, `auctionWaitPlacement` | +| `Opportunity → request` | Browser `performance.now()`: recorder observation to matched GPT `slotRequested` | `opportunityToRequestMs` | +| `GAM request → response` | Browser `slotRequested` to `slotResponseReceived` | `durations.requestToResponseMs` | +| `GAM response → render` | Browser `slotResponseReceived` to `slotRenderEnded` | `responseToRenderMs` | +| `GAM request → render` | Browser `slotRequested` to `slotRenderEnded` | `requestToRenderMs` | +| `Render → load` | Browser `slotRenderEnded` to `slotOnload` | `renderToLoadMs` | +| `Render → viewable` | Browser `slotRenderEnded` to `impressionViewable` | `renderToViewableMs` | +| `Replaced rendered request` | Earlier browser render callback to later request callback | `previousRenderToRequestMs` | + +Server offsets are not browser timestamps. `auctionResolvedMs` means collection completed (including timeout handling), not that a network byte arrived at that exact instant. + +## Size, visibility, binding, and GAM fields + +| Label | Raw field / source | Meaning and limits | +| ------------------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `Requested sizes` | `requestedSlotSizes` | Configured sizes supplied to GPT; ordinary sizes such as 300×250 remain visible. Missing data is shown as `Requested sizes: Not observed`. | +| `GPT-reported size` | `size` | Exact `slotRenderEnded.size`. A 1×1 placeholder is shown as `GPT-reported size: 1×1 placeholder hidden` and retained unchanged in V1 JSON. | +| `Size filled` / `Measured outer slot size` | `observedSlotSize` | CSS outer box of the exact uniquely bound slot after fill. Missing data is shown as `Size filled: Not observed · Measured outer slot size`. | +| `GPT visibility` | current/maximum visibility percentage | Values from GPT visibility callbacks; absence is `GPT visibility: Not observed`. | +| `Binding: Bound` / `Bound` | `binding.status=bound` | Exactly one connected publisher element matched. | +| `Binding: unbound` / `Unbound` | `binding.status=unbound` | No safe element binding; reason is shown. | +| `Binding: ambiguous` / `Ambiguous binding` | `binding.status=ambiguous` | Duplicate ID or GPT-slot evidence prevented a safe choice. No badge or locate action is invented. | +| `Outside viewport` | binding geometry | The exact element does not intersect the viewport; it remains in the panel. | +| `Ad Manager response class: empty/backfill/reservation/unclassified non-empty` | `responseClass` | Source-neutral classification derived from GPT render facts. | +| `Ad Manager reported …` | `adManager` identifiers | GAM-reported line item, order, advertiser, creative, yield-group, and company IDs. They do not identify the demand source. | +| `Ad Manager fields: Not observed` | missing `adManager` | No allowlisted GAM identifier was captured. | +| `Ad Manager response class: Not observed` | missing `responseClass` | GPT did not provide enough render evidence to classify the response. | +| `Backfill yes/no` | `isBackfill` | GPT's callback value. | +| `Slot content changed yes/no` | `slotContentChanged` | GPT's callback value; not proof pixels changed. | +| `Creative changed/unchanged` | retained GAM creative IDs | Comparison only when both cycles supplied an ID. | + +Binding reasons are `missing_slot_element_id`, `missing_element`, `duplicate_dom_id`, `dom_uniqueness_unverifiable`, and `duplicate_gpt_slot_id`. Other exact technical fact prefixes are `Replaced rendered request`, `Creative changed`, `Creative unchanged`, `Trusted Server creative request observed at`, and `Trusted Server markup response sent at`. + +## Failures, attribution, coverage, and retention + +Creative bridge labels are `Creative bridge failure: missing render source`, `Creative bridge failure: cache fetch failed`, `Creative bridge failure: invalid cache payload`, and `Creative bridge failure: response post failed`. They map directly to `missing_render_source`, `cache_fetch_failed`, `invalid_cache_payload`, and `response_post_failed`; they describe only the observed bridge step and contain no URL, markup, payload, or stack trace. + +Attribution issue labels map to `creative_request_without_slot`, `creative_request_without_cycle`, `creative_request_ambiguous_cycle`, `creative_request_on_empty_cycle`, `creative_attempt_capacity`, `creative_attempt_unknown`, `creative_attempt_expired`, and `creative_attempt_evicted`. Callback coverage separately counts `observed`, `matched`, `unmatched`, and `ambiguous` for `slotRequested`, `slotResponseReceived`, `slotRenderEnded`, `slotOnload`, `impressionViewable`, and `slotVisibilityChanged`. + +The V1 export remains additive and compatible: optional Prebid evidence and optional currency fields are new; existing fields keep their meanings. Bounds are 64 slots, ten request cycles per slot, 128 callback issues, 128 attribution issues, 64 direct associations, 16 requested sizes, and 128 creative attempts. Metadata reports dropped callbacks/issues and evicted slots/cycles. + +## Missing-value vocabulary + +- **Not observed**: the relevant callback or explicit evidence was not captured. +- **Unavailable**: the value cannot be calculated or safely retained. +- **Not applicable**: the fact does not apply, for example delivery conclusions before render or for an explicitly empty result. +- **Unknown**: evidence exists but is insufficient to select a more specific state. + +## Acronyms + +- **TS**: Trusted Server. +- **SSAT**: server-side ad targeting on the initial page request. +- **SPA**: single-page application. +- **GPT**: Google Publisher Tag. +- **GAM**: Google Ad Manager. +- **Prebid**: the client-side header bidding library observed here. +- **PBS**: Prebid Server. +- **PUC**: Prebid Universal Creative. +- **CPM**: cost per thousand impressions; diagnostics retains only a bucketed targeting value, never raw CPM. +- **T0**: the start of one server request's `RequestTimings` clock. diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 0f6c81ce8..61308f685 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -10,8 +10,7 @@ GPT Runtime Diagnostics is an opt-in browser console for documented Google Publisher Tag (GPT) lifecycle callbacks and Trusted Server integration evidence. It groups observations into per-slot request cycles, shows auction and GPT timings, -links each page badge to the same stable `Ad #N` in the side panel, binds slots to exact -DOM elements, and downloads the same allowlisted data as versioned JSON. +links each accessible page badge to the same stable `Ad #N` and `Request #M` in the side panel, binds slots to exact DOM elements, and downloads the same allowlisted data as versioned JSON. See the complete [label dictionary](./gpt-diagnostics-dictionary.md) for every operator-facing term. The console reports positive observations, not inferred ownership. A filled result means only that GPT emitted `slotRenderEnded` with `isEmpty === false`. A Trusted @@ -109,11 +108,11 @@ Visible, Filled, Empty, Pending/Incomplete, and Unbound/Ambiguous slots. Each request cycle can show: -- The same stable `Ad #N` used by the creative's page badge. +- The same stable `Ad #N` and `Request #M` used by the request's page badge. - The observed request path, request-intent ID, and direct Trusted Server opportunity. -- Auction classification: SSAT, TS auction, client-side auction, or competing auctions. +- Explicit auction classification: initial-page server, SPA server, completed client-side Prebid, multiple observed paths, or not observed. - Opaque Trusted Server auction-ID correlation and opportunity-to-request latency when available. -- The winning bidder and bucketed bid price from the existing `hb_bidder` and `hb_pb` bid fields. +- Source-scoped server-auction winner, Prebid targeting candidate, and documented Prebid win observations. None is presented as the final served creative. - Server-measured auction dispatch, resolution, commit, and wait timing when available. - Observed replacement of an earlier retained filled render, including GPT creative-ID transitions. - Requesting, Response received, Filled, Empty, or Rendered (fill unknown) GPT @@ -139,12 +138,13 @@ and `responseClass` remains absent. `unclassified_non_empty` requires an explici Auction labels describe the path observed for that GPT request cycle: -| Label | Meaning | -| ------------------- | ---------------------------------------------------------------------------------------------------------- | -| SSAT | The initial document's server-side auction populated the direct request. | -| TS auction | The SPA `/_ts/page-bids` server auction populated the direct request. | -| Client-side auction | The installed Prebid refresh path was the only auction path observed. | -| Competing auctions | Trusted Server direct and client-side Prebid auction evidence were both observed for the same GPT request. | +| Label | Meaning | +| --------------------------------- | ---------------------------------------------------------------------------------------------------- | +| SSAT: initial-page server auction | Explicit completed initial-document server-auction evidence populated the direct request. | +| TS auction: SPA server auction | Explicit completed `/_ts/page-bids` server-auction evidence populated the direct request. | +| Client-side Prebid auction | A completed Prebid attempt was joined by exact auction ID, ad-unit code, GPT slot, and next request. | +| Multiple auction paths observed | Server and completed client-side evidence were both observed. This does not prove a race or winner. | +| Auction not observed | Explicit completed-auction evidence was absent or malformed. It never defaults to SSAT. | Publisher refresh evidence does not by itself establish another auction. It remains visible in the request-path classification but does not turn an SSAT, TS auction, or @@ -152,19 +152,24 @@ client-side auction label into “Competing auctions.” Publisher-only refreshe unattributed requests have no auction label because the available evidence does not establish an auction implementation. +For synthetic refresh auctions, diagnostics correlates the auction ID supplied by +Prebid's own `bidsBackHandler`; it does not provide or override Prebid auction IDs. The +`bidWon` listener, targeting reads, and bounded correlation state are installed only +when an active diagnostics recorder exists, so an inactive console does not change +Prebid-visible behavior. + Server auction timing and browser GPT timing use separate clocks and are never subtracted from each other. Initial SSAT offsets use edge-request T0. SPA TS auction offsets use a local server clock started when the page-bids handler begins, not the -browser's navigation clock or the edge's request-receipt time. Diagnostics retain that -timing origin separately from the aggregate auction classification, so a request marked -`competing` still labels SPA offsets from SPA page-bids T0. The server facts are: +browser's navigation clock or the edge's request-receipt time. Diagnostics retains that +timing origin separately from aggregate classification. The server facts are: - `auctionDispatchedMs`: bid dispatch offset from that timing origin. -- `auctionResolvedMs`: final bid or timeout offset from the same timing origin. +- `auctionResolvedMs`: offset when auction collection completed, including timeout handling. - `auctionCommittedMs`: offset when winning bids were available to page state. -- `auctionWaitMs`: time spent awaiting the auction. -- `auctionWaitPlacement`: whether that wait completed before HTTP response headers or - occurred while a document response was streaming. +- `auctionWaitMs`: actual time blocked in auction collection. +- `auctionWaitPlacement`: whether that wait occurred before response headers or while + the document response was streaming. The SPA page-bids response includes these server timings only for an activated console session and a successfully dispatched auction. Failures before any provider @@ -242,8 +247,8 @@ For the direct path, `adInit` records one opportunity: | `unrenderable_candidate` | Bid targeting was applied, but the current bridge lacked the complete ID/render-source combination needed to serve markup. | | `no_candidate` | `adInit` explicitly observed no direct Trusted Server bid targeting for that configured slot. | -An absent opportunity is displayed as unknown. It must not be converted into a -negative demand-source conclusion. +An absent opportunity is displayed as `Not observed`. It must not be converted into +a negative demand-source conclusion. ## Trusted Server Evidence Ladder @@ -265,9 +270,9 @@ The derived `delivery` value uses these evidence-safe meanings: | Delivery state | Panel wording | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `trusted_server_response_sent` | Trusted Server selected; markup response sent to PUC | -| `trusted_server_selected` | Trusted Server selected; no markup response confirmed | -| `candidate_unconfirmed` | Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible | +| `trusted_server_response_sent` | Creative markup sent; execution not confirmed | +| `trusted_server_selected` | Server bid selected by the creative bridge; response not confirmed | +| `candidate_unconfirmed` | Server bid available; selection not confirmed | | `no_candidate` | adInit observed no direct Trusted Server candidate for this request | | `unknown` | Delivery status unknown — required GPT or direct-candidate evidence was not observed | | `pending` | Waiting for Trusted Server creative evidence | From b316aee52f0e821fc65ec5291f552055e51f5ed1 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 14 Sep 2026 13:36:05 -0500 Subject: [PATCH 2/4] Address GPT diagnostics review feedback --- .../src/integrations/gpt_diagnostics/api.ts | 21 +------ .../integrations/gpt_diagnostics/badges.ts | 48 ++++++++++----- .../integrations/gpt_diagnostics/evidence.ts | 14 +++++ .../integrations/gpt_diagnostics/overlay.ts | 58 ++++++++++++------- .../src/integrations/gpt_diagnostics/store.ts | 12 +--- .../lib/src/integrations/prebid/index.ts | 9 +-- .../integrations/gpt_diagnostics/api.test.ts | 2 + .../gpt_diagnostics/badges.test.ts | 18 +++++- .../gpt_diagnostics/overlay.test.ts | 31 ++++++++-- .../test/integrations/prebid/index.test.ts | 50 +++++++++++++++- .../gpt-diagnostics-dictionary.md | 9 +-- docs/guide/integrations/gpt-diagnostics.md | 27 +++++---- 12 files changed, 207 insertions(+), 92 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index 956824f84..d8f5c6268 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -10,6 +10,7 @@ import type { } from '../../core/types'; import type { GptDiagnosticsBindingManager } from './binding'; +import { clonePrebidAuctionEvidence } from './evidence'; import type { GptDiagnosticsStoreSnapshot } from './store'; interface ApiStore { @@ -82,15 +83,7 @@ function cloneExportSnapshot(snapshot: GptDiagnosticsExportV1): GptDiagnosticsEx observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), ...(cycle.prebidAuction - ? { - prebidAuction: { - ...cycle.prebidAuction, - ...(cycle.prebidAuction.targetingCandidate - ? { targetingCandidate: { ...cycle.prebidAuction.targetingCandidate } } - : {}), - ...(cycle.prebidAuction.win ? { win: { ...cycle.prebidAuction.win } } : {}), - }, - } + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } : {}), ...(cycle.serverAuctionTimings ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } @@ -236,15 +229,7 @@ export class GptDiagnosticsApiController { observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), ...(cycle.prebidAuction - ? { - prebidAuction: { - ...cycle.prebidAuction, - ...(cycle.prebidAuction.targetingCandidate - ? { targetingCandidate: { ...cycle.prebidAuction.targetingCandidate } } - : {}), - ...(cycle.prebidAuction.win ? { win: { ...cycle.prebidAuction.win } } : {}), - }, - } + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } : {}), ...(cycle.serverAuctionTimings ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts index 18f080695..a93fac8a7 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts @@ -200,7 +200,13 @@ export class GptDiagnosticsBadgeManager { update(): void { if (this.destroyed || !this.layer?.isConnected) return; const observedElements: HTMLElement[] = []; - const badges: HTMLElement[] = []; + const existingBadges = new Map( + Array.from(this.layer.querySelectorAll('.tsgd-badge')).map((badge) => [ + badge.dataset.runtimeSlot, + badge, + ]) + ); + const badges = new Set(); for (const slot of this.slots) { const cycle = latestCycle(slot); @@ -213,18 +219,29 @@ export class GptDiagnosticsBadgeManager { if (!intersectsViewport(rectangle, this.window)) continue; observedElements.push(element); - const badge = this.document.createElement('button'); - badge.type = 'button'; - badge.className = 'tsgd-badge'; - badge.dataset.runtimeSlot = String(slot.runtimeSlotNumber); + const runtimeSlot = String(slot.runtimeSlotNumber); + let badge = existingBadges.get(runtimeSlot); + if (!badge) { + const createdBadge = this.document.createElement('button'); + createdBadge.type = 'button'; + createdBadge.className = 'tsgd-badge'; + createdBadge.addEventListener('click', () => { + const runtimeSlotNumber = Number(createdBadge.dataset.runtimeSlot); + const requestNumber = Number(createdBadge.dataset.requestNumber); + if (!Number.isSafeInteger(runtimeSlotNumber) || !Number.isSafeInteger(requestNumber)) { + return; + } + this.onActivate(runtimeSlotNumber, requestNumber); + }); + badge = createdBadge; + } + const text = badgeText(cycle); + badge.dataset.runtimeSlot = runtimeSlot; badge.dataset.requestNumber = String(cycle.requestNumber); - badge.textContent = `Ad #${slot.runtimeSlotNumber} · Request #${cycle.requestNumber} · ${badgeText(cycle)}`; + badge.textContent = `Ad #${slot.runtimeSlotNumber} · Request #${cycle.requestNumber} · ${text}`; badge.setAttribute( 'aria-label', - `Open diagnostics for Ad #${slot.runtimeSlotNumber}, Request #${cycle.requestNumber}` - ); - badge.addEventListener('click', () => - this.onActivate(slot.runtimeSlotNumber, cycle.requestNumber) + `Open diagnostics for Ad #${slot.runtimeSlotNumber}, Request #${cycle.requestNumber}: ${text}` ); badge.style.maxWidth = `${BADGE_MAX_WIDTH_PX}px`; badge.style.left = `${Math.max( @@ -239,12 +256,17 @@ export class GptDiagnosticsBadgeManager { BADGE_EDGE_GUTTER_PX, rectangle.top + BADGE_EDGE_GUTTER_PX )}px`; + badge.style.transform = ''; } - badges.push(badge); + badges.add(badge); } - for (const badge of this.layer.querySelectorAll('.tsgd-badge')) badge.remove(); - this.layer.append(...badges); + for (const badge of Array.from(this.layer.querySelectorAll('.tsgd-badge'))) { + if (!badges.has(badge)) badge.remove(); + } + for (const badge of badges) { + if (!badge.isConnected) this.layer.append(badge); + } this.resizeObserver?.disconnect(); for (const element of observedElements) this.resizeObserver?.observe(element); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts new file mode 100644 index 000000000..af9779597 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts @@ -0,0 +1,14 @@ +import type { GptDiagnosticsPrebidAuctionEvidence } from '../../core/types'; + +/** Return a mutable copy of retained Prebid auction evidence. */ +export function clonePrebidAuctionEvidence( + evidence: GptDiagnosticsPrebidAuctionEvidence +): GptDiagnosticsPrebidAuctionEvidence { + return { + ...evidence, + ...(evidence.targetingCandidate + ? { targetingCandidate: { ...evidence.targetingCandidate } } + : {}), + ...(evidence.win ? { win: { ...evidence.win } } : {}), + }; +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index a58ec1986..f73c79d7e 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -161,7 +161,7 @@ function deliveryFact(cycle: GptDiagnosticsRequestCycle): string { case undefined: return 'Delivery evidence: Not observed'; default: - return unhandledCase(cycle.delivery); + return unhandledCase(cycle.delivery) ?? 'Delivery evidence: Not observed'; } } @@ -248,6 +248,10 @@ function responseClassFact(cycle: GptDiagnosticsRequestCycle): string | undefine } } +function priceBucket(value: { priceBucket: string; currency?: string }): string { + return `${value.priceBucket}${value.currency ? ` ${value.currency}` : ''}`; +} + function auctionFacts(cycle: GptDiagnosticsRequestCycle): string[] { const facts = [ requestPathFact(cycle), @@ -255,23 +259,17 @@ function auctionFacts(cycle: GptDiagnosticsRequestCycle): string[] { ]; if (cycle.auctionWinner) { facts.push(`Server auction winner: ${cycle.auctionWinner.bidder}`); - facts.push( - `Server bid price bucket: ${cycle.auctionWinner.priceBucket} ${cycle.auctionWinner.currency ?? '(currency not supplied)'}` - ); + facts.push(`Server bid price bucket: ${priceBucket(cycle.auctionWinner)}`); } if (cycle.prebidAuction?.targetingCandidate) { const candidate = cycle.prebidAuction.targetingCandidate; facts.push(`Prebid targeting candidate: ${candidate.bidder}`); - facts.push( - `Prebid candidate price bucket: ${candidate.priceBucket} ${candidate.currency ?? '(currency not supplied)'}` - ); + facts.push(`Prebid candidate price bucket: ${priceBucket(candidate)}`); } if (cycle.prebidAuction?.win) { const win = cycle.prebidAuction.win; facts.push(`Prebid bidWon observation: ${win.bidder}`); - facts.push( - `Prebid win price bucket: ${win.priceBucket} ${win.currency ?? '(currency not supplied)'}` - ); + facts.push(`Prebid win price bucket: ${priceBucket(win)}`); } const servedBidder = servedBidderFact(cycle); if (servedBidder) facts.push(servedBidder); @@ -481,6 +479,7 @@ export class GptDiagnosticsOverlay { private destroyed = false; private filter: GptDiagnosticsFilter = 'all'; private selectedRequest?: { runtimeSlotNumber: number; requestNumber: number }; + private historySlotToOpenOnce?: number; private selectedRequestHasFocus = false; constructor(store: OverlayStore, bindings: OverlayBindings, options: OverlayOptions = {}) { @@ -516,6 +515,7 @@ export class GptDiagnosticsOverlay { selectRequest(runtimeSlotNumber: number, requestNumber: number): void { if (this.destroyed) return; this.selectedRequest = { runtimeSlotNumber, requestNumber }; + this.historySlotToOpenOnce = runtimeSlotNumber; this.filter = 'all'; this.collapsed = false; this.show(); @@ -652,11 +652,19 @@ export class GptDiagnosticsOverlay { const previousContent = panel.querySelector('.tsgd-content'); const previousScrollTop = previousContent?.scrollTop ?? 0; const selectedRequestWasFocused = this.selectedRequestHasFocus; + const helpOpen = panel.querySelector('.tsgd-help')?.open ?? false; const openHistorySlots = new Set( Array.from(panel.querySelectorAll('.tsgd-history[open]')) .map((details) => details.closest('.tsgd-slot')?.dataset.runtimeSlot) .filter((runtimeSlot): runtimeSlot is string => runtimeSlot !== undefined) ); + const openTechnicalSlots = new Set( + Array.from(panel.querySelectorAll('.tsgd-technical[open]')) + .map((details) => details.closest('.tsgd-slot')?.dataset.runtimeSlot) + .filter((runtimeSlot): runtimeSlot is string => runtimeSlot !== undefined) + ); + const historySlotToOpenOnce = this.historySlotToOpenOnce; + this.historySlotToOpenOnce = undefined; panel.replaceChildren(); const header = this.document.createElement('header'); @@ -717,6 +725,7 @@ export class GptDiagnosticsOverlay { const help = this.document.createElement('details'); help.className = 'tsgd-help'; + help.open = helpOpen; const helpSummary = this.document.createElement('summary'); helpSummary.textContent = 'How to read this evidence'; const helpText = this.document.createElement('p'); @@ -767,13 +776,12 @@ export class GptDiagnosticsOverlay { content.append(empty); } else { for (const slot of filteredSlots) { - const selectedPreviousRequest = - this.selectedRequest?.runtimeSlotNumber === slot.runtimeSlotNumber && - this.selectedRequest.requestNumber !== latestCycle(slot)?.requestNumber; + const runtimeSlot = String(slot.runtimeSlotNumber); content.append( this.renderSlot( slot, - openHistorySlots.has(String(slot.runtimeSlotNumber)) || selectedPreviousRequest + openHistorySlots.has(runtimeSlot) || historySlotToOpenOnce === slot.runtimeSlotNumber, + openTechnicalSlots.has(runtimeSlot) ) ); } @@ -785,7 +793,11 @@ export class GptDiagnosticsOverlay { } } - private renderSlot(slot: GptDiagnosticsStoreSlotSnapshot, historyOpen: boolean): HTMLElement { + private renderSlot( + slot: GptDiagnosticsStoreSlotSnapshot, + historyOpen: boolean, + technicalOpen: boolean + ): HTMLElement { const container = this.document.createElement('article'); container.className = 'tsgd-slot'; const latest = latestCycle(slot); @@ -872,16 +884,22 @@ export class GptDiagnosticsOverlay { } const technical = this.document.createElement('details'); + technical.className = 'tsgd-technical'; + technical.open = technicalOpen; const technicalSummary = this.document.createElement('summary'); technicalSummary.textContent = 'Technical details'; technical.append(technicalSummary); appendFacts(this.document, technical, [ slot.adUnitPath ? `Ad unit ${slot.adUnitPath}` : 'Ad unit: Unavailable', - binding.binding.status === 'bound' - ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` - : binding.binding.status === 'ambiguous' - ? `Ambiguous binding · ${binding.binding.reason ?? 'reason unavailable'}` - : `Unbound · ${binding.binding.reason ?? 'reason unavailable'}`, + ...(latest + ? [] + : [ + binding.binding.status === 'bound' + ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` + : binding.binding.status === 'ambiguous' + ? `Ambiguous binding · ${binding.binding.reason ?? 'reason unavailable'}` + : `Unbound · ${binding.binding.reason ?? 'reason unavailable'}`, + ]), ...(latest ? technicalCycleFacts(latest) : []), ]); container.append(technical); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index 6fcbacfb5..344e20221 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -23,6 +23,8 @@ import type { Size, } from '../../core/types'; +import { clonePrebidAuctionEvidence } from './evidence'; + export const MAX_DIAGNOSTIC_SLOTS = 64; export const MAX_REQUEST_CYCLES_PER_SLOT = 10; export const MAX_CALLBACK_ISSUES = 128; @@ -369,15 +371,7 @@ function copyCycle(cycle: MutableRequestCycle, nowMs: number): GptDiagnosticsReq : undefined, ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), ...(cycle.prebidAuction - ? { - prebidAuction: { - ...cycle.prebidAuction, - ...(cycle.prebidAuction.targetingCandidate - ? { targetingCandidate: { ...cycle.prebidAuction.targetingCandidate } } - : {}), - ...(cycle.prebidAuction.win ? { win: { ...cycle.prebidAuction.win } } : {}), - }, - } + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } : {}), ...(cycle.serverAuctionTimings ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } 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 ae4dbae0d..51be7d665 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -462,14 +462,7 @@ function targetingCandidate(slot: RefreshGptSlot): GptDiagnosticsAuctionWinner | const bidder = boundedTargetingValue(slot, 'hb_bidder', 128); const priceBucket = boundedTargetingValue(slot, 'hb_pb', 64); if (!bidder || !priceBucket || !/^\d+(?:\.\d+)?$/.test(priceBucket)) return undefined; - const suppliedCurrency = boundedTargetingValue(slot, 'hb_cur', 3)?.toUpperCase(); - return { - bidder, - priceBucket, - ...(suppliedCurrency && /^[A-Z]{3}$/.test(suppliedCurrency) - ? { currency: suppliedCurrency } - : {}), - }; + return { bidder, priceBucket }; } function recordCompletedPrebidAuction( diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts index 71e71ddb8..68f4a9f9a 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -66,6 +66,8 @@ function fakeApiStore() { subscribe: vi.fn(() => () => undefined), recordTrustedServerOpportunity: vi.fn(), recordPrebidRefresh: vi.fn(), + recordPrebidAuction: vi.fn(), + recordPrebidWin: vi.fn(), recordTrustedServerCreativeRequest: vi.fn((_auctionSlotId: string) => 41), recordTrustedServerCreativeResponse: vi.fn(), recordTrustedServerCreativeFailure: vi.fn(), diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts index fd635fd8b..a6e1f0d54 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts @@ -140,10 +140,22 @@ describe('GptDiagnosticsBadgeManager', () => { const badge = layer.querySelector('.tsgd-badge'); expect(badge).toBeInstanceOf(HTMLButtonElement); expect(badge?.textContent).toContain('Ad #1 · Request #2'); - expect(badge?.getAttribute('aria-label')).toContain('Ad #1, Request #2'); + expect(badge?.getAttribute('aria-label')).toBe( + 'Open diagnostics for Ad #1, Request #2: Pending' + ); badge?.click(); expect(activate).toHaveBeenCalledWith(1, 2); + badge?.focus(); + store.recordSlotRequested(observedSlot); + runFrame(frames); + const updatedBadge = layer.querySelector('.tsgd-badge'); + expect(updatedBadge).toBe(badge); + expect(document.activeElement).toBe(badge); + expect(updatedBadge?.textContent).toContain('Ad #1 · Request #3'); + updatedBadge?.click(); + expect(activate).toHaveBeenLastCalledWith(1, 3); + const highlight = document.createElement('div'); highlight.className = 'tsgd-highlight'; layer.append(highlight); @@ -414,6 +426,8 @@ describe('GptDiagnosticsBadgeManager', () => { expect(firstBadge.style.left).toBe('100px'); expect(firstBadge.style.top).toBe('112px'); expect(firstBadge.style.maxWidth).toBe('260px'); + firstBadge.focus(); + expect(document.activeElement).toBe(firstBadge); currentRectangle = rectangle(220, 260, 300, 250); window.dispatchEvent(new Event('scroll')); @@ -421,6 +435,8 @@ describe('GptDiagnosticsBadgeManager', () => { expect(frames).toHaveLength(1); runFrame(frames); const movedBadge = layer.querySelector('.tsgd-badge')!; + expect(movedBadge).toBe(firstBadge); + expect(document.activeElement).toBe(firstBadge); expect(movedBadge.style.left).toBe('220px'); expect(movedBadge.style.top).toBe('252px'); expect(element.getAttributeNames().map((name) => [name, element.getAttribute(name)])).toEqual( diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts index 598cdc00a..a5c4bf812 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts @@ -129,7 +129,7 @@ describe('GptDiagnosticsOverlay', () => { undefined, { auctionType: 'ssat', - winner: { bidder: 'example-bidder', priceBucket: '1.20' }, + winner: { bidder: 'example-bidder', priceBucket: '1.20', currency: 'usd' }, serverTimings: { auctionDispatchedMs: 4, auctionResolvedMs: 84, @@ -217,6 +217,10 @@ describe('GptDiagnosticsOverlay', () => { const prebidSlot = slot('prebid-slot'); now = 50; store.recordPrebidRefresh([prebidSlot]); + store.recordPrebidAuction(prebidSlot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); store.recordSlotRequested(prebidSlot); now = 51; store.recordSlotResponseReceived(prebidSlot); @@ -274,7 +278,8 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).toContain('Trusted Server auction: auction-123'); expect(responseSentArticle).toContain('Auction evidence: SSAT: initial-page server auction'); expect(responseSentArticle).toContain('Server auction winner: example-bidder'); - expect(responseSentArticle).toContain('Server bid price bucket: 1.20'); + expect(responseSentArticle).toContain('Server bid price bucket: 1.20 USD'); + expect(responseSentArticle).not.toContain('(currency not supplied)'); expect(responseSentArticle).toContain('Edge request T0 → auction dispatched 4 ms'); expect(responseSentArticle).toContain('Edge request T0 → auction collected 84 ms'); expect(responseSentArticle).toContain('Edge request T0 → bids ready 85 ms'); @@ -326,6 +331,8 @@ describe('GptDiagnosticsOverlay', () => { const prebidArticle = slotArticle(root!, 'prebid-slot').textContent ?? ''; expect(prebidArticle).toContain('Request path: Prebid refresh'); + expect(prebidArticle).toContain('Prebid candidate price bucket: 2.40'); + expect(prebidArticle).not.toContain('(currency not supplied)'); expect(prebidArticle).toContain('Direct opportunity: Not observed'); expect(prebidArticle).toContain('Delivery status unknown — required evidence was not observed'); @@ -509,7 +516,10 @@ describe('GptDiagnosticsOverlay', () => { expect(root!.textContent).toContain('GAM request → response 10 ms'); expect(root!.textContent).toContain('GPT visibility 60%'); expect(root!.textContent).toContain('Requesting'); - expect(root!.textContent).toContain('Ambiguous binding'); + expect(root!.textContent).toContain('Ambiguous binding · duplicate_dom_id'); + expect(slotArticle(root!, 'filled-slot').textContent?.match(/Bound · Visible/g)).toHaveLength( + 1 + ); expect(root!.textContent).toContain('Incomplete sequence'); expect(slotArticle(root!, 'pending-slot').textContent).toContain( 'Delivery evidence: Not applicable' @@ -607,9 +617,14 @@ describe('GptDiagnosticsOverlay', () => { const focus = vi.spyOn(HTMLElement.prototype, 'focus'); selected?.focus(); const focusCallsBeforeUpdate = focus.mock.calls.length; + const history = root?.querySelector('.tsgd-history'); + expect(history?.open).toBe(true); + if (history) history.open = false; + store.recordSlotResponseReceived(observedSlot); runNextFrame(frames); expect(focus.mock.calls.length).toBeGreaterThan(focusCallsBeforeUpdate); + expect(root?.querySelector('.tsgd-history')?.open).toBe(false); button(root!, 'Locate on page').click(); expect(scrollIntoView).toHaveBeenCalledWith({ @@ -669,14 +684,20 @@ describe('GptDiagnosticsOverlay', () => { runNextFrame(frames); const content = root!.querySelector('.tsgd-content')!; - const history = root!.querySelector('.tsgd-slot details')!; + const history = root!.querySelector('.tsgd-history')!; + const help = root!.querySelector('.tsgd-help')!; + const technical = root!.querySelector('.tsgd-technical')!; history.open = true; + help.open = true; + technical.open = true; content.scrollTop = 42; store.recordSlotResponseReceived(diagnosticSlot); runNextFrame(frames); expect(root!.textContent).toContain('Rendered (fill unknown)'); - expect(root!.querySelector('.tsgd-slot details')?.open).toBe(true); + expect(root!.querySelector('.tsgd-history')?.open).toBe(true); + expect(root!.querySelector('.tsgd-help')?.open).toBe(true); + expect(root!.querySelector('.tsgd-technical')?.open).toBe(true); expect(root!.querySelector('.tsgd-content')?.scrollTop).toBe(42); overlay.destroy(); }); 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 00306dd61..a5b9a756c 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 @@ -2494,6 +2494,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { delete testWindow.__tsjs_prebid; testWindow.tsjs = undefined; delete testWindow.googletag; + vi.restoreAllMocks(); }); function installGpt(slots: Array>) { @@ -2585,10 +2586,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); it('records completed client auction evidence only for the exact Prebid attempt', () => { + const getTargeting = vi.fn((key: string) => + key === 'hb_bidder' ? ['example-client'] : key === 'hb_pb' ? ['2.40'] : [] + ); const slot = { getSlotElementId: () => 'example-client-slot', - getTargeting: (key: string) => - key === 'hb_bidder' ? ['example-client'] : key === 'hb_pb' ? ['2.40'] : [], + getTargeting, clearTargeting: vi.fn(), }; const recordPrebidRefresh = vi.fn(); @@ -2616,6 +2619,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidder: 'example-client', priceBucket: '2.40', }); + expect(getTargeting).not.toHaveBeenCalledWith('hb_cur'); const bidWon = mockOnEvent.mock.calls.find(([event]) => event === 'bidWon')?.[1]; expect(bidWon).toBeTypeOf('function'); bidWon?.({ @@ -2635,6 +2639,48 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); }); + it.each(['expiry', 'navigation change'] as const)( + 'rejects a Prebid bidWon observation after %s', + (reason) => { + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const adUnitCode = `example-${reason.replace(' ', '-')}`; + const slot = { + getSlotElementId: () => adUnitCode, + getTargeting: (key: string) => + key === 'hb_bidder' ? ['example-client'] : key === 'hb_pb' ? ['2.40'] : [], + clearTargeting: vi.fn(), + }; + const recordPrebidWin = vi.fn(); + testWindow.tsjs = { + navGeneration: 1, + gptDiagnosticsRecorder: { + recordPrebidRefresh: vi.fn(), + recordPrebidAuction: vi.fn(), + recordPrebidWin, + }, + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-client-auction'); + }); + + pubads.refresh([slot]); + const bidWon = mockOnEvent.mock.calls.find(([event]) => event === 'bidWon')?.[1]; + expect(bidWon).toBeTypeOf('function'); + if (reason === 'expiry') now = 30_001; + else testWindow.tsjs!.navGeneration = 2; + bidWon?.({ + auctionId: 'example-client-auction', + adUnitCode, + adserverTargeting: { hb_bidder: 'example-client', hb_pb: '2.40' }, + }); + + expect(recordPrebidWin).not.toHaveBeenCalled(); + } + ); + it('records a publisher delivery refresh immediately before its GPT request', () => { const slot = { getSlotElementId: () => 'example-delivery-marker', diff --git a/docs/guide/integrations/gpt-diagnostics-dictionary.md b/docs/guide/integrations/gpt-diagnostics-dictionary.md index 605186887..896fc0cce 100644 --- a/docs/guide/integrations/gpt-diagnostics-dictionary.md +++ b/docs/guide/integrations/gpt-diagnostics-dictionary.md @@ -57,12 +57,11 @@ Panel status is `GPT observed` or `Waiting for GPT`. Filter values are `All`, `V | `Multiple auction paths observed` | `Multiple paths` | `auctionType=competing` | Completed server and Prebid auction evidence both exist for the request. This does not prove a race, overwrite, or winner. | | `Auction not observed` | no auction badge | missing or malformed explicit facts | Diagnostics has no qualifying completed-auction evidence. It never defaults to SSAT. | | `Server auction winner` | — | compatibility `auctionWinner`, server `hb_bidder` | Winner selected by the server auction. It is not the final served bidder. | -| `Server bid price bucket:` | — | server `hb_pb` | Already-bucketed price; raw CPM is never exported. Currency is shown only when supplied. | +| `Server bid price bucket:` | — | server `hb_pb` | Already-bucketed price; raw CPM is never exported. A supplied currency follows the bucket value; an absent currency adds no suffix. | | `Prebid targeting candidate:` | — | `prebidAuction.targetingCandidate` after exact `setTargetingForGPTAsync` boundary | Candidate targeting observed on the exact GPT slot for the exact Prebid callback auction ID. It is not a final win. | -| `Prebid candidate price bucket:` | — | candidate `hb_pb` | Bucketed targeting value for the candidate; it is not a winning-price claim. | +| `Prebid candidate price bucket:` | — | candidate `hb_pb` | Bucketed targeting value for the candidate; it is not a winning-price claim. Diagnostics do not infer or read a Prebid currency targeting key. | | `Prebid bidWon observation:` | — | `prebidAuction.win`, documented `bidWon` payload | A bounded event joined by exact auction ID, ad-unit code, slot object, navigation generation, and retention window. It remains distinct from GPT render and served-source proof. | -| `Prebid win price bucket:` | — | `bidWon.adserverTargeting.hb_pb` | Bucketed value observed on the correlated `bidWon` event; it is not proof of the creative GAM served. | -| `(currency not supplied)` | — | absent validated ISO currency | No verified currency was supplied. The console never assumes USD and does not compare currencies. | +| `Prebid win price bucket:` | — | `bidWon.adserverTargeting.hb_pb` | Bucketed value observed on the correlated `bidWon` event; it is not proof of the creative GAM served. Diagnostics do not infer a currency. | Bidder names are limited to 128 UTF-8 bytes, numeric bucket strings to 64 bytes, currencies to three ASCII letters, and auction IDs to 256 UTF-8 bytes. Prebid supplies its own auction ID through `bidsBackHandler`; diagnostics does not override Prebid auction identity. Duplicate, ambiguous, expired, late, prior-navigation, and malformed observations are rejected. Only an active diagnostics recorder installs the `bidWon` listener or retains candidate/win state, which is bounded to 128 pending attempts and 30 seconds. No raw CPM, creative markup, targeting dump, or losing bids are retained. @@ -125,6 +124,8 @@ Server offsets are not browser timestamps. `auctionResolvedMs` means collection | `Slot content changed yes/no` | `slotContentChanged` | GPT's callback value; not proof pixels changed. | | `Creative changed/unchanged` | retained GAM creative IDs | Comparison only when both cycles supplied an ID. | +The unprefixed `Bound`, `Unbound`, and `Ambiguous binding` variants appear in Technical details only when a slot has no request cycle. Once request groups exist, binding appears only under Size and visibility. + Binding reasons are `missing_slot_element_id`, `missing_element`, `duplicate_dom_id`, `dom_uniqueness_unverifiable`, and `duplicate_gpt_slot_id`. Other exact technical fact prefixes are `Replaced rendered request`, `Creative changed`, `Creative unchanged`, `Trusted Server creative request observed at`, and `Trusted Server markup response sent at`. ## Failures, attribution, coverage, and retention diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 61308f685..7b8345400 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -148,7 +148,7 @@ Auction labels describe the path observed for that GPT request cycle: Publisher refresh evidence does not by itself establish another auction. It remains visible in the request-path classification but does not turn an SSAT, TS auction, or -client-side auction label into “Competing auctions.” Publisher-only refreshes and +client-side auction label into `Multiple auction paths observed`. Publisher-only refreshes and unattributed requests have no auction label because the available evidence does not establish an auction implementation. @@ -268,15 +268,15 @@ that acknowledgement is outside the zero-publisher-change design. The derived `delivery` value uses these evidence-safe meanings: -| Delivery state | Panel wording | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `trusted_server_response_sent` | Creative markup sent; execution not confirmed | -| `trusted_server_selected` | Server bid selected by the creative bridge; response not confirmed | -| `candidate_unconfirmed` | Server bid available; selection not confirmed | -| `no_candidate` | adInit observed no direct Trusted Server candidate for this request | -| `unknown` | Delivery status unknown — required GPT or direct-candidate evidence was not observed | -| `pending` | Waiting for Trusted Server creative evidence | -| `not_applicable` | No delivery conclusion is displayed before render or for an explicitly empty result, provided no Trusted Server creative evidence was stamped on the cycle. | +| Delivery state | Panel wording | +| ------------------------------ | ------------------------------------------------------------------ | +| `trusted_server_response_sent` | Creative markup sent; execution not confirmed | +| `trusted_server_selected` | Server bid selected by the creative bridge; response not confirmed | +| `candidate_unconfirmed` | Server bid available; selection not confirmed | +| `no_candidate` | No direct Trusted Server candidate | +| `unknown` | Delivery status unknown — required evidence was not observed | +| `pending` | Waiting for Trusted Server creative evidence | +| `not_applicable` | Delivery evidence: Not applicable | For an explicit non-empty candidate, diagnostics wait five seconds from `slotRenderEnded` for positive creative evidence. If no matched PUC request arrives, @@ -436,14 +436,17 @@ content or altering the APS sandbox, so this field cannot prove the inner creati pixels. Badges and the panel live in a closed Shadow DOM. Diagnostics do not add attributes, -classes, or inline styles to publisher slot elements. +classes, or inline styles to publisher slot elements. Each badge is an interactive button +positioned over the slot's top-left area. While diagnostics are active, the badge intercepts +pointer input within its own bounds instead of passing that input to the creative. ## Presentation Lifecycle - **Collapse** reduces the panel while preserving capture. - **Close** dismisses the presentation for the current document. - External removal by hydration or DOM reconciliation triggers a debounced remount. -- Live re-renders preserve open request-history disclosures and panel scroll position. +- Live re-renders preserve open request-history, help, and technical disclosures plus panel scroll position. +- Selecting an earlier request from a badge opens its history once. A later user collapse remains closed across live updates. - Explicit Close or `hide()` prevents remount until `show()` is called. - Capture continues while the panel is hidden. From 9b5bb419187b114c2f323002268a745fcd2c2510 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 17 Sep 2026 11:21:00 -0500 Subject: [PATCH 3/4] Align GPT diagnostics tests with current metadata --- .../lib/src/integrations/gpt/index.ts | 3 +++ .../lib/test/integrations/gpt/ad_init.test.ts | 12 ++++-------- .../test/integrations/gpt_diagnostics/store.test.ts | 4 +--- 3 files changed, 8 insertions(+), 11 deletions(-) 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 a4a575396..e204de0e1 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1438,6 +1438,9 @@ export function installSpaAuctionHook(): void { if (path === currentPath) return; currentPath = path; ts.navGeneration = (ts.navGeneration ?? 0) + 1; + // Server timings belong to the route that produced them. Clear them before + // page-bids starts so failure or supersession cannot relabel stale offsets. + ts.auctionDiagnostics = undefined; // A route change invalidates hydration aliases before the new route's // publisher can define a same-prefix slot while page-bids is in flight. for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs ?? {})) { 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 5a8f97619..4d8fc208a 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 @@ -332,8 +332,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', expectedOpportunity, undefined, - undefined, - { auctionType: 'ssat' } + undefined ); } ); @@ -455,8 +454,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'no_candidate', undefined, - formats, - { auctionType: 'ssat' } + formats ); }); @@ -482,8 +480,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'no_candidate', undefined, - formats, - { auctionType: 'ssat' } + formats ); }); @@ -523,8 +520,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'no_candidate', undefined, - undefined, - { auctionType: 'ssat' } + undefined ); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index 9872e33ff..22c9760c1 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -662,9 +662,7 @@ describe('GptDiagnosticsStore', () => { const slot = fakeSlot('path-slot'); if (direct !== undefined) { - store.recordTrustedServerOpportunity(slot, 'auction-slot', direct, undefined, undefined, { - auctionType: 'ssat', - }); + store.recordTrustedServerOpportunity(slot, 'auction-slot', direct); } if (prebid) store.recordPrebidRefresh([slot]); if (publisher) store.recordPublisherRefresh([slot]); From 54d33addf6350c40c8a679ee9535f222c8144d7c Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 17 Sep 2026 12:09:42 -0500 Subject: [PATCH 4/4] Hide placeholder dimensions from diagnostics overlay --- .../lib/src/integrations/gpt_diagnostics/overlay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index f73c79d7e..b69b40194 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -345,7 +345,7 @@ function sizeFacts(cycle: GptDiagnosticsRequestCycle): string[] { fillSize ? `GPT-reported size ${formatSizes([fillSize])}` : cycle.size?.[0] === 1 && cycle.size[1] === 1 - ? 'GPT-reported size: 1×1 placeholder hidden' + ? 'GPT-reported size: placeholder hidden' : 'GPT-reported size: Not observed', cycle.observedSlotSize ? `Size filled ${formatSizes([cycle.observedSlotSize])} · Measured outer slot size`