Skip to content
Draft
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 @@ -614,7 +614,7 @@ test.describe("tester-only auction trace contract", () => {
});
});

test("actual generated Prebid selects the traced TS bid before an unattributed GAM render", async ({
test("actual generated Prebid selects the traced TS bid before GAM delivers other demand", async ({
page,
}) => {
await openTesterPage(page);
Expand Down Expand Up @@ -645,34 +645,40 @@ test.describe("tester-only auction trace contract", () => {
win.adTraceFixture.setSuppressCreative(true);
win.googletag.pubads().refresh([win.adTraceFixture.latestSlot()]);
});
// The suppressed creative never requests markup, so the bounded
// attribution window resolves the render as other Ad Manager demand
// instead of leaving it a permanent Trusted Server candidate.
await expect
.poll(async () => {
const result = await exported(page);
const slot = result.slots.find(
(item) => item.slotId === "ad-trace-slot",
) as
| {
stages?: Record<
string,
{ outcome?: string; confidence?: string }
>;
}
| undefined;
return {
prebid: slot?.stages?.prebid,
gam: slot?.stages?.gam,
};
})
.poll(
async () => {
const result = await exported(page);
const slot = result.slots.find(
(item) => item.slotId === "ad-trace-slot",
) as
| {
stages?: Record<
string,
{ outcome?: string; confidence?: string }
>;
}
| undefined;
return {
prebid: slot?.stages?.prebid,
gam: slot?.stages?.gam,
};
},
{ timeout: 15_000 },
)
.toMatchObject({
prebid: { outcome: "won", confidence: "definitive" },
gam: {
outcome: "trusted_server_candidate",
confidence: "probable",
outcome: "other_gam_demand",
confidence: "strong",
},
});
});

test("client selection, backfill, direct-or-unattributed, and retained generations stay independent", async ({
test("client selection, backfill, named reservation, and retained generations stay independent", async ({
page,
}) => {
await openTesterPage(page);
Expand Down Expand Up @@ -744,6 +750,7 @@ test.describe("tester-only auction trace contract", () => {
const win = window as Window & {
adTraceFixture: {
latestSlot(): { clearTargeting(): void };
setNextRender(flags: Record<string, boolean>): void;
requestCurrent(): void;
};
tsjs: {
Expand All @@ -753,19 +760,35 @@ test.describe("tester-only auction trace contract", () => {
const slot = win.adTraceFixture.latestSlot();
slot.clearTargeting();
win.tsjs.captureAdTraceRequest(slot, "fixture_direct");
win.adTraceFixture.setNextRender({ isReservation: true });
win.adTraceFixture.requestCurrent();
});
// Ad Manager reported its own reservation line item for a slot carrying
// no Trusted Server targeting, so the render is named rather than
// reported as unattributed.
await expect
.poll(async () => {
const result = await exported(page);
const slot = result.slots.find(
(item) => item.slotId === "ad-trace-slot",
) as
| { stages?: Record<string, { outcome?: string }> }
| {
stages?: Record<string, { outcome?: string }>;
generations?: Array<{
diagnostics?: {
gamIdentity?: { lineItemId?: number };
};
}>;
}
| undefined;
return slot?.stages?.gam?.outcome;
return {
gam: slot?.stages?.gam?.outcome,
lineItemId:
slot?.generations?.[slot.generations.length - 1]
?.diagnostics?.gamIdentity?.lineItemId,
};
})
.toBe("direct_or_unattributed");
.toEqual({ gam: "other_reservation", lineItemId: 101 });

const generations = await page.evaluate(() => {
const win = window as Window & {
Expand Down
98 changes: 96 additions & 2 deletions crates/trusted-server-js/lib/src/core/ad_trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
AdTraceEvent,
AdTraceEventKind,
AdTraceExport,
AdTraceGamIdentity,
AdTraceObservation,
AdTraceStage,
AdTraceStageName,
Expand Down Expand Up @@ -43,6 +44,7 @@ const EVENT_KINDS = new Set<AdTraceEventKind>([
'gpt_slot_requested',
'gpt_slot_response_received',
'gpt_slot_render_ended',
'gpt_render_unclaimed',
'gpt_slot_onload',
'gpt_impression_viewable',
'gpt_slot_visibility_changed',
Expand Down Expand Up @@ -214,9 +216,11 @@ function updateStage(target: Record<AdTraceStageName, AdTraceStage>, event: AdTr
}
break;
case 'gpt_slot_render_ended':
// Cooperative acknowledgement is stronger than later GPT callbacks and
// Cooperative creative evidence is stronger than later GPT callbacks and
// must never be downgraded to a probable candidate.
if (target.gam.confidence === 'definitive') break;
if (target.gam.confidence === 'definitive' || target.gam.outcome === 'trusted_server_won') {
break;
}
if (explicit?.outcome === 'unresolved') target.gam = explicit;
else if (event.isEmpty)
target.gam = { outcome: 'empty', confidence: 'definitive', reason: 'gpt_empty' };
Expand All @@ -237,13 +241,59 @@ function updateStage(target: Record<AdTraceStageName, AdTraceStage>, event: AdTr
confidence: 'probable',
reason: 'client_bid_won_and_gpt_rendered',
};
// Ad Manager reported a reservation line item it chose on its own. No
// Trusted Server targeting was live on the slot, so the delivered ad came
// from other Ad Manager demand — direct-sold or house.
else if (event.responseClass === 'reservation')
target.gam = {
outcome: 'other_reservation',
confidence: 'strong',
reason: 'reservation_reported_without_trusted_server_targeting',
};
// Non-empty with no reservation or backfill identifiers: Ad Manager's own
// default or backup image, or a render by a service other than PubAds.
else if (event.responseClass === 'unclassified_non_empty')
target.gam = {
outcome: 'gam_default_or_unclassified',
confidence: 'probable',
reason: 'non_empty_without_ad_manager_identifiers',
};
else
target.gam = {
outcome: 'direct_or_unattributed',
confidence: 'probable',
reason: 'non_empty_unattributed',
};
break;
case 'gpt_render_unclaimed':
// Ad Manager rendered while Trusted Server targeting was live, but the
// rendered creative never asked Trusted Server for markup. Ad Manager
// delivered other demand — house, direct-sold, or its own default.
if (
target.gam.confidence !== 'definitive' &&
target.gam.outcome !== 'trusted_server_won' &&
target.gam.outcome !== 'empty' &&
target.gam.outcome !== 'backfill'
) {
target.gam = {
outcome: 'other_gam_demand',
confidence: 'strong',
reason: event.reason ?? 'creative_markup_never_requested',
};
}
break;
case 'pb_render_requested':
// The Prebid Universal Creative only executes when Ad Manager selected the
// header-bidding line item, and it asked for this generation's own ad ID.
// That settles the Ad Manager decision even if the creative never loads.
if (target.gam.confidence !== 'definitive') {
target.gam = {
outcome: 'trusted_server_won',
confidence: 'strong',
reason: 'creative_requested_markup',
};
}
break;
case 'aps_display_bids_set':
// APS setting display bids is a handoff only. GAM attribution remains
// unobserved until a correlated non-empty GPT render arrives.
Expand Down Expand Up @@ -273,6 +323,15 @@ function updateStage(target: Record<AdTraceStageName, AdTraceStage>, event: AdTr
reason: event.reason ?? 'pb_render_response',
};
}
// Serving markup to the creative Ad Manager rendered is the same Ad
// Manager decision evidence as the request that preceded it.
if (target.gam.confidence !== 'definitive') {
target.gam = {
outcome: 'trusted_server_won',
confidence: 'strong',
reason: 'creative_markup_served',
};
}
break;
case 'direct_render_rejected':
if (target.creative.confidence === 'none') {
Expand Down Expand Up @@ -378,6 +437,37 @@ function emptyCoverage(): Record<AdTraceCoverageCategory, AdTraceCoverageCounter
) as Record<AdTraceCoverageCategory, AdTraceCoverageCounter>;
}

const GAM_IDENTITY_ID_KEYS = [
'lineItemId',
'creativeId',
'campaignId',
'advertiserId',
'sourceAgnosticLineItemId',
'sourceAgnosticCreativeId',
] as const;
const GAM_IDENTITY_LIST_KEYS = ['yieldGroupIds', 'companyIds'] as const;
const GAM_IDENTITY_MAX_LIST = 8;

function sanitizeGamIdentity(value: unknown): AdTraceGamIdentity | undefined {
if (!value || typeof value !== 'object') return undefined;
const source = value as Record<string, unknown>;
const identity: Record<string, number | readonly number[]> = {};
for (const key of GAM_IDENTITY_ID_KEYS) {
const id = boundedInteger(source[key], 1, Number.MAX_SAFE_INTEGER);
if (id !== undefined) identity[key] = id;
}
for (const key of GAM_IDENTITY_LIST_KEYS) {
const raw = source[key];
if (!Array.isArray(raw)) continue;
const ids = raw
.map((item) => boundedInteger(item, 1, Number.MAX_SAFE_INTEGER))
.filter((item): item is number => item !== undefined)
.slice(0, GAM_IDENTITY_MAX_LIST);
if (ids.length > 0) identity[key] = ids;
}
return Object.keys(identity).length > 0 ? (identity as AdTraceGamIdentity) : undefined;
}

function boundedInteger(value: unknown, minimum: number, maximum: number): number | undefined {
return typeof value === 'number' &&
Number.isInteger(value) &&
Expand Down Expand Up @@ -428,6 +518,7 @@ function updateGenerationDiagnostics(
timestamps.renderedAt ??= event.timestamp;
diagnostics.terminalState = event.isEmpty ? 'empty' : 'rendered';
if (event.responseClass) diagnostics.responseClass = event.responseClass;
if (event.gamIdentity) diagnostics.gamIdentity = event.gamIdentity;
if (event.renderedWidth !== undefined && event.renderedHeight !== undefined) {
diagnostics.renderedSize = [event.renderedWidth, event.renderedHeight];
}
Expand Down Expand Up @@ -705,6 +796,9 @@ export function createAdTraceStore(
...(boundedInteger(observation.prebidAuctionDurationMs, 0, 300_000) !== undefined
? { prebidAuctionDurationMs: observation.prebidAuctionDurationMs }
: {}),
...(sanitizeGamIdentity(observation.gamIdentity)
? { gamIdentity: sanitizeGamIdentity(observation.gamIdentity) }
: {}),
};
if (event.kind !== 'gpt_slot_visibility_changed') {
events.push(event);
Expand Down
22 changes: 22 additions & 0 deletions crates/trusted-server-js/lib/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export type AdTraceEventKind =
| 'gpt_slot_requested'
| 'gpt_slot_response_received'
| 'gpt_slot_render_ended'
| 'gpt_render_unclaimed'
| 'gpt_slot_onload'
| 'gpt_impression_viewable'
| 'gpt_slot_visibility_changed'
Expand Down Expand Up @@ -152,6 +153,7 @@ export interface AdTraceObservation {
sizeMatchesConfigured?: boolean;
inViewPercentage?: number;
prebidAuctionDurationMs?: number;
gamIdentity?: AdTraceGamIdentity;
}

export interface AdTraceEvent extends AdTraceObservation {
Expand All @@ -160,6 +162,25 @@ export interface AdTraceEvent extends AdTraceObservation {
}

export type AdTraceResponseClass = 'empty' | 'backfill' | 'reservation' | 'unclassified_non_empty';

/**
* Ad Manager's own account of what it served, as reported by `slotRenderEnded`.
*
* These are the publisher's own Ad Manager identifiers for the delivered ad —
* the same values Google's `?google_console=1` shows for the page. They are the
* only authoritative statement of which line item beat the Trusted Server
* candidate, so a render that Trusted Server cannot claim can still be named.
*/
export interface AdTraceGamIdentity {
lineItemId?: number;
creativeId?: number;
campaignId?: number;
advertiserId?: number;
sourceAgnosticLineItemId?: number;
sourceAgnosticCreativeId?: number;
yieldGroupIds?: readonly number[];
companyIds?: readonly number[];
}
export type AdTraceAcknowledgementState =
| 'confirmed'
| 'timed_out'
Expand All @@ -179,6 +200,7 @@ export interface GenerationTraceDiagnostics {
requestNumber: number;
terminalState: AdTraceGenerationTerminalState;
responseClass?: AdTraceResponseClass;
gamIdentity?: AdTraceGamIdentity;
renderedSize?: readonly [number, number];
slotContentChanged?: boolean;
sizeMatchesConfigured?: boolean;
Expand Down
Loading
Loading