Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions crates/trusted-server-js/lib/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Size>;
/** 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;
Expand Down Expand Up @@ -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<Size>
): void;
/** Mark slots whose next observed GPT request follows the Prebid refresh path. */
recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void;
Expand Down
21 changes: 7 additions & 14 deletions crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchrequestedSlotSizes records sizes GPT never received on publisher-defined slots.

slot.formats is passed unconditionally, but Trusted Server only supplies formats to GPT on the defineSlot path above (line 1023). When existingSlot is found — the publisher defined the div first — TS reuses that slot and never applies sizes to it; there is no defineSizeMapping/setSizes anywhere in the integration. On those slots the panel shows Requested slot sizes 300×250, 728×90 for a list GPT never saw, and the docs assert it is "the configured AuctionSlot.formats list Trusted Server supplied to GPT for that request". That is the exact misdirection #1031 exists to remove.

Note tsOwned ? slot.formats : undefined is not the fix either: refresh cycles re-find TS's own slot through getSlots(), so tsOwned is false there and the accurate list would be dropped. Keying on the handoff record survives refreshes:

const tsDefinedFormats = ts.gptSlotHandoffs?.[actualDivId]?.formats;
ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity(
  gptSlot,
  slot.id,
  opportunity,
  bid.hb_auction_id,
  tsDefinedFormats
);

Alternatively keep recording it but relabel the field, badge, panel, and docs as Trusted Server's configured sizes rather than what GPT was given.

);
} catch {
// Diagnostics must not alter ad delivery.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ interface ApiStore {
slot: GptDiagnosticsSlotHandle,
auctionSlotId: string,
opportunity: GptDiagnosticsTrustedServerOpportunity,
trustedServerAuctionId?: string
trustedServerAuctionId?: string,
requestedSlotSizes?: ReadonlyArray<readonly [number, number]>
): void;
recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void;
recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ function deliveryLabel(cycle: GptDiagnosticsRequestCycle): string | undefined {
}
}

function formatSizes(sizes: ReadonlyArray<readonly [number, number]>): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpickformatSizes here duplicates the inline .map(...).join(', ') in overlay.ts (cycleFacts). One shared helper keeps the two surfaces from drifting in separator or × glyph.

return sizes.map((size) => `${size[0]}×${size[1]}`).join(', ');
}

function badgeText(cycle: GptDiagnosticsRequestCycle): string {
const firstLine: string[] = [];
if (cycle.isEmpty === true) firstLine.push('Empty');
Expand All @@ -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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactorMAX_REQUESTED_SLOT_SIZES is 16 and the badge renders them all on the first line inside a 260px-max-width badge, so a many-format responsive slot produces a badge that wraps into a wall of text occluding the page. Suggest capping the badge display and leaving the full list to the overlay panel and export:

const shown = cycle.requestedSlotSizes.slice(0, 3);
const extra = cycle.requestedSlotSizes.length - shown.length;
firstLine.push(`Requested ${formatSizes(shown)}${extra > 0 ? ` +${extra}` : ''}`);

firstLine.push(`Requested ${formatSizes(cycle.requestedSlotSizes)}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — the badge line can carry up to 16 sizes.

MAX_REQUESTED_SLOT_SIZES is 16, and formatSizes joins all of them into a badge capped at BADGE_MAX_WIDTH_PX (260px), so a multi-format slot wraps a long Requested … string over the creative. Consider truncating the badge to the first two or three plus +N more, and keeping the complete list in the panel, which already has room for it.

}
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -83,6 +86,7 @@ export function installGptDiagnosticsRuntime(
apiController?.destroy();
overlay?.destroy();
badges?.destroy();
slotSizeObserver?.destroy();
bindings?.destroy();
delete target.__tsjs_gpt_diagnostics_runtime;
},
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 & {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchSlotSizeWindow omits HTMLElement, so element instanceof this.window.HTMLElement (line 105) does not typecheck under strict mode, and the Element → HTMLElement narrowing at lines 106–107 fails with it. tsc --noEmit is not a CI gate, but these errors are new relative to main, and the sibling binding.ts:10-14 already solves this exact problem by declaring the property on its window type.

Fix:

type SlotSizeWindow = Window & {
  HTMLElement: typeof HTMLElement;
  ResizeObserver?: typeof ResizeObserver;
};

ResizeObserver?: typeof ResizeObserver;
};

interface SlotSizeObserverOptions {
window?: SlotSizeWindow;
scheduleFrame?: (callback: () => void) => void;
}

interface ObservedCycle {
runtimeSlotNumber: number;
requestNumber: number;
}

function defaultScheduleFrame(callback: () => void): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpickdefaultScheduleFrame uses the global requestAnimationFrame rather than the injected window's, so an option-provided window is ignored for frame scheduling. Same wart already exists in binding.ts and badges.ts, so this is consistency, not a regression.

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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — Every store/bindings notification tears down and re-creates the ResizeObserver and re-observes every bound element — and per spec each observe() fires an initial callback, so every notification schedules a redundant measure per slot on top of the direct scheduleMeasure below. The no-change guard in recordObservedSlotSize keeps this convergent, so it is churn rather than a bug, but a single long-lived observer with set-diffing would do less layout work per notification. Fine to defer.

const observations = new Map<HTMLElement, ObservedCycle>();
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchinstanceof this.window.HTMLElement throws for a window that satisfies the declared type.

SlotSizeWindow (line 17) never declares HTMLElement, and lib.dom's Window does not carry it — only Window & typeof globalThis does. Injecting { ResizeObserver } throws TypeError: Right-hand side of 'instanceof' is not an object, uncaught, from inside the ResizeObserver callback, which contradicts the module's "diagnostics never interrupt the page" invariant. tsc already reports three errors here (105–107). binding.ts gets this right by declaring HTMLElement: typeof HTMLElement on its window type.

Not reachable through installGptDiagnosticsRuntime, which passes a real window, so downgrade this if you prefer — but the fix is one line:

type SlotSizeWindow = Window & {
  HTMLElement: typeof HTMLElement;
  ResizeObserver?: typeof ResizeObserver;
};

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactormeasure() has no destroyed guard, so pending scheduleFrame measure callbacks still run after destroy(). Today this is harmless only because installGptDiagnosticsRuntime destroys bindings in the same synchronous sequence, making bindings.get() return the unbound default — that is coupling to destroy order in index.ts. refresh() and scheduleRefresh() both carry the guard; measure() is the odd one out.

Fix: add if (this.destroyed) return; as the first statement.

const binding = this.bindings.get(cycle.runtimeSlotNumber);
if (binding.binding.status !== 'bound' || binding.element !== element || !element.isConnected) {
return;
}

const rectangle = element.getBoundingClientRect();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — A filled render whose container is display:none or collapsed measures 0×0 here, and both validation layers deliberately allow 0 (< 0 checks only), so the badge shows Outer box 0×0. If "0×0 = collapsed slot evidence" is intended, a sentence in the docs would keep operators from reading it as a measurement bug; if not, drop zero boxes.

if (
!Number.isFinite(rectangle.width) ||
!Number.isFinite(rectangle.height) ||
rectangle.width < 0 ||
rectangle.height < 0
) {
return;
}
this.store.recordObservedSlotSize(cycle.runtimeSlotNumber, cycle.requestNumber, [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchgetBoundingClientRect() returns fractional CSS pixels (page zoom, transforms, responsive layouts), and the value flows unrounded into the store, the badge (Outer box 383.99667358398438×90), the overlay, and the JSON export. The tests only exercise integer mocks, so this never surfaces. Rounding here also stabilizes the store's change-dedupe guard against sub-pixel jitter, which otherwise re-notifies and re-runs the whole refresh pipeline on every fraction-of-a-pixel resize.

Fix:

this.store.recordObservedSlotSize(cycle.runtimeSlotNumber, cycle.requestNumber, [
  Math.round(rectangle.width),
  Math.round(rectangle.height),
]);

rectangle.width,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — round the measured box.

getBoundingClientRect() returns sub-pixel values and nothing rounds them, so the store retains e.g. [728.328125, 90.5] verbatim and the badge reads Outer box 728.328125×90.5 next to a clean GPT 1×1. Every sub-pixel delta also clears the equality guard in recordObservedSlotSize, so it notifies, re-renders badges and panel, and schedules another observer refresh. Math.round on both dimensions fixes the display and the churn:

this.store.recordObservedSlotSize(cycle.runtimeSlotNumber, cycle.requestNumber, [
  Math.round(rectangle.width),
  Math.round(rectangle.height),
]);

rectangle.height,
]);
}
}
Loading
Loading