Skip to content

Add GPT runtime diagnostics overlay - #974

Open
ChristianPavilonis wants to merge 3 commits into
mainfrom
feature/gpt-runtime-diagnostics-overlay
Open

Add GPT runtime diagnostics overlay#974
ChristianPavilonis wants to merge 3 commits into
mainfrom
feature/gpt-runtime-diagnostics-overlay

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • Add an opt-in, tab-local GPT runtime diagnostics console for inspecting GPT-observed slot lifecycles and exact DOM bindings.
  • Keep diagnostics memory-only, bounded, local, and inactive by default without changing GPT, publisher, browser, auction, or network behavior.
  • Add deterministic unit and browser coverage plus operator documentation for activation, interpretation, export, privacy, and limitations.

Changes

File Change
crates/trusted-server-core/src/integrations/gpt_diagnostics* Register the deployment-controlled integration and inject the early ts_console activation bootstrap.
crates/trusted-server-core/src/{config,html_processor,migration_guards}.rs Wire configuration, injection ordering, validation, and migration guards.
crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/ Implement bounded lifecycle storage, documented GPT observation, conservative correlation, exact DOM binding, the read-only API/export, closed-shadow panel, and viewport badges.
crates/trusted-server-js/lib/src/core/ Publish the versioned diagnostics API and allowlisted export types.
crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/ Cover activation, retention, matching, invalid order, binding ambiguity, API isolation, presentation lifecycle, badges, and runtime composition.
crates/trusted-server-integration-tests/ Add a deterministic GPT stub, Next.js fixture, and Playwright coverage for active/inactive behavior, lifecycle capture, ambiguity, remounting, export, privacy, and non-interference.
docs/guide/integrations/gpt-diagnostics.md Document deployment, tab activation, observed facts, export, privacy boundaries, and limitations.
docs/superpowers/{specs,plans}/2026-07-28-gpt-runtime-diagnostics-overlay* Record the finalized design, implementation plan, decisions, and verification status.
trusted-server.example.toml Add the disabled-by-default integration configuration example.

Closes

Closes #973

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other:
    • cargo test-cloudflare && cargo test-spin
    • cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm
    • cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity
    • ./scripts/test-cli.sh
    • CI=1 ./scripts/integration-tests.sh --nocapture
    • ./scripts/integration-tests-browser.sh
    • cd crates/trusted-server-js/lib && node build-all.mjs
    • cd docs && npm run build

Live-publisher acceptance remains pending because no source-controlled publisher URL or credentials are available. The deterministic browser fixture covers the same activation, lifecycle, binding, export, privacy, and non-interference boundaries locally.

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses project logging macros rather than println!
  • New code has tests
  • No secrets or credentials committed

Give publisher operators an opt-in, tab-local view of GPT-observed slot lifecycles and exact DOM bindings without changing auction or publisher behavior. Keep capture bounded, export allowlisted facts only, and cover activation, correlation, presentation, and non-interference across unit and browser tests.
@ChristianPavilonis ChristianPavilonis self-assigned this Jul 28, 2026
Scope the diagnostics navigation assertion to the fixture navigation landmark so Playwright does not match the separate site-wide Home link in CI.
@ChristianPavilonis

Copy link
Copy Markdown
Collaborator Author

Fixed the browser integration failure in 79d0832. The test used an unscoped Home link locator, which matched both the site navigation and the GPT fixture navigation in CI. The locator is now scoped to the Fixture navigation landmark.

Local verification: ./scripts/integration-tests-browser.sh — Next.js 15 passed / 1 skipped; WordPress 5 passed / 11 skipped.

@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review July 29, 2026 15:19
@ChristianPavilonis
ChristianPavilonis requested review from aram356 and prk-Jr and removed request for prk-Jr July 29, 2026 15:20
ChristianPavilonis added a commit that referenced this pull request Jul 29, 2026
# Conflicts:
#	crates/trusted-server-js/lib/src/core/types.ts

@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

A well-built, self-contained diagnostics module: bounded memory, exact-ID binding that refuses to guess, ambiguity kept as a first-class outcome, closed shadow DOM, and no mutation of publisher slot elements. No correctness or security defects found, so everything below is a suggestion rather than a blocker.

Suggestions

🤔 thinking

  • Enabling diagnostics ships 30 KB of tester-only code to every visitor: tsjs-gpt_diagnostics.js is 30,343 bytes minified, the largest module in the bundle and 3× core, concatenated synchronously for 100% of traffic while every tab stays inactive until ?ts_console=1. Immediate loading is the right call and is test-locked; the tension is that sessionStorage-only activation gives the server no way to scope the payload to activated sessions. (crates/trusted-server-core/src/integrations/gpt_diagnostics.rs:56)
  • Unfiltered childList mutations drive full snapshots and forced layout on every DOM insertion, amplified by four independent per-frame snapshot consumers. (badges.ts:183)
  • slotOnload / impressionViewable are discarded when GPT omits isEmpty, because both matchers require isEmpty === false while the overlay treats undefined as pending. (store.ts:245, store.ts:264)

♻️ refactor

  • A missing CSS.escape surfaces as duplicate_dom_id in the exported binding reason, reporting a collision that does not exist. (binding.ts:180)

⛏ nitpick

  • Badge clamp hardcodes the stylesheet width (264 vs max-width: 260px). (badges.ts:134)

🌱 seedling

  • Confirmed auction-to-render traceability is the natural next layer. slotRenderEnded already carries lineItemId, campaignId, advertiserId and the sourceAgnostic* pair on the same event object the observer reads, and the render bridge already knows when the rendered creative requests markup from Trusted Server. Together those answer whether the line item Trusted Server won is the one that rendered, or whether a house ad, direct-sold line item, or Ad Manager default took the slot — on observed evidence, never inferred from targeting or price. Implemented and opening stacked on this PR; it keeps the export clear of the privacy regex this PR's spec asserts.

👍 praise

  • The observed = matched + unmatched + ambiguous invariant asserted directly in both the unit and browser tests.
  • Binding refuses prefix and container-ID guesses, and keeps ambiguity visible instead of picking a winner.
  • referencesUnchanged() pins the non-invasiveness claim to an assertion — cmd.push, display, defineSlot, refresh, fetch, XHR.open, and the history methods all verified unpatched — and the export is checked against a privacy regex.
  • Remount handling for hydration-driven removal, with an explicit host-collision guard.

CI Status

All 19 checks pass on 79d0832.

  • fmt: PASS
  • clippy / cargo check (fastly, axum, cloudflare, spin): PASS
  • rust tests (incl. cross-adapter parity, ts CLI): PASS
  • js tests (vitest) and format-typescript: PASS
  • browser integration tests: PASS
  • format-docs: PASS

let integration = Arc::new(GptDiagnosticsIntegration);
Ok(Some(
IntegrationRegistration::builder(GPT_DIAGNOSTICS_INTEGRATION_ID)
.with_head_injector(integration)

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 — Worth weighing: enabling diagnostics ships 30 KB of tester-only code to every visitor.

Built from a clean worktree of 79d0832, tsjs-gpt_diagnostics.js is 30,343 bytes minified — the largest module in the bundle and 3× tsjs-core.js (10,031 B). With no .with_deferred_js(), js_module_ids_immediate() includes it in the synchronous first-party bundle injected at <head> start, so once an operator sets enabled = true every visitor downloads and parses it even though every tab stays inactive until ?ts_console=1.

enabled_registry_includes_diagnostics_in_immediate_modules shows this is deliberate, and the reasoning holds: deferring would delay listener installation past the first slotRequested/slotRenderEnded and lose the initial impression's cycle, which is the one operators most want. So this is not a suggestion to defer.

The tension comes from sessionStorage-only activation leaving the server no signal, which forces an all-or-nothing payload decision. A server-set session cookie on the activation directive would resolve both at once — the module is concatenated only for an activated tester, and for that tester it still loads immediately with listeners installed exactly as early as today. That is what #961 did with __Host-ts-console, and this test could keep asserting immediate loading, just scoped to activated sessions.

If shipping to all visitors is an accepted cost for now, it may be worth recording the rationale in gpt-diagnostics.md beside the deployment configuration so operators know what enabling it costs their page weight.

);
const relevant = records.some(
(record) =>
record.type === 'childList' ||

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 — Any DOM insertion anywhere on the page triggers a full snapshot and a forced layout.

Attribute records are correctly filtered to known slot element IDs, but record.type === 'childList' matches every childList mutation in the document. On an ad-heavy page that fires more or less continuously, and each resulting update() deep-clones the whole store and calls getBoundingClientRect() for every bound slot.

It compounds because four consumers snapshot independently, each cloning the entire store per frame: binding.ts:109, badges.ts:119, overlay.ts:353, api.ts:78.

Filtering childList records the way the attribute records already are — added or removed nodes intersecting a known slot element ID — and/or sharing one snapshot per frame would remove most of the churn.

slot,
timestampMs,
(cycle) =>
cycle.renderAtMs !== undefined && cycle.isEmpty === false && cycle.loadAtMs === 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 — A load or viewable callback is dropped when GPT omits isEmpty.

This matcher and the impressionViewable one at line 264 both require cycle.isEmpty === false. The observer normalizes a non-boolean isEmpty to undefined, and the overlay already treats that state as "Pending render" rather than "not filled" — so a legitimate slotOnload on such a cycle becomes unmatched plus a no_compatible_request_cycle issue instead of being recorded.

isEmpty !== true keeps the conservative intent — never attach a load to a known-empty cycle — without discarding the ambiguous one:

Suggested change
cycle.renderAtMs !== undefined && cycle.isEmpty === false && cycle.loadAtMs === undefined,
cycle.renderAtMs !== undefined && cycle.isEmpty !== true && cycle.loadAtMs === undefined,


const element = candidate as HTMLElement;
const escape = this.window.CSS?.escape;
if (typeof escape !== 'function') {

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 — A missing CSS.escape is reported to the operator as a duplicate DOM ID.

When CSS.escape is unavailable this returns ambiguous / duplicate_dom_id, and GptDiagnosticsBindingReason is part of the exported JSON, so an operator debugging an unbound slot is told they have an ID collision that does not exist.

A distinct reason in core/types.ts would keep the export honest:

export type GptDiagnosticsBindingReason =
  | 'missing_slot_element_id'
  | 'missing_element'
  | 'duplicate_dom_id'
  | 'duplicate_check_unavailable'
  | 'duplicate_gpt_slot_id';

badge.className = 'tsgd-badge';
badge.dataset.runtimeSlot = String(slot.runtimeSlotNumber);
badge.textContent = badgeText(cycle);
badge.style.left = `${Math.max(4, Math.min(rectangle.left, this.window.innerWidth - 264))}px`;

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.

nitpick264 duplicates the stylesheet's max-width: 260px plus margin. The two will drift the next time the badge width changes; a shared constant would keep them together.

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.

Add opt-in GPT runtime diagnostics overlay

2 participants