diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 0c68d43fe..da6597442 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -207,7 +207,15 @@ export interface GptDiagnosticsRequestCycle { viewableAtMs?: number; durations: GptDiagnosticsDurations; isEmpty?: boolean; + /** Configured sizes Trusted Server supplied to GPT for this request. */ + requestedSlotSizes?: ReadonlyArray; + /** Exact fill size fact GPT reported in its `slotRenderEnded` callback. */ size?: Size; + /** + * Outer CSS box observed on the uniquely bound, connected slot element after + * a filled GPT render. This is not an assertion about internal creative pixels. + */ + observedSlotSize?: Size; isBackfill?: boolean; slotContentChanged?: boolean; incompleteSequence: boolean; @@ -318,12 +326,13 @@ export interface GptDiagnosticsApi { * and stops the writers from becoming part of the public contract. */ export interface GptDiagnosticsRecorder { - /** Record Trusted Server's creative opportunity for an associated GPT slot. */ + /** Record Trusted Server's creative opportunity and configured sizes for an associated GPT slot. */ recordTrustedServerOpportunity( slot: GptDiagnosticsSlotHandle, auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, - trustedServerAuctionId?: string + trustedServerAuctionId?: string, + requestedSlotSizes?: ReadonlyArray ): void; /** Mark slots whose next observed GPT request follows the Prebid refresh path. */ recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; 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 f0df35974..88bf659fe 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1053,20 +1053,13 @@ export function installTsAdInit(): void { // implementation must never interrupt slot mapping or delivery. try { const opportunity = trustedServerOpportunity(bid); - if (bid.hb_auction_id !== undefined) { - ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( - gptSlot, - slot.id, - opportunity, - bid.hb_auction_id - ); - } else { - ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( - gptSlot, - slot.id, - opportunity - ); - } + ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + opportunity, + bid.hb_auction_id, + slot.formats + ); } catch { // Diagnostics must not alter ad delivery. } 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 99f876b3f..475bc7f93 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 @@ -17,7 +17,8 @@ interface ApiStore { slot: GptDiagnosticsSlotHandle, auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, - trustedServerAuctionId?: string + trustedServerAuctionId?: string, + requestedSlotSizes?: ReadonlyArray ): void; recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; @@ -63,7 +64,9 @@ function cloneExportSnapshot(snapshot: GptDiagnosticsExportV1): GptDiagnosticsEx requests: slot.requests.map((cycle) => ({ ...cycle, durations: { ...cycle.durations }, + requestedSlotSizes: cycle.requestedSlotSizes?.map((size) => [...size]), size: cycle.size ? [...cycle.size] : undefined, + observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, adManager: cycle.adManager ? { ...cycle.adManager, @@ -149,18 +152,21 @@ export class GptDiagnosticsApiController { }; this.recorder = { - recordTrustedServerOpportunity: (slot, auctionSlotId, opportunity, trustedServerAuctionId) => + recordTrustedServerOpportunity: ( + slot, + auctionSlotId, + opportunity, + trustedServerAuctionId, + requestedSlotSizes + ) => safelyRecord(() => { - if (trustedServerAuctionId === undefined) { - this.store.recordTrustedServerOpportunity(slot, auctionSlotId, opportunity); - } else { - this.store.recordTrustedServerOpportunity( - slot, - auctionSlotId, - opportunity, - trustedServerAuctionId - ); - } + this.store.recordTrustedServerOpportunity( + slot, + auctionSlotId, + opportunity, + trustedServerAuctionId, + requestedSlotSizes + ); }), recordPrebidRefresh: (slots) => safelyRecord(() => this.store.recordPrebidRefresh(slots)), recordTrustedServerCreativeRequest: (auctionSlotId) => @@ -191,7 +197,9 @@ export class GptDiagnosticsApiController { requests: slot.requests.map((cycle) => ({ ...cycle, durations: { ...cycle.durations }, + requestedSlotSizes: cycle.requestedSlotSizes?.map((size) => [...size]), size: cycle.size ? [...cycle.size] : undefined, + observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, adManager: cycle.adManager ? { ...cycle.adManager, 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 57fce3d85..408fcb1f9 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 @@ -106,6 +106,10 @@ function deliveryLabel(cycle: GptDiagnosticsRequestCycle): string | undefined { } } +function formatSizes(sizes: ReadonlyArray): string { + return sizes.map((size) => `${size[0]}×${size[1]}`).join(', '); +} + function badgeText(cycle: GptDiagnosticsRequestCycle): string { const firstLine: string[] = []; if (cycle.isEmpty === true) firstLine.push('Empty'); @@ -115,7 +119,13 @@ function badgeText(cycle: GptDiagnosticsRequestCycle): string { const delivery = deliveryLabel(cycle); if (delivery) firstLine.push(delivery); if (cycle.requestPath === 'competing') firstLine.push('Competing paths'); - if (cycle.size) firstLine.push(`${cycle.size[0]}×${cycle.size[1]}`); + if (cycle.requestedSlotSizes) { + firstLine.push(`Requested ${formatSizes(cycle.requestedSlotSizes)}`); + } + if (cycle.size) firstLine.push(`GPT fill ${cycle.size[0]}×${cycle.size[1]}`); + if (cycle.observedSlotSize) { + firstLine.push(`Outer box ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); + } const timingLine: string[] = []; const response = formatMilliseconds(cycle.durations.requestToResponseMs); 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 75bf97823..d7271710c 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 @@ -7,6 +7,7 @@ import { GptDiagnosticsBindingManager } from './binding'; import { GptDiagnosticsObserver } from './observer'; import type { GptObserverWindow } from './observer'; import { GptDiagnosticsOverlay } from './overlay'; +import { GptDiagnosticsSlotSizeObserver } from './slot_size_observer'; import { GptDiagnosticsStore } from './store'; interface GptDiagnosticsRuntime { @@ -44,6 +45,7 @@ export function installGptDiagnosticsRuntime( let bindings: GptDiagnosticsBindingManager | undefined; let badges: GptDiagnosticsBadgeManager | undefined; let overlay: GptDiagnosticsOverlay | undefined; + let slotSizeObserver: GptDiagnosticsSlotSizeObserver | undefined; let apiController: GptDiagnosticsApiController | undefined; try { @@ -59,6 +61,7 @@ export function installGptDiagnosticsRuntime( window: target, document: target.document, }); + slotSizeObserver = new GptDiagnosticsSlotSizeObserver(store, bindings, { window: target }); overlay = new GptDiagnosticsOverlay(store, bindings, { window: target, document: target.document, @@ -83,6 +86,7 @@ export function installGptDiagnosticsRuntime( apiController?.destroy(); overlay?.destroy(); badges?.destroy(); + slotSizeObserver?.destroy(); bindings?.destroy(); delete target.__tsjs_gpt_diagnostics_runtime; }, @@ -95,6 +99,7 @@ export function installGptDiagnosticsRuntime( apiController?.destroy(); overlay?.destroy(); badges?.destroy(); + slotSizeObserver?.destroy(); bindings?.destroy(); log.warn('gpt diagnostics: runtime installation failed', error); return undefined; 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 e99f1345b..1eeb4976e 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 @@ -277,7 +277,17 @@ function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { 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.size) facts.push(`Rendered size ${cycle.size[0]}×${cycle.size[1]}`); + if (cycle.requestedSlotSizes) { + facts.push( + `Requested slot sizes ${cycle.requestedSlotSizes + .map((size) => `${size[0]}×${size[1]}`) + .join(', ')}` + ); + } + if (cycle.size) facts.push(`GPT-reported fill size ${cycle.size[0]}×${cycle.size[1]}`); + if (cycle.observedSlotSize) { + facts.push(`Observed outer slot box ${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'}`); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/slot_size_observer.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/slot_size_observer.ts new file mode 100644 index 000000000..ab49a451a --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/slot_size_observer.ts @@ -0,0 +1,146 @@ +import type { Size } from '../../core/types'; + +import type { GptDiagnosticsBindingManager } from './binding'; +import type { GptDiagnosticsStoreSnapshot } from './store'; + +interface SlotSizeStore { + snapshot(): GptDiagnosticsStoreSnapshot; + recordObservedSlotSize(runtimeSlotNumber: number, requestNumber: number, size: Size): void; + subscribe(listener: () => void): () => void; +} + +interface SlotSizeBindings { + get: GptDiagnosticsBindingManager['get']; + subscribe(listener: () => void): () => void; +} + +type SlotSizeWindow = Window & { + ResizeObserver?: typeof ResizeObserver; +}; + +interface SlotSizeObserverOptions { + window?: SlotSizeWindow; + scheduleFrame?: (callback: () => void) => void; +} + +interface ObservedCycle { + runtimeSlotNumber: number; + requestNumber: number; +} + +function defaultScheduleFrame(callback: () => void): void { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => callback()); + } else { + queueMicrotask(callback); + } +} + +function latestFilledCycle( + slot: GptDiagnosticsStoreSnapshot['slots'][number] +): ObservedCycle | undefined { + const cycle = slot.requests[slot.requests.length - 1]; + if (!cycle || cycle.isEmpty !== false || cycle.renderAtMs === undefined) return undefined; + return { runtimeSlotNumber: slot.runtimeSlotNumber, requestNumber: cycle.requestNumber }; +} + +/** + * Observes the outer CSS boxes of uniquely bound elements after filled GPT renders. + * + * Measurements remain separately labelled from GPT's reported creative size and + * are conditionally written with the runtime-slot and request-cycle identity that + * was current when the measurement was scheduled. + */ +export class GptDiagnosticsSlotSizeObserver { + private readonly store: SlotSizeStore; + private readonly bindings: SlotSizeBindings; + private readonly window: SlotSizeWindow; + private readonly scheduleFrame: (callback: () => void) => void; + private readonly unsubscribeStore: () => void; + private readonly unsubscribeBindings: () => void; + private resizeObserver?: ResizeObserver; + private refreshScheduled = false; + private destroyed = false; + + constructor( + store: SlotSizeStore, + bindings: SlotSizeBindings, + options: SlotSizeObserverOptions = {} + ) { + this.store = store; + this.bindings = bindings; + this.window = options.window ?? (window as unknown as SlotSizeWindow); + this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.unsubscribeStore = this.store.subscribe(this.scheduleRefresh); + this.unsubscribeBindings = this.bindings.subscribe(this.scheduleRefresh); + this.refresh(); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.unsubscribeStore(); + this.unsubscribeBindings(); + this.resizeObserver?.disconnect(); + } + + private readonly scheduleRefresh = (): void => { + if (this.destroyed || this.refreshScheduled) return; + this.refreshScheduled = true; + this.scheduleFrame(() => { + this.refreshScheduled = false; + this.refresh(); + }); + }; + + private refresh(): void { + if (this.destroyed) return; + this.resizeObserver?.disconnect(); + const observations = new Map(); + const ResizeObserverConstructor = this.window.ResizeObserver; + if (typeof ResizeObserverConstructor === 'function') { + this.resizeObserver = new ResizeObserverConstructor((entries) => { + for (const entry of entries) { + const element = entry.target; + if (!(element instanceof this.window.HTMLElement)) continue; + const cycle = observations.get(element); + if (cycle) this.scheduleMeasure(element, cycle); + } + }); + } + + for (const slot of this.store.snapshot().slots) { + const cycle = latestFilledCycle(slot); + const binding = this.bindings.get(slot.runtimeSlotNumber); + if (!cycle || binding.binding.status !== 'bound' || !binding.element?.isConnected) continue; + observations.set(binding.element, cycle); + this.resizeObserver?.observe(binding.element); + this.scheduleMeasure(binding.element, cycle); + } + } + + private scheduleMeasure(element: HTMLElement, cycle: ObservedCycle): void { + this.scheduleFrame(() => this.measure(element, cycle)); + } + + private measure(element: HTMLElement, cycle: ObservedCycle): void { + const binding = this.bindings.get(cycle.runtimeSlotNumber); + if (binding.binding.status !== 'bound' || binding.element !== element || !element.isConnected) { + return; + } + + const rectangle = element.getBoundingClientRect(); + if ( + !Number.isFinite(rectangle.width) || + !Number.isFinite(rectangle.height) || + rectangle.width < 0 || + rectangle.height < 0 + ) { + return; + } + this.store.recordObservedSlotSize(cycle.runtimeSlotNumber, cycle.requestNumber, [ + rectangle.width, + rectangle.height, + ]); + } +} 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 0324a56cc..03d887aa0 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 @@ -21,6 +21,7 @@ export const MAX_DIAGNOSTIC_SLOTS = 64; export const MAX_REQUEST_CYCLES_PER_SLOT = 10; export const MAX_CALLBACK_ISSUES = 128; export const MAX_TRUSTED_SERVER_ASSOCIATIONS = 64; +export const MAX_REQUESTED_SLOT_SIZES = 16; export const CREATIVE_ATTEMPT_WINDOW_MS = 30_000; export const MAX_CREATIVE_ATTEMPTS = 128; export const MAX_ATTRIBUTION_ISSUES = 128; @@ -106,6 +107,7 @@ interface PendingSourceEvidence { observedAtMs: number; trustedServerOpportunity?: GptDiagnosticsTrustedServerOpportunity; trustedServerAuctionId?: string; + requestedSlotSizes?: ReadonlyArray; } interface PendingRequestIntent { @@ -205,6 +207,29 @@ function normalizedAuctionId(value: unknown): string | undefined { return new TextEncoder().encode(trimmed).length <= 256 ? trimmed : undefined; } +function normalizedRequestedSlotSizes(value: unknown): ReadonlyArray | undefined { + if (!Array.isArray(value)) return undefined; + + const requestedSlotSizes: Size[] = []; + for (const candidate of value.slice(0, MAX_REQUESTED_SLOT_SIZES)) { + if ( + !Array.isArray(candidate) || + candidate.length !== 2 || + typeof candidate[0] !== 'number' || + typeof candidate[1] !== 'number' || + !Number.isFinite(candidate[0]) || + !Number.isFinite(candidate[1]) || + candidate[0] <= 0 || + candidate[1] <= 0 + ) { + continue; + } + requestedSlotSizes.push(Object.freeze([candidate[0], candidate[1]] as [number, number])); + } + + return requestedSlotSizes.length > 0 ? Object.freeze(requestedSlotSizes) : undefined; +} + function responseClass(cycle: MutableRequestCycle): GptDiagnosticsResponseClass | undefined { if (cycle.renderAtMs === undefined) return undefined; if (cycle.isEmpty === true) return 'empty'; @@ -248,7 +273,9 @@ function copyCycle(cycle: MutableRequestCycle, nowMs: number): GptDiagnosticsReq return { ...cycle, durations: derivedDurations(cycle), + requestedSlotSizes: cycle.requestedSlotSizes?.map((size) => [...size] as Size), size: cycle.size ? ([...cycle.size] as Size) : undefined, + observedSlotSize: cycle.observedSlotSize ? ([...cycle.observedSlotSize] as Size) : undefined, adManager: cycle.adManager ? { ...cycle.adManager, @@ -309,7 +336,8 @@ export class GptDiagnosticsStore { slot: GptDiagnosticsSlotLike, auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, - trustedServerAuctionId?: string + trustedServerAuctionId?: string, + requestedSlotSizes?: ReadonlyArray ): void { if ( !isSlotObject(slot) || @@ -331,6 +359,7 @@ export class GptDiagnosticsStore { this.recordRequestIntentSource(slot, 'trusted_server_direct', { trustedServerOpportunity: opportunity, trustedServerAuctionId: normalizedAuctionId(trustedServerAuctionId), + requestedSlotSizes: normalizedRequestedSlotSizes(requestedSlotSizes), }); } @@ -578,6 +607,9 @@ export class GptDiagnosticsStore { ...(trustedServerEvidence?.trustedServerAuctionId !== undefined ? { trustedServerAuctionId: trustedServerEvidence.trustedServerAuctionId } : {}), + ...(trustedServerEvidence?.requestedSlotSizes !== undefined + ? { requestedSlotSizes: trustedServerEvidence.requestedSlotSizes } + : {}), ...(trustedServerEvidence ? { opportunityToRequestMs: validDuration(trustedServerEvidence.observedAtMs, timestampMs), @@ -666,6 +698,45 @@ export class GptDiagnosticsStore { ); } + /** + * Retain an outer CSS box only when this exact slot and request cycle still + * identify a filled render. Async DOM measurements use this guard so a prior + * render cannot alter a later refresh cycle. + */ + recordObservedSlotSize(runtimeSlotNumber: number, requestNumber: number, size: Size): void { + if ( + !Number.isSafeInteger(requestNumber) || + requestNumber <= 0 || + !Number.isFinite(size[0]) || + !Number.isFinite(size[1]) || + size[0] < 0 || + size[1] < 0 + ) { + return; + } + + const record = this.slots.get(runtimeSlotNumber); + const cycle = record?.requests.find((candidate) => candidate.requestNumber === requestNumber); + if ( + !cycle || + record.requests[record.requests.length - 1] !== cycle || + cycle.isEmpty !== false || + cycle.renderAtMs === undefined + ) { + return; + } + + const observedSlotSize: Size = [size[0], size[1]]; + if ( + cycle.observedSlotSize?.[0] === observedSlotSize[0] && + cycle.observedSlotSize[1] === observedSlotSize[1] + ) { + return; + } + cycle.observedSlotSize = observedSlotSize; + this.notify(); + } + recordSlotOnload(slot: GptDiagnosticsSlotLike): void { const timestampMs = this.timestamp(); this.matchCycle( @@ -861,7 +932,10 @@ export class GptDiagnosticsStore { private recordRequestIntentSource( slot: object, source: RequestIntentSource, - facts: Pick = {} + facts: Pick< + PendingSourceEvidence, + 'trustedServerOpportunity' | 'trustedServerAuctionId' | 'requestedSlotSizes' + > = {} ): void { const observedAtMs = this.now(); let intent = this.pendingRequestIntents.get(slot); 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 179a810d5..ddeecb315 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 @@ -206,7 +206,8 @@ describe('installTsAdInit', () => { function configureOpportunityDiagnostics( bid: AuctionBidData | undefined, - recordTrustedServerOpportunity: ReturnType + recordTrustedServerOpportunity: ReturnType, + formats: Array<[number, number]> = [[300, 250]] ) { const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -232,7 +233,7 @@ describe('installTsAdInit', () => { id: 'atf_sidebar_ad', gam_unit_path: '/123/atf', div_id: 'div-atf-sidebar', - formats: [[300, 250]], + formats, targeting: {}, }, ], @@ -293,7 +294,9 @@ describe('installTsAdInit', () => { expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( mockSlot, 'atf_sidebar_ad', - expectedOpportunity + expectedOpportunity, + undefined, + [[300, 250]] ); } ); @@ -318,7 +321,34 @@ describe('installTsAdInit', () => { mockSlot, 'atf_sidebar_ad', 'unrenderable_candidate', - 'auction-123' + 'auction-123', + [[300, 250]] + ); + }); + + it('captures every configured Trusted Server format when associating a GPT slot', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const formats: Array<[number, number]> = [ + [300, 250], + [728, 90], + [320, 50], + ]; + const { mockSlot } = configureOpportunityDiagnostics( + undefined, + recordTrustedServerOpportunity, + formats + ); + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( + mockSlot, + 'atf_sidebar_ad', + 'no_candidate', + undefined, + formats ); }); @@ -334,7 +364,9 @@ describe('installTsAdInit', () => { expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( mockSlot, 'atf_sidebar_ad', - 'no_candidate' + 'no_candidate', + undefined, + [[300, 250]] ); }); 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 2e2ae2d2b..2e3a63b4a 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 @@ -124,7 +124,9 @@ describe('GptDiagnosticsApiController', () => { expect(store.recordTrustedServerOpportunity).toHaveBeenCalledWith( slot, 'auction-slot-example', - 'renderable_candidate' + 'renderable_candidate', + undefined, + undefined ); expect(store.recordPrebidRefresh).toHaveBeenCalledTimes(1); expect(store.recordPrebidRefresh).toHaveBeenCalledWith(slots); @@ -156,7 +158,8 @@ describe('GptDiagnosticsApiController', () => { slot, 'auction-slot-example', 'renderable_candidate', - 'auction-123' + 'auction-123', + undefined ); }); @@ -214,6 +217,10 @@ describe('GptDiagnosticsApiController', () => { requestNumber: 1, durations: {}, incompleteSequence: false, + requestedSlotSizes: [ + [300, 250], + [728, 90], + ], adManager: { yieldGroupIds: [10], companyIds: [20], @@ -253,6 +260,14 @@ describe('GptDiagnosticsApiController', () => { expect(snapshot.attributionIssues).toEqual(source.attributionIssues); expect(snapshot.attributionIssues).not.toBe(source.attributionIssues); expect(snapshot.attributionIssues?.[0]).not.toBe(source.attributionIssues[0]); + expect(cycle?.requestedSlotSizes).toEqual([ + [300, 250], + [728, 90], + ]); + expect(cycle?.requestedSlotSizes).not.toBe(source.slots[0]?.requests[0]?.requestedSlotSizes); + expect(cycle?.requestedSlotSizes?.[0]).not.toBe( + source.slots[0]?.requests[0]?.requestedSlotSizes?.[0] + ); expect(cycle?.trustedServerCreativeFailures).toEqual(['cache_fetch_failed']); expect(cycle?.trustedServerCreativeFailures).not.toBe( source.slots[0]?.requests[0]?.trustedServerCreativeFailures @@ -348,10 +363,12 @@ describe('GptDiagnosticsApiController', () => { 'incompleteSequence', 'isBackfill', 'isEmpty', + 'observedSlotSize', 'renderAtMs', 'requestNumber', 'requestPath', 'requestedAtMs', + 'requestedSlotSizes', 'responseAtMs', 'responseClass', 'size', @@ -428,6 +445,10 @@ describe('GptDiagnosticsApiController', () => { requestNumber: 1, durations: { requestToResponseMs: 10 }, incompleteSequence: false, + requestedSlotSizes: [ + [300, 250], + [728, 90], + ], adManager: { yieldGroupIds: [10], companyIds: [20] }, trustedServerCreativeFailures: ['cache_fetch_failed' as const], }, @@ -463,6 +484,9 @@ describe('GptDiagnosticsApiController', () => { controller.api.subscribe((snapshot) => { const cycle = snapshot.slots[0]!.requests[0]!; cycle.durations.requestToResponseMs = 999; + const requestedSlotSizes = cycle.requestedSlotSizes as unknown as Array<[number, number]>; + requestedSlotSizes[0]![0] = 1; + requestedSlotSizes.push([970, 250]); cycle.adManager!.yieldGroupIds!.push(99); cycle.trustedServerCreativeFailures!.push('response_post_failed'); snapshot.attributionIssues?.push({ @@ -484,6 +508,10 @@ describe('GptDiagnosticsApiController', () => { expect(observedSnapshot?.capturedAt).toBe('2026-08-10T00:00:00.000Z'); const observedCycle = observedSnapshot?.slots[0]?.requests[0]; expect(observedCycle?.durations.requestToResponseMs).toBe(10); + expect(observedCycle?.requestedSlotSizes).toEqual([ + [300, 250], + [728, 90], + ]); expect(observedCycle?.adManager?.yieldGroupIds).toEqual([10]); expect(observedCycle?.trustedServerCreativeFailures).toEqual(['cache_fetch_failed']); expect(observedSnapshot?.attributionIssues).toHaveLength(1); 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 675e4f442..7ac981b6d 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 @@ -252,7 +252,12 @@ describe('GptDiagnosticsBadgeManager', () => { renderAtMs: 318, viewableAtMs: 1318, isEmpty: false, + requestedSlotSizes: [ + [728, 90], + [970, 250], + ], size: [728, 90], + observedSlotSize: [980, 270], incompleteSequence: false, durations: { requestToResponseMs: 276, @@ -260,7 +265,9 @@ describe('GptDiagnosticsBadgeManager', () => { renderToViewableMs: 1000, }, }) - ).toBe('Filled · 728×90\nResponse 276 ms · Render 42 ms\nViewable after 1 s'); + ).toBe( + 'Filled · Requested 728×90, 970×250 · GPT fill 728×90 · Outer box 980×270\nResponse 276 ms · Render 42 ms\nViewable after 1 s' + ); expect( gptDiagnosticsBadgeTextForTest({ requestNumber: 1, 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 b89a8f507..9c9765ed1 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 @@ -405,6 +405,16 @@ describe('GptDiagnosticsOverlay', () => { const element = document.createElement('div'); element.id = 'filled-slot'; document.body.append(element); + store.recordTrustedServerOpportunity( + filledSlot, + 'filled-slot-auction', + 'renderable_candidate', + undefined, + [ + [300, 250], + [728, 90], + ] + ); store.recordSlotRequested(filledSlot); now = 20; store.recordSlotResponseReceived(filledSlot); @@ -414,6 +424,7 @@ describe('GptDiagnosticsOverlay', () => { size: [300, 250], isBackfill: true, }); + store.recordObservedSlotSize(1, 1, [320, 270]); now = 30; store.recordSlotOnload(filledSlot); now = 35; @@ -457,7 +468,9 @@ describe('GptDiagnosticsOverlay', () => { expect(root!.textContent).toContain('/example/site/filled-slot'); expect(root!.textContent).toContain('Empty'); expect(root!.textContent).toContain('Previous requests (1)'); - expect(root!.textContent).toContain('Rendered size 300×250'); + expect(root!.textContent).toContain('Requested slot sizes 300×250, 728×90'); + expect(root!.textContent).toContain('GPT-reported fill size 300×250'); + expect(root!.textContent).toContain('Observed outer slot box 320×270'); expect(root!.textContent).toContain('Backfill yes'); expect(root!.textContent).toContain('GPT slot onload observed'); expect(root!.textContent).toContain('GPT impressionViewable observed'); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts new file mode 100644 index 000000000..86ad532c7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { GptDiagnosticsRequestCycle } from '../../../src/core/types'; +import { GptDiagnosticsSlotSizeObserver } from '../../../src/integrations/gpt_diagnostics/slot_size_observer'; +import type { GptDiagnosticsStoreSnapshot } from '../../../src/integrations/gpt_diagnostics/store'; + +class ResizeObserverMock { + static instances: ResizeObserverMock[] = []; + readonly observe = vi.fn(); + readonly disconnect = vi.fn(); + + constructor(readonly callback: ResizeObserverCallback) { + ResizeObserverMock.instances.push(this); + } + + emit(element: Element): void { + this.callback([{ target: element } as ResizeObserverEntry], this as unknown as ResizeObserver); + } +} + +function cycle(requestNumber: number, isEmpty: boolean | undefined): GptDiagnosticsRequestCycle { + return { + requestNumber, + isEmpty, + renderAtMs: 1, + durations: {}, + incompleteSequence: false, + }; +} + +function snapshot(requests: GptDiagnosticsRequestCycle[]): GptDiagnosticsStoreSnapshot { + return { + gptObserved: true, + slots: [ + { + runtimeSlotNumber: 1, + slotElementId: 'ad-slot-example', + requests, + }, + ], + callbackIssues: [], + attributionIssues: [], + coverage: { + slotRequested: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotResponseReceived: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotRenderEnded: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotOnload: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + impressionViewable: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotVisibilityChanged: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + }, + metadata: { + droppedCallbacks: 0, + droppedAttributionIssues: 0, + evictedSlots: 0, + evictedRequestCycles: 0, + }, + }; +} + +describe('GptDiagnosticsSlotSizeObserver', () => { + afterEach(() => { + ResizeObserverMock.instances = []; + document.body.replaceChildren(); + }); + + it('keeps GPT 1×1 distinct from the observed outer box and updates it on resize', () => { + const element = document.createElement('div'); + document.body.append(element); + const getBoundingClientRect = vi.spyOn(element, 'getBoundingClientRect'); + getBoundingClientRect.mockReturnValue({ width: 728, height: 90 } as DOMRect); + const requests = [cycle(1, false)]; + requests[0].size = [1, 1]; + const store = { + snapshot: () => snapshot(requests), + recordObservedSlotSize: vi.fn(), + subscribe: () => () => undefined, + }; + const bindings = { + get: () => ({ binding: { status: 'bound' as const }, element, visible: true }), + subscribe: () => () => undefined, + }; + + const observer = new GptDiagnosticsSlotSizeObserver(store, bindings, { + window: { HTMLElement, ResizeObserver: ResizeObserverMock } as unknown as Window, + scheduleFrame: (callback) => callback(), + }); + + expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [728, 90]); + expect(requests[0].size).toEqual([1, 1]); + + getBoundingClientRect.mockReturnValue({ width: 970, height: 250 } as DOMRect); + ResizeObserverMock.instances.at(-1)!.emit(element); + expect(store.recordObservedSlotSize).toHaveBeenLastCalledWith(1, 1, [970, 250]); + observer.destroy(); + }); + + it.each(['unbound', 'ambiguous'] as const)('does not observe %s slots', (status) => { + const element = document.createElement('div'); + document.body.append(element); + const store = { + snapshot: () => snapshot([cycle(1, false)]), + recordObservedSlotSize: vi.fn(), + subscribe: () => () => undefined, + }; + const bindings = { + get: () => ({ binding: { status }, element, visible: false }), + subscribe: () => () => undefined, + }; + + const observer = new GptDiagnosticsSlotSizeObserver(store, bindings, { + window: { HTMLElement, ResizeObserver: ResizeObserverMock } as unknown as Window, + scheduleFrame: (callback) => callback(), + }); + + expect(store.recordObservedSlotSize).not.toHaveBeenCalled(); + expect(ResizeObserverMock.instances.at(-1)!.observe).not.toHaveBeenCalled(); + observer.destroy(); + }); + + it('cannot apply a delayed prior-cycle measurement to a later refresh', () => { + const element = document.createElement('div'); + document.body.append(element); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + width: 300, + height: 250, + } as DOMRect); + const requests = [cycle(1, false)]; + const listeners: Array<() => void> = []; + const store = { + snapshot: () => snapshot(requests), + recordObservedSlotSize: vi.fn(), + subscribe: (listener: () => void) => { + listeners.push(listener); + return () => undefined; + }, + }; + const bindings = { + get: () => ({ binding: { status: 'bound' as const }, element, visible: true }), + subscribe: () => () => undefined, + }; + const frames: Array<() => void> = []; + const observer = new GptDiagnosticsSlotSizeObserver(store, bindings, { + window: { HTMLElement, ResizeObserver: ResizeObserverMock } as unknown as Window, + scheduleFrame: (callback) => frames.push(callback), + }); + const firstObserver = ResizeObserverMock.instances[0]; + + requests.push(cycle(2, false)); + listeners[0](); + frames.shift()!(); + firstObserver.emit(element); + while (frames.length > 0) frames.shift()!(); + + expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [300, 250]); + expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 2, [300, 250]); + observer.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 4c6721f3a..52aef6a7f 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 @@ -7,6 +7,7 @@ import { MAX_CALLBACK_ISSUES, MAX_CREATIVE_ATTEMPTS, MAX_DIAGNOSTIC_SLOTS, + MAX_REQUESTED_SLOT_SIZES, MAX_REQUEST_CYCLES_PER_SLOT, MAX_TRUSTED_SERVER_ASSOCIATIONS, REQUEST_PATH_ATTRIBUTION_WINDOW_MS, @@ -519,6 +520,38 @@ describe('GptDiagnosticsStore', () => { expect(cycle.responseClass).toBe('reservation'); }); + it('retains an observed outer slot box separately from GPT reported size', () => { + const store = new GptDiagnosticsStore({ now: () => 10 }); + const slot = fakeSlot('ad-slot-outer-box'); + + store.recordSlotRequested(slot); + store.recordSlotResponseReceived(slot); + store.recordSlotRenderEnded(slot, { isEmpty: false, size: [1, 1] }); + store.recordObservedSlotSize(1, 1, [728, 90]); + + const cycle = store.snapshot().slots[0].requests[0]; + expect(cycle.size).toEqual([1, 1]); + expect(cycle.observedSlotSize).toEqual([728, 90]); + }); + + it('rejects a stale prior-cycle outer-box measurement after a refresh', () => { + const store = new GptDiagnosticsStore({ now: () => 10 }); + const slot = fakeSlot('ad-slot-stale-outer-box'); + + store.recordSlotRequested(slot); + store.recordSlotResponseReceived(slot); + store.recordSlotRenderEnded(slot, { isEmpty: false }); + store.recordSlotRequested(slot); + store.recordSlotResponseReceived(slot); + store.recordSlotRenderEnded(slot, { isEmpty: false }); + store.recordObservedSlotSize(1, 1, [300, 250]); + store.recordObservedSlotSize(1, 2, [970, 250]); + + const requests = store.snapshot().slots[0].requests; + expect(requests[0].observedSlotSize).toBeUndefined(); + expect(requests[1].observedSlotSize).toEqual([970, 250]); + }); + it('separates a fill without Ad Manager identifiers from a reservation', () => { const store = new GptDiagnosticsStore({ now: () => 10 }); const slot = fakeSlot('ad-slot-default'); @@ -645,6 +678,62 @@ describe('GptDiagnosticsStore', () => { expect(cycles[1].trustedServerOpportunity).toBeUndefined(); }); + it('retains all configured requested slot sizes on only the correlated next request', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('requested-sizes'); + const formats: Array<[number, number]> = [ + [300, 250], + [728, 90], + [320, 50], + ]; + + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + 'renderable_candidate', + undefined, + formats + ); + formats[0]![0] = 1; + formats.push([970, 250]); + store.recordSlotRequested(slot); + store.recordSlotRequested(slot); + + const cycles = store.snapshot().slots[0]!.requests; + expect(cycles[0]?.requestedSlotSizes).toEqual([ + [300, 250], + [728, 90], + [320, 50], + ]); + expect(cycles[1]?.requestedSlotSizes).toBeUndefined(); + }); + + it('bounds and validates configured requested slot sizes before retaining them', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('validated-requested-sizes'); + const formats: Array<[number, number]> = Array.from( + { length: MAX_REQUESTED_SLOT_SIZES + 2 }, + (_, index) => [index + 1, 250] + ); + formats[0] = [0, 250]; + formats[1] = [300, Number.NaN]; + + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + 'renderable_candidate', + undefined, + formats + ); + store.recordSlotRequested(slot); + + const requested = store.snapshot().slots[0]!.requests[0]!.requestedSlotSizes; + expect(requested).toHaveLength(MAX_REQUESTED_SLOT_SIZES - 2); + expect(requested).not.toContainEqual([0, 250]); + expect(requested).not.toContainEqual([300, Number.NaN]); + expect(requested).not.toContainEqual([MAX_REQUESTED_SLOT_SIZES + 1, 250]); + }); + it('consumes a combined request intent with independent source facts', () => { let now = 10; const deferred: Array<() => void> = []; diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 78c657bf6..8c7c00f18 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -89,7 +89,7 @@ Each request cycle can show: - GPT slot-onload, impression-viewable, and visibility observations. - Non-negative request-to-response, response-to-render, render-to-load, and render-to-viewable durations. -- Rendered size, backfill, and slot-content-change facts exposed by GPT. +- GPT-reported rendered size, a separately labelled observed outer slot box when safely bound, backfill, and slot-content-change facts. - Current DOM binding status and viewport intersection. Elapsed time alone never changes a pending GPT request to Incomplete. Incomplete @@ -276,6 +276,30 @@ because selector support is unavailable or throws, the export reports `dom_uniqueness_unverifiable`. Framework replacement of an element with a new unique element using the same exact ID is rebound automatically. +When Trusted Server associates a GPT slot with its next request, diagnostics retains +`requestedSlotSizes`: the configured `AuctionSlot.formats` list Trusted Server supplied +to GPT for that request. It is a bounded validated copy of the complete configured +list, not an inferred responsive size or a claim about the final selected size. It is +omitted for publisher and otherwise unknown request paths where Trusted Server did not +supply formats. + +For an explicitly filled render, diagnostics can also retain `observedSlotSize`: the +current outer CSS box of the uniquely bound, connected slot element. This is measured +after `slotRenderEnded`. When `ResizeObserver` is available, it remains current +while that same request cycle is latest for the GPT slot; otherwise it is the most +recently sampled box. It is displayed separately from `size`, which remains the exact +GPT-reported `slotRenderEnded.size` fill-size fact. The panel and badge label the three +separate facts as requested slot sizes, GPT-reported fill size, and observed outer slot +box. The observed box may differ from GPT's reported size (for example, a flexible APS +creative can report `1×1` while its allocated outer slot box is larger). It is a +publisher-page layout measurement, not a claim about universal internal creative-pixel +dimensions. Empty, unbound, missing, or ambiguous slots do not report an observed box; +delayed measurements from an older cycle are rejected after a refresh. + +Cross-origin and SafeFrame boundaries prevent diagnostics from inspecting iframe +content. It does not inspect iframe content or alter the APS sandbox, so it cannot +use this field to prove the inner creative's pixels. + Badges and the panel live in a closed Shadow DOM. Diagnostics do not add attributes, classes, or inline styles to publisher slot elements. @@ -326,6 +350,8 @@ The allowlisted export contains: - `version: 1` and an ISO `capturedAt` timestamp. - Current page origin and pathname, excluding query parameters and fragments. - Retained slots, binding facts, visibility, and request cycles. +- `requestedSlotSizes` when Trusted Server supplied configured formats for that exact + request, plus GPT-reported fill `size` and an optional observed outer `observedSlotSize`. - Request path, request intent ID, opportunity, creative-progress timestamps, and safe failure enums. - The per-auction diagnostics token (`trustedServerAuctionId`) and the @@ -362,6 +388,7 @@ inaccessible to JavaScript. - Retained request cycles per slot: 10. - Retained callback issues: 128. - Retained auction-slot-to-GPT-slot associations: 64. +- Requested slot sizes per correlated request: 16 valid positive sizes. - Retained creative attempts, including status tombstones: 128. - Retained attribution issues: 128.