Conversation
|
@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.
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
Instrumentation for the APS Universal Creative render path: each silent guard now names itself, the creative frame relays its reason to the top window through a null-prototype allowlist, and the GPT bridge opens a diagnostics attempt around the capability handshake. The security reasoning in the description is unusually careful and the allowlist is the right shape for a cross-origin relay.
Three defects block it, all verified with runnable probes rather than read off the diff. The headline one is that none of the fifteen new reason codes can reach the store: isCreativeFailure in store.ts still allowlists only the original four, so every recordTrustedServerCreativeFailure(attemptId, 'aps_*') call added here is a no-op. ts_console will still report zero creative failures on the APS path, which is the exact blind spot the PR exists to close. Separately, adding a reason key to the renderer document's failure message breaks the direct (non-Prebid) render path, which gates on an exact two-key match.
1 of the inline comments below carries a one-click GitHub
suggestion— use Commit suggestion to apply it as a commit on the PR branch. The remaining comments describe the fix in prose because the change touches files or lines outside this diff and can't be auto-applied.
Blocking
🔧 wrench
- Every new
aps_*reason is dropped before it reaches the store — see inline atcrates/trusted-server-js/lib/src/core/types.ts:213 creativeFailureFactis non-exhaustive; ts_console will renderundefined— see inline atcrates/trusted-server-js/lib/src/core/types.ts:229- Direct APS render path stops tearing down on
renderer-failed— see inline atcrates/trusted-server-core/src/integrations/aps.rs:60 - The new bridge tests mock the recorder, so they cannot catch the store gap — see inline at
crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts:3379 - The relay branch has no test — see inline at
crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1725
Non-blocking
🤔 thinking / ♻️ refactor / 📝 note
source_mismatchburns the one-shot report and is attacker-triggerable — see inline atcrates/trusted-server-core/src/integrations/aps.rs:103- The opportunity lands on the slot's next request cycle, mislabeling it — see inline at
crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1619 beginApsCreativeAttemptruns before the source check — see inline atcrates/trusted-server-js/lib/src/integrations/gpt/index.ts:1760window.googletaguntyped access — see inline atcrates/trusted-server-js/lib/src/integrations/gpt/index.ts:1616(carries a suggestion)
👍 praise
- Null-prototype allowlist, and tests that actually prove it — see inline at
crates/trusted-server-js/lib/src/integrations/aps/render.ts:56
Cross-cutting / body-level findings
- 📝 New type errors introduced, though
tscis not a gate here. Atsc --noEmitdelta between the merge-base and this head shows 8 new errors: theoverlay.tsTS2366 and thegpt/index.tsTS2339 covered inline, plus 6 × TS2532 (Object is possibly 'undefined') inad_init.test.ts. The base already carries 289 errors so this is not a regression in gate terms, but the first two are pointing at real defects — worth noting that the type system did flag both of the JS-side bugs found in this review, and nothing was listening.
CI Status
- browser integration tests: PASS
- cargo check (cloudflare native + wasm32-unknown-unknown): PASS
- cargo check/build/test (spin native + wasm32-wasip1): PASS
- cargo fmt: PASS
- cargo test: PASS
- cargo test (axum native): PASS
- cargo test (cross-adapter parity): PASS
- cargo test (ts CLI, native): PASS
- format-docs: PASS
- format-typescript: PASS
- integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- prepare integration artifacts: PASS
- vitest: PASS
All 14 checks green. Every finding below survives green CI — noted in each comment where the existing tests structurally cannot catch the defect.
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.
cb1de47 to
fae9e35
Compare
aram356
left a comment
There was a problem hiding this comment.
Summary
This is careful, well-documented instrumentation, and the reasoning in the PR description about the security posture of the relay is sound. But the change does not currently deliver the outcome it is written for: I verified against a real GptDiagnosticsStore that every one of the 15 new aps_* reasons is discarded before it reaches a diagnostics record, so a blank APS render still reports delivery: unknown. Two further defects sit behind that one, and one of them is a behavioural regression on the direct render path rather than a diagnostics-only concern.
The full JS suite passes on this branch (899/899). That is precisely the problem: the new tests mock gptDiagnosticsRecorder with vi.fn()s and never exercise the store, so the gap between the widened type union and the store's runtime guard is invisible to them.
A note on the PR description: the 27 vitest failures you reported are not reproducible here. On the repo-pinned Node 24.12.0 the suite is fully green, which matches your own diagnosis that they were a Node 26 artifact.
None of the inline comments below carry a one-click suggestion. Each fix either lands in a file outside this diff (store.ts), touches lines outside a diff hunk, or needs a design decision, so all of them describe the change in prose instead.
Blocking
wrench
- The store rejects all 15 new
aps_*reasons; nothing is ever recorded - see inline atcrates/trusted-server-js/lib/src/core/types.ts:197 - Relayed frame failures land on a
completedattempt and are dropped - see inline atcrates/trusted-server-js/lib/src/integrations/gpt/index.ts:1809 beginApsCreativeAttemptcorrupts attribution for the next request cycle - see inline atcrates/trusted-server-js/lib/src/integrations/gpt/index.ts:1616- The new 3-key failure message breaks the direct render path - see inline at
crates/trusted-server-js/lib/src/integrations/aps/render.ts:771 - Reordering the source check lets a co-resident script cancel a successful render - see inline at
crates/trusted-server-core/src/integrations/aps.rs:104
Non-blocking
thinking / nitpick
renderable_candidateis semantically wrong on this path - see inline atcrates/trusted-server-js/lib/src/integrations/gpt/index.ts:1618- The relay handler applies no source validation - see inline at
crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1723 m.nonce===undefinedwidens what can cancel a pending render - see inline atcrates/trusted-server-js/lib/src/integrations/aps/render.ts:775- The guard-coverage test is close to a tautology - see inline at
crates/trusted-server-core/src/integrations/aps.rs:2629 - The server-side APS path gets no instrumentation - see the cross-cutting section below
Cross-cutting / body-level findings
-
thinking - The server-side APS render path is left uninstrumented.
installTsRenderBridgehas two APS paths. This PR instruments the Prebid-capability path thoroughly, but the server-side path atgpt/index.ts:1848-1875(reached whenmatchedBid.renderer !== undefined) still has the same silent returns the PR is written to eliminate:if (!renderer) return;andif (!rendererUrl) return false;, with nosafelyRecordCreativeFailureon either. Given the PR's framing, a reader will assume APS render failures are now attributable in general, and on this path they are not. If the omission is deliberate because the live investigation only involved the publisher-Prebid path, that is a reasonable scope decision, but it is worth a sentence in the PR description so the next person debugging a blank server-APS slot is not misled by the reason codes' apparent coverage. -
note - How these findings were verified. Findings 1, 2, and 3 were each confirmed by running throwaway tests against the real
GptDiagnosticsStorerather than by reading alone, and finding 4 by drivingrenderApsCreativeend to end. The scratch tests were discarded; the observed outputs are quoted in the relevant inline comments. Gates run locally on this head:cargo fmt --checkPASS,cargo clippy-fastlyPASS,cargo test -p trusted-server-core2255 passed / 0 failed,npx vitest run899 passed / 0 failed,npm run formatPASS.
CI Status
- No GitHub checks reported on
aps-renderer-failure-diagnostics(gh pr checksreturns "no checks reported on the branch"); not run remotely. Locally on this head:cargo fmt --checkPASS,cargo clippy-fastlyPASS,cargo test -p trusted-server-corePASS (2255),npx vitest runPASS (899),npm run formatPASS. The remaining adapter gates (axum, cloudflare, spin, parity) were not run.
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.