Skip to content

A blank APS creative render is indistinguishable from a delivered one #1299

Closed

Description

@aram356

Description

A blank APS creative render is currently indistinguishable from a delivered one, and nothing on the render path says why.

An APS bid that wins Prebid targeting is served by Ad Manager as a 1x1 universal creative that resizes itself only once the creative draws. Ad Manager reports a non-empty 1x1 render either way, and the tester framework reports the slot as "filled". So a slot that never drew and a slot that drew correctly look identical from the outside.

Two blind spots make this close to undiagnosable:

  1. Every guard on the APS render path returns silently. In the sandboxed renderer document (crates/trusted-server-core/src/integrations/aps.rs, APS_RENDERER_DOCUMENT), a frame that rejected the descriptor, one that never received it, and one that timed out all produce the same observable: an iframe that loaded and did nothing.

  2. The APS capability handshake never reports to GPT diagnostics. The APS path runs on the publisher's own Prebid ad units, which never pass through Trusted Server slot mapping, so no creative opportunity is recorded for them. The store rejects the attempt as creative_request_without_slot and the request cycle stays delivery: unknown. On one publisher page under investigation that was 24 of 24 cycles unattributed, while APS bids were rendering blank.

Background

PR #1052 attempted this and is being replaced rather than revived. Its head sat unchanged from 2026-09-01, accumulated five CHANGES_REQUESTED reviews from three reviewers with none addressed, and now conflicts with main, which has since rewritten large parts of gpt/index.ts. Its anchor, #1019, is closed unmerged.

The security shape of that attempt was sound and is being carried forward: fixed reason categories rather than echoed descriptor content, one-shot reporting per frame, and a null-prototype frozen allowlist for reasons crossing the origin boundary.

Review #pullrequestreview-5262485758 records the six defects that stopped it, each with a reproduction. Three of them share one root cause worth stating plainly: the bridge reused diagnostics-store APIs against their contracts.

Scope: carried into the replacement PR

Reason codes that actually reach the console:

  • The sandboxed renderer document names the guard that stopped it instead of returning silently.
  • The Universal Creative source labels its own frame timeout and frame load error, and relays whichever reason it holds to the top window.
  • Reasons resolve through a null-prototype allowlist, so an unlisted or hostile value from the cross-origin creative frame is dropped.
  • The runtime allowlist in gpt_diagnostics/store.ts and the overlay's fact renderer both learn the new categories, so a reason is recorded and displayed rather than silently discarded.
  • Fix the direct render path's failure teardown, which the added reason field breaks: renderApsCreative's listener exact-matches two keys, so a three-key failure message is dropped and the hidden frame survives for the full 10s timeout over publisher content.

Scope: deferred, tracked here

These are the parts that need design work rather than a port, and are deliberately not in the replacement PR:

  1. A narrow APS-shaped store association API. The bridge needs to associate a GPT slot so an attempt can be attributed, but recordTrustedServerOpportunity means "Trusted Server supplied this slot's next GPT request" and plants a pending request intent consumed by the next slotRequested. Using it at render time mis-attributes the following cycle: unattributed becomes trusted_server_direct, a genuine publisher_refresh becomes competing, and delivery flips unknown to pending. What is needed is a call that populates trustedServerSlots without planting a request intent.

  2. A post-completion failure state. recordTrustedServerCreativeResponse marks the attempt completed and clears its cycle, after which recordTrustedServerCreativeFailure returns early. The bridge records a response the moment the descriptor is posted, but every relayed render failure arrives later (the frame timeout is 10s). So late evidence is dropped and the cycle reports a clean confirmed delivery. Either the response recording moves to a genuine render-confirmed signal, or the store gains a state that lets late evidence downgrade a completed attempt.

  3. Opportunity revision on failure. renderable_candidate is asserted before any render is attempted and never revised when the render fails.

  4. Tombstone and attempt lifetimes disagree. A tombstone lives for the renderer entry TTL (300s default) while a creative attempt expires after 30s, so attemptId is handed out stale for minutes. Harmless today, but once the two items above are fixed, the expired branch starts emitting creative_attempt_expired into a 128-entry ring buffer on exactly the long-tail replay case.

  5. Source validation on the relay branch. The relay accepts a failure report with no check on e.source, so any frame with a top handle and a live ad ID can forge one, including the security-signal categories. The ad ID is not secret from the ad-serving chain: it is published as hb_adid targeting. This must land together with the store allowlist — the allowlist is the only reason the forgery is currently inert.

  6. Behavioural coverage for the renderer document. Its inline JS is asserted only by Rust substring checks over the source string, which pass against a document that never calls report(). The JS suite already evaluates a sibling document in jsdom, so the same technique would let these assertions test the guards rather than the source text.

Notes

Related to #1081, which covers TS Console wording and observability UX (creative numbering, auction timing metrics, auction labelling). That issue is about how the console presents what it knows; this one is about the APS render path producing the evidence in the first place. Kept separate deliberately.

Activity

  1. aram356 commented on Oct 12, 2026

    @aram356
    CollaboratorAuthor

    The carried-forward scope is implemented in #1300. The deferred items in this issue stay open after it merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions