diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7bbecd747..40bf0e6c3 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -18,8 +18,9 @@ use crate::error::TrustedServerError; use crate::integrations::{ adserver_mock::AdServerMockConfig, aps::ApsConfig, datadome::DataDomeConfig, didomi::DidomiIntegrationConfig, google_tag_manager::GoogleTagManagerConfig, gpt::GptConfig, - lockr::LockrConfig, nextjs::NextJsIntegrationConfig, osano::OsanoConfig, - permutive::PermutiveConfig, prebid, sourcepoint::SourcepointConfig, testlight::TestlightConfig, + gpt_diagnostics::GptDiagnosticsConfig, lockr::LockrConfig, nextjs::NextJsIntegrationConfig, + osano::OsanoConfig, permutive::PermutiveConfig, prebid, sourcepoint::SourcepointConfig, + testlight::TestlightConfig, }; use crate::settings::{IntegrationConfig, Settings}; @@ -39,6 +40,7 @@ const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ "google_tag_manager", "datadome", "gpt", + "gpt_diagnostics", ]; /// Typed app-config root used by the `ts` CLI. @@ -155,6 +157,7 @@ fn validate_enabled_integrations( validate_integration::(settings, "google_tag_manager")?; validate_integration::(settings, "datadome")?; validate_integration::(settings, "gpt")?; + validate_integration::(settings, "gpt_diagnostics")?; Ok(enabled_auction_providers) } diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index ad69e51c5..32fdcbbe2 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -792,6 +792,56 @@ mod tests { ); } + #[test] + fn gpt_diagnostics_bootstrap_precedes_immediate_bundle_once() { + let html = "Test"; + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &json!({ "enabled": true })) + .expect("should insert GPT diagnostics config"); + + let mut config = create_test_config(); + config.integrations = + IntegrationRegistry::new(&settings).expect("should build integration registry"); + + let processor = create_html_processor(config); + let pipeline_config = PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size: 8192, + }; + let mut pipeline = StreamingPipeline::new(pipeline_config, processor); + let mut output = Vec::new(); + + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("should process HTML"); + let processed = String::from_utf8(output).expect("should produce valid UTF-8"); + let bootstrap_marker = "__tsjs_gpt_diagnostics_active"; + let bundle_marker = "id=\"trustedserver-js\""; + + assert_eq!( + processed.matches(bootstrap_marker).count(), + 1, + "should inject the diagnostics bootstrap once" + ); + assert_eq!( + processed.matches(bundle_marker).count(), + 1, + "should inject the immediate TSJS bundle once" + ); + assert!( + processed + .find(bootstrap_marker) + .expect("should include diagnostics bootstrap") + < processed + .find(bundle_marker) + .expect("should include immediate TSJS bundle"), + "should activate diagnostics before the immediate bundle executes" + ); + } + #[test] fn test_create_html_processor_url_replacement() { let config = create_test_config(); diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs new file mode 100644 index 000000000..b781bfca2 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -0,0 +1,233 @@ +//! GPT runtime diagnostics integration. +//! +//! This integration only makes the browser diagnostics module available and +//! injects its early tab-activation bootstrap. GPT observation and presentation +//! remain entirely client-side and do not alter ad serving behavior. + +use std::sync::Arc; + +use error_stack::Report; +use serde::Deserialize; +use validator::Validate; + +use crate::error::TrustedServerError; +use crate::settings::{IntegrationConfig, Settings}; + +use super::{IntegrationHeadInjector, IntegrationHtmlContext, IntegrationRegistration}; + +const GPT_DIAGNOSTICS_INTEGRATION_ID: &str = "gpt_diagnostics"; +const GPT_DIAGNOSTICS_BOOTSTRAP_JS: &str = include_str!("gpt_diagnostics_bootstrap.js"); + +/// Configuration for the GPT runtime diagnostics integration. +#[derive(Debug, Clone, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct GptDiagnosticsConfig { + /// Whether the GPT diagnostics browser module is available. + #[serde(default)] + pub enabled: bool, +} + +impl IntegrationConfig for GptDiagnosticsConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +struct GptDiagnosticsIntegration; + +/// Register GPT diagnostics when explicitly enabled. +/// +/// # Errors +/// +/// Returns an error when the integration configuration cannot be parsed or +/// fails validation. +pub fn register( + settings: &Settings, +) -> Result, Report> { + let Some(_config) = + settings.integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID)? + else { + return Ok(None); + }; + + let integration = Arc::new(GptDiagnosticsIntegration); + Ok(Some( + IntegrationRegistration::builder(GPT_DIAGNOSTICS_INTEGRATION_ID) + .with_head_injector(integration) + .build(), + )) +} + +impl IntegrationHeadInjector for GptDiagnosticsIntegration { + fn integration_id(&self) -> &'static str { + GPT_DIAGNOSTICS_INTEGRATION_ID + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + vec![format!("")] + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::integrations::{IntegrationDocumentState, IntegrationRegistry}; + use crate::test_support::tests::create_test_settings; + + #[test] + fn register_returns_none_without_config() { + let settings = create_test_settings(); + + let registration = register(&settings).expect("should evaluate diagnostics config"); + + assert!( + registration.is_none(), + "should not register diagnostics without explicit config" + ); + } + + #[test] + fn register_returns_none_when_disabled() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config(GPT_DIAGNOSTICS_INTEGRATION_ID, &json!({ "enabled": false })) + .expect("should insert diagnostics config"); + + let registration = register(&settings).expect("should parse diagnostics config"); + + assert!( + registration.is_none(), + "should not register disabled diagnostics" + ); + } + + #[test] + fn register_adds_only_immediate_js_and_head_bootstrap() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config(GPT_DIAGNOSTICS_INTEGRATION_ID, &json!({ "enabled": true })) + .expect("should insert diagnostics config"); + + let registration = register(&settings) + .expect("should parse diagnostics config") + .expect("should register enabled diagnostics"); + + assert_eq!(registration.integration_id, GPT_DIAGNOSTICS_INTEGRATION_ID); + assert!( + !registration.js_deferred, + "should load diagnostics immediately" + ); + assert!(!registration.js_disabled, "should include diagnostics JS"); + assert!( + registration.proxies.is_empty(), + "should register no proxy routes" + ); + assert!( + registration.attribute_rewriters.is_empty(), + "should register no attribute rewriters" + ); + assert!( + registration.script_rewriters.is_empty(), + "should register no script rewriters" + ); + assert!( + registration.html_post_processors.is_empty(), + "should register no HTML post-processors" + ); + assert!( + registration.request_filters.is_empty(), + "should register no request filters" + ); + assert_eq!( + registration.head_injectors.len(), + 1, + "should register one activation bootstrap" + ); + } + + #[test] + fn enabled_registry_includes_diagnostics_in_immediate_modules() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config(GPT_DIAGNOSTICS_INTEGRATION_ID, &json!({ "enabled": true })) + .expect("should insert diagnostics config"); + + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + + assert!( + registry + .js_module_ids_immediate() + .contains(&GPT_DIAGNOSTICS_INTEGRATION_ID), + "should include diagnostics in the immediate JS bundle" + ); + assert!( + !registry + .js_module_ids_deferred() + .contains(&GPT_DIAGNOSTICS_INTEGRATION_ID), + "should not defer diagnostics listener installation" + ); + } + + #[test] + fn head_bootstrap_only_exposes_private_activation_logic() { + let integration = GptDiagnosticsIntegration; + let document_state = IntegrationDocumentState::default(); + let context = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&context); + + assert_eq!(inserts.len(), 1, "should emit one bootstrap script"); + assert!( + inserts[0].contains("ts_console"), + "should read the activation query parameter" + ); + assert!( + inserts[0].contains("sessionStorage"), + "should persist activation within the tab" + ); + assert!( + inserts[0].contains("history.replaceState"), + "should remove recognized activation directives" + ); + assert!( + inserts[0].contains("__tsjs_gpt_diagnostics_active"), + "should expose only the private document activation flag" + ); + assert!( + !inserts[0].contains("gptDiagnostics ="), + "bootstrap should not expose the public diagnostics API" + ); + } + + #[test] + fn config_rejects_unknown_fields() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + GPT_DIAGNOSTICS_INTEGRATION_ID, + &json!({ "enabled": true, "typo": true }), + ) + .expect("should insert diagnostics config"); + + let error = settings + .integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID) + .expect_err("should reject unknown diagnostics config fields"); + let error_text = format!("{error:?}"); + + assert!( + error_text.contains("typo") || error_text.contains("unknown field"), + "error should mention the unknown field: {error:?}" + ); + } +} diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js new file mode 100644 index 000000000..a857fc984 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js @@ -0,0 +1,59 @@ +// Early activation bootstrap for the GPT diagnostics integration. +// +// This script intentionally owns only tab-local activation and one-time URL +// cleanup. The TypeScript integration reads the document flag below and owns +// all GPT observation, storage, API, and presentation behavior. +(function () { + if (typeof window === "undefined") return; + + var queryName = "ts_console"; + var storageKey = "tsjs:gptDiagnostics:active"; + var activeFlag = "__tsjs_gpt_diagnostics_active"; + var active = false; + var directiveRecognized = false; + var url; + + try { + url = new URL(window.location.href); + var value = url.searchParams.get(queryName); + + if (value === "1" || value === "true") { + active = true; + directiveRecognized = true; + } else if (value === "0" || value === "false") { + active = false; + directiveRecognized = true; + } + } catch (_) { + url = undefined; + } + + if (directiveRecognized) { + try { + window.sessionStorage.setItem(storageKey, active ? "1" : "0"); + } catch (_) { + // The recognized directive still applies to this document. + } + + if (url) { + url.searchParams.delete(queryName); + try { + window.history.replaceState( + window.history.state, + "", + url.pathname + url.search + url.hash, + ); + } catch (_) { + // URL cleanup is optional and must not block diagnostics activation. + } + } + } else { + try { + active = window.sessionStorage.getItem(storageKey) === "1"; + } catch (_) { + active = false; + } + } + + window[activeFlag] = active; +})(); diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index bf6d63216..50df4b393 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -17,6 +17,7 @@ pub mod datadome; pub mod didomi; pub mod google_tag_manager; pub mod gpt; +pub mod gpt_diagnostics; pub mod lockr; pub mod nextjs; pub mod osano; @@ -332,6 +333,10 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { id: "gpt", build: gpt::register, }, + IntegrationBuilder { + id: "gpt_diagnostics", + build: gpt_diagnostics::register, + }, ] } diff --git a/crates/trusted-server-core/src/migration_guards.rs b/crates/trusted-server-core/src/migration_guards.rs index 24c899f39..ad3e5350c 100644 --- a/crates/trusted-server-core/src/migration_guards.rs +++ b/crates/trusted-server-core/src/migration_guards.rs @@ -115,6 +115,10 @@ fn checked_sources() -> &'static [(&'static str, &'static str)] { include_str!("integrations/google_tag_manager.rs"), ), ("integrations/gpt.rs", include_str!("integrations/gpt.rs")), + ( + "integrations/gpt_diagnostics.rs", + include_str!("integrations/gpt_diagnostics.rs"), + ), ( "integrations/lockr.rs", include_str!("integrations/lockr.rs"), diff --git a/crates/trusted-server-integration-tests/README.md b/crates/trusted-server-integration-tests/README.md index e7263755d..66da0c759 100644 --- a/crates/trusted-server-integration-tests/README.md +++ b/crates/trusted-server-integration-tests/README.md @@ -150,6 +150,7 @@ Playwright directly. | `navigation` | 4-page SPA navigation chain preserves injection without full reload, back button works, deferred route script executes after SPA transition | | `api-passthrough` | API routes return JSON without script injection (`/api/hello`, `/api/data`) | | `form-rewriting` | `
` URL rewritten from origin to proxy on `/contact` page | +| `gpt-diagnostics` | Inactive/active tab behavior, GPT lifecycle capture, conservative overlap handling, exact binding, remount, local export, and non-interference | ### Browser-level — WordPress diff --git a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts new file mode 100644 index 000000000..6763b6b4b --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts @@ -0,0 +1,116 @@ +import type { Page } from "@playwright/test"; + +/** Install a deterministic documented-event GPT stub before publisher scripts run. */ +export async function installGptStub(page: Page): Promise { + await page.addInitScript(() => { + type StubSlot = { + getSlotElementId(): string; + getAdUnitPath(): string; + }; + type StubEvent = { slot: StubSlot } & Record; + type StubListener = (event: StubEvent) => void; + + const listeners = new Map(); + const slots = new Map(); + const pubadsService = { + addEventListener(name: string, listener: StubListener) { + const current = listeners.get(name) ?? []; + current.push(listener); + listeners.set(name, current); + }, + refresh() {}, + }; + const commandQueue = { + push(callback: () => void) { + callback(); + return 1; + }, + }; + const googletag = { + cmd: commandQueue, + display() {}, + defineSlot() {}, + pubads: () => pubadsService, + }; + const references = { + commandPush: commandQueue.push, + display: googletag.display, + defineSlot: googletag.defineSlot, + refresh: pubadsService.refresh, + fetch: window.fetch, + xhrOpen: window.XMLHttpRequest.prototype.open, + pushState: window.history.pushState, + replaceState: window.history.replaceState, + }; + + const browserWindow = window as unknown as { + googletag: typeof googletag; + __gptDiagnosticsStub: { + slot(id: string, adUnitPath?: string): StubSlot; + emit( + name: string, + slotId: string, + facts?: Record, + ): void; + listenerCounts(): Record; + captureReferences(): void; + referencesUnchanged(): boolean; + }; + }; + browserWindow.googletag = googletag; + browserWindow.__gptDiagnosticsStub = { + slot(id: string, adUnitPath = `/example/site/${id}`) { + let slot = slots.get(id); + if (!slot) { + slot = { + getSlotElementId: () => id, + getAdUnitPath: () => adUnitPath, + }; + slots.set(id, slot); + } + return slot; + }, + emit( + name: string, + slotId: string, + facts: Record = {}, + ) { + const slot = this.slot(slotId); + for (const listener of listeners.get(name) ?? []) { + listener({ slot, ...facts }); + } + }, + listenerCounts() { + return Object.fromEntries( + [...listeners.entries()].map(([name, registered]) => [ + name, + registered.length, + ]), + ); + }, + captureReferences() { + references.commandPush = commandQueue.push; + references.display = googletag.display; + references.defineSlot = googletag.defineSlot; + references.refresh = pubadsService.refresh; + references.fetch = window.fetch; + references.xhrOpen = window.XMLHttpRequest.prototype.open; + references.pushState = window.history.pushState; + references.replaceState = window.history.replaceState; + }, + referencesUnchanged() { + return ( + commandQueue.push === references.commandPush && + googletag.display === references.display && + googletag.defineSlot === references.defineSlot && + pubadsService.refresh === references.refresh && + window.fetch === references.fetch && + window.XMLHttpRequest.prototype.open === + references.xhrOpen && + window.history.pushState === references.pushState && + window.history.replaceState === references.replaceState + ); + }, + }; + }); +} diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts new file mode 100644 index 000000000..c5f7e7958 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts @@ -0,0 +1,359 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installGptStub } from "../../helpers/gpt-stub.js"; +import { readState, runtimeUrl } from "../../helpers/state.js"; + +const HOST_ID = "trusted-server-gpt-diagnostics"; +const EVENT_NAMES = [ + "slotRequested", + "slotResponseReceived", + "slotRenderEnded", + "slotOnload", + "impressionViewable", + "slotVisibilityChanged", +] as const; + +test.beforeEach(async ({ page }, testInfo) => { + const state = readState(); + if (state.framework !== "nextjs") testInfo.skip(); + await installGptStub(page); +}); + +async function waitForApi(page: Page): Promise { + await page.waitForFunction(() => + Boolean((window as any).tsjs?.gptDiagnostics), + ); +} + +async function emit( + page: Page, + name: string, + slotId: string, + facts: Record = {}, +): Promise { + await page.evaluate( + ({ eventName, id, eventFacts }) => { + (window as any).__gptDiagnosticsStub.emit( + eventName, + id, + eventFacts, + ); + }, + { eventName: name, id: slotId, eventFacts: facts }, + ); +} + +test.describe("GPT runtime diagnostics", () => { + test("is completely inactive without a tab directive", async ({ page }) => { + const diagnosticNetworkRequests: string[] = []; + page.on("request", (request) => { + if ( + ["fetch", "xhr", "beacon"].includes(request.resourceType()) && + /diagnostic|trace/i.test(request.url()) + ) { + diagnosticNetworkRequests.push(request.url()); + } + }); + + await page.goto(runtimeUrl("/gpt-diagnostics"), { waitUntil: "load" }); + + expect( + await page.evaluate(() => ({ + api: Boolean((window as any).tsjs?.gptDiagnostics), + host: Boolean( + document.getElementById("trusted-server-gpt-diagnostics"), + ), + listenerCounts: ( + window as any + ).__gptDiagnosticsStub.listenerCounts(), + })), + ).toEqual({ api: false, host: false, listenerCounts: {} }); + expect(diagnosticNetworkRequests).toEqual([]); + }); + + test("activates, cleans the URL, persists in the tab, and deactivates", async ({ + browser, + page, + }) => { + await page.goto( + runtimeUrl( + "/gpt-diagnostics?unrelated=kept&ts_console=true#fixture", + ), + { waitUntil: "load" }, + ); + await waitForApi(page); + + expect(new URL(page.url()).search).toBe("?unrelated=kept"); + expect(new URL(page.url()).hash).toBe("#fixture"); + const listenerCounts = await page.evaluate(() => + (window as any).__gptDiagnosticsStub.listenerCounts(), + ); + for (const eventName of EVENT_NAMES) + expect(listenerCounts[eventName]).toBe(1); + + await page + .getByRole("navigation", { name: "Fixture navigation" }) + .getByRole("link", { name: "Home" }) + .click(); + await page.waitForURL("**/"); + expect( + await page.evaluate(() => + Boolean((window as any).tsjs?.gptDiagnostics), + ), + ).toBe(true); + await page.goBack(); + await page.waitForURL("**/gpt-diagnostics?unrelated=kept#fixture"); + await waitForApi(page); + + await page.goto( + runtimeUrl("/gpt-diagnostics?unrelated=second#persisted"), + { + waitUntil: "load", + }, + ); + await waitForApi(page); + expect(new URL(page.url()).search).toBe("?unrelated=second"); + expect(new URL(page.url()).hash).toBe("#persisted"); + + await page.goto( + runtimeUrl( + "/gpt-diagnostics?unrelated=kept&ts_console=false#disabled", + ), + { waitUntil: "load" }, + ); + expect(new URL(page.url()).search).toBe("?unrelated=kept"); + expect(new URL(page.url()).hash).toBe("#disabled"); + expect( + await page.evaluate(() => ({ + api: Boolean((window as any).tsjs?.gptDiagnostics), + host: Boolean( + document.getElementById("trusted-server-gpt-diagnostics"), + ), + listeners: ( + window as any + ).__gptDiagnosticsStub.listenerCounts(), + })), + ).toEqual({ api: false, host: false, listeners: {} }); + + const separateContext = await browser.newContext(); + const separatePage = await separateContext.newPage(); + await installGptStub(separatePage); + await separatePage.goto(runtimeUrl("/gpt-diagnostics"), { + waitUntil: "load", + }); + expect( + await separatePage.evaluate(() => + Boolean((window as any).tsjs?.gptDiagnostics), + ), + ).toBe(false); + await separatePage.waitForLoadState("networkidle"); + await separateContext.close(); + }); + + test("captures lifecycle truth, conservative overlap, binding changes, remount, and export", async ({ + page, + }, testInfo) => { + const pageErrors: string[] = []; + const diagnosticNetworkRequests: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("request", (request) => { + if ( + ["fetch", "xhr", "beacon"].includes(request.resourceType()) && + /diagnostic|trace/i.test(request.url()) + ) { + diagnosticNetworkRequests.push(request.url()); + } + }); + + await page.goto(runtimeUrl("/gpt-diagnostics?ts_console=1"), { + waitUntil: "load", + }); + await waitForApi(page); + await page.evaluate(() => + (window as any).__gptDiagnosticsStub.captureReferences(), + ); + await expect(page.locator(`#${HOST_ID}`)).toHaveCount(1); + expect( + await page + .locator(`#${HOST_ID}`) + .evaluate((host) => host.shadowRoot === null), + ).toBe(true); + + await emit(page, "slotRequested", "gpt-diagnostics-slot-primary"); + await emit( + page, + "slotResponseReceived", + "gpt-diagnostics-slot-primary", + ); + await emit(page, "slotRenderEnded", "gpt-diagnostics-slot-primary", { + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: true, + }); + await emit(page, "slotOnload", "gpt-diagnostics-slot-primary"); + await emit(page, "impressionViewable", "gpt-diagnostics-slot-primary"); + await emit( + page, + "slotVisibilityChanged", + "gpt-diagnostics-slot-primary", + { + inViewPercentage: 80, + }, + ); + await emit(page, "slotRequested", "gpt-diagnostics-slot-secondary"); + await emit( + page, + "slotResponseReceived", + "gpt-diagnostics-slot-secondary", + ); + await emit(page, "slotRenderEnded", "gpt-diagnostics-slot-secondary", { + isEmpty: true, + }); + await emit(page, "slotRequested", "gpt-diagnostics-slot-primary"); + await emit(page, "slotRequested", "gpt-diagnostics-slot-primary"); + await emit( + page, + "slotResponseReceived", + "gpt-diagnostics-slot-primary", + ); + + await page.waitForFunction(() => { + const snapshot = (window as any).tsjs.gptDiagnostics.snapshot(); + return ( + snapshot.slots.length === 2 && + snapshot.callbackIssues.length > 0 + ); + }); + const snapshot = await page.evaluate(() => + (window as any).tsjs.gptDiagnostics.snapshot(), + ); + expect(snapshot.slots).toHaveLength(2); + const primary = snapshot.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-primary", + ); + const secondary = snapshot.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-secondary", + ); + expect(primary.binding).toEqual({ status: "bound" }); + expect( + primary.requests.map((cycle: any) => cycle.requestNumber), + ).toEqual([1, 2, 3]); + expect(primary.requests[0]).toMatchObject({ + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: true, + }); + expect( + primary.requests[0].durations.requestToResponseMs, + ).toBeGreaterThanOrEqual(0); + expect( + primary.requests[0].durations.responseToRenderMs, + ).toBeGreaterThanOrEqual(0); + expect(secondary.requests[0].isEmpty).toBe(true); + expect(snapshot.callbackIssues).toContainEqual( + expect.objectContaining({ + kind: "slotResponseReceived", + disposition: "ambiguous", + reason: "overlapping_request_cycles", + }), + ); + for (const counters of Object.values(snapshot.coverage) as any[]) { + expect(counters.observed).toBe( + counters.matched + counters.unmatched + counters.ambiguous, + ); + } + + await page.getByRole("button", { name: "Toggle duplicate ID" }).click(); + await page.waitForFunction( + () => + (window as any).tsjs.gptDiagnostics.snapshot().slots[0].binding + .status === "ambiguous", + ); + expect( + await page.evaluate( + () => + (window as any).tsjs.gptDiagnostics.snapshot().slots[0] + .binding, + ), + ).toEqual({ status: "ambiguous", reason: "duplicate_dom_id" }); + await page.getByRole("button", { name: "Toggle duplicate ID" }).click(); + await page + .getByRole("button", { name: "Replace primary slot" }) + .click(); + await page.waitForFunction( + () => + (window as any).tsjs.gptDiagnostics.snapshot().slots[0].binding + .status === "bound", + ); + + await page + .getByRole("button", { name: "Remove diagnostics host" }) + .click(); + await expect(page.locator(`#${HOST_ID}`)).toHaveCount(1); + + await page.evaluate(() => (window as any).tsjs.gptDiagnostics.hide()); + await expect(page.locator(`#${HOST_ID}`)).toHaveCount(0); + await emit(page, "slotRequested", "gpt-diagnostics-slot-secondary"); + await page.evaluate(() => (window as any).tsjs.gptDiagnostics.show()); + await expect(page.locator(`#${HOST_ID}`)).toHaveCount(1); + const hiddenPeriodSnapshot = await page.evaluate(() => + (window as any).tsjs.gptDiagnostics.snapshot(), + ); + expect( + hiddenPeriodSnapshot.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-secondary", + ).requests, + ).toHaveLength(2); + + const downloadPromise = page.waitForEvent("download"); + await page.evaluate(() => (window as any).tsjs.gptDiagnostics.export()); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch( + /^trusted-server-gpt-diagnostics-.*\.json$/, + ); + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + const exported = JSON.parse(Buffer.concat(chunks).toString("utf8")); + expect(exported.version).toBe(1); + expect(exported.slots).toHaveLength(hiddenPeriodSnapshot.slots.length); + expect(exported.callbackIssues).toHaveLength( + hiddenPeriodSnapshot.callbackIssues.length, + ); + expect(exported.page).toEqual({ + origin: new URL(page.url()).origin, + pathname: "/gpt-diagnostics", + }); + expect(JSON.stringify(exported)).not.toMatch( + /bidder|targeting|creativeMarkup|cookie|userId|auction/i, + ); + + expect( + await page.evaluate(() => + (window as any).__gptDiagnosticsStub.referencesUnchanged(), + ), + ).toBe(true); + expect( + await page + .locator("#gpt-diagnostics-slot-primary") + .evaluate((element) => ({ + className: element.className, + diagnosticAttributes: element + .getAttributeNames() + .filter((name) => /diagnostic|tsjs/i.test(name)), + })), + ).toEqual({ className: "", diagnosticAttributes: [] }); + const badgeEvidence = await page.screenshot(); + expect(badgeEvidence.byteLength).toBeGreaterThan(0); + await testInfo.attach("gpt-diagnostics-badge-evidence", { + body: badgeEvidence, + contentType: "image/png", + }); + expect(diagnosticNetworkRequests).toEqual([]); + expect(pageErrors).toEqual([]); + }); +}); diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index d6b436a9f..a9afaf6e6 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -90,6 +90,9 @@ script_url = "https://ads.example.com/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true +[integrations.gpt_diagnostics] +enabled = true + [proxy] certificate_check = false diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/gpt-diagnostics/page.tsx b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/gpt-diagnostics/page.tsx new file mode 100644 index 000000000..109543599 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/gpt-diagnostics/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; + +export default function GptDiagnosticsFixture() { + const [slotVersion, setSlotVersion] = useState(1); + const [duplicateId, setDuplicateId] = useState(false); + const [showPrimarySlot, setShowPrimarySlot] = useState(true); + + return ( +
+

GPT diagnostics fixture

+

Controlled fictional GPT slots for browser integration tests.

+ + +
+ + + + +
+ + {showPrimarySlot ? ( +
+ Primary fictional slot +
+ ) : null} + + {duplicateId ? ( +
+ Duplicate fixture ID +
+ ) : null} + +
+ ); +} diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 21cd9d447..5d8e41971 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -1,5 +1,11 @@ // Public tsjs core bundle: sets up the global API, queue, and default methods. -export type { AdUnit, TsjsApi } from './types'; +export type { + AdUnit, + GptDiagnosticsApi, + GptDiagnosticsExportV1, + GptDiagnosticsRequestCycle, + TsjsApi, +} from './types'; import type { TsjsApi } from './types'; import { addAdUnits } from './registry'; import { renderAdUnit, renderAllAdUnits } from './render'; diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 6de6959db..c969f986e 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -79,6 +79,101 @@ export interface AuctionBidData { debug_bid?: AuctionDebugBidData; } +export type GptDiagnosticsCallbackKind = + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged'; + +export type GptDiagnosticsCallbackDisposition = 'matched' | 'unmatched' | 'ambiguous'; + +export type GptDiagnosticsBindingReason = + | 'missing_slot_element_id' + | 'missing_element' + | 'duplicate_dom_id' + | 'duplicate_gpt_slot_id'; + +export interface GptDiagnosticsBinding { + status: 'bound' | 'unbound' | 'ambiguous'; + reason?: GptDiagnosticsBindingReason; +} + +export interface GptDiagnosticsDurations { + requestToResponseMs?: number; + responseToRenderMs?: number; + requestToRenderMs?: number; + renderToLoadMs?: number; + renderToViewableMs?: number; +} + +export interface GptDiagnosticsRequestCycle { + requestNumber: number; + requestedAtMs?: number; + responseAtMs?: number; + renderAtMs?: number; + loadAtMs?: number; + viewableAtMs?: number; + durations: GptDiagnosticsDurations; + isEmpty?: boolean; + size?: Size; + isBackfill?: boolean; + slotContentChanged?: boolean; + incompleteSequence: boolean; +} + +export interface GptDiagnosticsSlotExport { + runtimeSlotNumber: number; + slotElementId?: string; + adUnitPath?: string; + binding: GptDiagnosticsBinding; + currentVisibilityPercentage?: number; + maximumVisibilityPercentage?: number; + requests: GptDiagnosticsRequestCycle[]; +} + +export interface GptDiagnosticsCallbackIssue { + kind: GptDiagnosticsCallbackKind; + runtimeSlotNumber: number; + slotElementId?: string; + timestampMs: number; + disposition: GptDiagnosticsCallbackDisposition; + reason: string; +} + +export interface GptDiagnosticsCoverageCounters { + observed: number; + matched: number; + unmatched: number; + ambiguous: number; +} + +export interface GptDiagnosticsExportV1 { + version: 1; + capturedAt: string; + page: { + origin: string; + pathname: string; + }; + slots: GptDiagnosticsSlotExport[]; + callbackIssues: GptDiagnosticsCallbackIssue[]; + coverage: Record; + metadata: { + droppedCallbacks: number; + evictedSlots: number; + evictedRequestCycles: number; + }; +} + +export interface GptDiagnosticsApi { + snapshot(): GptDiagnosticsExportV1; + export(): void; + subscribe(listener: (snapshot: GptDiagnosticsExportV1) => void): () => void; + show(): void; + hide(): void; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -139,4 +234,6 @@ export interface TsjsApi { gptInitialLoadDisabled?: boolean; /** Guards SPA pushState hook installation. */ spaHookInstalled?: boolean; + /** Read-only GPT lifecycle diagnostics API, present only in an activated tab. */ + gptDiagnostics?: GptDiagnosticsApi; } diff --git a/crates/trusted-server-js/lib/src/index.ts b/crates/trusted-server-js/lib/src/index.ts index e3204b5dc..aa0f7931d 100644 --- a/crates/trusted-server-js/lib/src/index.ts +++ b/crates/trusted-server-js/lib/src/index.ts @@ -1,5 +1,11 @@ // Barrel re-export for convenience and tests. // At build time, each module (core + integrations) is built as a separate IIFE // by build-all.mjs. The Rust server concatenates the enabled modules at runtime. -export type { AdUnit, TsjsApi } from './core/types'; +export type { + AdUnit, + GptDiagnosticsApi, + GptDiagnosticsExportV1, + GptDiagnosticsRequestCycle, + TsjsApi, +} from './core/types'; export { log } from './core/log'; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts new file mode 100644 index 000000000..4665113e6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -0,0 +1,157 @@ +import type { GptDiagnosticsApi, GptDiagnosticsExportV1 } from '../../core/types'; + +import type { GptDiagnosticsBindingManager } from './binding'; +import type { GptDiagnosticsStoreSnapshot } from './store'; + +interface ApiStore { + snapshot(): GptDiagnosticsStoreSnapshot; + subscribe(listener: () => void): () => void; +} + +interface ApiBindingManager { + exportBinding: GptDiagnosticsBindingManager['exportBinding']; + subscribe(listener: () => void): () => void; +} + +interface PresentationControls { + show(): void; + hide(): void; +} + +type ApiWindow = Window & { + Blob: typeof Blob; + URL: typeof URL; +}; + +interface ApiOptions { + window?: ApiWindow; + document?: Document; + now?: () => Date; + schedule?: (callback: () => void) => void; +} + +type ApiListener = (snapshot: GptDiagnosticsExportV1) => void; + +/** Owns the public read-only diagnostics API and its source subscriptions. */ +export class GptDiagnosticsApiController { + readonly api: GptDiagnosticsApi; + + private readonly store: ApiStore; + private readonly bindings: ApiBindingManager; + private readonly presentation: PresentationControls; + private readonly window: ApiWindow; + private readonly document: Document; + private readonly now: () => Date; + private readonly schedule: (callback: () => void) => void; + private readonly listeners = new Set(); + private readonly unsubscribeStore: () => void; + private readonly unsubscribeBindings: () => void; + private notificationScheduled = false; + private destroyed = false; + + constructor( + store: ApiStore, + bindings: ApiBindingManager, + presentation: PresentationControls, + options: ApiOptions = {} + ) { + this.store = store; + this.bindings = bindings; + this.presentation = presentation; + this.window = options.window ?? (window as unknown as ApiWindow); + this.document = options.document ?? document; + this.now = options.now ?? (() => new Date()); + this.schedule = options.schedule ?? ((callback) => queueMicrotask(callback)); + this.unsubscribeStore = this.store.subscribe(() => this.scheduleNotification()); + this.unsubscribeBindings = this.bindings.subscribe(() => this.scheduleNotification()); + + this.api = { + snapshot: () => this.snapshot(), + export: () => this.download(), + subscribe: (listener) => this.subscribe(listener), + show: () => this.presentation.show(), + hide: () => this.presentation.hide(), + }; + } + + snapshot(): GptDiagnosticsExportV1 { + const store = this.store.snapshot(); + return { + version: 1, + capturedAt: this.now().toISOString(), + page: { + origin: this.window.location.origin, + pathname: this.window.location.pathname, + }, + slots: store.slots.map((slot) => ({ + runtimeSlotNumber: slot.runtimeSlotNumber, + slotElementId: slot.slotElementId, + adUnitPath: slot.adUnitPath, + binding: this.bindings.exportBinding(slot.runtimeSlotNumber), + currentVisibilityPercentage: slot.currentVisibilityPercentage, + maximumVisibilityPercentage: slot.maximumVisibilityPercentage, + requests: slot.requests.map((cycle) => ({ + ...cycle, + durations: { ...cycle.durations }, + size: cycle.size ? [...cycle.size] : undefined, + })), + })), + callbackIssues: store.callbackIssues.map((issue) => ({ ...issue })), + coverage: Object.fromEntries( + Object.entries(store.coverage).map(([kind, counters]) => [kind, { ...counters }]) + ) as GptDiagnosticsExportV1['coverage'], + metadata: { ...store.metadata }, + }; + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.unsubscribeStore(); + this.unsubscribeBindings(); + this.listeners.clear(); + } + + private subscribe(listener: ApiListener): () => void { + if (this.destroyed) return () => undefined; + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private download(): void { + const snapshot = this.snapshot(); + const blob = new this.window.Blob([JSON.stringify(snapshot, null, 2)], { + type: 'application/json', + }); + const objectUrl = this.window.URL.createObjectURL(blob); + const anchor = this.document.createElement('a'); + anchor.href = objectUrl; + anchor.download = `trusted-server-gpt-diagnostics-${snapshot.capturedAt.replace(/[:.]/g, '-')}.json`; + anchor.hidden = true; + this.document.body?.append(anchor); + + try { + anchor.click(); + } finally { + anchor.remove(); + this.window.URL.revokeObjectURL(objectUrl); + } + } + + private scheduleNotification(): void { + if (this.destroyed || this.notificationScheduled) return; + this.notificationScheduled = true; + this.schedule(() => { + this.notificationScheduled = false; + if (this.destroyed) return; + + for (const listener of this.listeners) { + try { + listener(this.snapshot()); + } catch { + // One API subscriber must not block the rest. + } + } + }); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts new file mode 100644 index 000000000..8c84fef2d --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts @@ -0,0 +1,203 @@ +import type { GptDiagnosticsRequestCycle } from '../../core/types'; + +import type { GptDiagnosticsBindingManager } from './binding'; +import type { GptDiagnosticsStoreSlotSnapshot, GptDiagnosticsStoreSnapshot } from './store'; + +interface BadgeStore { + snapshot(): GptDiagnosticsStoreSnapshot; + subscribe(listener: () => void): () => void; +} + +interface BadgeBindings { + get: GptDiagnosticsBindingManager['get']; + subscribe(listener: () => void): () => void; +} + +type BadgeWindow = Window & { + MutationObserver?: typeof MutationObserver; + ResizeObserver?: typeof ResizeObserver; +}; + +interface BadgeOptions { + window?: BadgeWindow; + document?: Document; + scheduleFrame?: (callback: () => void) => void; +} + +function defaultScheduleFrame(callback: () => void): void { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => callback()); + } else { + queueMicrotask(callback); + } +} + +function intersectsViewport(rectangle: DOMRect, window: Window): boolean { + return ( + rectangle.width > 0 && + rectangle.height > 0 && + rectangle.bottom > 0 && + rectangle.right > 0 && + rectangle.top < window.innerHeight && + rectangle.left < window.innerWidth + ); +} + +function formatMilliseconds(value: number | undefined): string | undefined { + if (value === undefined || !Number.isFinite(value) || value < 0) return undefined; + if (value >= 1000) return `${Math.round(value / 100) / 10} s`; + return `${Math.round(value)} ms`; +} + +function badgeText(cycle: GptDiagnosticsRequestCycle): string { + const firstLine: string[] = []; + if (cycle.isEmpty === true) firstLine.push('Empty'); + else if (cycle.isEmpty === false) firstLine.push('Filled'); + else firstLine.push('Pending'); + if (cycle.size) firstLine.push(`${cycle.size[0]}×${cycle.size[1]}`); + + const timingLine: string[] = []; + const response = formatMilliseconds(cycle.durations.requestToResponseMs); + const render = formatMilliseconds(cycle.durations.responseToRenderMs); + if (response) timingLine.push(`Response ${response}`); + if (render) timingLine.push(`Render ${render}`); + + const lines = [firstLine.join(' · ')]; + if (timingLine.length > 0) lines.push(timingLine.join(' · ')); + const viewable = formatMilliseconds(cycle.durations.renderToViewableMs); + if (viewable) lines.push(`Viewable after ${viewable}`); + if (cycle.incompleteSequence) lines.push('Incomplete sequence'); + return lines.join('\n'); +} + +function latestCycle( + slot: GptDiagnosticsStoreSlotSnapshot +): GptDiagnosticsRequestCycle | undefined { + return slot.requests[slot.requests.length - 1]; +} + +/** Renders viewport-positioned badges inside the diagnostics shadow layer. */ +export class GptDiagnosticsBadgeManager { + private readonly store: BadgeStore; + private readonly bindings: BadgeBindings; + private readonly window: BadgeWindow; + private readonly document: Document; + private readonly scheduleFrame: (callback: () => void) => void; + private readonly unsubscribeStore: () => void; + private readonly unsubscribeBindings: () => void; + private layer?: HTMLElement; + private mutationObserver?: MutationObserver; + private resizeObserver?: ResizeObserver; + private scheduled = false; + private destroyed = false; + + constructor(store: BadgeStore, bindings: BadgeBindings, options: BadgeOptions = {}) { + this.store = store; + this.bindings = bindings; + this.window = options.window ?? (window as unknown as BadgeWindow); + this.document = options.document ?? document; + this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.unsubscribeStore = this.store.subscribe(this.scheduleUpdate); + this.unsubscribeBindings = this.bindings.subscribe(this.scheduleUpdate); + this.window.addEventListener('scroll', this.scheduleUpdate, { passive: true }); + this.window.addEventListener('resize', this.scheduleUpdate, { passive: true }); + this.installMutationObserver(); + this.installResizeObserver(); + } + + setLayer(layer: HTMLElement | undefined): void { + if (this.destroyed) return; + this.layer = layer; + this.scheduleUpdate(); + } + + update(): void { + if (this.destroyed || !this.layer?.isConnected) return; + const observedElements: HTMLElement[] = []; + const badges: HTMLElement[] = []; + + for (const slot of this.store.snapshot().slots) { + const cycle = latestCycle(slot); + if (!cycle) continue; + const binding = this.bindings.get(slot.runtimeSlotNumber); + const element = binding.element; + if (binding.binding.status !== 'bound' || !element?.isConnected) continue; + + const rectangle = element.getBoundingClientRect(); + if (!intersectsViewport(rectangle, this.window)) continue; + observedElements.push(element); + + const badge = this.document.createElement('div'); + 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`; + if (rectangle.top >= 56) { + badge.style.top = `${rectangle.top - 8}px`; + badge.style.transform = 'translateY(-100%)'; + } else { + badge.style.top = `${Math.max(4, rectangle.top + 4)}px`; + } + badges.push(badge); + } + + this.layer.replaceChildren(...badges); + this.resizeObserver?.disconnect(); + for (const element of observedElements) this.resizeObserver?.observe(element); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.unsubscribeStore(); + this.unsubscribeBindings(); + this.window.removeEventListener('scroll', this.scheduleUpdate); + this.window.removeEventListener('resize', this.scheduleUpdate); + this.mutationObserver?.disconnect(); + this.resizeObserver?.disconnect(); + this.layer?.replaceChildren(); + this.layer = undefined; + } + + private readonly scheduleUpdate = (): void => { + if (this.destroyed || this.scheduled) return; + this.scheduled = true; + this.scheduleFrame(() => { + this.scheduled = false; + this.update(); + }); + }; + + private installMutationObserver(): void { + const Observer = this.window.MutationObserver; + if (typeof Observer !== 'function' || !this.document.documentElement) return; + this.mutationObserver = new Observer((records) => { + const slotElementIds = new Set( + this.store + .snapshot() + .slots.map((slot) => slot.slotElementId) + .filter((slotElementId): slotElementId is string => Boolean(slotElementId)) + ); + const relevant = records.some( + (record) => + record.type === 'childList' || + (record.target.nodeType === 1 && slotElementIds.has((record.target as Element).id)) + ); + if (relevant) this.scheduleUpdate(); + }); + this.mutationObserver.observe(this.document.documentElement, { + attributes: true, + attributeFilter: ['id', 'style', 'class'], + childList: true, + subtree: true, + }); + } + + private installResizeObserver(): void { + const Observer = this.window.ResizeObserver; + if (typeof Observer !== 'function') return; + this.resizeObserver = new Observer(this.scheduleUpdate); + } +} + +export const gptDiagnosticsBadgeTextForTest = badgeText; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts new file mode 100644 index 000000000..94bc2d881 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts @@ -0,0 +1,232 @@ +import type { GptDiagnosticsBinding, GptDiagnosticsSlotExport } from '../../core/types'; + +import type { GptDiagnosticsStoreSnapshot } from './store'; + +interface BindingStore { + snapshot(): GptDiagnosticsStoreSnapshot; + subscribe(listener: () => void): () => void; +} + +type BindingWindow = Window & { + CSS?: typeof CSS; + HTMLElement: typeof HTMLElement; + MutationObserver?: typeof MutationObserver; +}; + +interface BindingOptions { + document?: Document; + window?: BindingWindow; + scheduleFrame?: (callback: () => void) => void; +} + +export interface GptDiagnosticsBindingView { + binding: GptDiagnosticsBinding; + element?: HTMLElement; + visible: boolean; +} + +type BindingListener = () => void; + +function defaultScheduleFrame(callback: () => void): void { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => callback()); + } else { + queueMicrotask(callback); + } +} + +function isVisibleInViewport(element: HTMLElement, window: BindingWindow): boolean { + const rectangle = element.getBoundingClientRect(); + if (rectangle.width <= 0 || rectangle.height <= 0) return false; + + return ( + rectangle.bottom > 0 && + rectangle.right > 0 && + rectangle.top < window.innerHeight && + rectangle.left < window.innerWidth + ); +} + +function bindingEquals( + previous: GptDiagnosticsBindingView | undefined, + next: GptDiagnosticsBindingView +): boolean { + return ( + previous?.binding.status === next.binding.status && + previous.binding.reason === next.binding.reason && + previous.element === next.element && + previous.visible === next.visible + ); +} + +/** Tracks conservative exact DOM bindings for retained GPT slot records. */ +export class GptDiagnosticsBindingManager { + private readonly store: BindingStore; + private readonly document: Document; + private readonly window: BindingWindow; + private readonly scheduleFrame: (callback: () => void) => void; + private readonly bindings = new Map(); + private readonly listeners = new Set(); + private readonly unsubscribeStore: () => void; + private mutationObserver?: MutationObserver; + private refreshScheduled = false; + private destroyed = false; + + constructor(store: BindingStore, options: BindingOptions = {}) { + this.store = store; + this.document = options.document ?? document; + this.window = options.window ?? (window as unknown as BindingWindow); + this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.unsubscribeStore = this.store.subscribe(() => this.scheduleRefresh()); + + this.window.addEventListener('scroll', this.scheduleRefresh, { passive: true }); + this.window.addEventListener('resize', this.scheduleRefresh, { passive: true }); + this.installMutationObserver(); + this.refresh(); + } + + subscribe(listener: BindingListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + get(runtimeSlotNumber: number): GptDiagnosticsBindingView { + return ( + this.bindings.get(runtimeSlotNumber) ?? { + binding: { status: 'unbound', reason: 'missing_element' }, + visible: false, + } + ); + } + + exportBinding(runtimeSlotNumber: number): GptDiagnosticsSlotExport['binding'] { + return { ...this.get(runtimeSlotNumber).binding }; + } + + refresh(): void { + if (this.destroyed) return; + + const slots = this.store.snapshot().slots; + const claimCounts = new Map(); + for (const slot of slots) { + if (!slot.slotElementId) continue; + claimCounts.set(slot.slotElementId, (claimCounts.get(slot.slotElementId) ?? 0) + 1); + } + + const nextBindings = new Map(); + let changed = this.bindings.size !== slots.length; + for (const slot of slots) { + const next = this.resolveBinding(slot.slotElementId, claimCounts); + nextBindings.set(slot.runtimeSlotNumber, next); + changed ||= !bindingEquals(this.bindings.get(slot.runtimeSlotNumber), next); + } + + this.bindings.clear(); + for (const [runtimeSlotNumber, binding] of nextBindings) { + this.bindings.set(runtimeSlotNumber, binding); + } + + if (changed) this.notify(); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.unsubscribeStore(); + this.mutationObserver?.disconnect(); + this.window.removeEventListener('scroll', this.scheduleRefresh); + this.window.removeEventListener('resize', this.scheduleRefresh); + this.listeners.clear(); + this.bindings.clear(); + } + + private readonly scheduleRefresh = (): void => { + if (this.destroyed || this.refreshScheduled) return; + this.refreshScheduled = true; + this.scheduleFrame(() => { + this.refreshScheduled = false; + this.refresh(); + }); + }; + + private resolveBinding( + slotElementId: string | undefined, + claimCounts: Map + ): GptDiagnosticsBindingView { + if (!slotElementId) { + return { + binding: { status: 'unbound', reason: 'missing_slot_element_id' }, + visible: false, + }; + } + + if ((claimCounts.get(slotElementId) ?? 0) > 1) { + return { + binding: { status: 'ambiguous', reason: 'duplicate_gpt_slot_id' }, + visible: false, + }; + } + + const candidate = this.document.getElementById(slotElementId); + if (!candidate || !(candidate instanceof this.window.HTMLElement) || !candidate.isConnected) { + return { + binding: { status: 'unbound', reason: 'missing_element' }, + visible: false, + }; + } + + const element = candidate as HTMLElement; + const escape = this.window.CSS?.escape; + if (typeof escape !== 'function') { + return { + binding: { status: 'ambiguous', reason: 'duplicate_dom_id' }, + visible: false, + }; + } + + try { + const matches = this.document.querySelectorAll(`#${escape(slotElementId)}`); + if (matches.length !== 1 || matches[0] !== element) { + return { + binding: { status: 'ambiguous', reason: 'duplicate_dom_id' }, + visible: false, + }; + } + } catch { + return { + binding: { status: 'ambiguous', reason: 'duplicate_dom_id' }, + visible: false, + }; + } + + return { + binding: { status: 'bound' }, + element, + visible: isVisibleInViewport(element, this.window), + }; + } + + private installMutationObserver(): void { + const Observer = this.window.MutationObserver; + if (typeof Observer !== 'function' || !this.document.documentElement) return; + + const observer = new Observer(() => this.scheduleRefresh()); + this.mutationObserver = observer; + observer.observe(this.document.documentElement, { + attributes: true, + attributeFilter: ['id'], + childList: true, + subtree: true, + }); + } + + private notify(): void { + for (const listener of this.listeners) { + try { + listener(); + } catch { + // One diagnostics consumer must not block binding updates. + } + } + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts new file mode 100644 index 000000000..5cb5c9daf --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -0,0 +1,101 @@ +import { log } from '../../core/log'; +import type { GptDiagnosticsApi, TsjsApi } from '../../core/types'; + +import { GptDiagnosticsApiController } from './api'; +import { GptDiagnosticsBadgeManager } from './badges'; +import { GptDiagnosticsBindingManager } from './binding'; +import { GptDiagnosticsObserver } from './observer'; +import type { GptObserverWindow } from './observer'; +import { GptDiagnosticsOverlay } from './overlay'; +import { GptDiagnosticsStore } from './store'; + +interface GptDiagnosticsRuntime { + api: GptDiagnosticsApi; + destroy(): void; +} + +type GptDiagnosticsWindow = Window & + typeof globalThis & + GptObserverWindow & { + __tsjs_gpt_diagnostics_active?: boolean; + __tsjs_gpt_diagnostics_runtime?: GptDiagnosticsRuntime; + tsjs?: TsjsApi; + }; + +/** Whether the early bootstrap activated diagnostics for this document. */ +export function isGptDiagnosticsActive( + target: Pick< + GptDiagnosticsWindow, + '__tsjs_gpt_diagnostics_active' + > = window as GptDiagnosticsWindow +): boolean { + return target.__tsjs_gpt_diagnostics_active === true; +} + +/** Installs one active diagnostics runtime for the current document. */ +export function installGptDiagnosticsRuntime( + target: GptDiagnosticsWindow = window as GptDiagnosticsWindow +): GptDiagnosticsApi | undefined { + if (!isGptDiagnosticsActive(target)) return undefined; + if (target.__tsjs_gpt_diagnostics_runtime) { + return target.__tsjs_gpt_diagnostics_runtime.api; + } + + let bindings: GptDiagnosticsBindingManager | undefined; + let badges: GptDiagnosticsBadgeManager | undefined; + let overlay: GptDiagnosticsOverlay | undefined; + let apiController: GptDiagnosticsApiController | undefined; + + try { + if (!target.tsjs) throw new Error('TSJS core API unavailable'); + + const store = new GptDiagnosticsStore(); + const observer = new GptDiagnosticsObserver(store, { window: target }); + bindings = new GptDiagnosticsBindingManager(store, { + window: target, + document: target.document, + }); + badges = new GptDiagnosticsBadgeManager(store, bindings, { + window: target, + document: target.document, + }); + overlay = new GptDiagnosticsOverlay(store, bindings, { + window: target, + document: target.document, + onExport: () => apiController?.api.export(), + onBadgeLayerChange: (layer) => badges?.setLayer(layer), + }); + apiController = new GptDiagnosticsApiController(store, bindings, overlay, { + window: target, + document: target.document, + }); + + observer.install(); + const api = apiController.api; + const runtime: GptDiagnosticsRuntime = { + api, + destroy: () => { + if (target.tsjs?.gptDiagnostics === api) delete target.tsjs.gptDiagnostics; + apiController?.destroy(); + overlay?.destroy(); + badges?.destroy(); + bindings?.destroy(); + delete target.__tsjs_gpt_diagnostics_runtime; + }, + }; + target.tsjs.gptDiagnostics = api; + target.__tsjs_gpt_diagnostics_runtime = runtime; + return api; + } catch (error) { + apiController?.destroy(); + overlay?.destroy(); + badges?.destroy(); + bindings?.destroy(); + log.warn('gpt diagnostics: runtime installation failed', error); + return undefined; + } +} + +if (typeof window !== 'undefined' && isGptDiagnosticsActive(window as GptDiagnosticsWindow)) { + installGptDiagnosticsRuntime(window as GptDiagnosticsWindow); +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts new file mode 100644 index 000000000..a8339ffc3 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts @@ -0,0 +1,173 @@ +import { log } from '../../core/log'; +import type { Size } from '../../core/types'; + +import type { GptDiagnosticsSlotLike, GptRenderFacts } from './store'; + +export interface GptDiagnosticsObserverStore { + markGptObserved(): void; + recordSlotRequested(slot: GptDiagnosticsSlotLike): void; + recordSlotResponseReceived(slot: GptDiagnosticsSlotLike): void; + recordSlotRenderEnded(slot: GptDiagnosticsSlotLike, facts: GptRenderFacts): void; + recordSlotOnload(slot: GptDiagnosticsSlotLike): void; + recordImpressionViewable(slot: GptDiagnosticsSlotLike): void; + recordSlotVisibilityChanged(slot: GptDiagnosticsSlotLike, percentage: number): void; +} + +interface GptEvent { + slot: GptDiagnosticsSlotLike; +} + +interface GptRenderEvent extends GptEvent { + isEmpty?: boolean; + size?: unknown; + isBackfill?: boolean; + slotContentChanged?: boolean; +} + +interface GptVisibilityEvent extends GptEvent { + inViewPercentage: number; +} + +type GptEventName = + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged'; + +type GptEventListener = (event: GptEvent) => void; + +interface GptPubAdsService { + addEventListener(name: GptEventName, listener: GptEventListener): void; +} + +interface GptCommandQueue { + push(...callbacks: Array<() => void>): number; +} + +interface GoogletagLike { + cmd: GptCommandQueue; + pubads?: () => GptPubAdsService; +} + +export interface GptObserverWindow { + googletag?: GoogletagLike; +} + +interface ObserverLogger { + warn(...args: unknown[]): void; +} + +interface ObserverOptions { + window?: GptObserverWindow; + logger?: ObserverLogger; +} + +function normalizeSize(value: unknown): Size | undefined { + if ( + !Array.isArray(value) || + value.length !== 2 || + typeof value[0] !== 'number' || + typeof value[1] !== 'number' || + !Number.isFinite(value[0]) || + !Number.isFinite(value[1]) + ) { + return undefined; + } + + return [value[0], value[1]]; +} + +/** Installs documented GPT event listeners through `googletag.cmd`. */ +export class GptDiagnosticsObserver { + private readonly store: GptDiagnosticsObserverStore; + private readonly window: GptObserverWindow; + private readonly logger: ObserverLogger; + private queued = false; + private installed = false; + + constructor(store: GptDiagnosticsObserverStore, options: ObserverOptions = {}) { + this.store = store; + this.window = options.window ?? (window as unknown as GptObserverWindow); + this.logger = options.logger ?? log; + } + + install(): void { + if (this.queued || this.installed) return; + this.queued = true; + + try { + const googletag = (this.window.googletag ??= { cmd: [] }); + googletag.cmd ??= []; + googletag.cmd.push(() => this.installWhenReady(googletag)); + } catch (error) { + this.queued = false; + this.logger.warn('gpt diagnostics: command queue installation failed', error); + } + } + + private installWhenReady(googletag: GoogletagLike): void { + if (this.installed) return; + + try { + const pubads = googletag.pubads?.(); + if (!pubads || typeof pubads.addEventListener !== 'function') { + this.logger.warn('gpt diagnostics: PubAdsService unavailable'); + return; + } + + pubads.addEventListener('slotRequested', (event) => { + this.handle('slotRequested', () => this.store.recordSlotRequested(event.slot)); + }); + pubads.addEventListener('slotResponseReceived', (event) => { + this.handle('slotResponseReceived', () => + this.store.recordSlotResponseReceived(event.slot) + ); + }); + pubads.addEventListener('slotRenderEnded', (event) => { + this.handle('slotRenderEnded', () => { + const renderEvent = event as GptRenderEvent; + this.store.recordSlotRenderEnded(renderEvent.slot, { + isEmpty: typeof renderEvent.isEmpty === 'boolean' ? renderEvent.isEmpty : undefined, + size: normalizeSize(renderEvent.size), + isBackfill: + typeof renderEvent.isBackfill === 'boolean' ? renderEvent.isBackfill : undefined, + slotContentChanged: + typeof renderEvent.slotContentChanged === 'boolean' + ? renderEvent.slotContentChanged + : undefined, + }); + }); + }); + pubads.addEventListener('slotOnload', (event) => { + this.handle('slotOnload', () => this.store.recordSlotOnload(event.slot)); + }); + pubads.addEventListener('impressionViewable', (event) => { + this.handle('impressionViewable', () => this.store.recordImpressionViewable(event.slot)); + }); + pubads.addEventListener('slotVisibilityChanged', (event) => { + this.handle('slotVisibilityChanged', () => { + const visibilityEvent = event as GptVisibilityEvent; + this.store.recordSlotVisibilityChanged( + visibilityEvent.slot, + visibilityEvent.inViewPercentage + ); + }); + }); + + this.installed = true; + this.store.markGptObserved(); + } catch (error) { + this.logger.warn('gpt diagnostics: listener installation failed', error); + } + } + + private handle(kind: GptEventName, callback: () => void): void { + try { + callback(); + } catch (error) { + this.logger.warn(`gpt diagnostics: ${kind} callback failed`, error); + } + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts new file mode 100644 index 000000000..2f2eed22a --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -0,0 +1,495 @@ +import type { GptDiagnosticsRequestCycle } from '../../core/types'; + +import type { GptDiagnosticsBindingManager } from './binding'; +import type { GptDiagnosticsStoreSlotSnapshot, GptDiagnosticsStoreSnapshot } from './store'; + +export const GPT_DIAGNOSTICS_HOST_ID = 'trusted-server-gpt-diagnostics'; + +export type GptDiagnosticsFilter = 'all' | 'visible' | 'filled' | 'empty' | 'pending' | 'unbound'; + +interface OverlayStore { + snapshot(): GptDiagnosticsStoreSnapshot; + subscribe(listener: () => void): () => void; +} + +interface OverlayBindings { + get: GptDiagnosticsBindingManager['get']; + subscribe(listener: () => void): () => void; +} + +type OverlayWindow = Window & { + MutationObserver?: typeof MutationObserver; +}; + +interface OverlayOptions { + window?: OverlayWindow; + document?: Document; + scheduleFrame?: (callback: () => void) => void; + onExport?: () => void; + onShadowRoot?: (root: ShadowRoot) => void; + onBadgeLayerChange?: (layer: HTMLElement | undefined) => void; +} + +const PANEL_STYLES = ` + :host { all: initial; } + *, *::before, *::after { box-sizing: border-box; } + .tsgd-panel { + position: fixed; + z-index: 2147483647; + right: 12px; + bottom: 12px; + width: min(460px, calc(100vw - 24px)); + max-height: min(720px, calc(100vh - 24px)); + display: flex; + flex-direction: column; + overflow: hidden; + color: #f8fafc; + background: #111827; + border: 1px solid #475569; + border-radius: 10px; + box-shadow: 0 16px 50px rgb(0 0 0 / 45%); + font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + pointer-events: auto; + } + .tsgd-panel[hidden] { display: none; } + .tsgd-header, .tsgd-toolbar, .tsgd-summary, .tsgd-slot { padding: 10px 12px; } + .tsgd-header { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #334155; } + .tsgd-title { margin: 0; flex: 1; font-size: 14px; font-weight: 700; } + .tsgd-status { color: #93c5fd; font-size: 12px; } + button, select { + color: inherit; + background: #1e293b; + border: 1px solid #475569; + border-radius: 5px; + min-height: 30px; + padding: 4px 8px; + font: inherit; + } + button { cursor: pointer; } + button:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } + .tsgd-toolbar { display: flex; gap: 8px; align-items: center; border-bottom: 1px solid #334155; } + .tsgd-toolbar label { color: #cbd5e1; } + .tsgd-summary { color: #cbd5e1; border-bottom: 1px solid #334155; } + .tsgd-coverage { margin: 6px 0 0; padding: 0; list-style: none; font-size: 12px; } + .tsgd-content { overflow: auto; overscroll-behavior: contain; } + .tsgd-empty { padding: 18px 12px; color: #94a3b8; } + .tsgd-slot { border-bottom: 1px solid #334155; } + .tsgd-slot:last-child { border-bottom: 0; } + .tsgd-slot-title { display: flex; gap: 8px; align-items: baseline; } + .tsgd-slot-title strong { overflow-wrap: anywhere; } + .tsgd-state { margin-left: auto; color: #fde68a; white-space: nowrap; } + .tsgd-facts { margin: 6px 0 0; padding: 0; list-style: none; color: #cbd5e1; font-size: 12px; } + .tsgd-facts li { overflow-wrap: anywhere; } + details { margin-top: 8px; } + summary { cursor: pointer; color: #93c5fd; } + .tsgd-cycle { margin: 6px 0 0; padding: 6px 8px; background: #0f172a; border-radius: 5px; } + .tsgd-badge-layer { position: fixed; z-index: 2147483646; inset: 0; pointer-events: none; } + .tsgd-badge { + position: fixed; + max-width: 260px; + padding: 5px 7px; + color: #fff; + background: rgb(15 23 42 / 94%); + border: 1px solid #60a5fa; + border-radius: 5px; + box-shadow: 0 2px 8px rgb(0 0 0 / 35%); + font: 11px/1.35 ui-sans-serif, system-ui, sans-serif; + white-space: pre-line; + } +`; + +function defaultScheduleFrame(callback: () => void): void { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => callback()); + } else { + queueMicrotask(callback); + } +} + +function latestCycle( + slot: GptDiagnosticsStoreSlotSnapshot +): GptDiagnosticsRequestCycle | undefined { + return slot.requests[slot.requests.length - 1]; +} + +function primaryState(cycle: GptDiagnosticsRequestCycle | undefined): string { + if (!cycle) return 'Waiting for request'; + if (cycle.isEmpty === true) return 'Empty'; + if (cycle.isEmpty === false) return 'Filled'; + if (cycle.renderAtMs !== undefined) return 'Pending render'; + if (cycle.responseAtMs !== undefined) return 'Response received'; + return 'Requesting'; +} + +function formatMilliseconds(value: number | undefined): string | undefined { + if (value === undefined || !Number.isFinite(value) || value < 0) return undefined; + return `${Math.round(value * 10) / 10} ms`; +} + +function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts: string[] = []; + if (cycle.loadAtMs !== undefined) facts.push('Loaded'); + else if (cycle.isEmpty === false) facts.push('Load not observed'); + if (cycle.viewableAtMs !== undefined) facts.push('Viewable'); + if (cycle.incompleteSequence) facts.push('Incomplete sequence'); + if (cycle.size) facts.push(`Rendered size ${cycle.size[0]}×${cycle.size[1]}`); + if (cycle.isBackfill !== undefined) facts.push(`Backfill ${cycle.isBackfill ? 'yes' : 'no'}`); + if (cycle.slotContentChanged !== undefined) { + facts.push(`Slot content changed ${cycle.slotContentChanged ? 'yes' : 'no'}`); + } + + const durations = [ + ['Request → response', cycle.durations.requestToResponseMs], + ['Response → render', cycle.durations.responseToRenderMs], + ['Request → render', cycle.durations.requestToRenderMs], + ['Render → load', cycle.durations.renderToLoadMs], + ['Render → viewable', cycle.durations.renderToViewableMs], + ] as const; + for (const [label, duration] of durations) { + const formatted = formatMilliseconds(duration); + if (formatted) facts.push(`${label} ${formatted}`); + } + return facts; +} + +function cycleLabel(cycle: GptDiagnosticsRequestCycle): string { + return cycle.requestNumber === 1 ? 'Initial request' : `Refresh ${cycle.requestNumber - 1}`; +} + +function matchesFilter( + slot: GptDiagnosticsStoreSlotSnapshot, + bindings: OverlayBindings, + filter: GptDiagnosticsFilter +): boolean { + if (filter === 'all') return true; + const binding = bindings.get(slot.runtimeSlotNumber); + const latest = latestCycle(slot); + if (filter === 'visible') return binding.binding.status === 'bound' && binding.visible; + if (filter === 'filled') return latest?.isEmpty === false; + if (filter === 'empty') return latest?.isEmpty === true; + if (filter === 'pending') + return !latest || latest.isEmpty === undefined || latest.incompleteSequence; + return binding.binding.status !== 'bound'; +} + +function appendFacts(document: Document, parent: HTMLElement, facts: string[]): void { + const list = document.createElement('ul'); + list.className = 'tsgd-facts'; + for (const fact of facts) { + const item = document.createElement('li'); + item.textContent = fact; + list.append(item); + } + parent.append(list); +} + +/** Owns hydration-safe mounting and the closed-shadow diagnostics panel. */ +export class GptDiagnosticsOverlay { + private readonly store: OverlayStore; + private readonly bindings: OverlayBindings; + private readonly window: OverlayWindow; + private readonly document: Document; + private readonly scheduleFrame: (callback: () => void) => void; + private readonly onExport: () => void; + private readonly onShadowRoot?: (root: ShadowRoot) => void; + private readonly onBadgeLayerChange?: (layer: HTMLElement | undefined) => void; + private readonly unsubscribeStore: () => void; + private readonly unsubscribeBindings: () => void; + private host?: HTMLElement; + private panel?: HTMLElement; + private lifecycleObserver?: MutationObserver; + private visualReady = false; + private mountWaitStarted = false; + private renderScheduled = false; + private remountScheduled = false; + private hostCollision = false; + private collapsed = false; + private dismissed = false; + private destroyed = false; + private filter: GptDiagnosticsFilter = 'all'; + + constructor(store: OverlayStore, bindings: OverlayBindings, options: OverlayOptions = {}) { + this.store = store; + this.bindings = bindings; + this.window = options.window ?? (window as unknown as OverlayWindow); + this.document = options.document ?? document; + this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.onExport = options.onExport ?? (() => undefined); + this.onShadowRoot = options.onShadowRoot; + this.onBadgeLayerChange = options.onBadgeLayerChange; + this.unsubscribeStore = this.store.subscribe(() => this.scheduleRender()); + this.unsubscribeBindings = this.bindings.subscribe(() => this.scheduleRender()); + this.installLifecycleObserver(); + this.beginMountWait(); + } + + show(): void { + if (this.destroyed) return; + this.dismissed = false; + if (this.visualReady) this.mount(); + else this.beginMountWait(); + } + + hide(): void { + if (this.destroyed) return; + this.dismissed = true; + this.removeHost(); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.dismissed = true; + this.unsubscribeStore(); + this.unsubscribeBindings(); + this.lifecycleObserver?.disconnect(); + this.document.removeEventListener('readystatechange', this.handleReadyStateChange); + this.window.removeEventListener('load', this.handleReadyStateChange); + this.removeHost(); + } + + private readonly handleReadyStateChange = (): void => { + this.beginMountWait(); + }; + + private beginMountWait(): void { + if (this.destroyed || this.visualReady || this.mountWaitStarted) return; + if (this.document.readyState !== 'complete') { + this.document.addEventListener('readystatechange', this.handleReadyStateChange); + this.window.addEventListener('load', this.handleReadyStateChange, { once: true }); + return; + } + + this.mountWaitStarted = true; + this.document.removeEventListener('readystatechange', this.handleReadyStateChange); + this.scheduleFrame(() => { + this.scheduleFrame(() => { + this.visualReady = true; + if (!this.dismissed) this.mount(); + }); + }); + } + + private mount(): void { + if (this.destroyed || this.dismissed || !this.visualReady || this.host?.isConnected) return; + + const existing = this.document.getElementById(GPT_DIAGNOSTICS_HOST_ID); + if (existing) { + this.hostCollision = true; + return; + } + this.hostCollision = false; + + const host = this.document.createElement('div'); + host.id = GPT_DIAGNOSTICS_HOST_ID; + const root = host.attachShadow({ mode: 'closed' }); + const style = this.document.createElement('style'); + style.textContent = PANEL_STYLES; + const panel = this.document.createElement('section'); + panel.className = 'tsgd-panel'; + panel.setAttribute('role', 'region'); + panel.setAttribute('aria-label', 'GPT runtime diagnostics'); + const badgeLayer = this.document.createElement('div'); + badgeLayer.className = 'tsgd-badge-layer'; + badgeLayer.setAttribute('aria-hidden', 'true'); + root.append(style, badgeLayer, panel); + + this.host = host; + this.panel = panel; + (this.document.body ?? this.document.documentElement).append(host); + this.onShadowRoot?.(root); + this.onBadgeLayerChange?.(badgeLayer); + this.render(); + } + + private removeHost(): void { + this.onBadgeLayerChange?.(undefined); + const host = this.host; + this.host = undefined; + this.panel = undefined; + host?.remove(); + } + + private installLifecycleObserver(): void { + const Observer = this.window.MutationObserver; + if (typeof Observer !== 'function' || !this.document.documentElement) return; + this.lifecycleObserver = new Observer(() => { + if ( + this.destroyed || + this.dismissed || + !this.visualReady || + this.host?.isConnected || + this.remountScheduled + ) { + return; + } + if (this.hostCollision) { + if (this.document.getElementById(GPT_DIAGNOSTICS_HOST_ID)) return; + this.hostCollision = false; + } + this.remountScheduled = true; + this.scheduleFrame(() => { + this.remountScheduled = false; + this.mount(); + }); + }); + this.lifecycleObserver.observe(this.document.documentElement, { + childList: true, + subtree: true, + }); + } + + private scheduleRender(): void { + if (this.destroyed || this.renderScheduled) return; + this.renderScheduled = true; + this.scheduleFrame(() => { + this.renderScheduled = false; + this.render(); + }); + } + + private render(): void { + if (!this.panel || !this.host?.isConnected) return; + const snapshot = this.store.snapshot(); + const panel = this.panel; + panel.replaceChildren(); + + const header = this.document.createElement('header'); + header.className = 'tsgd-header'; + const title = this.document.createElement('h2'); + title.className = 'tsgd-title'; + title.textContent = 'GPT runtime diagnostics'; + const status = this.document.createElement('span'); + status.className = 'tsgd-status'; + status.textContent = snapshot.gptObserved ? 'GPT observed' : 'Waiting for GPT'; + const collapse = this.button(this.collapsed ? 'Expand' : 'Collapse', () => { + this.collapsed = !this.collapsed; + this.render(); + }); + collapse.setAttribute('aria-expanded', String(!this.collapsed)); + const close = this.button('Close', () => this.hide()); + header.append(title, status, collapse, close); + panel.append(header); + + if (this.collapsed) return; + + const toolbar = this.document.createElement('div'); + toolbar.className = 'tsgd-toolbar'; + const filterLabel = this.document.createElement('label'); + filterLabel.textContent = 'Filter'; + const select = this.document.createElement('select'); + select.setAttribute('aria-label', 'Filter diagnostic slots'); + const filters: Array<[GptDiagnosticsFilter, string]> = [ + ['all', 'All'], + ['visible', 'Visible'], + ['filled', 'Filled'], + ['empty', 'Empty'], + ['pending', 'Pending/Incomplete'], + ['unbound', 'Unbound/Ambiguous'], + ]; + for (const [value, label] of filters) { + const option = this.document.createElement('option'); + option.value = value; + option.textContent = label; + option.selected = this.filter === value; + select.append(option); + } + select.addEventListener('change', () => { + this.filter = select.value as GptDiagnosticsFilter; + this.render(); + }); + const exportButton = this.button('Export JSON', () => this.onExport()); + filterLabel.append(select); + toolbar.append(filterLabel, exportButton); + panel.append(toolbar); + + const summary = this.document.createElement('div'); + summary.className = 'tsgd-summary'; + summary.textContent = `${snapshot.slots.length} slots · ${snapshot.callbackIssues.length} callback issues`; + const coverage = this.document.createElement('ul'); + coverage.className = 'tsgd-coverage'; + for (const [kind, counters] of Object.entries(snapshot.coverage)) { + if (counters.observed === 0) continue; + const item = this.document.createElement('li'); + item.textContent = `${kind}: ${counters.observed} observed · ${counters.matched} matched · ${counters.unmatched} unmatched · ${counters.ambiguous} ambiguous`; + coverage.append(item); + } + summary.append(coverage); + panel.append(summary); + + const content = this.document.createElement('div'); + content.className = 'tsgd-content'; + const filteredSlots = snapshot.slots.filter((slot) => + matchesFilter(slot, this.bindings, this.filter) + ); + if (filteredSlots.length === 0) { + const empty = this.document.createElement('div'); + empty.className = 'tsgd-empty'; + empty.textContent = + snapshot.slots.length === 0 ? 'No GPT slots observed yet.' : 'No slots match.'; + content.append(empty); + } else { + for (const slot of filteredSlots) content.append(this.renderSlot(slot)); + } + panel.append(content); + } + + private renderSlot(slot: GptDiagnosticsStoreSlotSnapshot): HTMLElement { + const container = this.document.createElement('article'); + container.className = 'tsgd-slot'; + container.dataset.runtimeSlot = String(slot.runtimeSlotNumber); + const title = this.document.createElement('div'); + title.className = 'tsgd-slot-title'; + const name = this.document.createElement('strong'); + name.textContent = slot.slotElementId ?? `Unbound GPT slot ${slot.runtimeSlotNumber}`; + const latest = latestCycle(slot); + const state = this.document.createElement('span'); + state.className = 'tsgd-state'; + state.textContent = primaryState(latest); + title.append(name, state); + container.append(title); + + const binding = this.bindings.get(slot.runtimeSlotNumber); + const facts = [ + slot.adUnitPath ? `Ad unit ${slot.adUnitPath}` : undefined, + binding.binding.status === 'bound' + ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` + : binding.binding.status === 'ambiguous' + ? `Ambiguous binding · ${binding.binding.reason ?? 'unknown'}` + : `Unbound · ${binding.binding.reason ?? 'unknown'}`, + slot.currentVisibilityPercentage !== undefined + ? `GPT visibility ${slot.currentVisibilityPercentage}% (maximum ${slot.maximumVisibilityPercentage ?? slot.currentVisibilityPercentage}%)` + : undefined, + latest ? cycleLabel(latest) : undefined, + ...(latest ? cycleFacts(latest) : []), + ].filter((fact): fact is string => fact !== undefined); + appendFacts(this.document, container, facts); + + if (slot.requests.length > 1) { + const history = this.document.createElement('details'); + const summary = this.document.createElement('summary'); + summary.textContent = `Previous requests (${slot.requests.length - 1})`; + history.append(summary); + for (const cycle of slot.requests.slice(0, -1).reverse()) { + const previous = this.document.createElement('div'); + previous.className = 'tsgd-cycle'; + const heading = this.document.createElement('strong'); + heading.textContent = `${cycleLabel(cycle)} · ${primaryState(cycle)}`; + previous.append(heading); + appendFacts(this.document, previous, cycleFacts(cycle)); + history.append(previous); + } + container.append(history); + } + return container; + } + + private button(label: string, action: () => void): HTMLButtonElement { + const button = this.document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.setAttribute('aria-label', label); + button.addEventListener('click', action); + return button; + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts new file mode 100644 index 000000000..18787fa8c --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -0,0 +1,473 @@ +import type { + GptDiagnosticsCallbackDisposition, + GptDiagnosticsCallbackIssue, + GptDiagnosticsCallbackKind, + GptDiagnosticsCoverageCounters, + GptDiagnosticsDurations, + GptDiagnosticsRequestCycle, + Size, +} from '../../core/types'; + +export const MAX_DIAGNOSTIC_SLOTS = 64; +export const MAX_REQUEST_CYCLES_PER_SLOT = 10; +export const MAX_CALLBACK_ISSUES = 128; + +const CALLBACK_KINDS: GptDiagnosticsCallbackKind[] = [ + 'slotRequested', + 'slotResponseReceived', + 'slotRenderEnded', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', +]; + +export interface GptDiagnosticsSlotLike { + getSlotElementId?(): string; + getAdUnitPath?(): string; +} + +export interface GptRenderFacts { + isEmpty?: boolean; + size?: Size; + isBackfill?: boolean; + slotContentChanged?: boolean; +} + +export interface GptDiagnosticsStoreSlotSnapshot { + runtimeSlotNumber: number; + slotElementId?: string; + adUnitPath?: string; + currentVisibilityPercentage?: number; + maximumVisibilityPercentage?: number; + requests: GptDiagnosticsRequestCycle[]; +} + +export interface GptDiagnosticsStoreSnapshot { + gptObserved: boolean; + slots: GptDiagnosticsStoreSlotSnapshot[]; + callbackIssues: GptDiagnosticsCallbackIssue[]; + coverage: Record; + metadata: { + droppedCallbacks: number; + evictedSlots: number; + evictedRequestCycles: number; + }; +} + +type MutableRequestCycle = GptDiagnosticsRequestCycle; + +interface MutableSlotRecord { + runtimeSlotNumber: number; + slotElementId?: string; + adUnitPath?: string; + currentVisibilityPercentage?: number; + maximumVisibilityPercentage?: number; + requests: MutableRequestCycle[]; +} + +interface StoreOptions { + now?: () => number; + schedule?: (callback: () => void) => void; +} + +type StoreListener = () => void; + +type CompatibleCycle = (cycle: MutableRequestCycle) => boolean; + +function emptyCoverage(): Record { + return Object.fromEntries( + CALLBACK_KINDS.map((kind) => [kind, { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }]) + ) as Record; +} + +function optionalNonEmptyString(read: (() => string) | undefined): string | undefined { + if (typeof read !== 'function') return undefined; + + try { + const value = read(); + return typeof value === 'string' && value.length > 0 ? value : undefined; + } catch { + return undefined; + } +} + +function validDuration(start: number | undefined, end: number | undefined): number | undefined { + if ( + start === undefined || + end === undefined || + !Number.isFinite(start) || + !Number.isFinite(end) + ) { + return undefined; + } + + const duration = end - start; + return duration >= 0 ? duration : undefined; +} + +function derivedDurations(cycle: MutableRequestCycle): GptDiagnosticsDurations { + return { + requestToResponseMs: validDuration(cycle.requestedAtMs, cycle.responseAtMs), + responseToRenderMs: validDuration(cycle.responseAtMs, cycle.renderAtMs), + requestToRenderMs: validDuration(cycle.requestedAtMs, cycle.renderAtMs), + renderToLoadMs: validDuration(cycle.renderAtMs, cycle.loadAtMs), + renderToViewableMs: validDuration(cycle.renderAtMs, cycle.viewableAtMs), + }; +} + +function copyCycle(cycle: MutableRequestCycle): GptDiagnosticsRequestCycle { + return { + ...cycle, + durations: derivedDurations(cycle), + size: cycle.size ? ([...cycle.size] as Size) : undefined, + }; +} + +/** Bounded store for facts observed directly from GPT callbacks. */ +export class GptDiagnosticsStore { + private readonly now: () => number; + private readonly schedule: (callback: () => void) => void; + private readonly slotNumbers = new WeakMap(); + private readonly slots = new Map(); + private readonly slotOrder: number[] = []; + private readonly listeners = new Set(); + private readonly coverage = emptyCoverage(); + private readonly callbackIssues: GptDiagnosticsCallbackIssue[] = []; + private readonly metadata = { + droppedCallbacks: 0, + evictedSlots: 0, + evictedRequestCycles: 0, + }; + private nextRuntimeSlotNumber = 1; + private notificationScheduled = false; + private gptObserved = false; + + constructor(options: StoreOptions = {}) { + this.now = options.now ?? (() => performance.now()); + this.schedule = options.schedule ?? ((callback) => queueMicrotask(callback)); + } + + markGptObserved(): void { + if (this.gptObserved) return; + this.gptObserved = true; + this.notify(); + } + + subscribe(listener: StoreListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + recordSlotRequested(slot: GptDiagnosticsSlotLike): void { + const timestampMs = this.timestamp(); + const record = this.prepareCallback('slotRequested', slot, timestampMs); + if (!record) return; + + if (record.requests.length >= MAX_REQUEST_CYCLES_PER_SLOT) { + record.requests.shift(); + this.metadata.evictedRequestCycles += 1; + } + + record.requests.push({ + requestNumber: this.nextRequestNumber(record), + requestedAtMs: timestampMs, + durations: {}, + incompleteSequence: false, + }); + this.incrementDisposition('slotRequested', 'matched'); + this.notify(); + } + + recordSlotResponseReceived(slot: GptDiagnosticsSlotLike): void { + const timestampMs = this.timestamp(); + this.matchCycle( + 'slotResponseReceived', + slot, + timestampMs, + (cycle) => cycle.requestedAtMs !== undefined && cycle.responseAtMs === undefined, + (record, cycle) => { + cycle.responseAtMs = timestampMs; + if ( + validDuration(cycle.requestedAtMs, timestampMs) === undefined || + (cycle.renderAtMs !== undefined && timestampMs > cycle.renderAtMs) + ) { + cycle.incompleteSequence = true; + this.addIssue( + 'slotResponseReceived', + record, + timestampMs, + 'matched', + 'invalid_event_order' + ); + } + } + ); + } + + recordSlotRenderEnded(slot: GptDiagnosticsSlotLike, facts: GptRenderFacts): void { + const timestampMs = this.timestamp(); + this.matchCycle( + 'slotRenderEnded', + slot, + timestampMs, + (cycle) => cycle.renderAtMs === undefined, + (record, cycle) => { + cycle.renderAtMs = timestampMs; + cycle.isEmpty = facts.isEmpty; + cycle.size = facts.size ? ([...facts.size] as Size) : undefined; + cycle.isBackfill = facts.isBackfill; + cycle.slotContentChanged = facts.slotContentChanged; + + if (cycle.responseAtMs === undefined) { + cycle.incompleteSequence = true; + this.addIssue( + 'slotRenderEnded', + record, + timestampMs, + 'matched', + 'missing_response_before_render' + ); + } else if (validDuration(cycle.responseAtMs, timestampMs) === undefined) { + cycle.incompleteSequence = true; + this.addIssue('slotRenderEnded', record, timestampMs, 'matched', 'invalid_event_order'); + } + } + ); + } + + recordSlotOnload(slot: GptDiagnosticsSlotLike): void { + const timestampMs = this.timestamp(); + this.matchCycle( + 'slotOnload', + slot, + timestampMs, + (cycle) => + cycle.renderAtMs !== undefined && cycle.isEmpty === false && cycle.loadAtMs === undefined, + (record, cycle) => { + cycle.loadAtMs = timestampMs; + if (validDuration(cycle.renderAtMs, timestampMs) === undefined) { + cycle.incompleteSequence = true; + this.addIssue('slotOnload', record, timestampMs, 'matched', 'invalid_event_order'); + } + } + ); + } + + recordImpressionViewable(slot: GptDiagnosticsSlotLike): void { + const timestampMs = this.timestamp(); + this.matchCycle( + 'impressionViewable', + slot, + timestampMs, + (cycle) => + cycle.renderAtMs !== undefined && + cycle.isEmpty === false && + cycle.viewableAtMs === undefined, + (record, cycle) => { + cycle.viewableAtMs = timestampMs; + if (validDuration(cycle.renderAtMs, timestampMs) === undefined) { + cycle.incompleteSequence = true; + this.addIssue( + 'impressionViewable', + record, + timestampMs, + 'matched', + 'invalid_event_order' + ); + } + } + ); + } + + recordSlotVisibilityChanged(slot: GptDiagnosticsSlotLike, percentage: number): void { + const timestampMs = this.timestamp(); + const record = this.prepareCallback('slotVisibilityChanged', slot, timestampMs); + if (!record) return; + + if (!Number.isFinite(percentage)) { + this.incrementDisposition('slotVisibilityChanged', 'unmatched'); + this.addIssue( + 'slotVisibilityChanged', + record, + timestampMs, + 'unmatched', + 'invalid_visibility_percentage' + ); + this.notify(); + return; + } + + record.currentVisibilityPercentage = percentage; + record.maximumVisibilityPercentage = Math.max( + record.maximumVisibilityPercentage ?? percentage, + percentage + ); + this.incrementDisposition('slotVisibilityChanged', 'matched'); + this.notify(); + } + + snapshot(): GptDiagnosticsStoreSnapshot { + return { + gptObserved: this.gptObserved, + slots: this.slotOrder.flatMap((runtimeSlotNumber) => { + const record = this.slots.get(runtimeSlotNumber); + if (!record) return []; + + return [ + { + runtimeSlotNumber: record.runtimeSlotNumber, + slotElementId: record.slotElementId, + adUnitPath: record.adUnitPath, + currentVisibilityPercentage: record.currentVisibilityPercentage, + maximumVisibilityPercentage: record.maximumVisibilityPercentage, + requests: record.requests.map(copyCycle), + }, + ]; + }), + callbackIssues: this.callbackIssues.map((issue) => ({ ...issue })), + coverage: Object.fromEntries( + CALLBACK_KINDS.map((kind) => [kind, { ...this.coverage[kind] }]) + ) as Record, + metadata: { ...this.metadata }, + }; + } + + private timestamp(): number { + this.gptObserved = true; + return this.now(); + } + + private prepareCallback( + kind: GptDiagnosticsCallbackKind, + slot: GptDiagnosticsSlotLike, + timestampMs: number + ): MutableSlotRecord | undefined { + this.coverage[kind].observed += 1; + const existingNumber = this.slotNumbers.get(slot); + if (existingNumber !== undefined) { + const existingRecord = this.slots.get(existingNumber); + if (existingRecord) { + this.refreshSlotMetadata(existingRecord, slot); + return existingRecord; + } + + this.incrementDisposition(kind, 'unmatched'); + this.addIssue( + kind, + { runtimeSlotNumber: existingNumber }, + timestampMs, + 'unmatched', + 'evicted_slot' + ); + this.notify(); + return undefined; + } + + if (this.slots.size >= MAX_DIAGNOSTIC_SLOTS) { + const evictedNumber = this.slotOrder.shift(); + if (evictedNumber !== undefined) { + this.slots.delete(evictedNumber); + this.metadata.evictedSlots += 1; + } + } + + const runtimeSlotNumber = this.nextRuntimeSlotNumber; + this.nextRuntimeSlotNumber += 1; + const record: MutableSlotRecord = { + runtimeSlotNumber, + requests: [], + }; + this.slotNumbers.set(slot, runtimeSlotNumber); + this.refreshSlotMetadata(record, slot); + this.slots.set(runtimeSlotNumber, record); + this.slotOrder.push(runtimeSlotNumber); + return record; + } + + private refreshSlotMetadata(record: MutableSlotRecord, slot: GptDiagnosticsSlotLike): void { + record.slotElementId ??= optionalNonEmptyString( + typeof slot.getSlotElementId === 'function' ? slot.getSlotElementId.bind(slot) : undefined + ); + record.adUnitPath ??= optionalNonEmptyString( + typeof slot.getAdUnitPath === 'function' ? slot.getAdUnitPath.bind(slot) : undefined + ); + } + + private nextRequestNumber(record: MutableSlotRecord): number { + const latest = record.requests[record.requests.length - 1]; + return (latest?.requestNumber ?? 0) + 1; + } + + private matchCycle( + kind: GptDiagnosticsCallbackKind, + slot: GptDiagnosticsSlotLike, + timestampMs: number, + compatible: CompatibleCycle, + attach: (record: MutableSlotRecord, cycle: MutableRequestCycle) => void + ): void { + const record = this.prepareCallback(kind, slot, timestampMs); + if (!record) return; + + const candidates = record.requests.filter(compatible); + if (candidates.length === 0) { + this.incrementDisposition(kind, 'unmatched'); + this.addIssue(kind, record, timestampMs, 'unmatched', 'no_compatible_request_cycle'); + this.notify(); + return; + } + if (candidates.length > 1) { + this.incrementDisposition(kind, 'ambiguous'); + this.addIssue(kind, record, timestampMs, 'ambiguous', 'overlapping_request_cycles'); + this.notify(); + return; + } + + attach(record, candidates[0]); + this.incrementDisposition(kind, 'matched'); + this.notify(); + } + + private incrementDisposition( + kind: GptDiagnosticsCallbackKind, + disposition: GptDiagnosticsCallbackDisposition + ): void { + this.coverage[kind][disposition] += 1; + } + + private addIssue( + kind: GptDiagnosticsCallbackKind, + record: Pick, + timestampMs: number, + disposition: GptDiagnosticsCallbackDisposition, + reason: string + ): void { + if (this.callbackIssues.length >= MAX_CALLBACK_ISSUES) { + this.callbackIssues.shift(); + this.metadata.droppedCallbacks += 1; + } + + this.callbackIssues.push({ + kind, + runtimeSlotNumber: record.runtimeSlotNumber, + slotElementId: record.slotElementId, + timestampMs, + disposition, + reason, + }); + } + + private notify(): void { + if (this.notificationScheduled) return; + this.notificationScheduled = true; + this.schedule(() => { + this.notificationScheduled = false; + for (const listener of this.listeners) { + try { + listener(); + } catch { + // One diagnostics subscriber must not block the rest. + } + } + }); + } +} diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts new file mode 100644 index 000000000..b3350de51 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GptDiagnosticsBinding } from '../../../src/core/types'; +import { GptDiagnosticsApiController } from '../../../src/integrations/gpt_diagnostics/api'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; + +class FakeBindings { + private readonly listeners = new Set<() => void>(); + + exportBinding(_runtimeSlotNumber: number): GptDiagnosticsBinding { + return { status: 'bound' }; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit(): void { + for (const listener of this.listeners) listener(); + } +} + +function fakeSlot() { + return { + getSlotElementId: () => 'ad-slot-example', + getAdUnitPath: () => '/example/site/banner', + }; +} + +function readBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => resolve(String(reader.result))); + reader.addEventListener('error', () => reject(reader.error)); + reader.readAsText(blob); + }); +} + +beforeEach(() => { + vi.restoreAllMocks(); + window.history.replaceState({}, '', '/article?private=value#fragment'); +}); + +describe('GptDiagnosticsApiController', () => { + it('creates a fresh V1 allowlist snapshot with current binding facts', () => { + let monotonicNow = 10; + const store = new GptDiagnosticsStore({ + now: () => monotonicNow, + schedule: (callback) => callback(), + }); + const slot = fakeSlot(); + store.recordSlotRequested(slot); + monotonicNow = 20; + store.recordSlotResponseReceived(slot); + monotonicNow = 25; + store.recordSlotRenderEnded(slot, { isEmpty: false, size: [300, 250] }); + const bindings = new FakeBindings(); + const presentation = { show: vi.fn(), hide: vi.fn() }; + const controller = new GptDiagnosticsApiController(store, bindings, presentation, { + now: () => new Date('2026-07-28T12:34:56.000Z'), + }); + + const snapshot = controller.api.snapshot(); + + expect(snapshot).toMatchObject({ + version: 1, + capturedAt: '2026-07-28T12:34:56.000Z', + page: { + origin: window.location.origin, + pathname: '/article', + }, + slots: [ + { + runtimeSlotNumber: 1, + slotElementId: 'ad-slot-example', + adUnitPath: '/example/site/banner', + binding: { status: 'bound' }, + requests: [ + { + requestNumber: 1, + isEmpty: false, + size: [300, 250], + durations: { + requestToResponseMs: 10, + responseToRenderMs: 5, + requestToRenderMs: 15, + }, + }, + ], + }, + ], + }); + expect(JSON.stringify(snapshot.page)).not.toContain('private'); + expect(JSON.stringify(snapshot.page)).not.toContain('fragment'); + expect(JSON.stringify(snapshot)).not.toMatch(/bidder|targeting|creativeMarkup|cookie|userId/i); + + const second = controller.api.snapshot(); + expect(second).not.toBe(snapshot); + expect(second.slots).not.toBe(snapshot.slots); + }); + + it('coalesces store and binding updates and isolates subscribers', () => { + const scheduled: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => callback(), + }); + const bindings = new FakeBindings(); + const controller = new GptDiagnosticsApiController( + store, + bindings, + { show: vi.fn(), hide: vi.fn() }, + { + now: () => new Date('2026-07-28T00:00:00.000Z'), + schedule: (callback) => scheduled.push(callback), + } + ); + controller.api.subscribe(() => { + throw new Error('subscriber failed'); + }); + const listener = vi.fn(); + const unsubscribe = controller.api.subscribe(listener); + + const slot = fakeSlot(); + store.recordSlotRequested(slot); + bindings.emit(); + store.recordSlotVisibilityChanged(slot, 20); + + expect(scheduled).toHaveLength(1); + scheduled.shift()!(); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ version: 1 })); + + unsubscribe(); + store.recordSlotVisibilityChanged(slot, 30); + scheduled.shift()!(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('delegates show and hide without mutating diagnostics data', () => { + const store = new GptDiagnosticsStore({ now: () => 1 }); + const presentation = { show: vi.fn(), hide: vi.fn() }; + const controller = new GptDiagnosticsApiController(store, new FakeBindings(), presentation); + + controller.api.show(); + controller.api.hide(); + + expect(presentation.show).toHaveBeenCalledTimes(1); + expect(presentation.hide).toHaveBeenCalledTimes(1); + expect(store.snapshot().slots).toEqual([]); + }); + + it('downloads the V1 snapshot locally and revokes the object URL', async () => { + const store = new GptDiagnosticsStore({ now: () => 1 }); + store.recordSlotRequested(fakeSlot()); + const bindings = new FakeBindings(); + let capturedBlob: Blob | undefined; + const createObjectURL = vi.fn((blob: Blob) => { + capturedBlob = blob; + return 'blob:gpt-diagnostics'; + }); + const revokeObjectURL = vi.fn(); + Object.defineProperty(window.URL, 'createObjectURL', { + configurable: true, + value: createObjectURL, + }); + Object.defineProperty(window.URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectURL, + }); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const fetchReference = window.fetch; + const controller = new GptDiagnosticsApiController( + store, + bindings, + { show: vi.fn(), hide: vi.fn() }, + { now: () => new Date('2026-07-28T12:34:56.000Z') } + ); + + controller.api.export(); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(click).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:gpt-diagnostics'); + expect(document.querySelector('a[download]')).toBeNull(); + expect(window.fetch).toBe(fetchReference); + + const exported = JSON.parse(await readBlob(capturedBlob!)); + expect(exported).toMatchObject({ + version: 1, + page: { pathname: '/article' }, + }); + expect(exported.page).not.toHaveProperty('search'); + expect(exported.page).not.toHaveProperty('hash'); + }); + + it('stops source notifications after destruction', () => { + const scheduled: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => callback(), + }); + const bindings = new FakeBindings(); + const controller = new GptDiagnosticsApiController( + store, + bindings, + { show: vi.fn(), hide: vi.fn() }, + { schedule: (callback) => scheduled.push(callback) } + ); + const listener = vi.fn(); + controller.api.subscribe(listener); + + controller.destroy(); + store.recordSlotRequested(fakeSlot()); + bindings.emit(); + + expect(scheduled).toEqual([]); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts new file mode 100644 index 000000000..4e8a58a3f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GptDiagnosticsBinding } from '../../../src/core/types'; +import type { GptDiagnosticsBindingView } from '../../../src/integrations/gpt_diagnostics/binding'; +import { + GptDiagnosticsBadgeManager, + gptDiagnosticsBadgeTextForTest, +} from '../../../src/integrations/gpt_diagnostics/badges'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; + +class FakeBindings { + private readonly listeners = new Set<() => void>(); + private readonly values = new Map(); + + get(runtimeSlotNumber: number): GptDiagnosticsBindingView { + return ( + this.values.get(runtimeSlotNumber) ?? { + binding: { status: 'unbound', reason: 'missing_element' }, + visible: false, + } + ); + } + + set( + runtimeSlotNumber: number, + binding: GptDiagnosticsBinding, + element?: HTMLElement, + visible = false + ): void { + this.values.set(runtimeSlotNumber, { binding, element, visible }); + for (const listener of this.listeners) listener(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +function slot(id: string) { + return { + getSlotElementId: () => id, + getAdUnitPath: () => `/example/site/${id}`, + }; +} + +function rectangle(left: number, top: number, width: number, height: number): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect; +} + +function runFrame(frames: Array<() => void>): void { + const frame = frames.shift(); + if (!frame) throw new Error('Missing scheduled frame'); + frame(); +} + +beforeEach(() => { + document.body.replaceChildren(); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 768 }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +describe('GptDiagnosticsBadgeManager', () => { + it('shows badges only for requested, uniquely bound, visible, non-zero slots', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const eligible = document.createElement('div'); + const zeroSize = document.createElement('div'); + const offscreen = document.createElement('div'); + const neverRequested = document.createElement('div'); + document.body.append(eligible, zeroSize, offscreen, neverRequested); + vi.spyOn(eligible, 'getBoundingClientRect').mockReturnValue(rectangle(100, 100, 300, 250)); + vi.spyOn(zeroSize, 'getBoundingClientRect').mockReturnValue(rectangle(0, 0, 0, 0)); + vi.spyOn(offscreen, 'getBoundingClientRect').mockReturnValue(rectangle(100, 900, 300, 250)); + vi.spyOn(neverRequested, 'getBoundingClientRect').mockReturnValue( + rectangle(100, 100, 300, 250) + ); + + store.recordSlotRequested(slot('eligible')); + store.recordSlotRequested(slot('zero')); + store.recordSlotRequested(slot('offscreen')); + store.recordSlotVisibilityChanged(slot('unbound'), 10); + bindings.set(1, { status: 'bound' }, eligible, true); + bindings.set(2, { status: 'bound' }, zeroSize, true); + bindings.set(3, { status: 'bound' }, offscreen, false); + bindings.set(4, { status: 'ambiguous', reason: 'duplicate_dom_id' }); + bindings.set(5, { status: 'bound' }, neverRequested, true); + + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + }); + manager.setLayer(layer); + runFrame(frames); + + expect(layer.querySelectorAll('.tsgd-badge')).toHaveLength(1); + expect(layer.querySelector('.tsgd-badge')?.dataset.runtimeSlot).toBe('1'); + manager.destroy(); + }); + + it('uses only GPT-observed lifecycle facts in badge text', () => { + expect( + gptDiagnosticsBadgeTextForTest({ + requestNumber: 1, + requestedAtMs: 0, + responseAtMs: 276, + renderAtMs: 318, + viewableAtMs: 1318, + isEmpty: false, + size: [728, 90], + incompleteSequence: false, + durations: { + requestToResponseMs: 276, + responseToRenderMs: 42, + renderToViewableMs: 1000, + }, + }) + ).toBe('Filled · 728×90\nResponse 276 ms · Render 42 ms\nViewable after 1 s'); + expect( + gptDiagnosticsBadgeTextForTest({ + requestNumber: 1, + isEmpty: true, + incompleteSequence: false, + durations: {}, + }) + ).toBe('Empty'); + expect( + gptDiagnosticsBadgeTextForTest({ + requestNumber: 1, + incompleteSequence: false, + durations: {}, + }) + ).toBe('Pending'); + expect(gptDiagnosticsBadgeTextForTest.toString()).not.toMatch( + /Trusted Server|GAM winner|Prebid|bidder|provenance/i + ); + }); + + it('positions in the overlay layer and coalesces scroll and resize updates', () => { + const frames: Array<() => void> = []; + let currentRectangle = rectangle(100, 120, 300, 250); + const element = document.createElement('div'); + element.id = 'publisher-slot'; + element.className = 'publisher-class'; + element.style.width = '300px'; + document.body.append(element); + vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => currentRectangle); + const originalAttributes = element + .getAttributeNames() + .map((name) => [name, element.getAttribute(name)]); + + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + store.recordSlotRequested(slot('publisher-slot')); + const bindings = new FakeBindings(); + bindings.set(1, { status: 'bound' }, element, true); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + }); + manager.setLayer(layer); + runFrame(frames); + + const firstBadge = layer.querySelector('.tsgd-badge')!; + expect(firstBadge.style.left).toBe('100px'); + expect(firstBadge.style.top).toBe('112px'); + + currentRectangle = rectangle(220, 260, 300, 250); + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + expect(frames).toHaveLength(1); + runFrame(frames); + const movedBadge = layer.querySelector('.tsgd-badge')!; + expect(movedBadge.style.left).toBe('220px'); + expect(movedBadge.style.top).toBe('252px'); + expect(element.getAttributeNames().map((name) => [name, element.getAttribute(name)])).toEqual( + originalAttributes + ); + manager.destroy(); + }); + + it('degrades when optional observer APIs are unavailable', () => { + const frames: Array<() => void> = []; + const layer = document.createElement('div'); + document.body.append(layer); + const store = new GptDiagnosticsStore(); + const bindings = new FakeBindings(); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + window: Object.assign(window, { + MutationObserver: undefined, + ResizeObserver: undefined, + }), + scheduleFrame: (callback) => frames.push(callback), + }); + + expect(() => { + manager.setLayer(layer); + runFrame(frames); + }).not.toThrow(); + manager.destroy(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts new file mode 100644 index 000000000..e6e696344 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GptDiagnosticsBindingManager } from '../../../src/integrations/gpt_diagnostics/binding'; +import { + GptDiagnosticsStore, + type GptDiagnosticsSlotLike, +} from '../../../src/integrations/gpt_diagnostics/store'; + +const managers: GptDiagnosticsBindingManager[] = []; + +function installCssEscape(): void { + Object.defineProperty(window, 'CSS', { + configurable: true, + value: { + escape: (value: string) => value.replace(/([^a-zA-Z0-9_-])/g, '\\$1'), + }, + }); +} + +function fakeSlot(elementId?: string): GptDiagnosticsSlotLike { + return { + getSlotElementId: () => elementId ?? '', + getAdUnitPath: () => '/example/site/banner', + }; +} + +function createStore(): GptDiagnosticsStore { + return new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => callback(), + }); +} + +function createManager( + store: GptDiagnosticsStore, + scheduleFrame?: (callback: () => void) => void +): GptDiagnosticsBindingManager { + const manager = new GptDiagnosticsBindingManager(store, { scheduleFrame }); + managers.push(manager); + return manager; +} + +function setRectangle( + element: HTMLElement, + rectangle: { top: number; left: number; width: number; height: number } +): void { + const { top, left, width, height } = rectangle; + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + x: left, + y: top, + top, + left, + width, + height, + right: left + width, + bottom: top + height, + toJSON: () => ({}), + }); +} + +beforeEach(() => { + document.body.replaceChildren(); + installCssEscape(); +}); + +afterEach(() => { + for (const manager of managers.splice(0)) manager.destroy(); + vi.restoreAllMocks(); +}); + +describe('GptDiagnosticsBindingManager', () => { + it('binds only the unique exact ID and never a prefix match', () => { + const prefix = document.createElement('div'); + prefix.id = 'ad-slot-1-extra'; + const exact = document.createElement('div'); + exact.id = 'ad-slot-1'; + document.body.append(prefix, exact); + setRectangle(exact, { top: 10, left: 10, width: 300, height: 250 }); + const originalClass = exact.className; + const originalStyle = exact.getAttribute('style'); + const originalAttributes = exact.getAttributeNames(); + const store = createStore(); + store.recordSlotRequested(fakeSlot('ad-slot-1')); + + const manager = createManager(store); + const view = manager.get(1); + + expect(view.binding).toEqual({ status: 'bound' }); + expect(view.element).toBe(exact); + expect(view.visible).toBe(true); + expect(exact.className).toBe(originalClass); + expect(exact.getAttribute('style')).toBe(originalStyle); + expect(exact.getAttributeNames()).toEqual(originalAttributes); + }); + + it('reports a missing exact element as unbound', () => { + const prefix = document.createElement('div'); + prefix.id = 'missing-extra'; + document.body.append(prefix); + const store = createStore(); + store.recordSlotRequested(fakeSlot('missing')); + + const manager = createManager(store); + + expect(manager.get(1)).toMatchObject({ + binding: { status: 'unbound', reason: 'missing_element' }, + visible: false, + }); + }); + + it('reports an empty GPT element ID as unbound without a synthetic DOM ID', () => { + const store = createStore(); + store.recordSlotRequested(fakeSlot()); + + const manager = createManager(store); + + expect(manager.get(1).binding).toEqual({ + status: 'unbound', + reason: 'missing_slot_element_id', + }); + expect(store.snapshot().slots[0].slotElementId).toBeUndefined(); + }); + + it('treats duplicate DOM IDs as ambiguous', () => { + const first = document.createElement('div'); + first.id = 'duplicate'; + const second = document.createElement('div'); + second.id = 'duplicate'; + document.body.append(first, second); + const store = createStore(); + store.recordSlotRequested(fakeSlot('duplicate')); + + const manager = createManager(store); + + expect(manager.get(1)).toMatchObject({ + binding: { status: 'ambiguous', reason: 'duplicate_dom_id' }, + visible: false, + }); + }); + + it('treats duplicate retained GPT slot IDs as ambiguous', () => { + const element = document.createElement('div'); + element.id = 'shared'; + document.body.append(element); + const store = createStore(); + store.recordSlotRequested(fakeSlot('shared')); + store.recordSlotRequested(fakeSlot('shared')); + + const manager = createManager(store); + + expect(manager.get(1).binding).toEqual({ + status: 'ambiguous', + reason: 'duplicate_gpt_slot_id', + }); + expect(manager.get(2).binding).toEqual({ + status: 'ambiguous', + reason: 'duplicate_gpt_slot_id', + }); + }); + + it('rebinds after element replacement and becomes unbound after disconnection', () => { + const first = document.createElement('div'); + first.id = 'replaceable'; + document.body.append(first); + const store = createStore(); + store.recordSlotRequested(fakeSlot('replaceable')); + const manager = createManager(store); + expect(manager.get(1).element).toBe(first); + + const replacement = document.createElement('div'); + replacement.id = 'replaceable'; + first.replaceWith(replacement); + manager.refresh(); + expect(manager.get(1).element).toBe(replacement); + + replacement.remove(); + manager.refresh(); + expect(manager.get(1).binding).toEqual({ + status: 'unbound', + reason: 'missing_element', + }); + }); + + it('requires non-zero viewport intersection for DOM visibility', () => { + const element = document.createElement('div'); + element.id = 'geometry'; + document.body.append(element); + const store = createStore(); + store.recordSlotRequested(fakeSlot('geometry')); + const manager = createManager(store); + + setRectangle(element, { top: 10, left: 10, width: 0, height: 250 }); + manager.refresh(); + expect(manager.get(1).visible).toBe(false); + + setRectangle(element, { + top: window.innerHeight + 10, + left: 10, + width: 300, + height: 250, + }); + manager.refresh(); + expect(manager.get(1).visible).toBe(false); + + setRectangle(element, { top: 10, left: 10, width: 300, height: 250 }); + manager.refresh(); + expect(manager.get(1).visible).toBe(true); + }); + + it('does not guess when DOM uniqueness cannot be verified', () => { + Object.defineProperty(window, 'CSS', { configurable: true, value: undefined }); + const element = document.createElement('div'); + element.id = 'unverifiable'; + document.body.append(element); + const store = createStore(); + store.recordSlotRequested(fakeSlot('unverifiable')); + + const manager = createManager(store); + + expect(manager.get(1).binding).toEqual({ + status: 'ambiguous', + reason: 'duplicate_dom_id', + }); + }); + + it('coalesces store-driven refreshes to one animation frame', () => { + const scheduled: Array<() => void> = []; + const store = createStore(); + const manager = createManager(store, (callback) => scheduled.push(callback)); + const listener = vi.fn(); + manager.subscribe(listener); + const slot = fakeSlot('scheduled'); + + store.recordSlotRequested(slot); + store.recordSlotVisibilityChanged(slot, 10); + store.recordSlotVisibilityChanged(slot, 20); + + expect(scheduled).toHaveLength(1); + scheduled.shift()!(); + expect(listener).toHaveBeenCalledTimes(1); + expect(manager.get(1).binding).toEqual({ + status: 'unbound', + reason: 'missing_element', + }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts new file mode 100644 index 000000000..79fea5f0a --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const bootstrapPath = resolve( + process.cwd(), + '../../trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js' +); +const bootstrapSource = readFileSync(bootstrapPath, 'utf8'); +const storageKey = 'tsjs:gptDiagnostics:active'; + +type BootstrapWindow = Window & { + __tsjs_gpt_diagnostics_active?: boolean; +}; + +function runBootstrap(): void { + window.eval(bootstrapSource); +} + +function setUrl(url: string): void { + window.history.replaceState({ fixture: true }, '', url); +} + +function activeFlag(): boolean | undefined { + return (window as BootstrapWindow).__tsjs_gpt_diagnostics_active; +} + +describe('GPT diagnostics activation bootstrap', () => { + beforeEach(() => { + vi.restoreAllMocks(); + window.sessionStorage.clear(); + delete (window as BootstrapWindow).__tsjs_gpt_diagnostics_active; + setUrl('/article?existing=1#section'); + }); + + it.each(['1', 'true'])('activates the current tab for %s', (value) => { + setUrl(`/article?existing=1&ts_console=${value}#section`); + + runBootstrap(); + + expect(activeFlag()).toBe(true); + expect(window.sessionStorage.getItem(storageKey)).toBe('1'); + expect(window.location.pathname).toBe('/article'); + expect(window.location.search).toBe('?existing=1'); + expect(window.location.hash).toBe('#section'); + expect(window.history.state).toEqual({ fixture: true }); + }); + + it.each(['0', 'false'])('deactivates the current tab for %s', (value) => { + window.sessionStorage.setItem(storageKey, '1'); + setUrl(`/article?ts_console=${value}&existing=1#section`); + + runBootstrap(); + + expect(activeFlag()).toBe(false); + expect(window.sessionStorage.getItem(storageKey)).toBe('0'); + expect(window.location.search).toBe('?existing=1'); + expect(window.location.hash).toBe('#section'); + }); + + it('restores activation from session storage without a directive', () => { + window.sessionStorage.setItem(storageKey, '1'); + + runBootstrap(); + + expect(activeFlag()).toBe(true); + expect(window.location.search).toBe('?existing=1'); + }); + + it('ignores case variants and leaves the directive visible', () => { + window.sessionStorage.setItem(storageKey, '1'); + setUrl('/article?ts_console=True&existing=1#section'); + + runBootstrap(); + + expect(activeFlag()).toBe(true); + expect(window.location.search).toBe('?ts_console=True&existing=1'); + }); + + it('applies a recognized directive to the current document when storage throws', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + setUrl('/article?ts_console=true'); + + expect(() => runBootstrap()).not.toThrow(); + expect(activeFlag()).toBe(true); + expect(window.location.search).toBe(''); + }); + + it('keeps activation when URL cleanup throws', () => { + setUrl('/article?ts_console=true&existing=1#section'); + vi.spyOn(window.history, 'replaceState').mockImplementation(() => { + throw new Error('history unavailable'); + }); + + expect(() => runBootstrap()).not.toThrow(); + expect(activeFlag()).toBe(true); + expect(window.sessionStorage.getItem(storageKey)).toBe('1'); + expect(window.location.search).toBe('?ts_console=true&existing=1'); + }); + + it('removes every activation parameter after recognizing the first value', () => { + setUrl('/article?ts_console=true&existing=1&ts_console=false#section'); + + runBootstrap(); + + expect(activeFlag()).toBe(true); + expect(window.location.search).toBe('?existing=1'); + }); + + it('cleans a recognized directive only once across repeated execution', () => { + const nativeReplaceState = window.history.replaceState.bind(window.history); + const replaceState = vi + .spyOn(window.history, 'replaceState') + .mockImplementation((data, unused, url) => nativeReplaceState(data, unused, url)); + setUrl('/article?ts_console=true&existing=1#section'); + replaceState.mockClear(); + + runBootstrap(); + runBootstrap(); + + expect(activeFlag()).toBe(true); + expect(replaceState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts new file mode 100644 index 000000000..55f00de57 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TsjsApi } from '../../../src/core/types'; +import { + installGptDiagnosticsRuntime, + isGptDiagnosticsActive, +} from '../../../src/integrations/gpt_diagnostics'; +import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; + +interface FakeSlot { + getSlotElementId(): string; + getAdUnitPath(): string; +} + +type Listener = (event: any) => void; + +type DiagnosticsTestWindow = NonNullable[0]>; + +const target = window as unknown as DiagnosticsTestWindow; + +function coreApi(): TsjsApi { + return { + version: 'test', + que: [], + addAdUnits: vi.fn(), + renderAdUnit: vi.fn(), + renderAllAdUnits: vi.fn(), + }; +} + +function installGptStub() { + const listeners = new Map(); + const addEventListener = vi.fn((name: string, listener: Listener) => { + const existing = listeners.get(name) ?? []; + existing.push(listener); + listeners.set(name, existing); + }); + const queue = { + push: vi.fn((callback: () => void) => { + callback(); + return 1; + }), + }; + target.googletag = { + cmd: queue, + pubads: () => ({ addEventListener }), + }; + return { + addEventListener, + queue, + emit(name: string, event: Record) { + for (const listener of listeners.get(name) ?? []) listener(event); + }, + }; +} + +function slot(id: string): FakeSlot { + return { + getSlotElementId: () => id, + getAdUnitPath: () => `/example/site/${id}`, + }; +} + +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + document.body.replaceChildren(); + vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete'); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + Object.defineProperty(window, 'CSS', { + configurable: true, + value: { escape: (value: string) => value }, + }); + target.tsjs = coreApi(); + delete target.googletag; + delete target.__tsjs_gpt_diagnostics_active; + delete target.__tsjs_gpt_diagnostics_runtime; +}); + +afterEach(() => { + target.__tsjs_gpt_diagnostics_runtime?.destroy(); + delete target.__tsjs_gpt_diagnostics_active; + delete target.__tsjs_gpt_diagnostics_runtime; + delete target.googletag; + delete target.tsjs; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +describe('GPT diagnostics integration composition', () => { + it('has no inactive side effects', () => { + const originalMutationObserver = window.MutationObserver; + + expect(isGptDiagnosticsActive(target)).toBe(false); + expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); + + expect(target.tsjs?.gptDiagnostics).toBeUndefined(); + expect(target.googletag).toBeUndefined(); + expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(window.MutationObserver).toBe(originalMutationObserver); + }); + + it('installs one idempotent active runtime and six listeners', () => { + target.__tsjs_gpt_diagnostics_active = true; + const gpt = installGptStub(); + const previousApi = target.tsjs; + + const first = installGptDiagnosticsRuntime(target); + const second = installGptDiagnosticsRuntime(target); + + expect(first).toBeDefined(); + expect(second).toBe(first); + expect(target.tsjs).toBe(previousApi); + expect(target.tsjs?.gptDiagnostics).toBe(first); + expect(gpt.queue.push).toHaveBeenCalledTimes(1); + expect(gpt.addEventListener).toHaveBeenCalledTimes(6); + expect(gpt.addEventListener.mock.calls.map(([name]) => name).sort()).toEqual( + [ + 'impressionViewable', + 'slotOnload', + 'slotRenderEnded', + 'slotRequested', + 'slotResponseReceived', + 'slotVisibilityChanged', + ].sort() + ); + expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); + }); + + it('keeps capture active while presentation is hidden', async () => { + target.__tsjs_gpt_diagnostics_active = true; + const gpt = installGptStub(); + const api = installGptDiagnosticsRuntime(target)!; + const observedSlot = slot('hidden-slot'); + + api.hide(); + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotResponseReceived', { slot: observedSlot }); + gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: false, size: [300, 250] }); + await settle(); + + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(api.snapshot().slots[0].requests).toHaveLength(1); + expect(api.snapshot().slots[0].requests[0].isEmpty).toBe(false); + + api.show(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); + }); + + it('keeps lifecycle, overlap issues, bindings, panel, and export snapshot consistent', async () => { + target.__tsjs_gpt_diagnostics_active = true; + const gpt = installGptStub(); + const element = document.createElement('div'); + element.id = 'lifecycle-slot'; + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + left: 20, + top: 100, + right: 320, + bottom: 350, + width: 300, + height: 250, + x: 20, + y: 100, + toJSON: () => ({}), + } as DOMRect); + document.body.append(element); + const api = installGptDiagnosticsRuntime(target)!; + const observedSlot = slot('lifecycle-slot'); + + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotResponseReceived', { slot: observedSlot }); + gpt.emit('slotRenderEnded', { + slot: observedSlot, + isEmpty: false, + size: [300, 250], + isBackfill: true, + }); + gpt.emit('slotOnload', { slot: observedSlot }); + gpt.emit('impressionViewable', { slot: observedSlot }); + gpt.emit('slotVisibilityChanged', { slot: observedSlot, inViewPercentage: 75 }); + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotResponseReceived', { slot: observedSlot }); + gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: true }); + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotResponseReceived', { slot: observedSlot }); + await settle(); + + const snapshot = api.snapshot(); + expect(snapshot.slots).toHaveLength(1); + expect(snapshot.slots[0]).toMatchObject({ + slotElementId: 'lifecycle-slot', + adUnitPath: '/example/site/lifecycle-slot', + binding: { status: 'bound' }, + currentVisibilityPercentage: 75, + }); + expect(snapshot.slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([1, 2, 3, 4]); + expect(snapshot.callbackIssues).toContainEqual( + expect.objectContaining({ + kind: 'slotResponseReceived', + disposition: 'ambiguous', + reason: 'overlapping_request_cycles', + }) + ); + expect(snapshot.coverage.slotResponseReceived.observed).toBe( + snapshot.coverage.slotResponseReceived.matched + + snapshot.coverage.slotResponseReceived.unmatched + + snapshot.coverage.slotResponseReceived.ambiguous + ); + expect(document.querySelector(`#${GPT_DIAGNOSTICS_HOST_ID}`)).not.toBeNull(); + expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); + expect(element.getAttributeNames()).toEqual(['id']); + }); + + it('leaves no half-initialized API when the core API is unavailable', () => { + target.__tsjs_gpt_diagnostics_active = true; + delete target.tsjs; + + expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); + expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts new file mode 100644 index 000000000..ab39491e6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + GptDiagnosticsObserver, + type GptDiagnosticsObserverStore, +} from '../../../src/integrations/gpt_diagnostics/observer'; +import type { GptDiagnosticsSlotLike } from '../../../src/integrations/gpt_diagnostics/store'; + +const EVENT_NAMES = [ + 'slotRequested', + 'slotResponseReceived', + 'slotRenderEnded', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', +] as const; + +type EventName = (typeof EVENT_NAMES)[number]; +type EventListener = (event: { slot: GptDiagnosticsSlotLike; [key: string]: unknown }) => void; + +function fakeStore(): GptDiagnosticsObserverStore { + return { + markGptObserved: vi.fn(), + recordSlotRequested: vi.fn(), + recordSlotResponseReceived: vi.fn(), + recordSlotRenderEnded: vi.fn(), + recordSlotOnload: vi.fn(), + recordImpressionViewable: vi.fn(), + recordSlotVisibilityChanged: vi.fn(), + }; +} + +function fakeSlot(): GptDiagnosticsSlotLike { + return { + getSlotElementId: () => 'ad-slot-example', + getAdUnitPath: () => '/example/site/banner', + }; +} + +function controlledGpt() { + const listeners = new Map(); + const addEventListener = vi.fn((name: EventName, listener: EventListener) => { + const current = listeners.get(name) ?? []; + current.push(listener); + listeners.set(name, current); + }); + const pubads = { + addEventListener, + refresh: vi.fn(), + }; + const display = vi.fn(); + const defineSlot = vi.fn(); + const cmd: Array<() => void> = []; + const googletag = { + cmd, + pubads: () => pubads, + display, + defineSlot, + }; + + return { + window: { googletag }, + googletag, + pubads, + listeners, + emit(name: EventName, event: Parameters[0]) { + for (const listener of listeners.get(name) ?? []) listener(event); + }, + }; +} + +describe('GptDiagnosticsObserver', () => { + it('installs exactly the six documented listeners through googletag.cmd', () => { + const store = fakeStore(); + const gpt = controlledGpt(); + const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); + + observer.install(); + + expect(gpt.googletag.cmd).toHaveLength(1); + expect(gpt.pubads.addEventListener).not.toHaveBeenCalled(); + + gpt.googletag.cmd[0](); + + expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); + expect(gpt.pubads.addEventListener.mock.calls.map(([name]) => name)).toEqual(EVENT_NAMES); + expect(store.markGptObserved).toHaveBeenCalledTimes(1); + }); + + it('is idempotent before and after command queue execution', () => { + const store = fakeStore(); + const gpt = controlledGpt(); + const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); + + observer.install(); + observer.install(); + expect(gpt.googletag.cmd).toHaveLength(1); + + gpt.googletag.cmd[0](); + observer.install(); + gpt.googletag.cmd[0](); + + expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); + expect(store.markGptObserved).toHaveBeenCalledTimes(1); + }); + + it('creates a command queue and waits when GPT is absent', () => { + const store = fakeStore(); + const delayedWindow: { + googletag?: { + cmd: Array<() => void>; + pubads?: () => { addEventListener: (name: EventName, listener: EventListener) => void }; + }; + } = {}; + const observer = new GptDiagnosticsObserver(store, { window: delayedWindow }); + + observer.install(); + + expect(delayedWindow.googletag?.cmd).toHaveLength(1); + const gpt = controlledGpt(); + delayedWindow.googletag!.pubads = gpt.googletag.pubads; + delayedWindow.googletag!.cmd[0](); + + expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); + }); + + it('preserves an already-loaded custom command push contract', () => { + const store = fakeStore(); + const gpt = controlledGpt(); + const callbacks: Array<() => void> = []; + const customPush = vi.fn((...next: Array<() => void>) => { + callbacks.push(...next); + for (const callback of next) callback(); + return callbacks.length; + }); + const observer = new GptDiagnosticsObserver(store, { + window: { + googletag: { + cmd: { push: customPush }, + pubads: gpt.googletag.pubads, + }, + }, + }); + + observer.install(); + + expect(customPush).toHaveBeenCalledTimes(1); + expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); + }); + + it('normalizes allowed callback facts and forwards every event kind', () => { + const store = fakeStore(); + const gpt = controlledGpt(); + const slot = fakeSlot(); + const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); + observer.install(); + gpt.googletag.cmd[0](); + + gpt.emit('slotRequested', { slot }); + gpt.emit('slotResponseReceived', { slot }); + gpt.emit('slotRenderEnded', { + slot, + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + creativeId: 'must-not-pass-through', + }); + gpt.emit('slotOnload', { slot }); + gpt.emit('impressionViewable', { slot }); + gpt.emit('slotVisibilityChanged', { slot, inViewPercentage: 42 }); + + expect(store.recordSlotRequested).toHaveBeenCalledWith(slot); + expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot); + expect(store.recordSlotRenderEnded).toHaveBeenCalledWith(slot, { + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot); + expect(store.recordImpressionViewable).toHaveBeenCalledWith(slot); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42); + }); + + it('drops unsupported or invalid rendered sizes', () => { + const store = fakeStore(); + const gpt = controlledGpt(); + const slot = fakeSlot(); + const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); + observer.install(); + gpt.googletag.cmd[0](); + + gpt.emit('slotRenderEnded', { slot, isEmpty: false, size: 'fluid' }); + + expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( + slot, + expect.objectContaining({ size: undefined }) + ); + }); + + it('contains callback and Slot accessor failures and warns', () => { + const store = fakeStore(); + vi.mocked(store.recordSlotRequested).mockImplementation(() => { + throw new Error('store failed'); + }); + const logger = { warn: vi.fn() }; + const gpt = controlledGpt(); + const observer = new GptDiagnosticsObserver(store, { window: gpt.window, logger }); + observer.install(); + gpt.googletag.cmd[0](); + const event = { + get slot(): GptDiagnosticsSlotLike { + throw new Error('slot accessor failed'); + }, + }; + + expect(() => gpt.emit('slotRequested', { slot: fakeSlot() })).not.toThrow(); + expect(() => gpt.emit('slotOnload', event)).not.toThrow(); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it('contains command queue and listener installation failures', () => { + const store = fakeStore(); + const logger = { warn: vi.fn() }; + const queueObserver = new GptDiagnosticsObserver(store, { + window: { + googletag: { + cmd: { + push: () => { + throw new Error('queue failed'); + }, + }, + }, + }, + logger, + }); + + expect(() => queueObserver.install()).not.toThrow(); + + const gpt = controlledGpt(); + gpt.pubads.addEventListener.mockImplementation(() => { + throw new Error('listener failed'); + }); + const listenerObserver = new GptDiagnosticsObserver(store, { + window: gpt.window, + logger, + }); + listenerObserver.install(); + + expect(() => gpt.googletag.cmd[0]()).not.toThrow(); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it('does not patch GPT or browser methods', () => { + const store = fakeStore(); + const gpt = controlledGpt(); + const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); + const references = { + display: gpt.googletag.display, + defineSlot: gpt.googletag.defineSlot, + refresh: gpt.pubads.refresh, + fetch: window.fetch, + XMLHttpRequest: window.XMLHttpRequest, + pushState: window.history.pushState, + replaceState: window.history.replaceState, + }; + + observer.install(); + gpt.googletag.cmd[0](); + + expect(gpt.googletag.display).toBe(references.display); + expect(gpt.googletag.defineSlot).toBe(references.defineSlot); + expect(gpt.pubads.refresh).toBe(references.refresh); + expect(window.fetch).toBe(references.fetch); + expect(window.XMLHttpRequest).toBe(references.XMLHttpRequest); + expect(window.history.pushState).toBe(references.pushState); + expect(window.history.replaceState).toBe(references.replaceState); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts new file mode 100644 index 000000000..a459e69f2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GptDiagnosticsBinding } from '../../../src/core/types'; +import type { GptDiagnosticsBindingView } from '../../../src/integrations/gpt_diagnostics/binding'; +import { + GPT_DIAGNOSTICS_HOST_ID, + GptDiagnosticsOverlay, +} from '../../../src/integrations/gpt_diagnostics/overlay'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; + +class FakeBindings { + private readonly listeners = new Set<() => void>(); + private readonly values = new Map(); + + get(runtimeSlotNumber: number): GptDiagnosticsBindingView { + return ( + this.values.get(runtimeSlotNumber) ?? { + binding: { status: 'unbound', reason: 'missing_element' }, + visible: false, + } + ); + } + + set( + runtimeSlotNumber: number, + binding: GptDiagnosticsBinding, + element?: HTMLElement, + visible = false + ): void { + this.values.set(runtimeSlotNumber, { binding, element, visible }); + for (const listener of this.listeners) listener(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +function slot(id: string, adUnitPath = `/example/site/${id}`) { + return { + getSlotElementId: () => id, + getAdUnitPath: () => adUnitPath, + }; +} + +function button(root: ShadowRoot, label: string): HTMLButtonElement { + const match = Array.from(root.querySelectorAll('button')).find( + (candidate) => candidate.textContent === label + ); + if (!(match instanceof HTMLButtonElement)) throw new Error(`Missing ${label} button`); + return match; +} + +function runNextFrame(frames: Array<() => void>): void { + const frame = frames.shift(); + if (!frame) throw new Error('Missing scheduled frame'); + frame(); +} + +beforeEach(() => { + document.body.replaceChildren(); + vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete'); +}); + +afterEach(() => { + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +describe('GptDiagnosticsOverlay', () => { + it('waits for document completion and two animation frames before mounting', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + store.recordSlotRequested(slot('early-slot')); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + runNextFrame(frames); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + runNextFrame(frames); + + const host = document.getElementById(GPT_DIAGNOSTICS_HOST_ID); + expect(host).not.toBeNull(); + expect(host!.shadowRoot).toBeNull(); + expect(root?.textContent).toContain('early-slot'); + overlay.destroy(); + }); + + it('renders lifecycle facts, coverage, history, controls, and scoped styles', () => { + const frames: Array<() => void> = []; + let now = 10; + const store = new GptDiagnosticsStore({ + now: () => now, + schedule: (callback) => callback(), + }); + const bindings = new FakeBindings(); + const filledSlot = slot('filled-slot'); + const element = document.createElement('div'); + element.id = 'filled-slot'; + document.body.append(element); + store.recordSlotRequested(filledSlot); + now = 20; + store.recordSlotResponseReceived(filledSlot); + now = 25; + store.recordSlotRenderEnded(filledSlot, { + isEmpty: false, + size: [300, 250], + isBackfill: true, + }); + now = 30; + store.recordSlotOnload(filledSlot); + now = 35; + store.recordImpressionViewable(filledSlot); + store.recordSlotVisibilityChanged(filledSlot, 60); + now = 40; + store.recordSlotRequested(filledSlot); + now = 50; + store.recordSlotResponseReceived(filledSlot); + now = 51; + store.recordSlotRenderEnded(filledSlot, { isEmpty: true }); + const pendingSlot = slot('pending-slot'); + store.recordSlotRequested(pendingSlot); + const issueSlot = slot('issue-slot'); + store.recordSlotRenderEnded(issueSlot, { isEmpty: false }); + const incompleteSlot = slot('incomplete-slot'); + store.recordSlotRequested(incompleteSlot); + store.recordSlotRenderEnded(incompleteSlot, { isEmpty: false }); + bindings.set(1, { status: 'bound' }, element, true); + bindings.set(2, { status: 'unbound', reason: 'missing_element' }); + bindings.set(3, { status: 'ambiguous', reason: 'duplicate_dom_id' }); + bindings.set(4, { status: 'unbound', reason: 'missing_element' }); + const exportSnapshot = vi.fn(); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + onExport: exportSnapshot, + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + expect(root).toBeDefined(); + expect(root!.querySelector('style')?.textContent).toContain('.tsgd-panel'); + expect(root!.textContent).toContain('GPT observed'); + expect(root!.textContent).toContain('callback issues'); + expect(root!.textContent).toContain('filled-slot'); + expect(root!.textContent).toContain('/example/site/filled-slot'); + expect(root!.textContent).toContain('Empty'); + expect(root!.textContent).toContain('Previous requests (1)'); + expect(root!.textContent).toContain('Rendered size 300×250'); + expect(root!.textContent).toContain('Backfill yes'); + expect(root!.textContent).toContain('Loaded'); + expect(root!.textContent).toContain('Viewable'); + expect(root!.textContent).toContain('Request → response 10 ms'); + expect(root!.textContent).toContain('GPT visibility 60%'); + expect(root!.textContent).toContain('Requesting'); + expect(root!.textContent).toContain('Ambiguous binding'); + expect(root!.textContent).toContain('Incomplete sequence'); + + button(root!, 'Export JSON').click(); + expect(exportSnapshot).toHaveBeenCalledTimes(1); + + button(root!, 'Collapse').click(); + expect(root!.textContent).not.toContain('filled-slot'); + expect(button(root!, 'Expand').getAttribute('aria-expanded')).toBe('false'); + button(root!, 'Expand').click(); + + const filter = root!.querySelector('select'); + expect(filter).toBeInstanceOf(HTMLSelectElement); + filter!.value = 'visible'; + filter!.dispatchEvent(new Event('change')); + expect(root!.textContent).toContain('filled-slot'); + expect(root!.textContent).not.toContain('pending-slot'); + + filter!.value = 'filled'; + filter!.dispatchEvent(new Event('change')); + expect(root!.textContent).toContain('incomplete-slot'); + expect(root!.textContent).not.toContain('pending-slot'); + + filter!.value = 'empty'; + filter!.dispatchEvent(new Event('change')); + expect(root!.textContent).toContain('filled-slot'); + expect(root!.textContent).not.toContain('incomplete-slot'); + + filter!.value = 'pending'; + filter!.dispatchEvent(new Event('change')); + expect(root!.textContent).toContain('pending-slot'); + expect(root!.textContent).toContain('incomplete-slot'); + + filter!.value = 'unbound'; + filter!.dispatchEvent(new Event('change')); + expect(root!.textContent).toContain('pending-slot'); + expect(root!.textContent).toContain('issue-slot'); + expect(element.getAttributeNames()).toEqual(['id']); + expect(element.className).toBe(''); + expect(element.getAttribute('style')).toBeNull(); + overlay.destroy(); + }); + + it('does not remove a publisher element that collides with the host ID', async () => { + const frames: Array<() => void> = []; + const publisherElement = document.createElement('div'); + publisherElement.id = GPT_DIAGNOSTICS_HOST_ID; + publisherElement.textContent = 'Publisher element'; + document.body.append(publisherElement); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => frames.push(callback), + }); + runNextFrame(frames); + runNextFrame(frames); + + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBe(publisherElement); + expect(publisherElement.textContent).toBe('Publisher element'); + expect(publisherElement.shadowRoot).toBeNull(); + + publisherElement.remove(); + await Promise.resolve(); + runNextFrame(frames); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); + overlay.destroy(); + }); + + it('remounts after external removal but not after hide or Close', async () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore(); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + const initialHost = document.getElementById(GPT_DIAGNOSTICS_HOST_ID)!; + + initialHost.remove(); + await Promise.resolve(); + runNextFrame(frames); + const remountedHost = document.getElementById(GPT_DIAGNOSTICS_HOST_ID); + expect(remountedHost).not.toBeNull(); + expect(remountedHost).not.toBe(initialHost); + expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); + + overlay.hide(); + await Promise.resolve(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(frames).toEqual([]); + + store.recordSlotRequested(slot('hidden-period-slot')); + overlay.show(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); + expect(root!.textContent).toContain('hidden-period-slot'); + + button(root!, 'Close').click(); + await Promise.resolve(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + overlay.show(); + expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); + overlay.destroy(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts new file mode 100644 index 000000000..94969d9df --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + GptDiagnosticsStore, + MAX_CALLBACK_ISSUES, + MAX_DIAGNOSTIC_SLOTS, + MAX_REQUEST_CYCLES_PER_SLOT, + type GptDiagnosticsSlotLike, +} from '../../../src/integrations/gpt_diagnostics/store'; + +function fakeSlot(elementId: string, adUnitPath = `/example/${elementId}`): GptDiagnosticsSlotLike { + return { + getSlotElementId: () => elementId, + getAdUnitPath: () => adUnitPath, + }; +} + +function assertCoverageEquation(store: GptDiagnosticsStore): void { + for (const counters of Object.values(store.snapshot().coverage)) { + expect(counters.observed).toBe(counters.matched + counters.unmatched + counters.ambiguous); + } +} + +describe('GptDiagnosticsStore', () => { + it('records a complete filled lifecycle with valid timings and visibility', () => { + let now = 10; + const store = new GptDiagnosticsStore({ now: () => now }); + const slot = fakeSlot('ad-slot-1', '/example/site/banner'); + + store.recordSlotRequested(slot); + now = 25; + store.recordSlotResponseReceived(slot); + now = 30; + store.recordSlotRenderEnded(slot, { + isEmpty: false, + size: [728, 90], + isBackfill: true, + slotContentChanged: true, + }); + now = 40; + store.recordSlotOnload(slot); + now = 60; + store.recordImpressionViewable(slot); + now = 70; + store.recordSlotVisibilityChanged(slot, 35); + now = 80; + store.recordSlotVisibilityChanged(slot, 20); + + const snapshot = store.snapshot(); + const recordedSlot = snapshot.slots[0]; + const cycle = recordedSlot.requests[0]; + + expect(snapshot.gptObserved).toBe(true); + expect(recordedSlot).toMatchObject({ + runtimeSlotNumber: 1, + slotElementId: 'ad-slot-1', + adUnitPath: '/example/site/banner', + currentVisibilityPercentage: 20, + maximumVisibilityPercentage: 35, + }); + expect(cycle).toMatchObject({ + requestNumber: 1, + requestedAtMs: 10, + responseAtMs: 25, + renderAtMs: 30, + loadAtMs: 40, + viewableAtMs: 60, + isEmpty: false, + size: [728, 90], + isBackfill: true, + slotContentChanged: true, + incompleteSequence: false, + durations: { + requestToResponseMs: 15, + responseToRenderMs: 5, + requestToRenderMs: 20, + renderToLoadMs: 10, + renderToViewableMs: 30, + }, + }); + expect(snapshot.callbackIssues).toEqual([]); + assertCoverageEquation(store); + }); + + it('keeps empty and pending cycles truthful without timeout-based incompleteness', () => { + let now = 1; + const store = new GptDiagnosticsStore({ now: () => now }); + const requesting = fakeSlot('requesting'); + const responded = fakeSlot('responded'); + const empty = fakeSlot('empty'); + + store.recordSlotRequested(requesting); + now += 1; + store.recordSlotRequested(responded); + now += 1; + store.recordSlotResponseReceived(responded); + now += 1; + store.recordSlotRequested(empty); + now += 1; + store.recordSlotResponseReceived(empty); + now += 1; + store.recordSlotRenderEnded(empty, { isEmpty: true }); + + const [requestingCycle, respondedCycle, emptyCycle] = store + .snapshot() + .slots.map((slot) => slot.requests[0]); + + expect(requestingCycle.incompleteSequence).toBe(false); + expect(requestingCycle.responseAtMs).toBeUndefined(); + expect(respondedCycle.incompleteSequence).toBe(false); + expect(respondedCycle.renderAtMs).toBeUndefined(); + expect(emptyCycle).toMatchObject({ isEmpty: true, incompleteSequence: false }); + }); + + it('creates sequential request numbers and retains refresh history', () => { + let now = 0; + const store = new GptDiagnosticsStore({ now: () => ++now }); + const slot = fakeSlot('refresh-slot'); + + for (let request = 0; request < 3; request += 1) { + store.recordSlotRequested(slot); + store.recordSlotResponseReceived(slot); + store.recordSlotRenderEnded(slot, { isEmpty: request === 1 }); + } + + expect(store.snapshot().slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([ + 1, 2, 3, + ]); + assertCoverageEquation(store); + }); + + it('keeps duplicate element IDs separated by Slot object identity', () => { + const store = new GptDiagnosticsStore({ now: () => 1 }); + const first = fakeSlot('duplicate-id', '/example/first'); + const second = fakeSlot('duplicate-id', '/example/second'); + + store.recordSlotRequested(first); + store.recordSlotRequested(second); + + expect(store.snapshot().slots).toMatchObject([ + { runtimeSlotNumber: 1, slotElementId: 'duplicate-id', adUnitPath: '/example/first' }, + { runtimeSlotNumber: 2, slotElementId: 'duplicate-id', adUnitPath: '/example/second' }, + ]); + }); + + it('tolerates empty and throwing Slot metadata methods', () => { + const store = new GptDiagnosticsStore({ now: () => 1 }); + const slot: GptDiagnosticsSlotLike = { + getSlotElementId: () => '', + getAdUnitPath: () => { + throw new Error('publisher method failed'); + }, + }; + + expect(() => store.recordSlotRequested(slot)).not.toThrow(); + expect(store.snapshot().slots[0]).toMatchObject({ runtimeSlotNumber: 1 }); + expect(store.snapshot().slots[0].slotElementId).toBeUndefined(); + expect(store.snapshot().slots[0].adUnitPath).toBeUndefined(); + }); + + it('records callbacks without a request as unmatched issues', () => { + const store = new GptDiagnosticsStore({ now: () => 12 }); + const slot = fakeSlot('unrequested'); + + store.recordSlotResponseReceived(slot); + store.recordSlotRenderEnded(slot, { isEmpty: false }); + store.recordSlotOnload(slot); + store.recordImpressionViewable(slot); + + const snapshot = store.snapshot(); + expect(snapshot.slots[0].requests).toEqual([]); + expect(snapshot.callbackIssues).toHaveLength(4); + expect(snapshot.callbackIssues.every((issue) => issue.disposition === 'unmatched')).toBe(true); + expect( + snapshot.callbackIssues.every((issue) => issue.reason === 'no_compatible_request_cycle') + ).toBe(true); + assertCoverageEquation(store); + }); + + it('preserves overlapping request callbacks as ambiguous instead of guessing', () => { + let now = 0; + const store = new GptDiagnosticsStore({ now: () => ++now }); + const slot = fakeSlot('overlap'); + + store.recordSlotRequested(slot); + store.recordSlotRequested(slot); + store.recordSlotResponseReceived(slot); + store.recordSlotRenderEnded(slot, { isEmpty: false }); + + const snapshot = store.snapshot(); + expect(snapshot.slots[0].requests).toHaveLength(2); + expect(snapshot.slots[0].requests.every((cycle) => cycle.responseAtMs === undefined)).toBe( + true + ); + expect(snapshot.slots[0].requests.every((cycle) => cycle.renderAtMs === undefined)).toBe(true); + expect(snapshot.callbackIssues).toMatchObject([ + { + kind: 'slotResponseReceived', + disposition: 'ambiguous', + reason: 'overlapping_request_cycles', + }, + { + kind: 'slotRenderEnded', + disposition: 'ambiguous', + reason: 'overlapping_request_cycles', + }, + ]); + assertCoverageEquation(store); + }); + + it('keeps a uniquely matched out-of-order callback matched and suppresses invalid duration', () => { + let now = 10; + const store = new GptDiagnosticsStore({ now: () => now }); + const slot = fakeSlot('out-of-order'); + + store.recordSlotRequested(slot); + now = 20; + store.recordSlotRenderEnded(slot, { isEmpty: false }); + now = 30; + store.recordSlotResponseReceived(slot); + + const snapshot = store.snapshot(); + const cycle = snapshot.slots[0].requests[0]; + expect(cycle.incompleteSequence).toBe(true); + expect(cycle.durations.requestToResponseMs).toBe(20); + expect(cycle.durations.requestToRenderMs).toBe(10); + expect(cycle.durations.responseToRenderMs).toBeUndefined(); + expect(snapshot.coverage.slotResponseReceived).toEqual({ + observed: 1, + matched: 1, + unmatched: 0, + ambiguous: 0, + }); + expect(snapshot.callbackIssues).toContainEqual( + expect.objectContaining({ + kind: 'slotResponseReceived', + disposition: 'matched', + reason: 'invalid_event_order', + }) + ); + assertCoverageEquation(store); + }); + + it('enforces slot, cycle, and callback issue retention limits', () => { + let now = 0; + const store = new GptDiagnosticsStore({ now: () => ++now }); + const slots = Array.from({ length: MAX_DIAGNOSTIC_SLOTS + 1 }, (_, index) => + fakeSlot(`slot-${index}`) + ); + + for (const slot of slots) store.recordSlotRequested(slot); + + let snapshot = store.snapshot(); + expect(snapshot.slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); + expect(snapshot.slots[0].runtimeSlotNumber).toBe(2); + expect(snapshot.metadata.evictedSlots).toBe(1); + + store.recordSlotResponseReceived(slots[0]); + snapshot = store.snapshot(); + expect(snapshot.callbackIssues[snapshot.callbackIssues.length - 1]).toMatchObject({ + runtimeSlotNumber: 1, + disposition: 'unmatched', + reason: 'evicted_slot', + }); + + const retainedSlot = slots[slots.length - 1]; + for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { + store.recordSlotRequested(retainedSlot); + } + snapshot = store.snapshot(); + const retainedRecord = snapshot.slots[snapshot.slots.length - 1]; + expect(retainedRecord.requests).toHaveLength(MAX_REQUEST_CYCLES_PER_SLOT); + expect(retainedRecord.requests[0].requestNumber).toBe(2); + expect(snapshot.metadata.evictedRequestCycles).toBe(1); + + const issueSlot = fakeSlot('issues'); + for (let index = 0; index < MAX_CALLBACK_ISSUES + 1; index += 1) { + store.recordSlotResponseReceived(issueSlot); + } + snapshot = store.snapshot(); + expect(snapshot.callbackIssues).toHaveLength(MAX_CALLBACK_ISSUES); + expect(snapshot.metadata.droppedCallbacks).toBeGreaterThanOrEqual(2); + assertCoverageEquation(store); + }); + + it('coalesces notifications and isolates throwing subscribers', () => { + const scheduled: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => scheduled.push(callback), + }); + const goodListener = vi.fn(); + store.subscribe(() => { + throw new Error('subscriber failed'); + }); + const unsubscribe = store.subscribe(goodListener); + + store.markGptObserved(); + store.recordSlotRequested(fakeSlot('notify')); + expect(scheduled).toHaveLength(1); + + scheduled.shift()!(); + expect(goodListener).toHaveBeenCalledTimes(1); + + unsubscribe(); + store.recordSlotVisibilityChanged(fakeSlot('other'), 10); + scheduled.shift()!(); + expect(goodListener).toHaveBeenCalledTimes(1); + }); + + it('returns detached snapshot data', () => { + const store = new GptDiagnosticsStore({ now: () => 1 }); + const slot = fakeSlot('detached'); + store.recordSlotRequested(slot); + + const first = store.snapshot(); + first.slots[0].requests[0].requestNumber = 999; + first.coverage.slotRequested.matched = 999; + + const second = store.snapshot(); + expect(second.slots[0].requests[0].requestNumber).toBe(1); + expect(second.coverage.slotRequested.matched).toBe(1); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts new file mode 100644 index 000000000..695eebda8 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import type { GptDiagnosticsExportV1, GptDiagnosticsSlotExport } from '../../../src/core/types'; + +describe('GPT diagnostics public types', () => { + it('represents the versioned allowlist schema', () => { + const slot: GptDiagnosticsSlotExport = { + runtimeSlotNumber: 1, + slotElementId: 'ad-slot-example', + adUnitPath: '/example/site/banner', + binding: { status: 'bound' }, + requests: [ + { + requestNumber: 1, + requestedAtMs: 10, + responseAtMs: 20, + durations: { requestToResponseMs: 10 }, + incompleteSequence: false, + }, + ], + }; + const snapshot: GptDiagnosticsExportV1 = { + version: 1, + capturedAt: '2026-07-28T00:00:00.000Z', + page: { + origin: 'https://example.com', + pathname: '/article', + }, + slots: [slot], + callbackIssues: [], + coverage: { + slotRequested: { observed: 1, matched: 1, unmatched: 0, ambiguous: 0 }, + slotResponseReceived: { observed: 1, matched: 1, unmatched: 0, ambiguous: 0 }, + slotRenderEnded: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotOnload: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + impressionViewable: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotVisibilityChanged: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + }, + metadata: { + droppedCallbacks: 0, + evictedSlots: 0, + evictedRequestCycles: 0, + }, + }; + + expect(snapshot.version).toBe(1); + expect(JSON.stringify(snapshot)).not.toMatch( + /bidder|targeting|creativeMarkup|auction|userId|cookie/i + ); + expectTypeOf(snapshot).toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('bidder'); + expectTypeOf().not.toHaveProperty('targeting'); + }); +}); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 228d4c888..c7630b097 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -155,7 +155,13 @@ export default withMermaid( }, { text: 'Ad Serving', - items: [{ text: 'GAM', link: '/guide/integrations/gam' }], + items: [ + { text: 'GAM', link: '/guide/integrations/gam' }, + { + text: 'GPT Runtime Diagnostics', + link: '/guide/integrations/gpt-diagnostics', + }, + ], }, { text: 'Demand Wrapper', diff --git a/docs/guide/integrations-overview.md b/docs/guide/integrations-overview.md index a0cc3cdab..960884718 100644 --- a/docs/guide/integrations-overview.md +++ b/docs/guide/integrations-overview.md @@ -4,14 +4,15 @@ Trusted Server provides built-in integrations with popular third-party services, ## Quick Comparison -| Integration | Type | Endpoints | HTML Rewriting | Primary Use Case | Status | -| --------------- | ---------------- | ---------- | ---------------------------- | --------------------------- | ----------- | -| **Prebid** | Proxy + Rewriter | 2-3 routes | Removes Prebid.js scripts | Server-side header bidding | Production | -| **Next.js** | Script Rewriter | None | Rewrites Next.js data | First-party Next.js routing | Production | -| **Permutive** | Proxy + Rewriter | 6 routes | Rewrites SDK URLs | First-party audience data | Production | -| **Sourcepoint** | Proxy + Rewriter | 2 routes | Rewrites CMP asset URLs | First-party CMP delivery | Development | -| **Osano** | Browser Mirror | None | Consent cookie mirroring | First-party consent signals | Development | -| **Testlight** | Proxy + Rewriter | 1 route | Rewrites integration scripts | Testing/development | Development | +| Integration | Type | Endpoints | HTML Rewriting | Primary Use Case | Status | +| ------------------- | ------------------- | ---------- | ---------------------------- | --------------------------- | ----------- | +| **Prebid** | Proxy + Rewriter | 2-3 routes | Removes Prebid.js scripts | Server-side header bidding | Production | +| **Next.js** | Script Rewriter | None | Rewrites Next.js data | First-party Next.js routing | Production | +| **Permutive** | Proxy + Rewriter | 6 routes | Rewrites SDK URLs | First-party audience data | Production | +| **Sourcepoint** | Proxy + Rewriter | 2 routes | Rewrites CMP asset URLs | First-party CMP delivery | Development | +| **Osano** | Browser Mirror | None | Consent cookie mirroring | First-party consent signals | Development | +| **GPT Diagnostics** | Browser Diagnostics | None | Closed-shadow local console | GPT lifecycle debugging | Development | +| **Testlight** | Proxy + Rewriter | 1 route | Rewrites integration scripts | Testing/development | Development | ## Integration Details @@ -183,6 +184,34 @@ enabled = true --- +### GPT Runtime Diagnostics + +**What it does:** Observes documented GPT lifecycle callbacks and presents directly observed slot, timing, coverage, binding, and visibility facts in a local browser console. + +**Key Features:** + +- Explicit, tab-local `ts_console` activation +- Initial and refresh request-cycle history +- Conservative unmatched and ambiguous callback reporting +- Exact DOM binding and non-layout-changing viewport badges +- Versioned local JSON export with no diagnostic upload +- No creative-provenance or auction attribution claims + +**Configuration:** + +```toml +[integrations.gpt_diagnostics] +enabled = true +``` + +**Endpoints:** None. The feature observes GPT in the browser and makes no diagnostic network request. + +**When to use:** You need to debug GPT request, response, render, load, viewability, refresh, and slot-binding behavior without changing ad delivery. + +**Learn more:** [GPT Runtime Diagnostics](./integrations/gpt-diagnostics.md) + +--- + ### Testlight **What it does:** Testing/development integration for validating the integration system with OpenRTB-like auctions. @@ -277,13 +306,14 @@ Are you developing/testing integrations? ## Performance Considerations -| Integration | Performance Impact | Caching Strategy | Notes | -| --------------- | ------------------ | --------------------------- | -------------------------------------------- | -| **Prebid** | Medium | Response caching possible | Timeout configurable (default 1s) | -| **Next.js** | Low | N/A (streaming rewrite) | Minimal overhead, runs during HTML streaming | -| **Permutive** | Low | SDK cached (1 hour default) | API calls proxied in real-time | -| **Sourcepoint** | Low | CDN cached (1 hour default) | JS rewriting adds minor overhead | -| **Testlight** | Low | No caching | Development use only | +| Integration | Performance Impact | Caching Strategy | Notes | +| ------------------- | ------------------ | --------------------------- | -------------------------------------------- | +| **Prebid** | Medium | Response caching possible | Timeout configurable (default 1s) | +| **Next.js** | Low | N/A (streaming rewrite) | Minimal overhead, runs during HTML streaming | +| **Permutive** | Low | SDK cached (1 hour default) | API calls proxied in real-time | +| **Sourcepoint** | Low | CDN cached (1 hour default) | JS rewriting adds minor overhead | +| **GPT Diagnostics** | Low when active | N/A | Inactive without explicit tab activation | +| **Testlight** | Low | No caching | Development use only | ## Environment Variables diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md new file mode 100644 index 000000000..048c513bc --- /dev/null +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -0,0 +1,191 @@ +# GPT Runtime Diagnostics + +**Category**: Ad Serving + +**Status**: Development +**Type**: Local browser diagnostics + +## Overview + +GPT Runtime Diagnostics is an opt-in browser console for documented Google Publisher Tag (GPT) lifecycle callbacks. It organizes observed callbacks into per-slot request cycles, displays directly observed timings and render facts, binds slots to exact DOM elements, and downloads the same information as versioned JSON. + +The console reports **GPT-observed facts, not creative provenance**. **Filled** means only that GPT emitted `slotRenderEnded` with `isEmpty === false`. It does not mean that Trusted Server, Prebid, Google Ad Manager, a particular bidder, or any other demand source supplied the creative. + +The diagnostics integration is independent of the [GPT first-party script integration](./gpt.md). Either integration can be enabled without the other. + +## Deployment Configuration + +The module is unavailable unless explicitly enabled for the deployment: + +```toml +[integrations.gpt_diagnostics] +enabled = true +``` + +Deployment configuration only makes the module available. Every browser tab remains inactive until explicitly activated. + +## Activate or Deactivate a Tab + +Open a page with one of these exact, case-sensitive query directives: + +| Directive | Effect | +| ------------------ | ------------------- | +| `ts_console=1` | Activate this tab | +| `ts_console=true` | Activate this tab | +| `ts_console=0` | Deactivate this tab | +| `ts_console=false` | Deactivate this tab | + +For example: + +```text +https://publisher.example.com/article?ts_console=true +``` + +A recognized directive is stored in `sessionStorage` and removed from the visible URL. The path, other query parameters, and fragment are preserved. Activation follows full-page and SPA navigation in the same origin and tab. A separately opened tab starts inactive. + +If `sessionStorage` is unavailable, a recognized directive applies only to the current document. Unrecognized values are ignored and remain in the URL. + +## What the Console Shows + +The panel opens expanded after document startup and provides filters for All, Visible, Filled, Empty, Pending/Incomplete, and Unbound/Ambiguous slots. + +Each slot may show: + +- Exact GPT slot element ID and ad unit path. +- Initial request and numbered refresh cycles. +- Requesting, Response received, Filled, or Empty lifecycle state. +- Loaded and Viewable augmentations when GPT emits those callbacks. +- Incomplete sequence only when an observed event proves a missing or invalid earlier step. +- Valid request-to-response, response-to-render, render-to-load, and render-to-viewable timings. +- Rendered size, backfill, slot-content-change, and GPT visibility facts when exposed by GPT. +- Current DOM binding status and viewport visibility. + +Elapsed time alone never changes a pending request to Incomplete. A filled render without a load callback remains Filled with **Load not observed**; missing viewability is not classified as a failure. + +### Callback Coverage + +Coverage is reported independently for each documented callback: + +```text +observed = matched + unmatched + ambiguous +``` + +Unmatched callbacks have no compatible retained request cycle. Ambiguous callbacks have more than one compatible cycle, such as overlapping refreshes. The console preserves these issues instead of guessing. A uniquely correlated out-of-order callback remains matched and also records an `invalid_event_order` issue. + +Coverage describes callback correlation, not fill rate or revenue. + +### Slot Binding and Badges + +A binding is valid only when one connected DOM element has the exact GPT slot element ID and one retained GPT slot claims that ID. Prefixes, container IDs, and likely-looking elements are never guessed. + +A concise viewport badge appears only when a slot: + +- Has at least one observed request. +- Has a unique, connected exact binding. +- Has a non-zero rectangle intersecting the viewport. + +Missing elements and duplicate DOM or GPT slot IDs remain visible in the panel as Unbound or Ambiguous and receive no badge. Framework replacement of an element with a new unique element using the same ID is rebound automatically. + +Badges and the panel live in a closed Shadow DOM. Diagnostics do not add attributes, classes, or inline styles to publisher slot elements. + +## Presentation Lifecycle + +- **Collapse** reduces the panel while preserving capture. +- **Close** dismisses the presentation for the current document. +- External removal by hydration or DOM reconciliation triggers a debounced remount. +- Explicit Close or `hide()` prevents remount until `show()` is called. +- Capture continues while the panel is hidden. + +The visual host mounts only after the document is complete and two animation frames have elapsed. GPT callback capture can begin earlier. + +## Browser API + +When active, the integration exposes a read-only API: + +```js +const diagnostics = window.tsjs.gptDiagnostics + +diagnostics.snapshot() +diagnostics.export() +const unsubscribe = diagnostics.subscribe((snapshot) => { + console.log(snapshot.version, snapshot.slots.length) +}) +diagnostics.hide() +diagnostics.show() +unsubscribe() +``` + +| Method | Semantics | +| --------------------- | ---------------------------------------------------------------------------------------------------- | +| `snapshot()` | Returns a fresh V1 snapshot from current store and binding facts | +| `export()` | Downloads the current V1 snapshot as local JSON; no upload occurs | +| `subscribe(listener)` | Delivers fresh snapshots after coalesced data or binding changes and returns an unsubscribe function | +| `hide()` | Dismisses presentation without stopping capture | +| `show()` | Clears dismissal and remounts presentation without resetting data | + +## V1 Export + +The allowlisted export contains: + +- `version: 1` and an ISO `capturedAt` timestamp. +- Current page origin and pathname, excluding query parameters and fragments. +- Retained slots, binding facts, visibility, and request cycles. +- Directly observed timestamps and render facts. +- Non-negative derived durations only. +- Callback issues and coverage counters. +- Retention eviction counters. + +It does not contain auction payloads, targeting, bid values, bidder or winner identity, creative markup, cookies, user identifiers, query strings, or URL fragments. + +## Storage and Privacy + +Diagnostics data is memory-only and local to the current document. Nothing is automatically transmitted, and no diagnostics endpoint exists. + +Initial bounds are: + +- 64 retained GPT slot objects. +- 10 retained request cycles per slot. +- 128 retained callback issues. + +The oldest eligible record is evicted when a bound is exceeded. Export metadata reports `evictedSlots`, `evictedRequestCycles`, and `droppedCallbacks`. + +The integration does not use cookies, `localStorage`, IndexedDB, or server upload for diagnostic records. `sessionStorage` contains only the tab activation state. + +## Troubleshooting + +### The API or panel is absent + +1. Confirm `[integrations.gpt_diagnostics]` is enabled in the deployed configuration. +2. Activate the tab with an exact recognized `ts_console` value. +3. Confirm the Trusted Server script bundle loaded successfully. +4. Use `ts_console=false` and then `ts_console=true` on a new document to reset tab activation explicitly. + +### The panel says Waiting for GPT + +GPT was not observed after listener installation. Confirm GPT initializes and executes queued `googletag.cmd` callbacks. Diagnostics do not create GPT, poll for it, or patch publisher request behavior. + +### Initial callbacks are missing + +The integration can observe only callbacks emitted after its listeners execute. Confirm the Trusted Server bundle precedes publisher GPT request code. The console reports coverage gaps rather than reconstructing unobserved activity. + +### A slot is Unbound + +Confirm `slot.getSlotElementId()` returns a non-empty ID and a connected element with that exact ID exists. Lazy or framework-created elements can bind later without losing request history. + +### A slot has Ambiguous binding + +Remove duplicate DOM IDs or ensure only one retained GPT Slot object claims the ID. Diagnostics intentionally do not choose one candidate. + +### Callbacks are Ambiguous + +Overlapping requests for the same GPT Slot object cannot be correlated safely because documented callbacks do not expose a request-cycle identifier. Avoid overlap in controlled tests, or use the issue record as evidence that correlation was not possible. + +## Limits + +The integration observes six documented PubAdsService events. It does not intercept GPT display, slot definition, refresh, targeting, auction, network, history, or rendering methods. It cannot identify the demand source of a filled creative and should not be used as attribution evidence. + +## Related + +- [Google Publisher Tags Integration](./gpt.md) +- [Integrations Overview](/guide/integrations-overview) +- [Ad Serving](/guide/ad-serving) diff --git a/docs/superpowers/plans/2026-07-28-gpt-runtime-diagnostics-overlay.md b/docs/superpowers/plans/2026-07-28-gpt-runtime-diagnostics-overlay.md new file mode 100644 index 000000000..acc3be18a --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-gpt-runtime-diagnostics-overlay.md @@ -0,0 +1,832 @@ +# GPT Runtime Diagnostics Overlay — Implementation Plan + +> **Status:** Implemented — live publisher acceptance remains environment-specific. +> +> **For agentic workers:** Execute this plan incrementally and keep the repository +> buildable after each task. Steps use checkbox (`- [ ]`) syntax for tracking. +> Follow `CLAUDE.md`; in particular, use the target-matched Cargo aliases rather +> than bare workspace commands. + +**Goal:** Add an opt-in, tab-local browser console that observes documented +Google Publisher Tag lifecycle callbacks, organizes them into bounded per-slot +request cycles, displays a hydration-safe panel and exact-binding badges, and +exports the same GPT-observed facts without claiming creative provenance. + +**Architecture:** A dedicated `gpt_diagnostics` integration injects a small +activation bootstrap before the synchronous TSJS bundle. The bootstrap owns only +query parsing, `sessionStorage`, document-local activation, and one-time URL +cleanup. When active, the integration module creates an observer, bounded store, +exact binding manager, read-only browser API, and presentation controller. Data +capture does not depend on the overlay. The observer uses only documented +`googletag.cmd` and PubAdsService event listeners; it never patches GPT, +publisher, networking, or history behavior. + +**Tech stack:** Rust 2024 (`trusted-server-core` integration registration and +head injection), TypeScript (`trusted-server-js`), Vitest/jsdom, Playwright, +closed Shadow DOM, documented GPT PubAdsService callbacks. + +**Spec:** +`docs/superpowers/specs/2026-07-28-gpt-runtime-diagnostics-overlay-design.md` + +--- + +## Approved Product and Data Decisions + +The implementation must preserve these resolved decisions from the spec: + +- The panel opens fully on first mount; the user can collapse it. +- `adUnitPath` is exported whenever GPT exposes it, independent of panel state. +- V1 uses badges only; click-to-highlight is deferred. +- Overlapping cycles are exercised with a deterministic GPT event-bus stub. +- Activation remains `ts_console`; deployment configuration is + `integrations.gpt_diagnostics`. +- Matching disposition and sequence validity are separate. A uniquely matched, + out-of-order callback remains matched and also creates an + `invalid_event_order` callback issue. +- **Incomplete sequence** augments the observed lifecycle state and requires + affirmative evidence; elapsed time alone never makes a cycle incomplete. +- Duplicate DOM IDs or duplicate retained GPT slot IDs produce an ambiguous + binding and no badge. The implementation never chooses the first element or + newest slot heuristically. + +No PR #961 tracing implementation is present at this branch's current base. +Do not add cleanup work for auction tracing unless such code is introduced by a +later merge. + +--- + +## Scope Guardrails + +### In scope + +- Dedicated deployment configuration and immediate JS module registration. +- Early activation bootstrap and tab-local persistence. +- Six documented GPT lifecycle listeners. +- Bounded slot, cycle, and callback-issue storage. +- Conservative cycle matching, timings, coverage, and issue reporting. +- Exact and unique DOM binding. +- Read-only API, versioned snapshot, and explicit local JSON download. +- Closed-Shadow-DOM panel and viewport badges. +- Hydration, SPA, element-replacement, and host-remount behavior. +- Rust, Vitest, Playwright, and environment-specific live acceptance coverage. +- Operator documentation and example configuration. + +### Explicitly out of scope + +- Auction, bid, winner, bidder, targeting, or creative provenance. +- OpenRTB, `/auction`, Prebid, creative renderer, telemetry, Tinybird, cookies, + or server response variants. +- Patching `display`, `defineSlot`, `refresh`, `fetch`, XHR, publisher callbacks, + `pushState`, or route handlers. +- Resource Timing inference or diagnostic network upload. +- Persistent diagnostic records beyond in-memory document state. +- Changes to the existing `gpt` integration's ad-serving behavior. + +Stop and request spec review if implementation appears to require any excluded +area. + +--- + +## Runtime Contracts to Pin in Tests + +### Activation + +- The dedicated integration is absent unless configured and enabled. +- Recognized `ts_console` values are exactly `1`, `true`, `0`, and `false`. +- A recognized directive updates tab-local `sessionStorage`, sets a private + document activation flag, and removes all `ts_console` parameters while + preserving path, other parameters, and fragment. +- An unrecognized value is ignored and remains in the URL. +- If storage throws or is unavailable, a recognized directive applies only to + the current document. +- Inactive module evaluation installs no GPT listeners, API, overlay host, DOM + observer, or network activity. + +### Coverage and issues + +For each callback kind: + +```text +observed = matched + unmatched + ambiguous +``` + +A callback issue is secondary diagnostic information, not a fourth matching +category. `callbackIssues` may therefore include: + +- `disposition: "unmatched"` with a no-compatible-cycle reason. +- `disposition: "ambiguous"` with `overlapping_request_cycles`. +- `disposition: "matched"` with `invalid_event_order`. + +### Lifecycle state + +Primary state is one of Waiting for request, Requesting, Response received, +Filled, or Empty. Loaded, Viewable, Incomplete sequence, Unbound, and Ambiguous +binding are independent facts or issues. Pending work is never converted to +Incomplete by a timer. + +### API + +Use these public semantics: + +- `snapshot()` returns a fresh `GptDiagnosticsExportV1` object. +- `export()` creates and clicks a local Blob download, then revokes its object + URL; it performs no network request. +- `subscribe(listener)` schedules the listener with fresh snapshots after + coalesced store or binding changes and returns an unsubscribe function. +- `hide()` has the same dismissal semantics as the Close control. +- `show()` clears dismissal and remounts presentation without restarting or + resetting capture. + +### Binding + +A binding is valid only when exactly one connected DOM element has the exact +`slotElementId` and exactly one retained GPT slot record claims that ID. +Ambiguity remains visible in the panel/export but never produces a badge. +Element replacement with the same unique ID rebinds the same retained slot. + +--- + +## File Map + +Paths under the new TypeScript integration may be consolidated if doing so +makes the code materially simpler, but the observer, store, and presentation +must remain independently testable. + +| File | Action | Responsibility | +| ------------------------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` | Create | Config, registration, and head injector for the dedicated integration | +| `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` | Create | Early activation, storage, document flag, and URL cleanup only | +| `crates/trusted-server-core/src/integrations/mod.rs` | Modify | Export and register `gpt_diagnostics` builder | +| `crates/trusted-server-core/src/config.rs` | Modify | Include diagnostics in deploy-time typed integration validation | +| `crates/trusted-server-core/src/migration_guards.rs` | Modify | Include the new platform-neutral Rust source in migration guards | +| `crates/trusted-server-core/src/html_processor.rs` | Modify tests only | Pin bootstrap-before-bundle ordering when diagnostics is enabled | +| `trusted-server.example.toml` | Modify | Add disabled-by-default diagnostics example | +| `crates/trusted-server-js/lib/src/core/types.ts` | Modify | Public V1 export/API types and optional `TsjsApi.gptDiagnostics` | +| `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` | Create | Active-path composition and idempotent installation | +| `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts` | Create | Slot identity, cycles, matching, timings, coverage, issues, bounds, subscriptions | +| `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts` | Create | Narrow GPT interfaces, command-queue installation, normalized callbacks, safety boundary | +| `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts` | Create | Exact lookup, uniqueness checks, rebinding, visibility, binding snapshots | +| `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts` | Create | Snapshot composition, subscription, JSON download, show/hide wiring | +| `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts` | Create | Closed shadow host, panel, filters, controls, lifecycle/remount manager | +| `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts` | Create | Non-layout-changing badges and scheduled geometry updates | +| `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/*.test.ts` | Create | Activation, store, observer, binding, API, panel, badge, and composition tests | +| `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml` | Modify | Enable module availability for browser tests; activation remains query-gated | +| `crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/gpt-diagnostics/page.tsx` | Create | Controlled slot DOM, hydration, replacement, scrolling, and refresh fixture | +| `crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts` | Create | Pre-document deterministic GPT command queue and event bus | +| `crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts` | Create | Real-browser activation, lifecycle, UI lifecycle, binding, export, and non-interference | +| `crates/trusted-server-integration-tests/README.md` | Modify | Document the new browser scenario | +| `docs/guide/integrations/gpt-diagnostics.md` | Create | Operator activation, UI, export, limits, privacy, and troubleshooting guide | +| `docs/guide/integrations-overview.md` | Modify | Add the diagnostics integration | +| `docs/.vitepress/config.mts` | Modify | Add diagnostics guide navigation | + +Generated `crates/trusted-server-js/dist/` output is build evidence, not a +hand-edited source. Follow the repository's existing tracked/ignored behavior; +do not manually edit generated bundles. + +--- + +## Task 1: Add Deployment Configuration and the Early Activation Bootstrap + +**Files:** Rust integration/config files, bootstrap JS, example TOML, and a +minimal diagnostics `index.ts` so build discovery remains green. + +- [x] **Step 1: Add failing Rust registration/configuration tests.** Cover: + - no `gpt_diagnostics` section → no registration; + - `enabled = false` → no registration; + - `enabled = true` → one head injector and immediate JS module ID; + - the module is not deferred and has no proxy/routes/rewriters; + - deploy validation recognizes every registered builder, including + `gpt_diagnostics`. +- [x] **Step 2: Add a failing HTML processor ordering test.** Process an HTML + document with diagnostics enabled and assert the activation bootstrap appears + before `script#trustedserver-js`, and each appears exactly once. +- [x] **Step 3: Define `GptDiagnosticsConfig`.** Use a boolean `enabled` with a + false default, implement `IntegrationConfig`, and keep the integration + independent of `[integrations.gpt]`. +- [x] **Step 4: Register the integration.** Add the module and builder entry in + `integrations/mod.rs`; return an `IntegrationRegistration` containing only the + head injector and the immediate JS module. +- [x] **Step 5: Add the bootstrap source and include it from the head injector.** + The bootstrap must: + - parse only `ts_console`; + - apply the exact recognized values; + - tolerate storage and history failures; + - write one private document activation flag without exposing the public API; + - call `history.replaceState` once only for recognized directives; + - preserve `history.state`, pathname, unrelated parameters, and fragment; + - avoid wrapping or retaining any history method. +- [x] **Step 6: Add a minimal `gpt_diagnostics/index.ts`.** It should perform + only the private activation-flag check and otherwise have no active behavior. + Later tasks replace the active branch with composition code. +- [x] **Step 7: Add bootstrap behavior tests.** In Vitest/jsdom, load the actual + bootstrap source and execute it against controlled URL/storage state. Cover + enable, disable, persistence without a directive, all four recognized values, + case-sensitive rejection, preservation of query/fragment, storage failure, + history failure, and idempotent re-execution. +- [x] **Step 8: Add `[integrations.gpt_diagnostics] enabled = false` to + `trusted-server.example.toml`.** Place it near the existing GPT configuration. +- [x] **Step 9: Add the new Rust source and config validation coverage.** Update + `migration_guards.rs` and the `config.rs` imports, validation calls, and test + constants. +- [x] **Step 10: Run focused validation.** Expected: all pass. + +```bash +cargo fmt --all -- --check +cargo test-fastly gpt_diagnostics +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics/bootstrap.test.ts +node build-all.mjs +``` + +- [ ] **Step 11: Commit.** Suggested message: + `Register GPT diagnostics and add tab activation bootstrap`. + +--- + +## Task 2: Define the Public Export and Browser API Types + +**Files:** `core/types.ts` and a new diagnostics test/type fixture as needed. + +- [x] **Step 1: Add the V1 public types.** Define: + - request-cycle timestamps, valid durations, render facts, and issue flag; + - slot export with runtime number, optional exact element ID, optional + `adUnitPath`, visibility facts, binding status/reason, and retained cycles; + - callback issue with kind, slot identity, timestamp, disposition, and reason; + - coverage counters and eviction metadata; + - `GptDiagnosticsExportV1` with `version: 1`; + - `GptDiagnosticsApi` with the API semantics pinned above. +- [x] **Step 2: Add optional `gptDiagnostics?: GptDiagnosticsApi` to `TsjsApi`.** + Do not add mutation methods or auction/bid/creative fields. +- [x] **Step 3: Add compile-time/type assertions or a small Vitest fixture.** It + should construct a valid V1 snapshot and reject provenance fields where + practical. +- [x] **Step 4: Run TypeScript tests and build.** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics +node build-all.mjs +``` + +- [ ] **Step 5: Commit.** Suggested message: + `Define GPT diagnostics browser API and export schema`. + +--- + +## Task 3: Implement the Bounded Diagnostics Store + +**Files:** `store.ts`, public/internal types, and `store.test.ts`. + +- [x] **Step 1: Write deterministic failing tests for slot identity.** Verify: + - the GPT `Slot` object is primary identity through a `WeakMap`; + - two objects with the same element ID remain separate runtime slots; + - missing/throwing slot methods produce safe optional display metadata; + - runtime slot numbers are monotonic and synthetic labels are never exported + as DOM IDs. +- [x] **Step 2: Write failing lifecycle tests.** Cover: + - initial request → response → filled/empty render; + - load and viewability on filled cycles; + - visibility current/maximum values; + - sequential refresh numbering (`1`, `2`, `3`) and retained history; + - callback without a request; + - response/render/load/viewability compatibility rules; + - missing response, render, load, and viewability behavior. +- [x] **Step 3: Write failing overlap and order tests.** For one Slot object emit + request 1, request 2, then response/render. Assert both later callbacks are + ambiguous with `overlapping_request_cycles` and no cycle is guessed. Also + verify a uniquely matched out-of-order callback stays matched, adds + `invalid_event_order`, marks the cycle incomplete, and produces no negative + duration. +- [x] **Step 4: Write failing coverage tests.** Assert every observed callback is + exactly one of matched/unmatched/ambiguous and matched invalid-order issues do + not break the coverage equation. +- [x] **Step 5: Write failing retention tests.** Pin constants at 64 slots, 10 + cycles per slot, and 128 callback issues. Verify deterministic oldest-record + eviction, latest-cycle retention, monotonic numbering, and metadata counters. + Keep any WeakMap tombstone/value strategy bounded even when an evicted Slot + later emits another callback. +- [x] **Step 6: Implement the store with an injected clock.** Production uses + `performance.now`; tests use a deterministic clock. Callback mutation is + synchronous and bounded. Subscriber notifications are scheduled/coalesced, + not run recursively inside callback mutation. +- [x] **Step 7: Implement valid derived timings only when both endpoints exist + and are ordered.** Preserve raw timestamps and issues even when a duration is + suppressed. +- [x] **Step 8: Run focused tests.** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics/store.test.ts +``` + +- [ ] **Step 9: Commit.** Suggested message: + `Add bounded GPT lifecycle diagnostics store`. + +--- + +## Task 4: Install the GPT Observer Through the Command Queue + +**Files:** `observer.ts` and `observer.test.ts`. + +- [x] **Step 1: Build a narrow GPT test stub.** It must preserve command-array + identity, provide a PubAdsService event bus, expose stable fake Slot objects, + and make listener counts observable. +- [x] **Step 2: Write failing tests for listener installation.** Verify exactly + one listener for each documented event: + `slotRequested`, `slotResponseReceived`, `slotRenderEnded`, `slotOnload`, + `impressionViewable`, and `slotVisibilityChanged`. +- [x] **Step 3: Verify installation is queued and idempotent.** Cover GPT absent + at module evaluation, delayed queue execution, already-loaded custom + `cmd.push`, repeated installation, and GPT never becoming available. Do not + poll. +- [x] **Step 4: Verify normalized event forwarding.** Render events retain only + allowed fields: `isEmpty`, rendered size, `isBackfill`, and + `slotContentChanged`. Visibility events retain only percentage. No creative, + line-item, targeting, bidder, or price field may reach the store. +- [x] **Step 5: Add exception-boundary tests.** Throw from slot methods, event + accessors, and the store. The listener must catch at its top level, warn + through the existing TSJS logger, and never throw into the fake GPT emitter. +- [x] **Step 6: Add non-interference assertions.** Capture identities for + `display`, `defineSlot`, `refresh`, `fetch`, XHR, `pushState`, and publisher + listener functions before installation and assert they remain unchanged. +- [x] **Step 7: Implement the observer and run focused tests.** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics/observer.test.ts +``` + +- [ ] **Step 8: Commit.** Suggested message: + `Observe documented GPT lifecycle callbacks`. + +--- + +## Task 5: Add Exact, Unique Slot Binding + +**Files:** `binding.ts` and `binding.test.ts`. + +- [x] **Step 1: Write failing exact-binding tests.** Verify only the exact + `slotElementId` is considered; no prefix/container/inner-ID guessing occurs. +- [x] **Step 2: Write duplicate tests.** Cover: + - two retained GPT slot records claiming one ID; + - two connected DOM elements with one ID; + - one duplicate later removed; + - inability to verify uniqueness. + All ambiguous cases must remain unbadged and export an explicit reason. +- [x] **Step 3: Write replacement/disconnection tests.** Replace a uniquely bound + element with a new connected element using the same ID and verify rebinding. + Disconnect it and verify the record remains but becomes unbound. +- [x] **Step 4: Write geometry/visibility tests.** Mock rectangles, viewport + dimensions, and scrolling to distinguish bound, non-zero, and intersecting + elements. Keep GPT visibility percentage separate from DOM viewport + intersection. +- [x] **Step 5: Implement targeted uniqueness verification.** Start with + `getElementById`; verify exact DOM uniqueness with a targeted escaped-ID query. + If selector escaping/querying is unavailable or throws, degrade to an + unbound/ambiguous result rather than guessing. +- [x] **Step 6: Add a debounced mutation refresh and a coalesced change + notification.** It must inspect only observed exact IDs, not arbitrary ad + patterns or prefix scans. +- [x] **Step 7: Assert the binding manager never mutates publisher slot + elements.** It must not write attributes, classes, or inline styles. +- [x] **Step 8: Run focused tests.** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics/binding.test.ts +``` + +- [ ] **Step 9: Commit.** Suggested message: + `Bind GPT diagnostics to unique exact slot elements`. + +--- + +## Task 6: Implement Snapshot, Subscription, and JSON Export + +**Files:** `api.ts` and `api.test.ts`. + +- [x] **Step 1: Write failing snapshot tests.** Combine store and current binding + facts into V1. Assert: + - `version` is exactly `1`; + - `capturedAt` is ISO wall-clock time; + - page contains current origin/pathname only; + - query and fragment are excluded; + - optional `adUnitPath` is included whenever captured; + - valid durations only, coverage equation, issues, and metadata are preserved; + - no target, bid, winner, creative, cookie, user, or auction field exists. +- [x] **Step 2: Write failing subscription tests.** Store and binding changes + should coalesce into fresh snapshots. Unsubscribe prevents later calls, and + one throwing subscriber does not block others. +- [x] **Step 3: Write failing export tests.** Stub `Blob`, object URL creation, + anchor click, and revocation. Assert one explicit local download and zero + fetch/XHR/beacon calls. +- [x] **Step 4: Implement the API factory.** Presentation callbacks are injected + so API logic does not depend on panel internals. Return new snapshot objects; + never leak mutable store records. +- [x] **Step 5: Run focused tests.** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics/api.test.ts +``` + +- [ ] **Step 6: Commit.** Suggested message: + `Expose versioned GPT diagnostics snapshots and export`. + +--- + +## Task 7: Build the Closed-Shadow-DOM Panel and Lifecycle Manager + +**Files:** `overlay.ts` and `overlay.test.ts`. + +- [x] **Step 1: Write failing mount-timing tests.** The host must not mount until + `document.readyState === "complete"` and two animation frames have elapsed. + Capture may already contain callbacks before this point. +- [x] **Step 2: Write failing host/isolation tests.** Assert one stable host ID, + one closed shadow root, scoped styles, and no publisher DOM classes/styles or + diagnostic attributes. +- [x] **Step 3: Write failing panel-state tests using an injected rendering test + handle.** Production uses `mode: "closed"`; tests may retain the created root + reference without exposing it on `window`. Cover: + - expanded by default and collapse/expand; + - GPT observed vs Waiting for GPT; + - callback coverage and issue counts; + - latest cycle plus expandable history; + - Filled, Empty, pending, Loaded, Viewable, Incomplete sequence; + - timings, rendered size, backfill, ad unit path, binding and visibility; + - All, Visible, Filled, Empty, Pending/Incomplete, Unbound/Ambiguous filters; + - Export and Close controls. +- [x] **Step 4: Write failing lifecycle tests.** External host removal triggers + one debounced remount. Framework root/body-child replacement also remounts. + Close or `hide()` sets document dismissal and prevents remount. `show()` clears + dismissal without clearing data. Repeated show/hide/removal never creates two + hosts. +- [x] **Step 5: Implement scheduled rendering.** Store callbacks only request a + coalesced UI update; no DOM rendering occurs synchronously inside GPT + callbacks. +- [x] **Step 6: Add basic accessibility and bounded layout.** Include accessible + names, button semantics, keyboard focus, bounded panel dimensions, and + scrollable history without expanding product scope. +- [x] **Step 7: Run focused tests.** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics/overlay.test.ts +``` + +- [ ] **Step 8: Commit.** Suggested message: + `Add hydration-safe GPT diagnostics panel`. + +--- + +## Task 8: Add Viewport Badges Without Publisher Layout Mutation + +**Files:** `badges.ts`, badge tests, and panel integration. + +- [x] **Step 1: Write failing eligibility tests.** A badge exists only when the + slot has at least one request, a unique connected binding, a non-zero + rectangle, and viewport intersection. Unbound, ambiguous, zero-size, + offscreen, or never-requested slots receive none. +- [x] **Step 2: Write failing content tests.** Badge text uses only GPT-observed + facts: Filled/Empty, rendered size, valid response/render durations, and + optional viewable timing. Add assertions forbidding Trusted Server, GAM + winner, Prebid, bidder, or provenance labels. +- [x] **Step 3: Write failing positioning tests.** Badges live in the overlay + layer, use viewport coordinates, and update after scroll, window resize, + element resize, relevant mutation, and callback state changes. +- [x] **Step 4: Implement one-animation-frame throttling.** Use `ResizeObserver` + when available; degrade cleanly when it, `MutationObserver`, or other optional + layout signals are missing. +- [x] **Step 5: Assert badge handling never mutates publisher elements.** No + publisher element may receive attributes, classes, or styles before, during, + or after badge creation/removal. +- [x] **Step 6: Run focused tests.** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics/badges.test.ts +``` + +- [ ] **Step 7: Commit.** Suggested message: + `Add non-interfering GPT slot diagnostic badges`. + +--- + +## Task 9: Compose the Active Integration and Pin the Inactive Path + +**Files:** `index.ts`, composition tests, and all diagnostics modules. + +- [x] **Step 1: Write failing composition tests.** With the private activation + flag absent/false, importing the module must expose no API, create no + `googletag`, install no listeners/observers, and create no host. With the flag + true, one idempotent runtime is installed. +- [x] **Step 2: Compose in dependency order:** store → observer → binding manager + → overlay/badges → API. Attach only `window.tsjs.gptDiagnostics`; preserve all + existing `window.tsjs` fields and methods. +- [x] **Step 3: Put a final exception boundary around active installation.** Log + a warning and leave publisher behavior untouched if setup fails. Do not expose + a half-initialized API. +- [x] **Step 4: Verify presentation removal does not stop observer/store + capture.** Hide, emit callbacks, show, and assert the hidden-period events are + present. +- [x] **Step 5: Add a full deterministic lifecycle test.** Initial request plus + refresh, filled/empty outcomes, overlap ambiguity, element replacement, + snapshot, panel state, and export counts must agree. +- [x] **Step 6: Run the full diagnostics suite, all JS tests, format, and build.** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt_diagnostics +npx vitest run +npm run format +node build-all.mjs +``` + +- [x] **Step 7: Run focused Rust/adapter validation after the rebuilt module is + embedded.** + +```bash +cargo test-fastly gpt_diagnostics +cargo check-fastly +``` + +- [ ] **Step 8: Commit.** Suggested message: + `Compose the opt-in GPT diagnostics runtime`. + +--- + +## Task 10: Add Controlled Real-Browser Coverage + +**Files:** integration-test config, Next.js fixture page, GPT stub helper, +Playwright spec, and integration-test README. + +- [x] **Step 1: Enable `[integrations.gpt_diagnostics]` in the source-controlled + integration-test config.** This only makes the module available; pages remain + inactive without a directive or tab state. +- [x] **Step 2: Create a dedicated Next.js fixture page.** Include: + - uniquely identified, sized, scrollable slot elements; + - controls or fixture hooks for replacement/removal and duplicate DOM IDs; + - enough route/hydration behavior to exercise host survival; + - only fictional/example ad unit paths and data. +- [x] **Step 3: Add a pre-document GPT stub helper with `page.addInitScript`.** It + should expose a command queue, PubAdsService listener registry, stable Slot + objects, event emitter, method-reference capture, and inspection counters. + It must not emulate auction behavior. +- [x] **Step 4: Test inactive behavior.** Without activation, assert no public + API, host, listener registrations, diagnostics-created `googletag`, or + diagnostic request. +- [x] **Step 5: Test activation/deactivation and URL cleanup.** Cover true/false, + unrelated query/fragment preservation, same-tab persistence across full + navigation and SPA navigation, and a separately opened tab starting inactive. +- [x] **Step 6: Test listener and lifecycle behavior.** Emit initial and refresh + callbacks, assert request numbering, direct render facts, non-negative + timings, load/viewability augmentations, coverage equation, and panel host + presence. +- [x] **Step 7: Test deterministic overlap.** Emit two requests for one Slot + before response/render and assert ambiguous issues with no forced cycle. +- [x] **Step 8: Test binding and badges.** Verify unique bindings in the public + snapshot and badge geometry through the fixture/test rendering handle or + browser screenshot evidence. Verify missing/duplicate IDs are unbound or + ambiguous and have no badge. Do not weaken the production closed shadow root + solely to make Playwright selectors convenient. +- [x] **Step 9: Test hydration and presentation lifecycle.** Remove the host, + replace relevant framework DOM, scroll, and wait for settling. Assert external + removal remounts, Close/hide does not, show does, and capture continues while + hidden. +- [x] **Step 10: Test JSON export and non-interference.** Capture the download, + parse V1, compare counts/status with `snapshot()`, and assert captured GPT, + browser networking, and history method references remain unchanged except for + the one allowed bootstrap `replaceState` invocation. +- [x] **Step 11: Test page errors and diagnostic networking.** Collect console + and page errors, ignore only existing documented fixture noise, and assert no + diagnostics endpoint/request exists. +- [x] **Step 12: Update the integration-test README scenario table.** +- [x] **Step 13: Run browser integration tests.** The wrapper is authoritative + because it builds WASM/images, generates config, installs Playwright, and runs + both framework suites. + +```bash +./scripts/integration-tests-browser.sh +``` + +For iterative runs after prerequisites are prepared: + +```bash +cd crates/trusted-server-integration-tests/browser +VICEROY_CONFIG_PATH=../../../target/integration-test-artifacts/configs/viceroy.toml \ +TEST_FRAMEWORK=nextjs npx playwright test tests/nextjs/gpt-diagnostics.spec.ts +``` + +- [ ] **Step 14: Commit.** Suggested message: + `Cover GPT diagnostics in the browser integration harness`. + +--- + +## Task 11: Document Configuration, Operation, and Limits + +**Files:** diagnostics guide, integrations overview, VitePress navigation, and +possibly the existing GPT guide for one cross-link only. + +- [x] **Step 1: Add the operator guide.** Document: + - deployment configuration and independence from `[integrations.gpt]`; + - all activation/deactivation directives and tab-local behavior; + - Filled/Empty semantics and explicit non-provenance warning; + - callback coverage, pending/incomplete distinction, bindings, refresh + numbering, and badges; + - API method semantics and V1 export shape; + - storage bounds and eviction counters; + - privacy/non-upload behavior; + - hydration, missing GPT, unbound slots, duplicate IDs, and overlap + troubleshooting. +- [x] **Step 2: Add the integration to the overview and VitePress Ad Serving + navigation.** Keep the existing GPT proxy integration distinct from GPT + diagnostics. +- [x] **Step 3: Use only fictional/example values and domains.** +- [x] **Step 4: Run docs formatting and build/check if available.** + +```bash +cd docs +npm run format +npm run build +``` + +- [ ] **Step 5: Commit.** Suggested message: + `Document the GPT runtime diagnostics console`. + +--- + +## Task 12: Full Verification and Live-Site Acceptance + +### Automated repository verification + +Run from a clean worktree after installing the pinned JS/docs dependencies. +Do not substitute bare `cargo test --workspace` or bare workspace clippy. + +- [x] **Rust formatting** + +```bash +cargo fmt --all -- --check +``` + +- [x] **Rust tests** + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +``` + +- [x] **Target-matched clippy** + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +- [x] **Integration parity** + +```bash +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +- [x] **JS tests, formatting, and module build** + +```bash +cd crates/trusted-server-js/lib +npx vitest run +npm run format +node build-all.mjs +``` + +- [x] **Docs formatting/build** + +```bash +cd docs +npm run format +npm run build +``` + +- [x] **HTTP integration harness** + +```bash +./scripts/integration-tests.sh +``` + +- [x] **Browser integration harness** + +```bash +./scripts/integration-tests-browser.sh +``` + +- [x] **Optional CLI validation because deploy-time integration validation + changed** + +```bash +./scripts/test-cli.sh +``` + +### Verification limitations + +The browser integration harness passed for Next.js and WordPress. The HTTP +integration harness also passed after restarting Docker, installing Wrangler, +prebuilding the Cloudflare Workers bundle, and running the local wrapper with +`CI=1` so it generated the same integration configuration used in CI. No +source-controlled live publisher URL or credentials are available, so the +environment-specific live publisher checklist remains pending. + +### Live publisher acceptance + +The repository currently contains the generic integration Playwright harness, +but no source-controlled live publisher URL or credentials. Confirm the +existing environment-specific live harness invocation before this step. Do not +commit a real publisher domain, credentials, storage state, or captured user +data. If no reusable harness exists, execute the checklist with an ephemeral +Playwright script outside source control and attach sanitized counts/results to +the PR. + +- [ ] Open a valid publisher session with `?ts_console=true` and a trace-off + control in the same tab when access is session-sensitive. +- [ ] Confirm the normal page loads and diagnostics creates no attributable page + error. +- [ ] Confirm API and host survive load, hydration, scrolling, and a settle + period. +- [ ] Confirm callback counts are non-zero when the page serves ads and every + callback satisfies the coverage equation. +- [ ] Confirm all displayed/exported durations are non-negative and consistent + with callback order. +- [ ] Confirm at least one usable unique visible slot receives a badge; unbound + or ambiguous slots remain in the panel without one. +- [ ] Trigger or observe a refresh and confirm a new request number rather than + replacement of the initial cycle. +- [ ] Export V1 and compare slot, cycle, status, coverage, issue, and eviction + counts with the API/panel. +- [ ] Confirm there are no provenance labels and no auction, bidder, targeting, + creative-markup, user, query-string, or fragment fields. +- [ ] Confirm `?ts_console=false` on the next document leaves no API, host, + badges, listeners, or diagnostics-created requests. +- [ ] Record sanitized acceptance evidence and any environmental limitations in + the PR description. + +--- + +## Suggested Review Checkpoints + +- [x] **After Task 1:** Review activation and cache behavior before building the + larger browser feature. Confirm no user-specific server response variant was + introduced. +- [x] **After Task 4:** Review callback matching and listener non-interference. + This is the data-truth boundary. +- [x] **After Task 6:** Review V1 export for privacy and schema stability. +- [x] **After Task 9:** Review the active/inactive side-effect boundary and + bundle-size impact. +- [x] **After Task 10:** Review hydration, duplicate-ID, closed-shadow testing, + and no-network evidence. +- [x] **Before merge:** Compare the final diff against the smaller-PR boundary in + the spec and remove any auction/provenance work that slipped in. + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Initial GPT callback occurs before diagnostics listener installation | Inject activation before the synchronous immediate bundle at `` start; document ordering limits rather than patching GPT | +| Overlapping refreshes cannot be correlated | Preserve ambiguous callbacks with `overlapping_request_cycles`; never guess | +| Out-of-order callbacks create negative timings | Keep matched disposition, add `invalid_event_order`, and suppress invalid durations | +| Framework removes the overlay | Keep capture independent; use a debounced document-level lifecycle manager and distinguish external removal from dismissal | +| Duplicate IDs point at the wrong slot | Require unique DOM and retained-slot claims; report ambiguous and omit badge | +| Closed Shadow DOM is hard to inspect in Playwright | Keep production closed; unit-test rendering through retained internal test handles and use public API/geometry/screenshot evidence in browser tests | +| Mutation/scroll work affects publisher performance | Inspect observed exact IDs only; debounce mutations and coalesce layout work to one animation frame | +| Optional browser APIs are missing | Degrade badge/remount updates without stopping GPT capture | +| Diagnostics accidentally affects auction behavior | No GPT/browser method replacement, no request gating, no targeting reads/writes, and explicit identity tests for publisher/GPT methods | +| Export leaks sensitive data | Versioned allowlist schema, origin/pathname only, banned-field tests, explicit local download only | +| Retention churn becomes unbounded | Fixed limits, deterministic eviction, weak slot identity mapping, bounded callback issues, and counters | +| Inline bootstrap conflicts with CSP | Use the existing integration head-injector pattern; validate on the live environment and report CSP limitations without adding a server session variant | +| Dedicated module increases every enabled deployment's bundle | Include it only when `gpt_diagnostics` is configured; measure built module size and keep inactive execution to the activation check | + +--- + +## Definition of Done + +- [ ] All spec acceptance criteria are covered by an automated test or explicit + live acceptance evidence. +- [x] The integration is deployment-disabled by default and tab-inactive by + default. +- [x] No callback is silently forced into a request cycle. +- [x] Store, observer, binding, API, panel, and badge behavior are bounded and + independently tested. +- [x] Inactive pages have no diagnostics listener/API/observer/host side effect. +- [x] Active pages make no diagnostic network request and do not replace GPT, + browser, or publisher methods. +- [x] Export is V1, allowlisted, local-only, and free of provenance/sensitive + fields. +- [x] Full target-matched Rust, JS, docs, integration, and browser verification + passes. +- [ ] Sanitized live-site evidence confirms hydration safety and normal ad + behavior. +- [x] The final diff remains within the smaller-PR boundary. diff --git a/docs/superpowers/specs/2026-07-28-gpt-runtime-diagnostics-overlay-design.md b/docs/superpowers/specs/2026-07-28-gpt-runtime-diagnostics-overlay-design.md new file mode 100644 index 000000000..853a711cf --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-gpt-runtime-diagnostics-overlay-design.md @@ -0,0 +1,790 @@ +# GPT Runtime Diagnostics Overlay Specification + +**Date:** 2026-07-28 +**Status:** Implemented — live publisher acceptance pending +**Branch:** `feature/gpt-runtime-diagnostics-overlay` + +## Summary + +Create an opt-in browser diagnostic console that reports facts directly observed from Google Publisher Tag (GPT) lifecycle callbacks. The console helps a publisher answer: + +- Which GPT slots requested ads? +- How long did each request take to receive a response and render? +- Did GPT report the slot as filled or empty? +- Did the rendered slot load and become viewable? +- Which callbacks were missing or could not be matched to a request cycle? +- Where is the corresponding slot on the page? + +The feature deliberately does **not** attempt to prove whether a rendered creative originated from Trusted Server, Prebid, a direct GAM line item, backfill, or another demand path. It presents GPT-observed facts without inferring creative provenance. + +The intended result is a substantially smaller replacement for the auction-to-creative tracing work in PR #961. + +## Context + +A live publisher test-site audit demonstrated that GPT lifecycle diagnostics remain useful even when end-to-end attribution does not. + +The successful run captured: + +- 9 GPT requests +- 9 GPT responses +- 7 GPT renders +- 6 GPT load callbacks +- 4 viewable-impression callbacks +- Request-to-response and response-to-render timing +- Filled, empty, and unresolved outcomes +- Callback correlation coverage and missing-generation anomalies + +The same run also demonstrated the limits of the broader design: + +- GAM did not preserve enough Trusted Server context to prove creative provenance. +- Initial request generations were invalidated by a page navigation signal before GPT callbacks arrived. +- Later GPT slots had synthetic trace identities and no usable DOM binding. +- All GPT load callbacks were unmatched. +- The overlay mounted and was then removed during page startup. +- No Trusted Server auction produced a winner, so winner and creative-acknowledgement behavior could not be evaluated. + +These observations support a narrower tool centered on the GPT runtime itself. + +## Problem + +Publishers can inspect GPT network traffic and individual console events, but reconstructing the lifecycle of each slot and refresh manually is difficult. The browser receives several independent callbacks at different times, and the same slot can participate in multiple request cycles. + +A useful diagnostic tool needs to organize those callbacks without claiming evidence that GPT does not expose. The current broader tracing model mixes reliable GPT observations with attempted server, Prebid, and creative correlation. That creates substantial implementation and testing complexity while leaving the most important provenance question unresolved. + +## Product Goal + +Provide a durable, low-interference GPT diagnostic overlay that organizes documented GPT callbacks into per-slot request cycles, computes directly observable timings, highlights missing callbacks, and points the user to the corresponding slot element. + +Success means a publisher can open a page with `ts_console=true`, inspect GPT activity visually, and export the same information for debugging without changing auction or rendering behavior. + +## Non-Goals + +This specification does not include: + +- Auction trace IDs or bid trace IDs. +- Trusted Server auction outcome propagation. +- Server-winner-to-GAM attribution. +- Prebid bid-response, targeting-selection, bid-won, or render correlation. +- Creative renderer acknowledgement. +- Attribution labels such as `trusted_server`, `gam_only`, or `direct_or_unattributed`. +- Confidence hierarchies for creative provenance. +- OpenRTB response extensions for diagnostics. +- Auction telemetry or Tinybird datasource changes. +- Collection of bid prices, targeting values, user identifiers, creative markup, or auction payloads. +- Modification of GPT slot definition, display, refresh, targeting, request, or rendering behavior. +- General-purpose ad-server debugging outside GPT. + +If provenance across Trusted Server, Prebid, GAM, and the final creative is revisited later, it should be specified as a separate integration that uses controlled identifiers or controlled test creatives. + +## Design Principles + +### Report observations, not provenance + +A non-empty `slotRenderEnded` callback proves that GPT reported a non-empty render. It does not prove which demand source supplied the creative. The UI must use labels such as **Filled** and **Empty**, not **GAM winner** or **Trusted Server winner**. + +### Prefer documented callbacks over interception + +The implementation should use `googletag.pubads().addEventListener(...)` through the GPT command queue. It must not patch `googletag.display`, `googletag.defineSlot`, `pubads.refresh`, `fetch`, XHR, history methods, or publisher callbacks. + +### Preserve raw callback truth + +Every observed callback is counted. If a callback cannot be matched safely to a request cycle, it remains visible as unmatched or ambiguous rather than being dropped or forced into a generation. + +### Keep activation local and explicit + +Diagnostics are available only when the integration is enabled for the deployment and the current browser tab has been explicitly activated. + +### Do not alter the ad lifecycle + +The diagnostic module observes GPT. It does not gate requests, delay publisher code, suppress display or refresh calls, create slots, apply targeting, or render creatives. + +## Primary User Flow + +1. A developer opens a publisher page with `?ts_console=true`. +2. A small early bootstrap enables diagnostics for the current tab and removes only the activation parameter from the visible URL. +3. GPT listeners install before the first ad request where integration ordering permits. +4. The data store begins recording GPT callbacks immediately. +5. The visual overlay mounts after page startup, without participating in framework hydration. +6. The developer sees one panel row per GPT slot and request cycle. +7. Visible, exactly bound slot elements receive concise badges. +8. Scrolling, lazy loading, and refreshes update the panel and badges. +9. The developer exports a versioned JSON snapshot if deeper inspection is needed. +10. `?ts_console=false` disables the console for the tab. + +## Scope + +### In scope + +- Client-side tab activation using `ts_console`. +- Early, idempotent GPT listener installation. +- GPT slot identity and exact DOM-element binding. +- Per-slot initial request and refresh cycles. +- GPT callback timing and callback coverage. +- Filled, empty, pending, loaded, and viewable state. +- A Shadow DOM panel and on-page badges. +- Overlay survival across hydration, DOM replacement, scrolling, resizing, and SPA navigation. +- A bounded in-memory data store. +- A versioned browser API and JSON export. +- Unit, browser-integration, and live-site acceptance tests. + +### Out of scope + +- Changes to `/auction` requests or responses. +- Changes to auction orchestration or bid selection. +- Changes to server telemetry. +- Changes to Prebid integration behavior. +- Changes to creative rendering behavior. +- Server-side correlation identifiers. +- Persistence or upload of diagnostic records. +- A remote diagnostics dashboard. + +## Architecture + +```mermaid +flowchart LR + A[ts_console activation bootstrap] --> B[Tab-local active state] + B --> C[GPT callback listeners] + C --> D[Bounded slot and request-cycle store] + D --> E[Derived timings and callback coverage] + D --> F[Browser API and JSON export] + E --> G[Overlay panel] + E --> H[Slot badges] + I[Publisher GPT behavior] --> C + C -. observes only .-> I +``` + +### Minimal server responsibility + +The server is responsible only for making the diagnostics integration available when configured and placing its bootstrap early enough in the document to observe initial GPT activity. + +The server must not: + +- Create an auction trace context. +- Modify auction responses. +- Add diagnostic fields to OpenRTB. +- Emit diagnostic telemetry. +- Vary auction behavior when the console is active. + +Because the response does not contain user-specific trace data, activation does not require a server session cookie or a special diagnostic response variant. + +### Browser components + +The browser implementation consists of four focused components: + +1. **Activation bootstrap** — manages tab-local activation and URL cleanup. +2. **GPT observer** — installs documented event listeners and normalizes callbacks. +3. **Diagnostics store** — retains bounded slot and request-cycle records and computes timings. +4. **Presentation layer** — renders the panel and badges from store snapshots. + +The store and observer must not depend on the overlay. Removing or closing the overlay must not stop callback capture. + +## Activation Requirements + +### Configuration gate + +The integration is disabled by default at the deployment level. If the integration is not configured, no GPT listeners or overlay are installed. + +A dedicated name such as the following should be used to reflect the reduced scope: + +```toml +[integrations.gpt_diagnostics] +enabled = false +``` + +The final configuration name may reuse an existing integration registry convention, but it should not be named `ad_trace` unless the implementation still performs broader ad tracing. + +### Query directives + +| Directive | Effect | +| ------------------ | --------------------------------------- | +| `ts_console=1` | Enable diagnostics for the current tab | +| `ts_console=true` | Enable diagnostics for the current tab | +| `ts_console=0` | Disable diagnostics for the current tab | +| `ts_console=false` | Disable diagnostics for the current tab | + +Values are case-sensitive. Unrecognized values are ignored. + +### Tab-local persistence + +Activation is stored in `sessionStorage`, scoped to the publisher origin and browser tab. This allows normal full-page and SPA navigation in the same tab without a server cookie. A new tab is inactive unless separately activated. + +If `sessionStorage` is unavailable, the query directive applies to the current document only. + +### URL cleanup + +After reading a recognized directive, the bootstrap removes only `ts_console` from the current URL using `history.replaceState`. All other query parameters, the path, and the fragment are preserved. + +This one-time URL cleanup is not a general history patch. The diagnostics module must not wrap `pushState`, `replaceState`, or route-change handlers. + +### Inactive behavior + +When inactive: + +- No GPT event listeners are registered. +- No diagnostics API is exposed. +- No overlay host is created. +- No DOM observers are started. +- No diagnostic network requests are made. + +## GPT Listener Requirements + +The observer installs through `googletag.cmd.push(...)` and registers these documented PubAdsService events: + +| GPT event | Diagnostic meaning | +| ----------------------- | -------------------------------------------- | +| `slotRequested` | Start of a new request cycle for a slot | +| `slotResponseReceived` | GPT received a response for the slot | +| `slotRenderEnded` | GPT reported a filled or empty render result | +| `slotOnload` | The slot's creative iframe load event fired | +| `impressionViewable` | GPT reported a viewable impression | +| `slotVisibilityChanged` | GPT reported a new visibility percentage | + +Listener installation must be: + +- Idempotent. +- Safe when GPT is absent or delayed. +- Early enough to observe initial requests where deployment ordering permits. +- Isolated so an exception in diagnostics cannot escape into publisher GPT callbacks. + +If GPT never becomes available, the overlay may show **Waiting for GPT**, but must not poll aggressively or modify the GPT queue contract. + +## Slot Identity and Element Binding + +### Runtime identity + +The GPT `Slot` object is the primary runtime identity. A `WeakMap` should associate each observed slot object with its diagnostic record. This prevents duplicate or reused element IDs from merging unrelated slot instances. + +### Display identity + +For presentation and export, the diagnostic record contains: + +- `slotElementId`, from `slot.getSlotElementId()`, when non-empty. +- `adUnitPath`, from `slot.getAdUnitPath()`, when available. +- A monotonically increasing runtime slot number for the current page. + +The preferred display label is the exact GPT slot element ID. If it is unavailable, the panel uses **Unbound GPT slot N**. A synthetic label must never be presented as a real DOM ID. + +### Exact DOM binding + +The presentation layer begins binding through an exact `document.getElementById(slotElementId)` lookup. A binding is valid only when exactly one connected DOM element has that ID and exactly one retained GPT slot record claims it. A targeted exact-ID query may verify DOM uniqueness. If uniqueness cannot be verified, the binding remains ambiguous. + +It must not: + +- Use prefix matching. +- Assume `googletag.display` receives a string. +- Call string methods on publisher-provided display arguments. +- Create a missing publisher slot element. +- Guess between container and inner-element IDs. +- Select the first DOM element or newest GPT slot when an ID is duplicated. + +Binding is retried when relevant callbacks occur and after DOM mutations. If no exact element exists, the slot remains visible in the panel as **Unbound** and receives no badge. If multiple DOM elements or retained GPT slot records use the same ID, each affected slot is shown with an **Ambiguous binding** and receives no badge. + +### Element replacement + +If a framework replaces a bound element with a new element using the same ID, the badge layer rebinds to the new element. The request records remain associated with the GPT slot object. + +## Request-Cycle Model + +### Cycle creation + +Every `slotRequested` callback creates a new request cycle for that GPT slot. Cycles use a one-based `requestNumber`: + +- `1` — Initial request +- `2` — Refresh 1 +- `3` — Refresh 2 +- And so on + +No cycle is created from `display`, `refresh`, route changes, DOM mutation, or server auction activity. + +### Callback matching + +GPT callbacks identify a slot but do not provide a stable request-cycle identifier. Matching therefore follows conservative stage compatibility: + +- `slotResponseReceived` can match a cycle with a request timestamp and no response timestamp. +- `slotRenderEnded` can match a cycle with no render timestamp. +- `slotOnload` can match a filled rendered cycle with no load timestamp. +- `impressionViewable` can match a filled rendered cycle with no prior viewable timestamp. +- `slotVisibilityChanged` updates slot-level current and maximum visibility and is not required to belong to one request cycle. + +A callback is attached only when there is one compatible cycle. If there are no compatible cycles, it is recorded as unmatched. If overlapping cycles make more than one cycle compatible, it is recorded as ambiguous with reason `overlapping_request_cycles`. + +The implementation must not guess a request cycle merely to improve a correlation percentage. + +### Navigation behavior + +A route change does not supersede or terminate an active GPT request cycle. GPT callbacks can legitimately arrive after publisher hydration, `replaceState`, SPA navigation, or other route signals. + +Cycles remain until their callbacks arrive or bounded retention evicts them. The overlay may mark the slot element as disconnected without changing the GPT lifecycle record. + +### Render resolution + +`slotRenderEnded` determines the directly observed render outcome: + +| GPT fact | Display outcome | +| ------------------------- | ------------------------- | +| `isEmpty === true` | Empty | +| `isEmpty === false` | Filled | +| No render callback yet | Pending render | +| Unmatched render callback | Unmatched render callback | + +The word **Filled** means only that GPT reported a non-empty render. + +### Load and viewability + +Load and viewability are independent flags on a filled cycle: + +- A filled cycle without `slotOnload` displays **Load not observed**. +- A load callback does not prove creative provenance. +- An `impressionViewable` callback displays **Viewable**. +- Absence of a viewable callback is not classified as failure. + +## Timing Model + +All lifecycle timestamps use `performance.now()` and are relative to the current document's performance time origin. + +Each request cycle may contain: + +| Field | Source | +| --------------- | ----------------------------------- | +| `requestedAtMs` | `slotRequested` | +| `responseAtMs` | `slotResponseReceived` | +| `renderAtMs` | `slotRenderEnded` | +| `loadAtMs` | `slotOnload` | +| `viewableAtMs` | First matching `impressionViewable` | + +Derived durations are emitted only when both endpoints exist and are ordered: + +| Duration | Calculation | +| ------------------ | ------------------------------ | +| Request → response | `responseAtMs - requestedAtMs` | +| Response → render | `renderAtMs - responseAtMs` | +| Request → render | `renderAtMs - requestedAtMs` | +| Render → load | `loadAtMs - renderAtMs` | +| Render → viewable | `viewableAtMs - renderAtMs` | + +Negative or invalid durations are not displayed. A uniquely correlated callback remains matched, while the store adds a callback issue with reason `invalid_event_order`. Matching disposition and sequence validity are recorded separately. + +No network timing is inferred from Performance Resource Timing entries in this scope. + +## Recorded GPT Facts + +For each render cycle, the store may record these fields when exposed by GPT: + +- `isEmpty` +- Rendered `size` +- `isBackfill` +- `slotContentChanged` +- Current visibility percentage +- Maximum observed visibility percentage + +The first version does not record: + +- Creative ID +- Line item ID +- Campaign ID +- Advertiser ID +- Company IDs +- Targeting keys or values +- Bidder identity +- Bid price +- Creative HTML + +These exclusions keep the tool focused and reduce privacy and export concerns. + +## Diagnostic States + +The panel derives a primary lifecycle state from observed GPT facts: + +| State | Meaning | +| ------------------- | ------------------------------------------------------- | +| Waiting for request | Slot observed, but no `slotRequested` callback captured | +| Requesting | Request observed; response not yet observed | +| Response received | Response observed; render not yet observed | +| Filled | GPT reported a non-empty render | +| Empty | GPT reported an empty render | + +The panel augments that primary state with independent facts and issues: + +| Augmentation | Meaning | +| ------------------- | -------------------------------------------------------------- | +| Loaded | A filled render later produced `slotOnload` | +| Viewable | A filled render later produced `impressionViewable` | +| Incomplete sequence | Affirmative evidence shows a missing or invalid lifecycle step | +| Unbound | No exact current DOM element matches the slot element ID | +| Ambiguous binding | More than one DOM element or GPT slot record claims the ID | + +A cycle is marked **Incomplete sequence** only when a matched callback violates lifecycle ordering, a later-stage callback proves an earlier stage was absent, or another recorded issue can be assigned to that cycle. Elapsed time alone never makes a cycle incomplete. An unmatched or ambiguous callback that cannot be assigned safely creates a slot-level issue instead. + +A request without a response remains **Requesting**, and a response without a render remains **Response received**. A filled cycle without a load remains **Filled** with **Load not observed**. Absence of a viewable callback is not classified as a failure. + +## Callback Coverage + +The store maintains mutually exclusive matching-disposition counters for each callback type: + +```text +observed / matched / unmatched / ambiguous +``` + +For each callback type, `observed` equals `matched + unmatched + ambiguous`. Sequence validity is tracked separately, so a uniquely correlated callback with invalid ordering remains matched and also produces a callback issue. + +Examples: + +```text +Responses: 9 observed · 6 matched · 3 unmatched +Loads: 6 observed · 6 matched +``` + +Coverage is a diagnostic of the observer's ability to organize callbacks. It is not an ad-fill or revenue metric. + +Callback issues include unmatched callbacks, ambiguous callbacks, and matched callbacks with invalid event ordering. Each issue retains: + +- Callback kind +- Runtime slot number +- Slot element ID, if available +- Relative timestamp +- Matching disposition +- Reason + +## Overlay Requirements + +### Host and isolation + +The overlay uses one host with a stable ID and a closed Shadow DOM. Styles must not leak into the publisher page, and publisher styles must not affect overlay controls. + +Only one overlay instance may exist. + +### Hydration-safe mounting + +GPT listeners and data capture begin immediately after activation, but the visual host mounts only after the document reaches `complete` and at least two animation frames have elapsed. + +A lifecycle manager outside the overlay host observes document-level child replacement. If the active host is removed by hydration, DOM reconciliation, or SPA shell replacement, the manager remounts it after a debounce. + +Explicit user closure is different from external removal: + +- **Close** sets a dismissed state and prevents automatic remounting for the current document. +- `window.tsjs.gptDiagnostics.show()` clears dismissal and mounts the UI again. +- External host removal does not set dismissal. + +### Panel + +The fixed panel shows: + +- Whether GPT has been observed. +- Callback coverage and unmatched counts. +- One row per slot, showing the latest request by default. +- Expandable previous request and refresh cycles. +- Slot element ID or **Unbound GPT slot N**. +- Ad unit path, when available. +- Filled, empty, pending, loaded, and viewable facts. +- Relevant timings. +- Rendered size and backfill flag, when available. +- Whether the exact slot element is currently bound and visible. + +Required controls: + +- Collapse/expand panel. +- Filter by All, Visible, Filled, Empty, Pending/Incomplete, and Unbound/Ambiguous. +- Export JSON. +- Close. + +### Badges + +A badge is shown only when: + +- The slot has an exact, connected DOM binding. +- The element has a non-zero rectangle. +- The element intersects the viewport. +- At least one request cycle has been observed. + +The badge is positioned adjacent to the slot without changing publisher layout. Positioning updates through throttled scroll, resize, element resize, and relevant DOM mutation signals. + +The first version may use one badge per bound slot without implementing complex collision optimization. If badges overlap, the panel remains the authoritative complete view. + +Example badge: + +```text +Filled · 728×90 +Response 276 ms · Render 42 ms +Viewable after 1.0 s +``` + +Example empty badge: + +```text +Empty +Response 277 ms · Render 3 ms +``` + +The badge must never say that a creative came from GAM, Trusted Server, Prebid, or a particular bidder. + +### Publisher DOM impact + +The overlay must not add diagnostic data attributes to publisher slot elements. Binding and state are held in internal maps. Temporary visual highlighting, if implemented, must use a separate overlay element rather than changing publisher classes or inline styles. + +## Browser API and Export + +When active, the feature exposes: + +```ts +window.tsjs.gptDiagnostics = { + snapshot(), + export(), + subscribe(listener), + show(), + hide(), +}; +``` + +The API is read-only except for presentation controls. It does not expose internal mutation methods. + +### Export schema + +The JSON export is versioned independently of internal implementation details. + +```ts +interface GptDiagnosticsExportV1 { + version: 1 + capturedAt: string + page: { + origin: string + pathname: string + } + slots: Array<{ + runtimeSlotNumber: number + slotElementId?: string + adUnitPath?: string + binding: { + status: 'bound' | 'unbound' | 'ambiguous' + reason?: + | 'missing_slot_element_id' + | 'missing_element' + | 'duplicate_dom_id' + | 'duplicate_gpt_slot_id' + } + currentVisibilityPercentage?: number + maximumVisibilityPercentage?: number + requests: GptRequestCycle[] + }> + callbackIssues: Array<{ + kind: string + runtimeSlotNumber: number + slotElementId?: string + timestampMs: number + disposition: 'matched' | 'unmatched' | 'ambiguous' + reason: string + }> + coverage: Record< + string, + { + observed: number + matched: number + unmatched: number + ambiguous: number + } + > + metadata: { + droppedCallbacks: number + evictedSlots: number + evictedRequestCycles: number + } +} +``` + +`GptRequestCycle` contains the request number, directly observed timestamps and render facts, and valid derived durations. It contains no server auction or bidder fields. + +The export includes the page origin and pathname, but excludes query parameters and fragments. + +## Bounded Storage + +The diagnostics store is memory-only and bounded. Initial limits should be simple constants with unit tests: + +- Maximum 64 observed GPT slot objects. +- Maximum 10 retained request cycles per slot. +- Maximum 128 retained callback issue records. + +When a limit is exceeded: + +- Evict the oldest eligible record. +- Increment the corresponding metadata counter. +- Keep the latest request cycles visible. +- Do not throw into publisher code. + +The implementation does not use IndexedDB, localStorage for trace data, cookies, or server upload. + +## SPA and Dynamic-Page Behavior + +- History changes do not reset the store. +- Route changes do not supersede GPT request cycles. +- New GPT slots discovered after navigation are added normally. +- Disconnected elements become unbound while their records remain available. +- Recreated elements with the same exact ID can be rebound. +- The overlay remounts if the publisher replaces the UI host. +- A full document navigation starts a new in-memory store while tab activation persists through `sessionStorage`. + +## Error Handling + +All diagnostic callback handlers have a top-level exception boundary. Errors are reported through the existing `tsjs` logger at warning level and do not escape into GPT. + +The observer must tolerate: + +- Missing GPT globals. +- Missing or throwing slot methods. +- Empty slot element IDs. +- Duplicate element IDs. +- Callbacks observed before the overlay exists. +- Callbacks with no request cycle. +- Overlapping request cycles. +- Publisher removal of the overlay host. +- Missing `ResizeObserver`, `MutationObserver`, or `sessionStorage`. + +A degraded browser may lose optional badge updates, but the GPT callback store should continue operating where event listeners are available. + +## Privacy and Security + +- Diagnostics are opt-in and local to the browser tab. +- No diagnostic record is automatically transmitted. +- No user identifier, cookie value, targeting value, bid value, creative markup, or auction payload is collected. +- The export excludes URL query strings and fragments. +- The feature is not an authentication or authorization boundary. +- Shadow DOM isolates the UI but is not treated as a security boundary. +- Export is initiated only by an explicit user action. + +## Performance and Non-Interference + +The inactive path should consist only of the activation check. + +When active: + +- GPT callback handlers perform bounded synchronous work. +- UI rendering is scheduled and coalesced, not performed directly inside GPT callbacks. +- Scroll, resize, and mutation-driven layout updates are limited to one per animation frame. +- DOM lookup is exact and scoped to observed slot IDs. +- Mutation handling is debounced and does not rescan the entire document for arbitrary ad patterns. +- No diagnostic network requests are made. +- No publisher or GPT function is replaced. + +## Smaller-PR Boundary + +The implementation PR should be limited to: + +- Deployment configuration for the diagnostics integration. +- Early client activation bootstrap. +- GPT observer and bounded diagnostics store. +- Overlay and badge presentation. +- Focused documentation. +- Unit and browser tests for this functionality. + +The PR should not modify: + +- Auction orchestrator or endpoint response formats. +- OpenRTB bid extensions. +- Prebid integration code, except deleting superseded tracing hooks if this replaces PR #961. +- Creative renderers or acknowledgement protocols. +- Tinybird schemas or telemetry clients. +- Adapter middleware for diagnostic session cookies. +- GPT request gating, slot handoff, display, refresh, or targeting behavior. + +Any required change in those excluded areas is a scope expansion and should trigger explicit review of this specification. + +## Test Strategy + +### Unit tests + +The diagnostics store must cover: + +- Initial request lifecycle. +- Multiple sequential refresh cycles. +- Filled and empty render outcomes. +- Load and viewability timing. +- Missing response, render, load, and viewability callbacks. +- Callback observed without a request. +- Overlapping request cycles. +- Invalid callback ordering. +- Slot object identity with duplicate element IDs. +- Ambiguous binding for duplicate DOM IDs and duplicate GPT slot IDs. +- Exact element binding and rebinding. +- Matched callback issues with invalid event ordering. +- Bounded retention and eviction counters. +- Export schema stability. +- Explicit absence of provenance labels and fields. + +### Browser integration tests + +A controlled GPT stub or fixture page must verify: + +- Listener installation through `googletag.cmd`. +- No patched GPT or browser methods. +- No listeners or overlay when inactive. +- Query activation, deactivation, and URL cleanup. +- Tab-local persistence across document navigation. +- Panel rendering before and after callbacks. +- Badge placement on an exact, uniquely bound visible element. +- No badge for an unbound or ambiguously bound slot. +- Refresh history in the panel. +- Overlay remount after external host removal. +- No remount after explicit Close. +- Overlay survival after a simulated framework root replacement. +- Export matching the visible panel state. + +### Live-site harness tests + +The publisher Playwright harness should verify: + +1. `?ts_console=true` loads the normal page successfully. +2. The diagnostic API is available. +3. The overlay remains present after load, hydration, scrolling, and a settle period. +4. No new page error is attributable to diagnostics. +5. GPT callback counts are non-zero when the page serves ads. +6. Every observed callback is represented as matched, unmatched, or ambiguous. +7. Timings are non-negative and consistent with callback order. +8. At least one exact, visible GPT slot receives a badge when the page exposes a usable slot element ID. +9. Unbound or ambiguously bound slots remain visible in the panel without a badge. +10. A refresh produces a new request number rather than replacing the initial request. +11. The JSON export matches panel counts and status. +12. `?ts_console=false` leaves no API, overlay, badge, or listeners active on the next document. + +A trace-off control should be run in the same valid browser tab when bot-protection access is session-sensitive. + +## Acceptance Criteria + +The specification is satisfied when all of the following are true: + +- [x] The console can be enabled and disabled using the documented query directives. +- [x] Activation persists only in the current tab. +- [x] The implementation uses documented GPT listeners without patching GPT behavior. +- [x] Each `slotRequested` creates a distinct initial or refresh request cycle. +- [x] Filled and empty labels are derived only from `slotRenderEnded.isEmpty`. +- [x] Lifecycle timings are displayed only from observed, ordered callbacks. +- [x] Unmatched and ambiguous callbacks remain visible in coverage diagnostics. +- [x] Matched callbacks with invalid ordering remain matched and expose an `invalid_event_order` issue. +- [x] Navigation does not invalidate an active GPT request cycle. +- [x] Exact, unique DOM binding produces a badge for eligible visible slots. +- [x] Unbound and ambiguously bound slots are clearly labeled and never receive guessed bindings. +- [x] The overlay survives external removal and normal SPA activity. +- [x] Explicit Close prevents automatic remount until Show is requested. +- [x] The overlay does not add attributes, classes, or styles to publisher slot elements. +- [x] Exported data contains no auction, bidder, targeting, creative-markup, or user fields. +- [x] The inactive path installs no listeners or observers. +- [x] The active path makes no diagnostic network requests. +- [ ] The console does not produce a hydration error or alter normal ad behavior on the live test site. +- [x] All data structures are bounded and expose eviction counters. +- [x] The implementation remains within the smaller-PR boundary. + +## Resolved Decisions + +- The panel opens fully by default. Activation is explicit, and the existing collapse control allows the user to reduce its visual impact. +- Exports include `adUnitPath` whenever GPT exposes it. Exported facts do not depend on panel expansion state. +- The first version uses badges as its only spatial indicator. Temporary click-to-highlight behavior is deferred. +- Overlapping refresh behavior is tested with a deterministic GPT event-bus stub. The fixture emits two `slotRequested` callbacks for the same `Slot` object before response or render callbacks and verifies that subsequent callbacks remain ambiguous rather than being forced into either cycle. +- The `ts_console` activation name is retained for compatibility. Deployment configuration uses the narrower `gpt_diagnostics` name. +- Matching disposition and sequence validity are separate. A uniquely correlated callback with invalid ordering remains matched and also produces an `invalid_event_order` callback issue. +- **Incomplete sequence** augments the observed lifecycle state and requires affirmative evidence of a gap or invalid ordering. Elapsed time alone does not make a lifecycle incomplete. +- DOM binding is conservative. Duplicate DOM IDs or duplicate retained GPT slot IDs produce an ambiguous binding with no badge rather than selecting an element or slot heuristically. + +## Open Questions + +No open questions remain for the first version. + +## Related + +- Prior exploratory implementation: [PR #961](https://github.com/IABTechLab/trusted-server/pull/961) diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 26d95d681..a3c5f2d6d 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -105,6 +105,9 @@ script_url = "https://ads.example.com/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true +[integrations.gpt_diagnostics] +enabled = false + [proxy] # certificate_check = true # Required for integrations.prebid.external_bundle_url and first-party proxy redirects.