Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions crates/trusted-server-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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.
Expand Down Expand Up @@ -155,6 +157,7 @@ fn validate_enabled_integrations(
validate_integration::<GoogleTagManagerConfig>(settings, "google_tag_manager")?;
validate_integration::<DataDomeConfig>(settings, "datadome")?;
validate_integration::<GptConfig>(settings, "gpt")?;
validate_integration::<GptDiagnosticsConfig>(settings, "gpt_diagnostics")?;

Ok(enabled_auction_providers)
}
Expand Down
50 changes: 50 additions & 0 deletions crates/trusted-server-core/src/html_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,56 @@ mod tests {
);
}

#[test]
fn gpt_diagnostics_bootstrap_precedes_immediate_bundle_once() {
let html = "<html><head><title>Test</title></head><body></body></html>";
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();
Expand Down
233 changes: 233 additions & 0 deletions crates/trusted-server-core/src/integrations/gpt_diagnostics.rs
Original file line number Diff line number Diff line change
@@ -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<Option<IntegrationRegistration>, Report<TrustedServerError>> {
let Some(_config) =
settings.integration_config::<GptDiagnosticsConfig>(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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤔 thinking — Worth weighing: enabling diagnostics ships 30 KB of tester-only code to every visitor.

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

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

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

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

.build(),
))
}

impl IntegrationHeadInjector for GptDiagnosticsIntegration {
fn integration_id(&self) -> &'static str {
GPT_DIAGNOSTICS_INTEGRATION_ID
}

fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec<String> {
vec![format!("<script>{GPT_DIAGNOSTICS_BOOTSTRAP_JS}</script>")]
}
}

#[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::<GptDiagnosticsConfig>(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:?}"
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
})();
5 changes: 5 additions & 0 deletions crates/trusted-server-core/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -332,6 +333,10 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] {
id: "gpt",
build: gpt::register,
},
IntegrationBuilder {
id: "gpt_diagnostics",
build: gpt_diagnostics::register,
},
]
}

Expand Down
Loading
Loading