Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
297325a
Add GAM ts cohort attribution design spec
aram356 Aug 13, 2026
a86c4ce
Add GAM ts cohort attribution design spec
aram356 Aug 13, 2026
400f299
Finalize GAM cohort attribution design
prk-Jr Aug 14, 2026
ec62128
Add GAM attribution implementation plan
prk-Jr Aug 14, 2026
71e0714
Add GPT GAM attribution option
prk-Jr Aug 14, 2026
990b6bf
Authorize GAM attribution bundle
prk-Jr Aug 14, 2026
0bfee82
Cover disabled GPT attribution
prk-Jr Aug 14, 2026
06ec213
Queue page-level GAM attribution
prk-Jr Aug 14, 2026
18d28a5
Add GAM attribution bundle fallback
prk-Jr Aug 15, 2026
d65f656
Characterize targeting collisions
prk-Jr Aug 15, 2026
ef37c11
Define GAM attribution streaming semantics
prk-Jr Aug 15, 2026
1749414
Document GAM attribution configuration
prk-Jr Aug 15, 2026
ef35343
Use explicit GAM attribution branch
prk-Jr Aug 15, 2026
97e097d
Align GAM rollback and failure coverage
prk-Jr Aug 15, 2026
367a593
Merge remote GAM attribution spec
prk-Jr Aug 15, 2026
78107e6
Merge GAM cohort attribution implementation
prk-Jr Aug 15, 2026
46b38f2
Merge branch 'main' into docs/gam-ts-cohort-attribution-spec
aram356 Aug 18, 2026
9ef74b1
Document GAM attribution review resolution
prk-Jr Aug 19, 2026
af1ca97
Plan GAM attribution review resolution
prk-Jr Aug 19, 2026
1a6c02e
Harden publisher tag attributes
prk-Jr Aug 19, 2026
00c48d0
Log GAM attribution bootstrap failures
prk-Jr Aug 19, 2026
d9798be
Clarify GAM attribution overlay requirement
prk-Jr Aug 19, 2026
bbadec3
Make attribute validation tests WASM-safe
prk-Jr Aug 19, 2026
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
44 changes: 44 additions & 0 deletions crates/trusted-server-cli/tests/config_env_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ ids = ["trusted_server_secrets"]
"#;
const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES";
const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES";
const GAM_ATTRIBUTION_ENV: &str = "TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED";

struct MigratedProject {
directory: TempDir,
Expand Down Expand Up @@ -112,6 +113,49 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() {
);
}

#[test]
fn migrated_legacy_config_applies_gam_attribution_environment_override() {
let project = migrated_legacy_project();
let output = Command::new(env!("CARGO_BIN_EXE_ts"))
.args(["config", "push", "--adapter", "axum", "--manifest"])
.arg(&project.manifest_path)
.arg("--app-config")
.arg(&project.config_path)
.args(["--yes", "--no-diff"])
.current_dir(project.directory.path())
.env(GAM_ATTRIBUTION_ENV, "true")
.output()
.expect("should run ts config push");

assert!(
output.status.success(),
"valid boolean overlay should push successfully: {}",
String::from_utf8_lossy(&output.stderr)
);

let local_store_path = project
.directory
.path()
.join(".edgezero/local-config-trusted_server_config.json");
let local_store: serde_json::Value = serde_json::from_str(
&fs::read_to_string(local_store_path).expect("should read pushed local config"),
)
.expect("should parse local config store");
let envelope_json = local_store
.as_object()
.and_then(|entries| entries.values().next())
.and_then(serde_json::Value::as_str)
.expect("should contain a blob envelope");
let envelope: serde_json::Value =
serde_json::from_str(envelope_json).expect("should parse blob envelope");

assert_eq!(
envelope["data"]["integrations"]["gpt"]["gam_attribution_enabled"],
serde_json::Value::Bool(true),
"pushed config should contain the GAM attribution environment override"
);
}

#[test]
fn migrated_legacy_config_applies_sanitize_creatives_environment_override() {
let project = migrated_legacy_project();
Expand Down
9 changes: 8 additions & 1 deletion crates/trusted-server-core/src/creative_opportunities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1711,11 +1711,18 @@ mod tests {

#[test]
fn to_ad_slot_sets_floor_price_and_formats() {
let slot = make_slot("atf", vec!["/"]);
let mut slot = make_slot("atf", vec!["/"]);
slot.targeting
.insert("ts".to_string(), "operator-value".to_string());
let ad_slot = slot.to_ad_slot();
assert_eq!(ad_slot.id, "atf");
assert_eq!(ad_slot.floor_price, Some(0.50));
assert_eq!(ad_slot.formats.len(), 1);
assert_eq!(
ad_slot.targeting.get("ts"),
Some(&serde_json::Value::String("operator-value".to_owned())),
"should preserve operator-provided ts targeting verbatim"
);
}

#[test]
Expand Down
76 changes: 75 additions & 1 deletion crates/trusted-server-core/src/html_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,11 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
}
// Main bundle: core + non-deferred integrations (synchronous).
let immediate_ids = integrations.js_module_ids_immediate();
snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids));
let script_attributes = integrations.tsjs_script_tag_attributes();
snippet.push_str(&tsjs::tsjs_script_tag_with_attributes(
&immediate_ids,
&script_attributes,
));
// Active diagnostics loads synchronously after core so its
// GPT listeners precede publisher scripts in the origin head.
if let Some(module_tag) = gpt_diagnostics
Expand Down Expand Up @@ -835,6 +839,76 @@ mod tests {
);
}

#[test]
fn integration_head_injector_marks_only_attribution_enabled_gpt_bundle() {
fn process(gpt_config: Option<(bool, bool)>) -> String {
let integrations = if let Some((enabled, gam_attribution_enabled)) = gpt_config {
let mut settings = create_test_settings();
settings
.integrations
.insert_config(
"gpt",
&json!({
"enabled": enabled,
"gam_attribution_enabled": gam_attribution_enabled
}),
)
.expect("should insert GPT config");
IntegrationRegistry::new(&settings).expect("should build GPT registry")
} else {
IntegrationRegistry::empty_for_tests()
};
let mut config = create_test_config();
config.integrations = integrations;
let mut processor = create_html_processor(config);
let output = processor
.process_chunk(b"<html><head></head><body></body></html>", true)
.expect("should process HTML");

String::from_utf8(output).expect("should produce valid UTF-8")
}

let attributed = process(Some((true, true)));
let unattributed = process(Some((true, false)));
let disabled_gpt = process(Some((false, true)));
let without_gpt = process(None);

for html in [&attributed, &unattributed, &disabled_gpt, &without_gpt] {
assert_eq!(
html.matches("id=\"trustedserver-js\"").count(),
1,
"should emit exactly one publisher bundle tag: {html}"
);
}
assert!(
attributed.contains("data-ts-gam-attribution=\"true\""),
"should mark only an attribution-enabled GPT publisher bundle"
);
assert!(
!unattributed.contains("data-ts-gam-attribution"),
"should leave an attribution-disabled GPT publisher bundle unmarked"
);
assert!(
!disabled_gpt.contains("data-ts-gam-attribution"),
"should let the GPT master switch suppress attribution metadata"
);
assert!(
!without_gpt.contains("data-ts-gam-attribution"),
"should leave a non-GPT publisher bundle unmarked"
);

let head_insert_index = attributed
.find("window.__tsjs_installGptShim")
.expect("should include the GPT head insert");
let publisher_bundle_index = attributed
.find("id=\"trustedserver-js\"")
.expect("should include the publisher bundle");
assert!(
head_insert_index < publisher_bundle_index,
"should keep integration head inserts before the publisher bundle"
);
}

#[test]
fn active_gpt_diagnostics_loads_standalone_after_unified_bundle_once() {
let html = "<html><head><title>Test</title></head><body></body></html>";
Expand Down
110 changes: 107 additions & 3 deletions crates/trusted-server-core/src/integrations/gpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ pub struct GptConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,

/// Enable page-level `ts=true` delivery attribution in GAM.
#[serde(default)]
pub gam_attribution_enabled: bool,

/// URL for the GPT bootstrap script (default: Google's CDN).
#[serde(default = "default_script_url")]
#[validate(url)]
Expand Down Expand Up @@ -487,10 +491,17 @@ impl IntegrationHeadInjector for GptIntegration {
/// route changes (see `auction/endpoints.rs`).
/// The `POST /auction` endpoint is not involved in scroll or refresh flows.
fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec<String> {
let gam_attribution_flag = if self.config.gam_attribution_enabled {
"window.__tsjs_gam_attribution_enabled=true;"
} else {
""
};

let mut scripts = vec![
"<script>window.__tsjs_gpt_enabled=true;\
window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>"
.to_string(),
format!(
"<script>window.__tsjs_gpt_enabled=true;{gam_attribution_flag}\
window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>"
),
format!("<script>{}</script>", GPT_BOOTSTRAP_JS),
];

Expand All @@ -508,6 +519,14 @@ impl IntegrationHeadInjector for GptIntegration {

scripts
}

fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> {
if self.config.gam_attribution_enabled {
vec![("data-ts-gam-attribution", "true")]
} else {
Vec::new()
}
}
}

/// Inline `window.tsjs.adInit` bootstrap injected at `<head>` so the bids
Expand Down Expand Up @@ -549,6 +568,7 @@ mod tests {
fn test_config() -> GptConfig {
GptConfig {
enabled: true,
gam_attribution_enabled: false,
script_url: default_script_url(),
cache_ttl_seconds: 3600,
rewrite_script: true,
Expand All @@ -573,6 +593,29 @@ mod tests {
.expect("should build HTTP request")
}

#[test]
fn gam_attribution_defaults_to_disabled() {
let config: GptConfig =
serde_json::from_value(serde_json::json!({})).expect("should parse defaults");

assert!(!config.gam_attribution_enabled);
}

#[test]
fn gam_attribution_deserializes_explicit_values() {
let disabled: GptConfig = serde_json::from_value(serde_json::json!({
"gam_attribution_enabled": false
}))
.expect("should parse explicit false");
let enabled: GptConfig = serde_json::from_value(serde_json::json!({
"gam_attribution_enabled": true
}))
.expect("should parse explicit true");

assert!(!disabled.gam_attribution_enabled);
assert!(enabled.gam_attribution_enabled);
}

// -- URL detection --

#[test]
Expand Down Expand Up @@ -1146,6 +1189,38 @@ mod tests {
"<script>window.__tsjs_gpt_enabled=true;window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>",
"should set the enable flag and call the GPT shim activation function"
);
assert!(
integration.tsjs_script_tag_attributes().is_empty(),
"should not authorize GAM attribution metadata by default"
);
}

#[test]
fn gam_attribution_true_adds_both_activation_signals_without_a_new_insert() {
let integration = GptIntegration::new(GptConfig {
gam_attribution_enabled: true,
..test_config()
});
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(), 2, "should not add another head insert");
assert!(
inserts[0].contains("window.__tsjs_gam_attribution_enabled=true;"),
"should activate the early bootstrap marker"
);
assert_eq!(
integration.tsjs_script_tag_attributes(),
vec![("data-ts-gam-attribution", "true")],
"should authorize the bundle fallback on the publisher tag"
);
}

#[test]
Expand Down Expand Up @@ -1352,6 +1427,35 @@ mod tests {
);
}

#[test]
fn head_inserts_queue_gam_attribution_before_guard_and_ad_requests() {
let targeting_index = GPT_BOOTSTRAP_JS
.find("gpt.setConfig({ targeting: { ts: \"true\" } })")
.expect("should apply the fixed page-level GAM targeting pair");
let guard_index = GPT_BOOTSTRAP_JS
.find("if (ts.adInit) return;")
.expect("should retain the preinstalled adInit guard");
let display_index = GPT_BOOTSTRAP_JS
.find("googletag.display(divId);")
.expect("should retain the executable GPT display call");
let refresh_index = GPT_BOOTSTRAP_JS
.find("googletag.pubads().refresh(slotsNeedingRefresh);")
.expect("should retain the bounded GPT refresh call");

assert!(
targeting_index < guard_index,
"should enqueue attribution before the preinstalled adInit guard"
);
assert!(
targeting_index < display_index,
"should enqueue attribution before the executable display call"
);
assert!(
targeting_index < refresh_index,
"should enqueue attribution before the executable refresh call"
);
}

#[test]
fn head_injector_integration_id() {
let integration = GptIntegration::new(test_config());
Expand Down
25 changes: 24 additions & 1 deletion crates/trusted-server-core/src/integrations/gpt_bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@
(function () {
if (typeof window === "undefined") return;
var ts = (window.tsjs = window.tsjs || {});
var tag;

if (window.__tsjs_gam_attribution_enabled === true) {
tag = window.googletag = window.googletag || { cmd: [] };
tag.cmd = tag.cmd || [];
tag.cmd.push(function () {
try {
var gpt = window.googletag;
if (gpt && typeof gpt.setConfig === "function") {
// "ts" is the fixed GAM key, not the local window.tsjs alias.
gpt.setConfig({ targeting: { ts: "true" } });
}
} catch (error) {
// Attribution must not interrupt the existing bootstrap queue.
ts.log &&
ts.log.warn &&
ts.log.warn("GAM attribution targeting failed", error);
}
});
}

if (ts.adInit) return;

// Track whether the publisher disabled GPT initial load. Read the effective
Expand All @@ -38,7 +59,9 @@
return true;
}

(window.googletag = window.googletag || { cmd: [] }).cmd.push(function () {
tag = tag || (window.googletag = window.googletag || { cmd: [] });
tag.cmd = tag.cmd || [];
tag.cmd.push(function () {
var gpt = window.googletag;
syncInitialLoadDisabled(gpt);
if (
Expand Down
Loading
Loading