Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,19 @@ test.describe("GPT runtime diagnostics", () => {
const pageErrors: string[] = [];
const diagnosticNetworkRequests: string[] = [];
await captureClosedShadowRoots(page);
await page.addInitScript(() => {
const originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (init: ShadowRootInit) {
const root = originalAttachShadow.call(this, init);
if (
(this as HTMLElement).id ===
"trusted-server-gpt-diagnostics"
) {
(window as any).__gptDiagnosticsTestRoot = root;
}
return root;
};
});
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("request", (request) => {
if (
Expand Down Expand Up @@ -378,6 +391,77 @@ test.describe("GPT runtime diagnostics", () => {
const hiddenPeriodSnapshot = await page.evaluate(() =>
(window as any).tsjs.gptDiagnostics.snapshot(),
);
await page.waitForFunction(() =>
Boolean(
(window as any).__gptDiagnosticsTestRoot?.querySelector(
".tsgd-badge",
),
),
);
const badgeIdentity = await page.evaluate(() => {
const badge = (
window as any
).__gptDiagnosticsTestRoot.querySelector(
".tsgd-badge",
) as HTMLButtonElement;
badge.focus();
return {
tagName: badge.tagName,
text: badge.textContent,
ariaLabel: badge.getAttribute("aria-label"),
runtimeSlotNumber: badge.dataset.runtimeSlot,
requestNumber: badge.dataset.requestNumber,
};
});
expect(badgeIdentity).toMatchObject({
tagName: "BUTTON",
text: expect.stringMatching(/Ad #\d+ · Request #\d+/),
ariaLabel: expect.stringMatching(/Ad #\d+, Request #\d+/),
});
await page.keyboard.press("Enter");
await page.waitForFunction(
({ runtimeSlotNumber, requestNumber }) => {
const root = (window as any)
.__gptDiagnosticsTestRoot as ShadowRoot;
const selected = root?.querySelector<HTMLElement>(
`[aria-current="true"][data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]`,
);
return selected !== null && root.activeElement === selected;
},
{
runtimeSlotNumber: badgeIdentity.runtimeSlotNumber,
requestNumber: badgeIdentity.requestNumber,
},
);
await page.evaluate(
({ runtimeSlotNumber, requestNumber }) => {
const root = (window as any)
.__gptDiagnosticsTestRoot as ShadowRoot;
const selected = root.querySelector<HTMLElement>(
`[aria-current="true"][data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]`,
);
const locate = Array.from(
selected
?.closest(".tsgd-slot")
?.querySelectorAll("button") ?? [],
).find(
(candidate) =>
candidate.textContent === "Locate on page",
);
locate?.click();
},
{
runtimeSlotNumber: badgeIdentity.runtimeSlotNumber,
requestNumber: badgeIdentity.requestNumber,
},
);
await page.waitForFunction(() =>
Boolean(
(window as any).__gptDiagnosticsTestRoot?.querySelector(
".tsgd-highlight",
),
),
);
const secondaryRequests = hiddenPeriodSnapshot.slots.find(
(slot: any) =>
slot.slotElementId === "gpt-diagnostics-slot-secondary",
Expand Down
30 changes: 25 additions & 5 deletions crates/trusted-server-js/lib/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,6 @@ export interface AuctionBidData {
hb_cache_path?: string;
/** Opaque server-auction correlation ID used only by GPT diagnostics. */
hb_auction_id?: string;
/** Winning creative width; the bridge sizes the inline render from this. */
w?: number;
/** Winning creative height; the bridge sizes the inline render from this. */
h?: number;
nurl?: string;
burl?: string;
/** Typed winning-bid renderer capability. */
Expand Down Expand Up @@ -123,10 +119,19 @@ export type GptDiagnosticsAuctionType = 'ssat' | 'trusted_server' | 'client_side
/** Clock origin for server auction timings, independent of aggregate auction classification. */
export type GptDiagnosticsServerAuctionTimingOrigin = 'navigation' | 'spa_auction';

/** Sanitized winning-bid facts already exposed in GPT targeting. */
/** Sanitized bid facts already exposed as bucketed ad-server targeting. */
export interface GptDiagnosticsAuctionWinner {
bidder: string;
priceBucket: string;
/** ISO currency supplied by the evidence source; absent means not supplied. */
currency?: string;
Comment thread
ChristianPavilonis marked this conversation as resolved.
}

/** A completed, exactly correlated client-side Prebid auction. */
export interface GptDiagnosticsPrebidAuctionEvidence {
auctionId: string;
targetingCandidate?: GptDiagnosticsAuctionWinner;
win?: GptDiagnosticsAuctionWinner;
}

/** Internal Trusted Server auction evidence attached to the next GPT request. */
Expand Down Expand Up @@ -253,7 +258,10 @@ export interface GptDiagnosticsRequestCycle {
requestIntentId?: number;
trustedServerAuctionId?: string;
auctionType?: GptDiagnosticsAuctionType;
/** Compatibility field: winner of the observed server auction, not necessarily the served creative. */
auctionWinner?: GptDiagnosticsAuctionWinner;
/** Completed Prebid facts, correlated to this exact slot, request, and auction attempt. */
prebidAuction?: GptDiagnosticsPrebidAuctionEvidence;
serverAuctionTimings?: AuctionDiagnosticsData;
/** Retained separately because `auctionType` can become `competing`. */
serverAuctionTimingOrigin?: GptDiagnosticsServerAuctionTimingOrigin;
Expand Down Expand Up @@ -370,6 +378,18 @@ export interface GptDiagnosticsRecorder {
): void;
/** Mark slots whose next observed GPT request follows the Prebid refresh path. */
recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void;
/** Record a completed Prebid attempt at its targeting boundary for one exact GPT slot. */
recordPrebidAuction(
slot: GptDiagnosticsSlotHandle,
auctionId: string,
targetingCandidate?: GptDiagnosticsAuctionWinner
): void;
/** Record Prebid's documented `bidWon` observation for that exact attempt. */
recordPrebidWin(
slot: GptDiagnosticsSlotHandle,
auctionId: string,
winner: GptDiagnosticsAuctionWinner
): void;
/** Record a creative markup request and return its opaque attempt ID. */
recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined;
/** Record that a creative attempt successfully posted markup. */
Expand Down
30 changes: 21 additions & 9 deletions crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ function diagnosticsAuctionFacts(
generation: number,
auctionDiagnostics: AuctionDiagnosticsData | undefined,
bid: AuctionBidData
): GptDiagnosticsAuctionFacts {
): GptDiagnosticsAuctionFacts | undefined {
const isSpaAuction = generation > 0;
const winner =
isNonEmptyString(bid.hb_bidder) && isNonEmptyString(bid.hb_pb)
? { bidder: bid.hb_bidder, priceBucket: bid.hb_pb }
: undefined;
if (!winner && auctionDiagnostics === undefined) return undefined;

return {
auctionType: isSpaAuction ? 'trusted_server' : 'ssat',
Expand Down Expand Up @@ -1110,14 +1111,25 @@ export function installTsAdInit(): void {
const requestedSlotSizes = ts.gptSlotHandoffs?.[slotDivId2]?.formats;
const opportunity = trustedServerOpportunity(bid);
const auctionFacts = diagnosticsAuctionFacts(generation, auctionDiagnostics, bid);
ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity(
gptSlot,
slot.id,
opportunity,
bid.hb_auction_id,
requestedSlotSizes,
auctionFacts
);
const recorder = ts.gptDiagnosticsRecorder;
if (auctionFacts) {
recorder?.recordTrustedServerOpportunity(
gptSlot,
slot.id,
opportunity,
bid.hb_auction_id,
requestedSlotSizes,
auctionFacts
);
} else {
recorder?.recordTrustedServerOpportunity(
gptSlot,
slot.id,
opportunity,
bid.hb_auction_id,
requestedSlotSizes
);
}
Comment thread
ChristianPavilonis marked this conversation as resolved.
Comment thread
ChristianPavilonis marked this conversation as resolved.
} catch {
// Diagnostics must not alter ad delivery.
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
GptDiagnosticsApi,
GptDiagnosticsAuctionFacts,
GptDiagnosticsAuctionWinner,
GptDiagnosticsCreativeFailure,
GptDiagnosticsExportV1,
GptDiagnosticsRecorder,
Expand All @@ -9,6 +10,7 @@ import type {
} from '../../core/types';

import type { GptDiagnosticsBindingManager } from './binding';
import { clonePrebidAuctionEvidence } from './evidence';
import type { GptDiagnosticsStoreSnapshot } from './store';

interface ApiStore {
Expand All @@ -23,6 +25,16 @@ interface ApiStore {
auctionFacts?: GptDiagnosticsAuctionFacts
): void;
recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void;
recordPrebidAuction(
slot: GptDiagnosticsSlotHandle,
auctionId: string,
targetingCandidate?: GptDiagnosticsAuctionWinner
): void;
recordPrebidWin(
slot: GptDiagnosticsSlotHandle,
auctionId: string,
winner: GptDiagnosticsAuctionWinner
): void;
recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined;
recordTrustedServerCreativeResponse(attemptId: number): void;
recordTrustedServerCreativeFailure(
Expand Down Expand Up @@ -70,6 +82,9 @@ function cloneExportSnapshot(snapshot: GptDiagnosticsExportV1): GptDiagnosticsEx
size: cycle.size ? [...cycle.size] : undefined,
observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined,
...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}),
Comment thread
ChristianPavilonis marked this conversation as resolved.
...(cycle.prebidAuction
? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) }
: {}),
...(cycle.serverAuctionTimings
? { serverAuctionTimings: { ...cycle.serverAuctionTimings } }
: {}),
Expand Down Expand Up @@ -177,6 +192,10 @@ export class GptDiagnosticsApiController {
);
}),
recordPrebidRefresh: (slots) => safelyRecord(() => this.store.recordPrebidRefresh(slots)),
recordPrebidAuction: (slot, auctionId, targetingCandidate) =>
safelyRecord(() => this.store.recordPrebidAuction(slot, auctionId, targetingCandidate)),
recordPrebidWin: (slot, auctionId, winner) =>
safelyRecord(() => this.store.recordPrebidWin(slot, auctionId, winner)),
recordTrustedServerCreativeRequest: (auctionSlotId) =>
safelyCreateAttempt(() => this.store.recordTrustedServerCreativeRequest(auctionSlotId)),
recordTrustedServerCreativeResponse: (attemptId) =>
Expand Down Expand Up @@ -209,6 +228,9 @@ export class GptDiagnosticsApiController {
size: cycle.size ? [...cycle.size] : undefined,
observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined,
...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}),
...(cycle.prebidAuction
? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) }
: {}),
...(cycle.serverAuctionTimings
? { serverAuctionTimings: { ...cycle.serverAuctionTimings } }
: {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { GptDiagnosticsRequestCycle } from '../../core/types';
import type { GptDiagnosticsBindingManager } from './binding';
import { unhandledCase } from './exhaustive';
import {
auctionTypeLabel,
auctionTypeBadgeLabel,
displayableGptFillSize,
formatSizes,
scheduleFrame,
Expand Down Expand Up @@ -38,6 +38,7 @@ interface BadgeOptions {
window?: BadgeWindow;
document?: Document;
scheduleFrame?: (callback: () => void) => void;
onActivate?: (runtimeSlotNumber: number, requestNumber: number) => void;
}

function intersectsViewport(rectangle: DOMRect, window: Window): boolean {
Expand Down Expand Up @@ -111,7 +112,7 @@ function badgeText(cycle: GptDiagnosticsRequestCycle): string {
else if (cycle.isEmpty === false) firstLine.push('Filled');
else if (cycle.renderAtMs !== undefined) firstLine.push('Rendered (fill unknown)');
else firstLine.push('Pending');
if (cycle.auctionType) firstLine.push(auctionTypeLabel(cycle.auctionType));
if (cycle.auctionType) firstLine.push(auctionTypeBadgeLabel(cycle.auctionType));
const delivery = deliveryLabel(cycle);
if (delivery) firstLine.push(delivery);
if (cycle.requestPath === 'competing' && cycle.auctionType !== 'competing') {
Expand Down Expand Up @@ -157,6 +158,7 @@ export class GptDiagnosticsBadgeManager {
private readonly window: BadgeWindow;
private readonly document: Document;
private readonly scheduleFrame: (callback: () => void) => void;
private readonly onActivate: (runtimeSlotNumber: number, requestNumber: number) => void;
private readonly unsubscribeStore: () => void;
private readonly unsubscribeBindings: () => void;
private readonly slotElementIds = new Set<string>();
Expand All @@ -174,6 +176,7 @@ export class GptDiagnosticsBadgeManager {
this.document = options.document ?? document;
this.scheduleFrame =
options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback));
this.onActivate = options.onActivate ?? (() => undefined);
this.refreshSlotElementIds();
this.unsubscribeStore = this.store.subscribe(() => {
this.refreshSlotElementIds();
Expand All @@ -197,7 +200,13 @@ export class GptDiagnosticsBadgeManager {
update(): void {
if (this.destroyed || !this.layer?.isConnected) return;
const observedElements: HTMLElement[] = [];
const badges: HTMLElement[] = [];
const existingBadges = new Map(
Array.from(this.layer.querySelectorAll<HTMLButtonElement>('.tsgd-badge')).map((badge) => [
badge.dataset.runtimeSlot,
badge,
])
);
const badges = new Set<HTMLButtonElement>();

for (const slot of this.slots) {
const cycle = latestCycle(slot);
Expand All @@ -210,10 +219,30 @@ export class GptDiagnosticsBadgeManager {
if (!intersectsViewport(rectangle, this.window)) continue;
observedElements.push(element);

const badge = this.document.createElement('div');
badge.className = 'tsgd-badge';
badge.dataset.runtimeSlot = String(slot.runtimeSlotNumber);
badge.textContent = `Ad #${slot.runtimeSlotNumber} · ${badgeText(cycle)}`;
const runtimeSlot = String(slot.runtimeSlotNumber);
let badge = existingBadges.get(runtimeSlot);
if (!badge) {
const createdBadge = this.document.createElement('button');
createdBadge.type = 'button';
createdBadge.className = 'tsgd-badge';
createdBadge.addEventListener('click', () => {
const runtimeSlotNumber = Number(createdBadge.dataset.runtimeSlot);
const requestNumber = Number(createdBadge.dataset.requestNumber);
if (!Number.isSafeInteger(runtimeSlotNumber) || !Number.isSafeInteger(requestNumber)) {
return;
}
this.onActivate(runtimeSlotNumber, requestNumber);
});
badge = createdBadge;
}
const text = badgeText(cycle);
badge.dataset.runtimeSlot = runtimeSlot;
badge.dataset.requestNumber = String(cycle.requestNumber);
badge.textContent = `Ad #${slot.runtimeSlotNumber} · Request #${cycle.requestNumber} · ${text}`;
badge.setAttribute(
'aria-label',
`Open diagnostics for Ad #${slot.runtimeSlotNumber}, Request #${cycle.requestNumber}: ${text}`
);
badge.style.maxWidth = `${BADGE_MAX_WIDTH_PX}px`;
badge.style.left = `${Math.max(
BADGE_EDGE_GUTTER_PX,
Expand All @@ -227,11 +256,17 @@ export class GptDiagnosticsBadgeManager {
BADGE_EDGE_GUTTER_PX,
rectangle.top + BADGE_EDGE_GUTTER_PX
)}px`;
badge.style.transform = '';
}
badges.push(badge);
badges.add(badge);
}

this.layer.replaceChildren(...badges);
for (const badge of Array.from(this.layer.querySelectorAll<HTMLButtonElement>('.tsgd-badge'))) {
if (!badges.has(badge)) badge.remove();
}
for (const badge of badges) {
if (!badge.isConnected) this.layer.append(badge);
}
this.resizeObserver?.disconnect();
for (const element of observedElements) this.resizeObserver?.observe(element);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { GptDiagnosticsPrebidAuctionEvidence } from '../../core/types';

/** Return a mutable copy of retained Prebid auction evidence. */
export function clonePrebidAuctionEvidence(
evidence: GptDiagnosticsPrebidAuctionEvidence
): GptDiagnosticsPrebidAuctionEvidence {
return {
...evidence,
...(evidence.targetingCandidate
? { targetingCandidate: { ...evidence.targetingCandidate } }
: {}),
...(evidence.win ? { win: { ...evidence.win } } : {}),
};
}
Loading
Loading