From 2650aa27294fa726456cc47f75a124aab5db7254 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 13 Aug 2026 17:03:58 -0500 Subject: [PATCH 1/3] Show observed slot size in GPT diagnostics --- .../trusted-server-js/lib/src/core/types.ts | 6 + .../src/integrations/gpt_diagnostics/api.ts | 2 + .../integrations/gpt_diagnostics/badges.ts | 5 +- .../src/integrations/gpt_diagnostics/index.ts | 5 + .../integrations/gpt_diagnostics/overlay.ts | 5 +- .../gpt_diagnostics/slot_size_observer.ts | 146 ++++++++++++++++ .../src/integrations/gpt_diagnostics/store.ts | 40 +++++ .../integrations/gpt_diagnostics/api.test.ts | 1 + .../gpt_diagnostics/badges.test.ts | 2 +- .../gpt_diagnostics/overlay.test.ts | 2 +- .../slot_size_observer.test.ts | 158 ++++++++++++++++++ .../gpt_diagnostics/store.test.ts | 32 ++++ docs/guide/integrations/gpt-diagnostics.md | 18 +- 13 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/slot_size_observer.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 0c68d43fe..464203046 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -207,7 +207,13 @@ export interface GptDiagnosticsRequestCycle { viewableAtMs?: number; durations: GptDiagnosticsDurations; isEmpty?: boolean; + /** Exact 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; 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..8871165dd 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 @@ -64,6 +64,7 @@ function cloneExportSnapshot(snapshot: GptDiagnosticsExportV1): GptDiagnosticsEx ...cycle, durations: { ...cycle.durations }, size: cycle.size ? [...cycle.size] : undefined, + observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, adManager: cycle.adManager ? { ...cycle.adManager, @@ -192,6 +193,7 @@ export class GptDiagnosticsApiController { ...cycle, durations: { ...cycle.durations }, 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..ab39f1f09 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 @@ -115,7 +115,10 @@ 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.size) firstLine.push(`GPT ${cycle.size[0]}×${cycle.size[1]}`); + if (cycle.observedSlotSize) { + firstLine.push(`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..ca5d8dd6d 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,10 @@ 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.size) facts.push(`GPT reported size ${cycle.size[0]}×${cycle.size[1]}`); + if (cycle.observedSlotSize) { + facts.push(`Observed 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..2917dc206 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 @@ -249,6 +249,7 @@ function copyCycle(cycle: MutableRequestCycle, nowMs: number): GptDiagnosticsReq ...cycle, durations: derivedDurations(cycle), size: cycle.size ? ([...cycle.size] as Size) : undefined, + observedSlotSize: cycle.observedSlotSize ? ([...cycle.observedSlotSize] as Size) : undefined, adManager: cycle.adManager ? { ...cycle.adManager, @@ -666,6 +667,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( 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..6ac993df7 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 @@ -348,6 +348,7 @@ describe('GptDiagnosticsApiController', () => { 'incompleteSequence', 'isBackfill', 'isEmpty', + 'observedSlotSize', 'renderAtMs', 'requestNumber', 'requestPath', 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..56de45842 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 @@ -260,7 +260,7 @@ describe('GptDiagnosticsBadgeManager', () => { renderToViewableMs: 1000, }, }) - ).toBe('Filled · 728×90\nResponse 276 ms · Render 42 ms\nViewable after 1 s'); + ).toBe('Filled · GPT 728×90\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..e3d80dae0 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 @@ -457,7 +457,7 @@ 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('GPT reported size 300×250'); 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..2d29d8b18 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 @@ -519,6 +519,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'); diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 78c657bf6..d19376b4b 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,22 @@ 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. +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 +`slotRenderEnded.size` fact GPT reported. 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. From f5ca22e277d2e12da1086e7ac5e47cae2cfebf9c Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 14 Aug 2026 09:06:46 -0500 Subject: [PATCH 2/3] Show requested slot sizes in GPT diagnostics --- .../trusted-server-js/lib/src/core/types.ts | 9 ++- .../lib/src/integrations/gpt/index.ts | 21 +++---- .../src/integrations/gpt_diagnostics/api.ts | 30 ++++++---- .../integrations/gpt_diagnostics/badges.ts | 11 +++- .../integrations/gpt_diagnostics/overlay.ts | 11 +++- .../src/integrations/gpt_diagnostics/store.ts | 38 ++++++++++++- .../lib/test/integrations/gpt/ad_init.test.ts | 42 ++++++++++++-- .../integrations/gpt_diagnostics/api.test.ts | 31 +++++++++- .../gpt_diagnostics/badges.test.ts | 9 ++- .../gpt_diagnostics/overlay.test.ts | 15 ++++- .../gpt_diagnostics/store.test.ts | 57 +++++++++++++++++++ docs/guide/integrations/gpt-diagnostics.md | 23 ++++++-- 12 files changed, 247 insertions(+), 50 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 464203046..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,9 @@ export interface GptDiagnosticsRequestCycle { viewableAtMs?: number; durations: GptDiagnosticsDurations; isEmpty?: boolean; - /** Exact size fact GPT reported in its `slotRenderEnded` callback. */ + /** 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 @@ -324,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 8871165dd..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,6 +64,7 @@ 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 @@ -150,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) => @@ -192,6 +197,7 @@ 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 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 ab39f1f09..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,9 +119,12 @@ 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(`GPT ${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(`Box ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); + firstLine.push(`Outer box ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); } const timingLine: string[] = []; 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 ca5d8dd6d..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,9 +277,16 @@ 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(`GPT reported 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 slot box ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); + 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) { 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 2917dc206..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,6 +273,7 @@ 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 @@ -310,7 +336,8 @@ export class GptDiagnosticsStore { slot: GptDiagnosticsSlotLike, auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, - trustedServerAuctionId?: string + trustedServerAuctionId?: string, + requestedSlotSizes?: ReadonlyArray ): void { if ( !isSlotObject(slot) || @@ -332,6 +359,7 @@ export class GptDiagnosticsStore { this.recordRequestIntentSource(slot, 'trusted_server_direct', { trustedServerOpportunity: opportunity, trustedServerAuctionId: normalizedAuctionId(trustedServerAuctionId), + requestedSlotSizes: normalizedRequestedSlotSizes(requestedSlotSizes), }); } @@ -579,6 +607,9 @@ export class GptDiagnosticsStore { ...(trustedServerEvidence?.trustedServerAuctionId !== undefined ? { trustedServerAuctionId: trustedServerEvidence.trustedServerAuctionId } : {}), + ...(trustedServerEvidence?.requestedSlotSizes !== undefined + ? { requestedSlotSizes: trustedServerEvidence.requestedSlotSizes } + : {}), ...(trustedServerEvidence ? { opportunityToRequestMs: validDuration(trustedServerEvidence.observedAtMs, timestampMs), @@ -901,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 6ac993df7..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 @@ -353,6 +368,7 @@ describe('GptDiagnosticsApiController', () => { 'requestNumber', 'requestPath', 'requestedAtMs', + 'requestedSlotSizes', 'responseAtMs', 'responseClass', 'size', @@ -429,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], }, @@ -464,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({ @@ -485,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 56de45842..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 · GPT 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 e3d80dae0..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('GPT reported 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/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index 2d29d8b18..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, @@ -677,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 d19376b4b..8c7c00f18 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -276,17 +276,25 @@ 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 -`slotRenderEnded.size` fact GPT reported. 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. +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 @@ -342,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 @@ -378,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. From 49e7094a7bfb25173fdf9f6bf20b4ebb94105f2b Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 20 Aug 2026 09:02:41 -0500 Subject: [PATCH 3/3] Address GPT diagnostics review feedback --- .../lib/src/integrations/gpt/index.ts | 3 +- .../integrations/gpt_diagnostics/badges.ts | 25 ++-- .../integrations/gpt_diagnostics/binding.ts | 12 +- .../integrations/gpt_diagnostics/overlay.ts | 18 +-- .../gpt_diagnostics/presentation_helpers.ts | 18 +++ .../gpt_diagnostics/slot_size_observer.ts | 19 ++- .../src/integrations/gpt_diagnostics/store.ts | 4 +- .../lib/test/integrations/gpt/ad_init.test.ts | 19 ++- .../gpt_diagnostics/badges.test.ts | 14 ++ .../gpt_diagnostics/overlay.test.ts | 4 +- .../slot_size_observer.test.ts | 121 ++++++++++++++++-- .../gpt_diagnostics/types.test.ts | 5 + docs/guide/integrations/gpt-diagnostics.md | 12 +- 13 files changed, 206 insertions(+), 68 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts 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 88bf659fe..b339c99ed 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1049,6 +1049,7 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); + const requestedSlotSizes = ts.gptSlotHandoffs?.[slotDivId2]?.formats; // Diagnostics are observational only. A missing or malformed debug // implementation must never interrupt slot mapping or delivery. try { @@ -1058,7 +1059,7 @@ export function installTsAdInit(): void { slot.id, opportunity, bid.hb_auction_id, - slot.formats + requestedSlotSizes ); } catch { // Diagnostics must not alter ad delivery. 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 408fcb1f9..4dc1250a2 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 @@ -2,6 +2,7 @@ import type { GptDiagnosticsRequestCycle } from '../../core/types'; import type { GptDiagnosticsBindingManager } from './binding'; import { unhandledCase } from './exhaustive'; +import { formatSizes, scheduleFrame } from './presentation_helpers'; import type { GptDiagnosticsBindingInput, GptDiagnosticsStoreSlotSnapshot, @@ -26,6 +27,7 @@ type BadgeWindow = Window & { const BADGE_MAX_WIDTH_PX = 260; const BADGE_EDGE_GUTTER_PX = 4; +const MAX_BADGE_REQUESTED_SLOT_SIZES = 3; interface BadgeOptions { window?: BadgeWindow; @@ -33,14 +35,6 @@ interface BadgeOptions { scheduleFrame?: (callback: () => void) => void; } -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); - } -} - function intersectsViewport(rectangle: DOMRect, window: Window): boolean { return ( rectangle.width > 0 && @@ -106,10 +100,6 @@ 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'); @@ -120,7 +110,13 @@ function badgeText(cycle: GptDiagnosticsRequestCycle): string { if (delivery) firstLine.push(delivery); if (cycle.requestPath === 'competing') firstLine.push('Competing paths'); if (cycle.requestedSlotSizes) { - firstLine.push(`Requested ${formatSizes(cycle.requestedSlotSizes)}`); + const displayedSizes = cycle.requestedSlotSizes.slice(0, MAX_BADGE_REQUESTED_SLOT_SIZES); + const remainingSizeCount = cycle.requestedSlotSizes.length - displayedSizes.length; + firstLine.push( + `Requested ${formatSizes(displayedSizes)}${ + remainingSizeCount > 0 ? ` +${remainingSizeCount}` : '' + }` + ); } if (cycle.size) firstLine.push(`GPT fill ${cycle.size[0]}×${cycle.size[1]}`); if (cycle.observedSlotSize) { @@ -169,7 +165,8 @@ export class GptDiagnosticsBadgeManager { this.bindings = bindings; this.window = options.window ?? (window as unknown as BadgeWindow); this.document = options.document ?? document; - this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.scheduleFrame = + options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); this.refreshSlotElementIds(); this.unsubscribeStore = this.store.subscribe(() => { this.refreshSlotElementIds(); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts index 489e7beb1..c6c46a727 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts @@ -1,5 +1,6 @@ import type { GptDiagnosticsBinding, GptDiagnosticsSlotExport } from '../../core/types'; +import { scheduleFrame } from './presentation_helpers'; import type { GptDiagnosticsBindingInput } from './store'; interface BindingStore { @@ -27,14 +28,6 @@ export interface GptDiagnosticsBindingView { type BindingListener = () => void; -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); - } -} - function isVisibleInViewport(element: HTMLElement, window: BindingWindow): boolean { const rectangle = element.getBoundingClientRect(); if (rectangle.width <= 0 || rectangle.height <= 0) return false; @@ -97,7 +90,8 @@ export class GptDiagnosticsBindingManager { this.store = store; this.document = options.document ?? document; this.window = options.window ?? (window as unknown as BindingWindow); - this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.scheduleFrame = + options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); this.unsubscribeStore = this.store.subscribe(() => this.scheduleRefresh()); this.window.addEventListener('scroll', this.scheduleRefresh, { passive: true }); 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 1eeb4976e..63c0b6fb3 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 @@ -3,6 +3,7 @@ import type { GptDiagnosticsRequestCycle } from '../../core/types'; import type { GptDiagnosticsBindingManager } from './binding'; import { unhandledCase } from './exhaustive'; +import { formatSizes, scheduleFrame } from './presentation_helpers'; import type { GptDiagnosticsStoreSlotSnapshot, GptDiagnosticsStoreSnapshot } from './store'; export const GPT_DIAGNOSTICS_HOST_ID = 'trusted-server-gpt-diagnostics'; @@ -99,14 +100,6 @@ const PANEL_STYLES = ` } `; -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); - } -} - function latestCycle( slot: GptDiagnosticsStoreSlotSnapshot ): GptDiagnosticsRequestCycle | undefined { @@ -278,11 +271,7 @@ function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { if (cycle.viewableAtMs !== undefined) facts.push('GPT impressionViewable observed'); if (cycle.incompleteSequence) facts.push('Incomplete sequence'); if (cycle.requestedSlotSizes) { - facts.push( - `Requested slot sizes ${cycle.requestedSlotSizes - .map((size) => `${size[0]}×${size[1]}`) - .join(', ')}` - ); + facts.push(`Requested slot sizes ${formatSizes(cycle.requestedSlotSizes)}`); } if (cycle.size) facts.push(`GPT-reported fill size ${cycle.size[0]}×${cycle.size[1]}`); if (cycle.observedSlotSize) { @@ -368,7 +357,8 @@ export class GptDiagnosticsOverlay { this.bindings = bindings; this.window = options.window ?? (window as unknown as OverlayWindow); this.document = options.document ?? document; - this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.scheduleFrame = + options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); this.onExport = options.onExport ?? (() => undefined); this.onShadowRoot = options.onShadowRoot; this.onBadgeLayerChange = options.onBadgeLayerChange; 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 new file mode 100644 index 000000000..b601833b8 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts @@ -0,0 +1,18 @@ +import type { Size } from '../../core/types'; + +/** Formats CSS sizes consistently across diagnostics presentation surfaces. */ +export function formatSizes(sizes: ReadonlyArray): string { + return sizes.map((size) => `${size[0]}×${size[1]}`).join(', '); +} + +/** Schedules presentation work in the target window's next animation frame. */ +export function scheduleFrame( + window: Pick, + callback: () => void +): void { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => callback()); + } else { + queueMicrotask(callback); + } +} 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 index ab49a451a..c3328f992 100644 --- 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 @@ -1,6 +1,7 @@ import type { Size } from '../../core/types'; import type { GptDiagnosticsBindingManager } from './binding'; +import { scheduleFrame } from './presentation_helpers'; import type { GptDiagnosticsStoreSnapshot } from './store'; interface SlotSizeStore { @@ -15,6 +16,7 @@ interface SlotSizeBindings { } type SlotSizeWindow = Window & { + HTMLElement: typeof HTMLElement; ResizeObserver?: typeof ResizeObserver; }; @@ -28,14 +30,6 @@ interface ObservedCycle { requestNumber: number; } -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); - } -} - function latestFilledCycle( slot: GptDiagnosticsStoreSnapshot['slots'][number] ): ObservedCycle | undefined { @@ -70,7 +64,8 @@ export class GptDiagnosticsSlotSizeObserver { this.store = store; this.bindings = bindings; this.window = options.window ?? (window as unknown as SlotSizeWindow); - this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.scheduleFrame = + options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); this.unsubscribeStore = this.store.subscribe(this.scheduleRefresh); this.unsubscribeBindings = this.bindings.subscribe(this.scheduleRefresh); this.refresh(); @@ -124,6 +119,8 @@ export class GptDiagnosticsSlotSizeObserver { } private measure(element: HTMLElement, cycle: ObservedCycle): void { + if (this.destroyed) return; + const binding = this.bindings.get(cycle.runtimeSlotNumber); if (binding.binding.status !== 'bound' || binding.element !== element || !element.isConnected) { return; @@ -139,8 +136,8 @@ export class GptDiagnosticsSlotSizeObserver { return; } this.store.recordObservedSlotSize(cycle.runtimeSlotNumber, cycle.requestNumber, [ - rectangle.width, - rectangle.height, + Math.round(rectangle.width), + Math.round(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 03d887aa0..ca3348ae9 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 @@ -716,7 +716,9 @@ export class GptDiagnosticsStore { } const record = this.slots.get(runtimeSlotNumber); - const cycle = record?.requests.find((candidate) => candidate.requestNumber === requestNumber); + if (!record) return; + + const cycle = record.requests.find((candidate) => candidate.requestNumber === requestNumber); if ( !cycle || record.requests[record.requests.length - 1] !== cycle || 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 ddeecb315..2a56cd606 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 @@ -296,7 +296,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', expectedOpportunity, undefined, - [[300, 250]] + undefined ); } ); @@ -322,11 +322,11 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'unrenderable_candidate', 'auction-123', - [[300, 250]] + undefined ); }); - it('captures every configured Trusted Server format when associating a GPT slot', async () => { + it('retains handoff formats when reusing a Trusted Server-defined GPT slot', async () => { const recordTrustedServerOpportunity = vi.fn(); const formats: Array<[number, number]> = [ [300, 250], @@ -338,6 +338,17 @@ describe('installTsAdInit', () => { recordTrustedServerOpportunity, formats ); + (window as TestWindow).tsjs!.gptSlotHandoffs = { + 'div-atf-sidebar': { + gamUnitPath: '/123/atf', + formats, + divIdPrefix: 'div-atf-sidebar', + slotElementId: 'div-atf-sidebar', + publisherClaimed: true, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }, + }; const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); installTsAdInit(); @@ -366,7 +377,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'no_candidate', undefined, - [[300, 250]] + undefined ); }); 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 7ac981b6d..80c240a3e 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 @@ -268,6 +268,20 @@ describe('GptDiagnosticsBadgeManager', () => { ).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, + isEmpty: false, + requestedSlotSizes: [ + [300, 250], + [320, 50], + [728, 90], + [970, 250], + ], + incompleteSequence: false, + durations: {}, + }) + ).toBe('Filled · Requested 300×250, 320×50, 728×90 +1'); 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 9c9765ed1..41cad667a 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 @@ -413,6 +413,8 @@ describe('GptDiagnosticsOverlay', () => { [ [300, 250], [728, 90], + [320, 50], + [970, 250], ] ); store.recordSlotRequested(filledSlot); @@ -468,7 +470,7 @@ 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('Requested slot sizes 300×250, 728×90'); + 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('Observed outer slot box 320×270'); expect(root!.textContent).toContain('Backfill yes'); 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 index 86ad532c7..3709221ab 100644 --- 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 @@ -4,6 +4,11 @@ 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'; +type SlotSizeTestWindow = Window & { + HTMLElement: typeof HTMLElement; + ResizeObserver?: typeof ResizeObserver; +}; + class ResizeObserverMock { static instances: ResizeObserverMock[] = []; readonly observe = vi.fn(); @@ -67,7 +72,7 @@ describe('GptDiagnosticsSlotSizeObserver', () => { const element = document.createElement('div'); document.body.append(element); const getBoundingClientRect = vi.spyOn(element, 'getBoundingClientRect'); - getBoundingClientRect.mockReturnValue({ width: 728, height: 90 } as DOMRect); + getBoundingClientRect.mockReturnValue({ width: 728.4, height: 90.5 } as DOMRect); const requests = [cycle(1, false)]; requests[0].size = [1, 1]; const store = { @@ -81,19 +86,111 @@ describe('GptDiagnosticsSlotSizeObserver', () => { }; const observer = new GptDiagnosticsSlotSizeObserver(store, bindings, { - window: { HTMLElement, ResizeObserver: ResizeObserverMock } as unknown as Window, + window: { HTMLElement, ResizeObserver: ResizeObserverMock } as unknown as SlotSizeTestWindow, scheduleFrame: (callback) => callback(), }); - expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [728, 90]); + expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [728, 91]); expect(requests[0].size).toEqual([1, 1]); - getBoundingClientRect.mockReturnValue({ width: 970, height: 250 } as DOMRect); - ResizeObserverMock.instances.at(-1)!.emit(element); + getBoundingClientRect.mockReturnValue({ width: 969.6, height: 250.2 } as DOMRect); + ResizeObserverMock.instances[ResizeObserverMock.instances.length - 1]!.emit(element); expect(store.recordObservedSlotSize).toHaveBeenLastCalledWith(1, 1, [970, 250]); observer.destroy(); }); + it('uses the provided window to schedule the initial measurement', () => { + const element = document.createElement('div'); + document.body.append(element); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + width: 300, + height: 250, + } as DOMRect); + const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + const store = { + snapshot: () => snapshot([cycle(1, false)]), + 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, + requestAnimationFrame, + } as unknown as SlotSizeTestWindow, + }); + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [300, 250]); + observer.destroy(); + }); + + it('records one initial measurement when ResizeObserver is unavailable', () => { + const element = document.createElement('div'); + document.body.append(element); + const getBoundingClientRect = vi.spyOn(element, 'getBoundingClientRect'); + getBoundingClientRect.mockReturnValue({ width: 300, height: 250 } as DOMRect); + const store = { + snapshot: () => snapshot([cycle(1, false)]), + 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 } as unknown as SlotSizeTestWindow, + scheduleFrame: (callback) => callback(), + }); + + expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [300, 250]); + expect(ResizeObserverMock.instances).toHaveLength(0); + getBoundingClientRect.mockReturnValue({ width: 728, height: 90 } as DOMRect); + expect(store.recordObservedSlotSize).toHaveBeenCalledTimes(1); + observer.destroy(); + }); + + it('does not measure after destruction when a frame is pending', () => { + const element = document.createElement('div'); + document.body.append(element); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + width: 300, + height: 250, + } as DOMRect); + const frames: Array<() => void> = []; + const store = { + snapshot: () => snapshot([cycle(1, false)]), + 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 SlotSizeTestWindow, + scheduleFrame: (callback) => frames.push(callback), + }); + observer.destroy(); + frames.shift()!(); + + expect(store.recordObservedSlotSize).not.toHaveBeenCalled(); + }); + it.each(['unbound', 'ambiguous'] as const)('does not observe %s slots', (status) => { const element = document.createElement('div'); document.body.append(element); @@ -108,12 +205,17 @@ describe('GptDiagnosticsSlotSizeObserver', () => { }; const observer = new GptDiagnosticsSlotSizeObserver(store, bindings, { - window: { HTMLElement, ResizeObserver: ResizeObserverMock } as unknown as Window, + window: { + HTMLElement, + ResizeObserver: ResizeObserverMock, + } as unknown as SlotSizeTestWindow, scheduleFrame: (callback) => callback(), }); expect(store.recordObservedSlotSize).not.toHaveBeenCalled(); - expect(ResizeObserverMock.instances.at(-1)!.observe).not.toHaveBeenCalled(); + expect( + ResizeObserverMock.instances[ResizeObserverMock.instances.length - 1]!.observe + ).not.toHaveBeenCalled(); observer.destroy(); }); @@ -140,7 +242,10 @@ describe('GptDiagnosticsSlotSizeObserver', () => { }; const frames: Array<() => void> = []; const observer = new GptDiagnosticsSlotSizeObserver(store, bindings, { - window: { HTMLElement, ResizeObserver: ResizeObserverMock } as unknown as Window, + window: { + HTMLElement, + ResizeObserver: ResizeObserverMock, + } as unknown as SlotSizeTestWindow, scheduleFrame: (callback) => frames.push(callback), }); const firstObserver = ResizeObserverMock.instances[0]; 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 6b3103060..f4f4c7486 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 @@ -11,6 +11,7 @@ import type { GptDiagnosticsRequestPath, GptDiagnosticsResponseClass, GptDiagnosticsSlotExport, + Size, TsjsApi, } from '../../../src/core/types'; @@ -138,6 +139,8 @@ describe('GPT diagnostics public types', () => { requestPath: 'publisher_refresh', requestIntentId: 7, trustedServerAuctionId: 'ts-auc-example', + requestedSlotSizes: [[300, 250]], + observedSlotSize: [728, 90], opportunityToRequestMs: 24, replacedRequestNumber: 1, previousRenderToRequestMs: 6048, @@ -174,6 +177,8 @@ describe('GPT diagnostics public types', () => { expectTypeOf(evidenceCycle.requestPath).toEqualTypeOf(); expectTypeOf(evidenceCycle.requestIntentId).toEqualTypeOf(); expectTypeOf(evidenceCycle.trustedServerAuctionId).toEqualTypeOf(); + expectTypeOf(evidenceCycle.requestedSlotSizes).toEqualTypeOf | undefined>(); + expectTypeOf(evidenceCycle.observedSlotSize).toEqualTypeOf(); expectTypeOf(evidenceSnapshot.attributionIssues).toEqualTypeOf< GptDiagnosticsAttributionIssue[] | undefined >(); diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 8c7c00f18..7e677938a 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -293,12 +293,14 @@ separate facts as requested slot sizes, GPT-reported fill size, and observed out 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. +dimensions. A collapsed or hidden bound element can report `0×0`, which records the +page layout state rather than an invalid measurement. 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. +content or altering the APS sandbox, so this field cannot 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. @@ -388,7 +390,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. +- Requested slot sizes per correlated request: the first 16 configured entries, with invalid entries dropped. - Retained creative attempts, including status tombstones: 128. - Retained attribution issues: 128.