Name the guard that stops an APS creative render - #1052
Conversation
An APS bid that wins Prebid targeting is served by Ad Manager as a 1x1 universal creative that resizes itself only after the creative draws. Every guard on that render path returned silently, so a slot that never drew was indistinguishable from one that did: Ad Manager reports a non-empty 1x1 render either way, and the tester framework reports "filled". Name the guard that stopped the render instead. The sandboxed renderer document now reports bad_hash, source_mismatch, nonce_mismatch, descriptor_keys, descriptor_fields, descriptor_envelope, and amazon_script_error on the existing failure message. Reporting is one-shot and answers through the parent, never the sender, so an unrelated sender cannot consume the frame's single report or learn anything from it. Traffic that is not shaped like the render handshake stays silent as before. The Universal Creative source labels its own frame_timeout and frame_load_error, and relays whichever reason it holds to the top window. That relay crosses an origin boundary, so reasons resolve through a null-prototype allowlist that drops anything unlisted and leaves a hostile __proto__ or constructor as undefined. Reasons are fixed categories. A descriptor is never echoed back.
The APS capability handshake never told diagnostics anything, so every request cycle on that path reported `delivery: unknown` and no creative failures at all. On a live page that meant 24 of 24 cycles were unattributed while APS bids were winning and rendering blank, which is the state that made this hard to diagnose from the outside. Record the attempt around the handshake. The path runs on the publisher's own Prebid ad units, which never pass through Trusted Server slot mapping, so no creative opportunity exists for them and the store would reject the attempt as `creative_request_without_slot`. Resolve the GPT slot by element ID and record the opportunity first. Each silent return that ends in a blank now names itself: aps_consumed_tombstone, aps_source_not_in_ad_unit, aps_descriptor_fields, aps_tombstone_capacity, and aps_missing_renderer_url. A successful post records a response. Consumed ad IDs carry the attempt they were served under, so a replay, or a failure the creative frame relays after the fact, is attributed to the render it belongs to rather than guessed at. The relay listener treats the creative as untrusted: the reason must resolve through the allowlist, the attempt comes from our own tombstone rather than the message, and it never answers the sender.
|
@ChristianPavilonis to understand if belongs in #1019 |
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
Reviewed cb1de4777cdd5efe2a32892c03d00c1b071795f6. Requesting changes because the new reporting path can alter APS delivery and the diagnostics pipeline does not currently retain the APS failure evidence this PR introduces. Four actionable findings are posted inline.
| | 'response_post_failed'; | ||
| | 'response_post_failed' | ||
| // Reported by the sandboxed renderer document and relayed by the creative. | ||
| | 'aps_bad_hash' |
There was a problem hiding this comment.
🔧 P1: Wire the new APS reasons through the real diagnostics consumers
The runtime validator in gpt_diagnostics/store.ts:168-174 still accepts only the four original failure values, so every new aps_* reason is discarded by recordTrustedServerCreativeFailure(). The overlay switch in gpt_diagnostics/overlay.ts:185-194 also has no APS cases. As a result, ts_console and exported snapshots will continue showing no APS creative failures. The new tests mock the recorder and therefore do not exercise either consumer.
Update the store allowlist and overlay labels for every new category, make the presentation switch exhaustive, and add a test that passes an APS reason through the actual recorder/store and verifies the snapshot and overlay output.
| // Stay silent for traffic that is not shaped like the render handshake, so an | ||
| // unrelated sender cannot consume this frame's single report. | ||
| if(!keys(message,['nonce','renderer']))return; | ||
| if(event.source!==parent){report('source_mismatch');return;} |
There was a problem hiding this comment.
🔧 P1: Do not let a foreign sender terminate rendering
A shaped message from a non-parent source now emits source_mismatch without a nonce. The Universal Creative wrapper accepts nonce-less failure messages from this iframe at render.ts:623, calls fail(), removes the iframe, and rejects the render. A sibling or ancestor window that obtains the iframe's WindowProxy can therefore race the valid parent message and suppress APS delivery. Previously this traffic was ignored, and the PR describes reporting as unable to influence delivery.
Keep event.source !== parent silent, or route this observation through a path that cannot trigger terminal renderer failure. Please add a test where a foreign shaped message arrives before the valid nonce-bound parent handshake and confirm the render still succeeds.
| height: validatedRenderer.height, | ||
| }) | ||
| ); | ||
| safelyRecordCreativeResponse(attemptId); |
There was a problem hiding this comment.
🔧 P1: Keep the attempt writable for downstream renderer failures
This marks the attempt completed as soon as port.postMessage returns. The store then clears its cycle at gpt_diagnostics/store.ts:529-531, and recordTrustedServerCreativeFailure() drops all completed attempts at line 549. Renderer timeouts, descriptor failures, Amazon script failures, and consumed-ID replays necessarily arrive after this response, so they cannot be retained even after the APS allowlist is fixed. The existing store test at store.test.ts:1707 explicitly verifies that post-completion failures are ignored.
Keep the cycle association writable for the 30-second mutation window after recording the independent response timestamp, or add a separate post-response failure channel. Cover response sent followed by a frame failure and a replay using the real diagnostics store.
| const pubads = window.googletag?.pubads?.(); | ||
| const slot = pubads ? findGptSlotByElementId(pubads, adUnitCode) : undefined; | ||
| if (slot) { | ||
| window.tsjs?.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( |
There was a problem hiding this comment.
🔧 P2: Do not create next-request evidence while associating the current APS attempt
This handshake occurs after the current GPT request, but recordTrustedServerOpportunity() explicitly records evidence for the slot's next request (gpt_diagnostics/store.ts:334 and :925-953). The creative attempt attaches to the latest existing cycle while this pending intent survives. A refresh within five seconds can therefore mislabel the next cycle as trusted_server_direct with a renderable candidate, or as competing when its real Prebid or publisher marker is also present.
Use a separate operation that associates the APS ad-unit code with the current GPT slot without creating next-request intent, or add an APS-specific current-cycle attempt method. Add a two-cycle test proving the following refresh receives only its own request-path evidence.
Why
Investigating blank ads on a live publisher page, APS bids were winning the
auction and Ad Manager was filling the slot, yet the creative never drew. The
tester framework reported the slot as "filled" at 1x1, which is exactly what a
successful universal-creative render looks like before it resizes. Nothing
downstream distinguished the two.
Two blind spots made this close to undiagnosable from the outside:
the descriptor, one that never received it, and one that timed out all looked
identical: an iframe that loaded and did nothing.
ts_consoleshowed
delivery: unknownon every request cycle and zero creative failures,while APS bids were rendering blank.
On the page under investigation that was 24 of 24 cycles unattributed.
What changed
The sandboxed renderer document (
aps.rs) reports which guard stopped it,on the existing failure message:
bad_hash,source_mismatch,nonce_mismatch,descriptor_keys,descriptor_fields,descriptor_envelope,amazon_script_error.The Universal Creative source (
render.ts) labels its ownframe_timeoutand
frame_load_errorand relays whichever reason it holds to the top window.The GPT bridge (
gpt/index.ts) records a creative attempt around thehandshake and names each silent return:
aps_consumed_tombstone,aps_source_not_in_ad_unit,aps_descriptor_fields,aps_tombstone_capacity,aps_missing_renderer_url. A successful post records a response, so this pathreports
trusted_server_response_sentrather thanunknown.Notes for review
The APS path runs on the publisher's own Prebid ad units, which never pass
through Trusted Server slot mapping. No creative opportunity exists for them, so
the store rejected the attempt as
creative_request_without_slot. The bridge nowresolves the GPT slot by element ID and records the opportunity first. That is
the least obvious part of the change and the part most worth a look.
Security posture, since a reason crosses an origin boundary:
parent, never thesender, so an unrelated sender cannot consume the report or learn from it.
Traffic not shaped like the handshake stays silent, as before.
__proto__,constructor, ortoStringresolves toundefined.This is instrumentation. It does not attempt a fix, because the root cause is
still unknown: the evidence says the handshake completes and the renderer frame
loads, then the chain dies before Amazon's
prebid-creative.jsis requested.These reason codes are what will name it on the next occurrence.
Testing
render.test.ts: relay of a frame reason, frame timeout, and the allowlistincluding inherited-key and non-string rejection
ad_init.test.ts: creative attempt recorded for a registered APS renderer,and the tombstone reason on a replayed ad ID
aps.rs: every guard reports a reason, reporting is one-shot, no descriptorecho, and the frame never answers the sender
Gates run:
cargo fmt --check,clippy(fastly target,-D warnings),cargo test -p trusted-server-core(2143 passed), JS build, JS format, and thefull vitest suite (850 passed).
Two caveats. The vitest suite has 27 pre-existing failures in
sourcepoint/index.test.tsandpermutive/segments.test.ts, all onelocalStorageerror from running Node 26 against a repo pinned to 24.12.0; thecount is unchanged by this branch. And the remaining adapter gates (axum,
cloudflare, spin, parity) were not run locally, because a
.cargo/config.tomlalias conflict with a stale sibling checkout meant cargo had to be invoked from
outside the repo without workspace aliases. CI covers those.