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
6 changes: 6 additions & 0 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,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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,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'}`);
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 & {
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<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;
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,
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ describe('GptDiagnosticsApiController', () => {
'incompleteSequence',
'isBackfill',
'isEmpty',
'observedSlotSize',
'renderAtMs',
'requestNumber',
'requestPath',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading