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
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;
}

/** 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
);
}
} 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