Skip to content

Combine Prakash changes for July RC#949

Draft
prk-Jr wants to merge 30 commits into
rc/julyfrom
rc/july-prakash
Draft

Combine Prakash changes for July RC#949
prk-Jr wants to merge 30 commits into
rc/julyfrom
rc/july-prakash

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Consolidate four open Prakash PR branches into the July release candidate so their combined behavior can be validated and shipped together.
  • Stabilize Fastly backend naming and stream publisher response bodies end to end without losing auction coordination.
  • Render SSAT winners inline and correlate auction winners with the creatives ultimately rendered in the browser.

Changes

File Change
crates/trusted-server-adapter-axum/src/app.rs Wire trace-cookie routing through the Axum adapter.
crates/trusted-server-adapter-cloudflare/src/app.rs Wire trace-cookie routing through the Cloudflare adapter.
crates/trusted-server-adapter-fastly/src/app.rs Add trace routing and preserve publisher streaming behavior.
crates/trusted-server-adapter-fastly/src/backend.rs Quantize transport timeouts used in dynamic backend names.
crates/trusted-server-adapter-fastly/src/platform.rs Correlate asynchronous auction requests by resolved backend name.
crates/trusted-server-adapter-spin/src/app.rs Wire trace-cookie routing through the Spin adapter.
crates/trusted-server-core/src/auction/orchestrator.rs Stabilize dispatched-auction backend correlation and timeout handling.
crates/trusted-server-core/src/auction/types.rs Preserve upstream bid identifiers used for render attribution.
crates/trusted-server-core/src/constants.rs Add the trace endpoint constant.
crates/trusted-server-core/src/integrations/prebid.rs Merge bidder sanitization with APS separation and stored-request fallback.
crates/trusted-server-core/src/lib.rs Export the trace-cookie module.
crates/trusted-server-core/src/publisher.rs Combine streaming auction collection, inline SSAT creatives, and render trace metadata.
crates/trusted-server-core/src/settings.rs Add trace-cookie settings.
crates/trusted-server-core/src/streaming_processor.rs Stream and bound compressed publisher response processing.
crates/trusted-server-core/src/trace_cookie.rs Implement the trace-cookie endpoint and cookie lifecycle.
crates/trusted-server-js/lib/src/core/auction.ts Carry auction and bid trace fields into client rendering.
crates/trusted-server-js/lib/src/core/request.ts Stamp direct auction renders with visibility and attribution metadata.
crates/trusted-server-js/lib/src/core/trace.ts Add render history, visibility, tooltip, and overlay behavior.
crates/trusted-server-js/lib/src/core/types.ts Define render records and trace-aware bid fields.
crates/trusted-server-js/lib/src/integrations/gpt/index.ts Correlate GAM and Universal Creative renders while preserving inline SSAT and APS paths.
crates/trusted-server-js/lib/src/integrations/prebid/index.ts Trace Prebid render lifecycle events.
crates/trusted-server-js/lib/test/core/auction.test.ts Cover client auction trace parsing.
crates/trusted-server-js/lib/test/core/trace.test.ts Cover trace records, overlay rendering, and attribution.
crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts Cover GAM, inline, cache, refresh, and stale-render interactions.
crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts Cover render tracing across SPA navigation.
crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts Cover Prebid render tracing and merged adapter behavior.
docs/guide/configuration.md Document publisher streaming configuration.
docs/superpowers/specs/2026-07-08-true-origin-streaming-fastly.md Update the streaming design notes.
trusted-server.example.toml Document trace-cookie configuration defaults.

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo test-cloudflare && cargo test-spin
  • Adapter parity suite
  • cargo clippy-fastly && cargo clippy-axum
  • cargo clippy-cloudflare && cargo clippy-cloudflare-wasm
  • cargo clippy-spin-native && cargo clippy-spin-wasm
  • cargo fmt --all -- --check
  • JS tests: 536 tests passed locally
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • JS build: cd crates/trusted-server-js/lib && node build-all.mjs
  • Docs format: cd docs && npm run format
  • WASM release build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() added to production code
  • Uses project logging macros rather than standard output
  • New behavior has regression coverage
  • No secrets or credentials committed

prk-Jr and others added 30 commits July 17, 2026 11:56
- Reject a mediator that is also listed in [auction].providers at startup;
  the overlap would fire the same provider twice per auction and its demand
  already flows through the mediation response
- Assert in both cardinality enumeration tests that canonical values never
  exceed the remaining budget (the mediator </body> hold bound invariant)
- Document the accepted worst-case rounding haircut on the coarse ladder
- Write the spec digest hex into one pre-sized buffer instead of allocating
  per byte, and defer the ensure() doc to compute_name
Replace flate2's MultiGzDecoder with a shared GzipStreamDecoder used by
both the streaming BodyStreamDecoder and the buffered pipeline (via a
Read adapter). At each member boundary the next bytes are sniffed for
the gzip magic number: a match starts the next member, anything else is
dropped once at least one member has fully decoded — matching GNU gzip,
main's single-member tolerance, and the deflate codec. Truncated or
corrupt members still error.
Once the close-body hold is released, decoded chunks were still copied
via to_vec() before processing. Use Cow so the post-release path borrows
the chunk and only the held path allocates.
HTML post-processor configs (the nextjs integration) accumulate the full
rewritten document until origin EOF, so those pages do not stream body
bytes early. Record the limitation and the intended follow-up (an
up-front or streaming should_process gate) in the plan's Out of scope
section.
Introduce an operator-armed render-trace overlay so the winning auction
recorded in window.tsjs.renders can be confirmed visually on the page,
on both the SSAT/GAM and /auction paths.

Server: GET /_ts/trace toggles a host-only ts-trace cookie and redirects
to /, gated by the new [debug] trace_route_enabled flag (404 when off).
Registered on all four adapters (Fastly/Cloudflare/Axum/Spin) and
documented in trusted-server.example.toml.

Client: while the cookie is armed, TSJS draws a floating
Google-Publisher-Console-style panel summarising every traced slot and a
confirmation badge on each genuinely-rendered creative. The panel reports
honest per-slot status — ok / hidden / gam-only / empty — derived from
separate signals (gamEmpty from GAM's slotRenderEnded, injected = whether
TS actually placed the creative, visible = ancestor-aware visibility) so
it never overclaims a render GAM merely fired. Clicking a row copies the
full record; the badge is shown only on ok slots.
The prod page runs two independent auctions against the same placements:
the one-time SSAT auction on navigation, and an ongoing client-side
/auction driven by GAM refresh through the Prebid.js trustedServer
adapter. Only the SSAT path was traced, so every Prebid render was
invisible to the overlay.

Instrument the /auction path at its authoritative render signal. The
adapter now forwards the server-side trace tuple to Prebid as
meta.tsAuctionId / meta.tsAdmHash, and a bidWon listener records an
`auction`-path entry (servedFrom: prebid) keyed by the ad-unit code with
that auction's own ID, hash and visibility. Only bids carrying
meta.tsAuctionId — i.e. the trustedServer seat — are traced, so a
client-side bidder's render is never attributed to Trusted Server. The
listener only observes and stamps; it does not touch Prebid's rendering.

Also fix an overclaim in the SSAT path: with inject_adm_for_testing off
there is no adm to inject, which left `injected` unset and let
panelStatus fall through to `ok` — claiming a confirmed TS render for a
slot TS had merely targeted. Targeting-only renders now report
`injected: false`, and `ok` requires an explicit confirmed placement so
any future path that omits the signal degrades to gam-only rather than
silently claiming success.
A client framework can replace the ad divs after GPT slots were bound to
them: the publisher's React app serves ids like `ad-header-0-_R_ssr_` and
swaps them for client ids (`ad-header-0-_r_1_`) during hydration. GPT is
left holding slots whose element no longer exists — GAM reports
"defineSlot was called without a corresponding DIV", still fetches a
creative for them, and the bid is silently wasted with nowhere to render.

Waiting for the divs to merely exist cannot help, because at adInit()
time the server-rendered divs are present and are only later replaced.
So rather than delaying the initial ad request, detect the swap after the
fact: a debounced MutationObserver armed after adInit() looks for
TS-defined slots whose element left the document and re-runs adInit(),
which destroys the orphans and re-binds against the live DOM — reusing
the publisher's own slot for that div when they have since defined one.

Bounded deliberately, since each re-bind re-requests the affected slots:
a 250ms quiet period, a 5s watch window (measured: the swap lands ~2-3s
in, so the existing 2s SPA wait would miss it), and at most two re-binds
per page load, with the attempt budget shared across the adInit() the
watcher itself triggers so it cannot loop.

Verified against the live page through the dev proxy: two orphaned slots
were detected and re-bound, leaving zero orphans, with all three slots
tracing to live client-id elements.

(cherry picked from commit e1badbb)
The panel collapsed each slot to a single row with a ×N counter, so a
publisher page that refreshes its slots on every render (autoblog's
ad-service refreshes from its own slotRenderEnded handler) showed a
climbing number instead of what actually happened. Keep an append-only
`window.tsjs.renderLog` alongside the per-slot `renders` registry and
render it newest-first, one entry per render, with a wall-clock time and
a `#N` sequence. The log is trimmed to the most recent entries so a page
that refreshes indefinitely cannot grow it without bound; `renders` still
collapses per slot for "did this ever render" checks.

Also badge every slot that actually shows a creative, not just confirmed
TS renders. Gating the badge on `ok` meant production — where
inject_adm_for_testing is off and TS only applies GAM targeting — never
displayed one at all, since every slot is honestly `gam-only`. The badge
now carries its status colour and mark: green ✓ for a confirmed TS
render, blue ◐ for gam-only. Slots with nothing on screen (`empty`) or
nothing visible (`hidden`) stay unbadged, as there is no creative to
label.

(cherry picked from commit 013f5e6)
The server-side auction runs once per navigation, but the slotRenderEnded
handler read the winning bid out of window.tsjs.bids on every render. That
map never changes, so each publisher-driven GAM refresh re-stamped the
page-load auction's id, bidder and adm hash and labelled itself `ssat` —
claiming a render the server-side auction never produced. A slot refreshed
six times over a minute showed six `ssat` rows all pointing at one auction.

Scope the claim to the render that actually consumes it: adInit arms a
per-slot flag when it applies bid targeting, and the first slotRenderEnded
clears it. Later renders are recorded as `gam-refresh` with the stale
attribution dropped from both the record and the DOM markers, since GAM
re-requested the slot on its own and the returned creative cannot be traced
to any Trusted Server auction.

These rows are where the client-side /auction path will report real
attribution via Prebid's bidWon once that bundle ships; labelling them
`ssat` hid that gap instead of showing it. The /auction recording path is
unchanged.

(cherry picked from commit c4999f7)
Three trace-panel fixes, all surfaced by the gam-refresh rows the previous
commit introduced:

- Restore the `gam:filled`/`gam:empty` marker on gam-refresh rows. It was
  gated on `path === 'ssat'`, which hid GAM's own fill signal on exactly the
  rows where "did GAM fill it this time" is the whole question.
- Stop rendering absent attribution as `? · ?` and `auction ?`. An
  unattributed GAM refresh carries no bidder/hash/auction id by design, so
  the row now says `no TS attribution` and drops the auction segment rather
  than looking like a failed lookup.
- Give each render a page-global `seq`, shown as `#N` on both the panel row
  and the on-creative badge so the two point at each other, and mark the row
  still live for its slot as `◂ current`. The per-slot render count keeps its
  own `×N`. seq is module-scoped, not stored on window.tsjs, so a re-executed
  bundle restarts the sequence instead of handing two renders one number.

(cherry picked from commit c0811bc)
The visible row text already says "no TS attribution" for a gam-refresh, but
the badge title and row hover tooltip still rendered the same absent fields
as `?`, which reads like a failed lookup rather than "there is nothing to
attribute here by design". Switch both to `—`, matching the `gam_empty ?? '—'`
convention the row tooltip already used elsewhere in the same list.

(cherry picked from commit b4908cd)
The pbRender bridge has two branches for serving a winning SSAT bid into
GAM's Universal Creative: fetch from PBS Cache, or use `bid.adm` directly
when present (added by the SSAT inline-creative work, and the only path
production actually exercises for bidders that carry markup inline). The
PBS Cache branch calls recordBridgeRender after replying; the inline-adm
branch replied, fired win/billing beacons, and logged success, but never
called it — so this render path, the strongest confirmation signal SSAT
has (TS supplies the exact bytes GAM's own creative asked for by name),
was invisible to the trace panel. Every SSAT row capped at gam-only even
when this branch was serving real creative.

Add the missing call, matching the PBS Cache branch's placement. Add
regression coverage on both branches — neither had a trace assertion
before, so this gap could recur silently on either one.

(cherry picked from commit 25316dd)
…re absent

Confirmed live on autoblog: Kargo's response carries neither a Prebid Cache
UUID nor an `adid`, so hb_adid was omitted for every SSAT bid from that
bidder. That broke the pbRender bridge's reverse lookup (adId -> hb_adid) for
GAM's Universal Creative postMessage protocol — verified in the browser that
GAM sends real 'Prebid Request' messages with real adIds on this account,
but window.tsjs.bids[slot].hb_adid was undefined for all three winning bids,
so the bridge could never match any of them. Confirmed rendering (injected:
true) was structurally unreachable for this bidder regardless of what GAM
did.

bid.bid_id (the OpenRTB bid's own `id`, always present per spec) already
flows through the pipeline unused for this purpose. It is unique per bid
instance rather than a creative identifier, but that is exactly what hb_adid
needs here: a stable value GAM's Universal Creative echoes back so the
bridge can find the winning bid. Add it as the last-resort fallback, after
cache_id, the APS renderer's bid id, and ad_id — all three still take
priority where present, locked in by test.

(cherry picked from commit 281df38)
The dispatched-auction post-launch guard re-checked the predicted backend
name the pre-launch guard had already proved absent, never reading
pending.backend_name(). Because collection correlates by the name the
response reports (Fastly's get_backend_name()), keying the map on the
prediction could drop a response whose resolved name diverged, and the
guard could never catch a resolved-name collision.

Derive request_backend_name from pending.backend_name() (falling back to
the prediction only when unset), then check, insert, and preserve the
pending under that resolved name, mirroring run_providers_parallel.

Add regression tests via a DivergentBackendProvider whose prediction
differs from its resolved name: one asserts a diverging response still
correlates, one asserts a post-launch resolved-name collision fails the
second provider attributably.
Address the streaming-pipeline review findings.

Deflate decode and finish now inflate through a fixed-size scratch buffer
whose write window is bounded by the remaining decode budget, and charge
produced bytes before appending them. `decompress_vec` previously let the
output `Vec` double and be filled to capacity before the cap was checked,
so a 16 MiB limit could peak at 32 MiB.

`GzipDecodeReader` takes a decode budget instead of an unconditional
`usize::MAX`; `StreamingPipeline::with_max_decoded_bytes` threads it and the
buffered publisher path passes `max_buffered_body_bytes`, so a gzip bomb is
rejected mid-decode rather than fully materialized before the bounded writer
can act.

The buffered auction hold path decodes concatenated gzip members through
`GzipDecodeReader` instead of `flate2::read::GzDecoder`, which decoded only
the first member and silently dropped the rest, potentially including
`</body>` and trailing markup on Axum, Cloudflare and Spin.

`GzipStreamDecoder`'s poisoned-state arms return an error instead of
`unreachable!()`, so a caller that retries after a mid-transition failure
cannot abort the Wasm instance.

Tests cover the deflate peak buffer capacity at the cap, a deflate bomb, a
low-limit gzip bomb through the reader, a buffered auction body split across
two gzip members, and early emission: the real lazy response body is polled
once against an origin that yields one chunk and then stays pending, for both
compressed HTML and the auction-hold prefix.

Configuration docs now describe the raw and decoded streaming caps, mid-stream
truncation after headers are committed, adapter differences, and the removal
of the old 10 MiB Fastly origin-body ceiling.
Per OpenRTB, nurl and burl are the canonical carriers of the
${AUCTION_PRICE} macro, and the render bridge fires both verbatim via
sendBeacon. Only the creative was expanded, so every TS-won impression
sent the SSP a literal macro instead of the clearing price — a
revenue-reporting error, and the kind of malformed notification some
SSPs reject outright. Expand both from the same winning CPM already in
scope for the creative.

The PBS Cache fallback in the bridge is covered by the same change: it
fires matchedBid's beacons, which now arrive expanded from the auction
clearing price rather than the cached copy's price. Note that at the
fire site so the expansion is not duplicated client-side.

The debug_bid mirror keeps its bidder-supplied creative, nurl, and burl
verbatim: it is diagnostic, nothing renders or fires from it, and
showing what the bidder actually sent is the point.

Also drop the too_many_arguments allow on collect_stream_auction. Split
AuctionCollectCtx into the per-auction state that is moved at collect
time and the borrowed dependencies that outlive it, so the helper takes
three arguments and request-origin threading no longer widens the
signature.
* Suppress fabricated empty Prebid bidder params

A configured bidder with no inline params and no matching override
expanded to `"bidder": {}`, which PBS rejects. After applying overrides,
drop fabricated empty bidders, preserve an explicitly supplied empty
object so genuine misconfiguration stays visible, and fall back to the
stored-request path when no eligible bidders remain.

* Prefer direct bidder params over fabricated-empty in mixed slots

A slot can carry both a direct bidder entry with valid inline params and a
trustedServer entry whose bidderParams omits that bidder. Because slot.bidders
is a HashMap, the trustedServer expansion (which fabricates an empty {} for the
omitted bidder) could run after the direct entry and overwrite its valid params
via extend. The direct entry already marked the bidder explicit, so the empty
object survived the retention drop and PBS rejected the impression — the failure
this path is meant to eliminate — nondeterministically, depending on iteration
order.

Replace the extend with an entry().or_insert() loop so trustedServer-expanded
bidders only fill in absent entries and direct params win regardless of order.
Add a looped regression test exercising a slot with both representations.

* Drop empty and non-object PBS bidder params instead of shipping them

expand_trusted_server_bidders and the direct merge could both put an
unusable value into imp.ext.prebid.bidder — an empty {} (fabricated or
publisher-supplied) or a non-object like null. PBS rejects such an imp, and
parse_response collapses one non-2xx into an auction-wide bid wipeout, so a
single misconfigured slot zeroed every slot's bids.

- Add is_unusable_bidder_params (empty object or non-object).
- Collect the trustedServer expansion and direct entries separately, then
  merge so a direct entry wins but an unusable value never clobbers real
  params from the expansion (fixes the asymmetric direct-{} overwrite).
- Drop every remaining unusable value after overrides and log::warn each
  drop; the slot then falls back to its stored request. Explicit empties are
  no longer preserved — PBS cannot tell them from fabricated ones.
- Correct the stored-request comment: the fallback also fires for real
  inline params naming a bidder absent from config.bidders.
- Tests: explicit-empty now drops to stored request; add direct-empty
  no-clobber, null drop, and unconfigured-bidder fallback.

* Split unusable-bidder drop logging by source and collapse per slot

The unconditional drop of empty/non-object PBS bidder params warned once
per bidder, including on the designed creative-opportunity path where the
trustedServer expansion fabricates empty params and no override rule fills
them. Routine slots emitted N warnings describing intended behavior, burying
the genuine publisher misconfiguration the log exists to surface.

Track which bidders the slot supplied inline and split the drop log by
source: slot-supplied unusable params warn, fabricated empties log at debug.
Both now emit one line per slot with sorted bidder names instead of one line
per bidder. Drop behavior is unchanged.
…-to-creatives

# Conflicts:
#	crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts
#	trusted-server.example.toml
…' into rc/july-prakash

# Conflicts:
#	crates/trusted-server-core/src/integrations/prebid.rs
#	crates/trusted-server-core/src/publisher.rs
…es' into rc/july-prakash

# Conflicts:
#	crates/trusted-server-core/src/auction/types.rs
#	crates/trusted-server-core/src/publisher.rs
#	crates/trusted-server-js/lib/src/core/request.ts
#	crates/trusted-server-js/lib/src/integrations/gpt/index.ts
#	crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts
#	crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts
@prk-Jr prk-Jr self-assigned this Jul 22, 2026
@prk-Jr
prk-Jr marked this pull request as draft July 22, 2026 13:56
@prk-Jr
prk-Jr marked this pull request as ready for review July 22, 2026 17:11
@aram356
aram356 marked this pull request as draft July 23, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants