Skip to content

Improve GPT auction diagnostics observability - #1121

Open
ChristianPavilonis wants to merge 2 commits into
spec/auction-timeline-offsetsfrom
feature/ts-console-improvements
Open

Improve GPT auction diagnostics observability#1121
ChristianPavilonis wants to merge 2 commits into
spec/auction-timeline-offsetsfrom
feature/ts-console-improvements

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • Link every creative badge to the same stable Ad #N in the TS Console panel and export.
  • Add SSAT, SPA Trusted Server, client-side, and genuinely competing-auction classification with winning bidder and bucketed price facts.
  • Surface session-gated server auction timings alongside clearly named GAM callback timings, and clean up 1×1 and size terminology.

This is stacked on #1076, which is stacked on #1074. Retarget this PR to main after those dependencies merge.

Changes

File Change
crates/trusted-server-core/src/publisher.rs Serialize initial and SPA auction timing facts, preserve generation safety, and gate SPA diagnostics on the active console session.
crates/trusted-server-js/lib/src/integrations/gpt/ Carry immutable auction facts through the initial scheduler and SPA page-bids path.
crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/ Validate, retain, classify, clone, and present auction diagnostics with stable slot numbering.
crates/trusted-server-js/lib/test/ and browser integration tests Cover timing propagation, session gating, winner validation, classification, stable numbering, terminology, and export isolation.
docs/guide/integrations/gpt-diagnostics.md Document auction labels, timing origins, winner/privacy boundaries, size behavior, and browser callback semantics.

Closes

Closes #1081

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo test-cloudflare
  • cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run (898 passed, no type errors)
  • JS lint/build: npm run lint && npm run build
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format/lint/build: cd docs && npm run format && npm run lint && npm run build
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Other: focused Next.js Playwright GPT diagnostics suite (3 passed)

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() added in production code
  • Logging conventions preserved; no println! / eprintln! added
  • New code has tests
  • No secrets or credentials committed

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Solid, well-tested addition: stable Ad #N identity, auction classification, and server auction timings all land behind the existing activation gate, with a normalization boundary and tests for the malformed cases. No correctness, security, or WASM-compatibility problem found; all 14 CI checks pass. The findings below are all non-blocking — the substantive ones are about the SPA timing anchor and label accuracy in a tool whose value is precise facts.

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 test files, spans more than one hunk, or is a design choice rather than a mechanical edit.

Non-blocking

🤔 thinking

  • SPA auctionDispatchedMs is always 0 by construction — see inline at crates/trusted-server-core/src/publisher.rs:6748
  • Navigation T0 overstates the anchor — see inline at crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts:246
  • The store infers ssat when auction facts are absent or malformed — see inline at crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts:1129
  • 1x1 suppression conflates "GPT reported 1x1" with "GPT reported nothing" — see inline at crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts:9

♻️ refactor

  • set_auction_diagnostics rebuilds the script cell and drops a debug prefix — see inline at crates/trusted-server-core/src/publisher.rs:3209 (suggestion)
  • Placement wire string has two sources of truth — see inline at crates/trusted-server-core/src/publisher.rs:6767

⛏ nitpick

  • Duplicated 5-arg / 6-arg recorder call — see inline at crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1113
  • Browser spec traded away its only non-1x1 fill-size case — see inline at crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts:223

👍 praise

  • Stable Ad #N on re-entry, and fail-closed dispatch gating — see inline at crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts:1201

Cross-cutting / body-level findings

  • 📝 Stacked base — this targets spec/auction-timeline-offsets (stacked on #1076#1074). Retarget to main after those land, as the description says. Nothing in the diff depends on that ordering beyond the base itself.
  • 📝 Coverage of the server write path is completewrite_bids_to_state has exactly two production call sites (collect_non_html_auction, collect_stream_auction) and both are now paired with set_auction_diagnostics, so there is no document path that commits bids without the timing facts. Verified by grep rather than assumed.

CI Status

  • browser integration tests: PASS
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • prepare integration artifacts: PASS
  • cargo test: PASS
  • cargo test (axum native): PASS
  • cargo test (ts CLI, native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo fmt: PASS
  • vitest: PASS
  • format-typescript: PASS
  • format-docs: PASS

gh pr checks --required returned no names for this base, so none of the above are annotated as branch-protection-required; all of them are gates CLAUDE.md treats as PR gates, and all pass.

};
match auction
let timing_started = web_time::Instant::now();
let auction_dispatched_ms = elapsed_millis(&timing_started);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — This field is a constant 0 on every SPA cycle: the clock starts on the line above and is read immediately, so auction_dispatched_ms can only ever be sub-millisecond. auction_wait_ms (resolved - dispatched) then becomes an exact duplicate of auction_resolved_ms, and the panel does render the zero — formatMilliseconds(0) returns "0 ms", so every SPA cycle shows SPA auction T0 → auction dispatched 0 ms.

The anchor also excludes everything the handler already did before this line (CSRF gate, consent gate, slot matching, EC/KV lookups) — which is precisely the pre-dispatch cost the initial-document path does surface, because there auctionDispatchedMs is a real T0 offset. So the two paths look comparable in the UI while measuring different things, and a slow pre-dispatch phase is invisible on the SPA side.

The request already carries a T0-anchored RequestTimings in its extensions (inserted at crates/trusted-server-adapter-fastly/src/main.rs:216 and crates/trusted-server-adapter-axum/src/timing.rs:87), so this path could use the same instrument it uses on the document path:

let timings = req
    .extensions()
    .get::<RequestTimings>()
    .cloned()
    .unwrap_or_default();
// … around the auction:
timings.mark_auction_dispatched(auction_id);
let result = auction.orchestrator.run_auction(&auction_request, &auction_context).await;
timings.record_auction_wait(AuctionWaitPlacement::PreHeader, wait_started.elapsed());
timings.mark_auction_resolved();
// after the bid map is built:
timings.mark_auction_committed();
let auction_diagnostics = gpt_diagnostics
    .browser_session_active()
    .then(|| BrowserAuctionDiagnostics::from_request_timings(&timings))
    .flatten();

That gives both paths one clock semantics, removes the parallel timing mechanism, and — because these are the same marks the Server-Timing header and auction telemetry read — makes the console numbers reconcilable with the logs instead of existing only in the JSON response. If you'd rather keep the local clock, then dropping auctionDispatchedMs (and with it the redundant auctionWaitMs) from the SPA payload is more honest than shipping a measured-looking 0.

Apply manually — spans the timing setup, the Ok(result) arm, and the commit patch below, so it can't be expressed as one contiguous suggestion.

auction_wait_ms: Some(
auction_resolved_ms.saturating_sub(auction_dispatched_ms),
),
auction_wait_placement: Some("pre_header"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — The wire values for this field now have two sources of truth: BrowserAuctionDiagnostics::from_request_timings maps the enum (publisher.rs:3140-3148), and this site hardcodes the string. The TS side pins them as a closed union ('pre_header' | 'in_stream' in core/types.ts) and normalizedServerAuctionTimings drops anything else, so a typo here degrades silently to placement unknown in the panel rather than failing anywhere.

Proposed fix (apply manually — the helper definition and this call site are in separate hunks):

const fn placement_wire(placement: AuctionWaitPlacement) -> &'static str {
    match placement {
        AuctionWaitPlacement::PreHeader => "pre_header",
        AuctionWaitPlacement::InStream => "in_stream",
    }
}

Then from_request_timings becomes snapshot.auction_wait_placement.map(placement_wire) and this line becomes auction_wait_placement: Some(placement_wire(AuctionWaitPlacement::PreHeader)), which also documents at the call site which placement the page-bids wait is being claimed as.

*self.bids.lock().expect("should lock bid map") = bid_map;
}

fn set_auction_diagnostics(&self, timings: &RequestTimings) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — This writes *self.script.lock() = Some(build_bids_script_with_diagnostics(...)), discarding whatever the cell already held. That is safe today only because prepend_auction_debug_comment (publisher.rs:4138) runs after it in collect_stream_auction; swap those two statements and the ts-debug comment silently disappears from the inline bids script, with no test covering the pair. prepend_to_script keeps debug_prefix for exactly this reason, but only build_seam_script re-applies it.

Cheapest fix is to record the ordering requirement next to the new write:

Suggested change
fn set_auction_diagnostics(&self, timings: &RequestTimings) {
/// Attach the server auction facts to the rendered bid script.
///
/// Rebuilds the script cell from scratch, so it must run **before**
/// [`Self::prepend_to_script`]: a debug comment already written into the
/// script cell would be discarded here, and only the shared-template seam
/// re-applies the retained `debug_prefix`.
fn set_auction_diagnostics(&self, timings: &RequestTimings) {

If you'd rather have it enforced than documented, re-apply the retained prefix the way build_seam_script already does, so the write cannot lose it regardless of call order.

const timingOrigin =
cycle.serverAuctionTimingOrigin ??
(cycle.auctionType === 'trusted_server' ? 'spa_auction' : 'navigation');
const timingAnchor = timingOrigin === 'spa_auction' ? 'SPA auction T0' : 'Navigation T0';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinkingRequestTimings::t0 is stamped when the edge starts processing the request, not at the browser's navigationStart. Labeling it Navigation T0 invites the operator to line these offsets up against browser Navigation Timing — the exact cross-clock subtraction the new documentation forbids two paragraphs earlier ("Server auction timing and browser GPT timing use separate clocks and are never subtracted from each other").

Edge request T0 (or Server request T0) names what is actually being measured and keeps the SPA/initial distinction intact:

const timingAnchor = timingOrigin === 'spa_auction' ? 'SPA auction T0' : 'Edge request T0';

Apply manually — test/integrations/gpt_diagnostics/overlay.test.ts asserts the rendered Navigation T0 → … strings, so the rename has to land with those assertions.

const hasClientSideAuction = intent?.sources.has('prebid_refresh') === true;
if (hasTrustedServerAuction && hasClientSideAuction) return 'competing';
if (hasClientSideAuction) return 'client_side';
if (hasTrustedServerAuction) return trustedServerEvidence?.auctionType ?? 'ssat';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — This is the one place the console infers rather than reports. Any direct evidence whose auctionFacts failed normalization is labelled SSAT, including a SPA request — your own test pins the behaviour (auctionType: 'invalid''ssat'), and the same fallback fires when the facts object is missing entirely. The panel then makes a positive claim about a path that was never observed, in a document that opens with "reports positive observations, not inferred ownership".

The producer side is already almost there: diagnosticsAuctionFacts always emits auctionType when generation > 0, and only returns undefined for the generation-0 case with no winner and no diagnostics. If it emitted { auctionType: 'ssat' } for that case too, this line could be return trustedServerEvidence?.auctionType; and a malformed payload would stay unlabelled instead of being relabelled.

Apply manually — touches gpt/index.ts, this file, and the store/ad_init expectations together.

}

/** Hide GPT's ubiquitous 1×1 placeholder from presentation while retaining it in exports. */
export function displayableGptFillSize(size: Size | undefined): Size | undefined {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — Returning undefined for 1x1 means the badge and panel omit the Fill line entirely, which is indistinguishable from a cycle where slotRenderEnded carried no size at all. The export keeps the truth (and the browser spec now asserts that), but the console is the first place an operator looks, and "GPT told us 1x1" and "GPT told us nothing" are different diagnoses — the first is a live flexible/APS creative, the second is a missing observation.

Labeling rather than hiding keeps the noise down without erasing the fact, e.g. render Fill 1x1 (GPT placeholder) and leave the helper as a presentation predicate:

/** GPT's ubiquitous 1x1 fill size carries no layout information on its own. */
export function isGptFillSizePlaceholder(size: Size | undefined): boolean {
  return size?.[0] === 1 && size[1] === 1;
}

Apply manually — the badge and overlay call sites plus their assertions move with it.

bid.hb_auction_id,
requestedSlotSizes
);
const auctionFacts = diagnosticsAuctionFacts(generation, auctionDiagnostics, bid);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — The if (auctionFacts) … else … branch repeats five arguments to make the sixth conditional, but auctionFacts is an optional parameter, so passing it through directly is equivalent:

ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity(
  gptSlot,
  slot.id,
  opportunity,
  bid.hb_auction_id,
  requestedSlotSizes,
  diagnosticsAuctionFacts(generation, auctionDiagnostics, bid)
);

I verified this in a scratch worktree rather than guessing: npx tsc --noEmit reports no new errors, but 8 assertions in test/integrations/gpt/ad_init.test.ts fail, because toHaveBeenCalledWith compares argument-list length and a trailing explicit undefined no longer matches a 5-argument expectation. So the branch currently exists only to preserve the call arity those assertions pin.

Apply manually — worth relaxing the assertions (they care about the facts payload, not the argument count) rather than keeping the duplicated call, but it needs the test file, so it can't be a one-click suggestion.

await emit(page, "slotRenderEnded", "gpt-diagnostics-slot-primary", {
isEmpty: false,
size: [300, 250],
size: [1, 1],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — This was the only place the browser suite asserted an exported GPT fill size, and it changed from [300, 250] to [1, 1] rather than gaining a case. The new "1x1 stays in the JSON evidence" behaviour deserves end-to-end coverage, but so does the ordinary size, which is now only covered in vitest (badges.test.ts). Emitting the second cycle with a real size — or adding one 1x1 slot alongside the existing [300, 250] one — keeps both paths proven against a real GPT surface.


const runtimeSlotNumber = this.nextRuntimeSlotNumber;
this.nextRuntimeSlotNumber += 1;
const runtimeSlotNumber = existingNumber ?? this.nextRuntimeSlotNumber;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 praise — Reusing the WeakMap number on re-entry is what finally makes the documented "Ad #N identity and request numbers remain stable and monotonic for the console lifetime" claim true, and it composes correctly with the surrounding bookkeeping: the eviction branch above is the only slots.delete site and it also splices slotOrder and shifts slotActivityOrder, so the reused number can't produce a duplicate slotOrder entry or clobber a live record. requestNumbers being a WeakMap too means the cycle numbering survives the same round trip.

Two other things worth calling out in this PR: gating the page-bids timings on the Ok(result) arm so a failure before any provider dispatch can't fabricate dispatch evidence (with a test that asserts exactly that), and the bounded normalization boundary in normalizedServerAuctionTimings / normalizedAuctionWinner — including the price-bucket shape check — backed by a malformed-input test.

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE

Follow-up to the detailed review above, correcting its verdict: none of its findings are blocking (no 🔧 wrench, no ❓ question), and all 14 CI checks pass, so this should have been submitted as an approval rather than a comment.

Every inline comment there stands as written and remains worth reading — 4 🤔 thinking, 2 ♻️ refactor (one as a one-click suggestion), 2 ⛏ nitpick, 1 👍 praise — but each is a merge-can-proceed observation. The two most substantive, if you want to pick any of them up here rather than in a follow-up:

  • SPA auctionDispatchedMs is a structural 0 (the clock is read on the line after it starts), which also makes auctionWaitMs a duplicate of auctionResolvedMscrates/trusted-server-core/src/publisher.rs:6748.
  • Navigation T0 in the panel is the edge request-receipt offset, not the browser's navigationStartcrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts:246.

Approved on ffc0438dd5f872e68c3ccad36d5c35bde47101d2.

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