Skip to content

Commit a04f47d

Browse files
committed
Merge #1034 docs/gam-ts-cohort-attribution-spec into rc/202608
2 parents 253ff9c + 78107e6 commit a04f47d

19 files changed

Lines changed: 3144 additions & 27 deletions

File tree

crates/trusted-server-cli/tests/config_env_overlay.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ ids = ["trusted_server_secrets"]
2929
"#;
3030
const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES";
3131
const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES";
32+
const GAM_ATTRIBUTION_ENV: &str = "TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED";
3233

3334
struct MigratedProject {
3435
directory: TempDir,
@@ -112,6 +113,49 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() {
112113
);
113114
}
114115

116+
#[test]
117+
fn migrated_legacy_config_applies_gam_attribution_environment_override() {
118+
let project = migrated_legacy_project();
119+
let output = Command::new(env!("CARGO_BIN_EXE_ts"))
120+
.args(["config", "push", "--adapter", "axum", "--manifest"])
121+
.arg(&project.manifest_path)
122+
.arg("--app-config")
123+
.arg(&project.config_path)
124+
.args(["--yes", "--no-diff"])
125+
.current_dir(project.directory.path())
126+
.env(GAM_ATTRIBUTION_ENV, "true")
127+
.output()
128+
.expect("should run ts config push");
129+
130+
assert!(
131+
output.status.success(),
132+
"valid boolean overlay should push successfully: {}",
133+
String::from_utf8_lossy(&output.stderr)
134+
);
135+
136+
let local_store_path = project
137+
.directory
138+
.path()
139+
.join(".edgezero/local-config-trusted_server_config.json");
140+
let local_store: serde_json::Value = serde_json::from_str(
141+
&fs::read_to_string(local_store_path).expect("should read pushed local config"),
142+
)
143+
.expect("should parse local config store");
144+
let envelope_json = local_store
145+
.as_object()
146+
.and_then(|entries| entries.values().next())
147+
.and_then(serde_json::Value::as_str)
148+
.expect("should contain a blob envelope");
149+
let envelope: serde_json::Value =
150+
serde_json::from_str(envelope_json).expect("should parse blob envelope");
151+
152+
assert_eq!(
153+
envelope["data"]["integrations"]["gpt"]["gam_attribution_enabled"],
154+
serde_json::Value::Bool(true),
155+
"pushed config should contain the GAM attribution environment override"
156+
);
157+
}
158+
115159
#[test]
116160
fn migrated_legacy_config_applies_sanitize_creatives_environment_override() {
117161
let project = migrated_legacy_project();

crates/trusted-server-core/src/creative_opportunities.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1911,11 +1911,18 @@ mod tests {
19111911

19121912
#[test]
19131913
fn to_ad_slot_sets_floor_price_and_formats() {
1914-
let slot = make_slot("atf", vec!["/"]);
1914+
let mut slot = make_slot("atf", vec!["/"]);
1915+
slot.targeting
1916+
.insert("ts".to_string(), "operator-value".to_string());
19151917
let ad_slot = slot.to_ad_slot();
19161918
assert_eq!(ad_slot.id, "atf");
19171919
assert_eq!(ad_slot.floor_price, Some(0.50));
19181920
assert_eq!(ad_slot.formats.len(), 1);
1921+
assert_eq!(
1922+
ad_slot.targeting.get("ts"),
1923+
Some(&serde_json::Value::String("operator-value".to_owned())),
1924+
"should preserve operator-provided ts targeting verbatim"
1925+
);
19191926
}
19201927

19211928
#[test]

crates/trusted-server-core/src/html_processor.rs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,11 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
429429
}
430430
// Main bundle: core + non-deferred integrations (synchronous).
431431
let immediate_ids = integrations.js_module_ids_immediate();
432-
snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids));
432+
let script_attributes = integrations.tsjs_script_tag_attributes();
433+
snippet.push_str(&tsjs::tsjs_script_tag_with_attributes(
434+
&immediate_ids,
435+
&script_attributes,
436+
));
433437
// Active diagnostics loads synchronously after core so its
434438
// GPT listeners precede publisher scripts in the origin head.
435439
if let Some(module_tag) = gpt_diagnostics
@@ -923,6 +927,76 @@ mod tests {
923927
);
924928
}
925929

930+
#[test]
931+
fn integration_head_injector_marks_only_attribution_enabled_gpt_bundle() {
932+
fn process(gpt_config: Option<(bool, bool)>) -> String {
933+
let integrations = if let Some((enabled, gam_attribution_enabled)) = gpt_config {
934+
let mut settings = create_test_settings();
935+
settings
936+
.integrations
937+
.insert_config(
938+
"gpt",
939+
&json!({
940+
"enabled": enabled,
941+
"gam_attribution_enabled": gam_attribution_enabled
942+
}),
943+
)
944+
.expect("should insert GPT config");
945+
IntegrationRegistry::new(&settings).expect("should build GPT registry")
946+
} else {
947+
IntegrationRegistry::empty_for_tests()
948+
};
949+
let mut config = create_test_config();
950+
config.integrations = integrations;
951+
let mut processor = create_html_processor(config);
952+
let output = processor
953+
.process_chunk(b"<html><head></head><body></body></html>", true)
954+
.expect("should process HTML");
955+
956+
String::from_utf8(output).expect("should produce valid UTF-8")
957+
}
958+
959+
let attributed = process(Some((true, true)));
960+
let unattributed = process(Some((true, false)));
961+
let disabled_gpt = process(Some((false, true)));
962+
let without_gpt = process(None);
963+
964+
for html in [&attributed, &unattributed, &disabled_gpt, &without_gpt] {
965+
assert_eq!(
966+
html.matches("id=\"trustedserver-js\"").count(),
967+
1,
968+
"should emit exactly one publisher bundle tag: {html}"
969+
);
970+
}
971+
assert!(
972+
attributed.contains("data-ts-gam-attribution=\"true\""),
973+
"should mark only an attribution-enabled GPT publisher bundle"
974+
);
975+
assert!(
976+
!unattributed.contains("data-ts-gam-attribution"),
977+
"should leave an attribution-disabled GPT publisher bundle unmarked"
978+
);
979+
assert!(
980+
!disabled_gpt.contains("data-ts-gam-attribution"),
981+
"should let the GPT master switch suppress attribution metadata"
982+
);
983+
assert!(
984+
!without_gpt.contains("data-ts-gam-attribution"),
985+
"should leave a non-GPT publisher bundle unmarked"
986+
);
987+
988+
let head_insert_index = attributed
989+
.find("window.__tsjs_installGptShim")
990+
.expect("should include the GPT head insert");
991+
let publisher_bundle_index = attributed
992+
.find("id=\"trustedserver-js\"")
993+
.expect("should include the publisher bundle");
994+
assert!(
995+
head_insert_index < publisher_bundle_index,
996+
"should keep integration head inserts before the publisher bundle"
997+
);
998+
}
999+
9261000
#[test]
9271001
fn active_gpt_diagnostics_loads_standalone_after_unified_bundle_once() {
9281002
let html = "<html><head><title>Test</title></head><body></body></html>";

crates/trusted-server-core/src/integrations/gpt.rs

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ pub struct GptConfig {
6868
#[serde(default = "default_enabled")]
6969
pub enabled: bool,
7070

71+
/// Enable page-level `ts=true` delivery attribution in GAM.
72+
#[serde(default)]
73+
pub gam_attribution_enabled: bool,
74+
7175
/// URL for the GPT bootstrap script (default: Google's CDN).
7276
#[serde(default = "default_script_url")]
7377
#[validate(url)]
@@ -487,10 +491,17 @@ impl IntegrationHeadInjector for GptIntegration {
487491
/// route changes (see `auction/endpoints.rs`).
488492
/// The `POST /auction` endpoint is not involved in scroll or refresh flows.
489493
fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec<String> {
494+
let gam_attribution_flag = if self.config.gam_attribution_enabled {
495+
"window.__tsjs_gam_attribution_enabled=true;"
496+
} else {
497+
""
498+
};
499+
490500
let mut scripts = vec![
491-
"<script>window.__tsjs_gpt_enabled=true;\
492-
window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>"
493-
.to_string(),
501+
format!(
502+
"<script>window.__tsjs_gpt_enabled=true;{gam_attribution_flag}\
503+
window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>"
504+
),
494505
format!("<script>{}</script>", GPT_BOOTSTRAP_JS),
495506
];
496507

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

509520
scripts
510521
}
522+
523+
fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> {
524+
if self.config.gam_attribution_enabled {
525+
vec![("data-ts-gam-attribution", "true")]
526+
} else {
527+
Vec::new()
528+
}
529+
}
511530
}
512531

513532
/// Inline `window.tsjs.adInit` bootstrap injected at `<head>` so the bids
@@ -549,6 +568,7 @@ mod tests {
549568
fn test_config() -> GptConfig {
550569
GptConfig {
551570
enabled: true,
571+
gam_attribution_enabled: false,
552572
script_url: default_script_url(),
553573
cache_ttl_seconds: 3600,
554574
rewrite_script: true,
@@ -573,6 +593,29 @@ mod tests {
573593
.expect("should build HTTP request")
574594
}
575595

596+
#[test]
597+
fn gam_attribution_defaults_to_disabled() {
598+
let config: GptConfig =
599+
serde_json::from_value(serde_json::json!({})).expect("should parse defaults");
600+
601+
assert!(!config.gam_attribution_enabled);
602+
}
603+
604+
#[test]
605+
fn gam_attribution_deserializes_explicit_values() {
606+
let disabled: GptConfig = serde_json::from_value(serde_json::json!({
607+
"gam_attribution_enabled": false
608+
}))
609+
.expect("should parse explicit false");
610+
let enabled: GptConfig = serde_json::from_value(serde_json::json!({
611+
"gam_attribution_enabled": true
612+
}))
613+
.expect("should parse explicit true");
614+
615+
assert!(!disabled.gam_attribution_enabled);
616+
assert!(enabled.gam_attribution_enabled);
617+
}
618+
576619
// -- URL detection --
577620

578621
#[test]
@@ -1146,6 +1189,38 @@ mod tests {
11461189
"<script>window.__tsjs_gpt_enabled=true;window.__tsjs_installGptShim&&window.__tsjs_installGptShim();</script>",
11471190
"should set the enable flag and call the GPT shim activation function"
11481191
);
1192+
assert!(
1193+
integration.tsjs_script_tag_attributes().is_empty(),
1194+
"should not authorize GAM attribution metadata by default"
1195+
);
1196+
}
1197+
1198+
#[test]
1199+
fn gam_attribution_true_adds_both_activation_signals_without_a_new_insert() {
1200+
let integration = GptIntegration::new(GptConfig {
1201+
gam_attribution_enabled: true,
1202+
..test_config()
1203+
});
1204+
let document_state = IntegrationDocumentState::default();
1205+
let context = IntegrationHtmlContext {
1206+
request_host: "edge.example.com",
1207+
request_scheme: "https",
1208+
origin_host: "origin.example.com",
1209+
document_state: &document_state,
1210+
};
1211+
1212+
let inserts = integration.head_inserts(&context);
1213+
1214+
assert_eq!(inserts.len(), 2, "should not add another head insert");
1215+
assert!(
1216+
inserts[0].contains("window.__tsjs_gam_attribution_enabled=true;"),
1217+
"should activate the early bootstrap marker"
1218+
);
1219+
assert_eq!(
1220+
integration.tsjs_script_tag_attributes(),
1221+
vec![("data-ts-gam-attribution", "true")],
1222+
"should authorize the bundle fallback on the publisher tag"
1223+
);
11491224
}
11501225

11511226
#[test]
@@ -1352,6 +1427,35 @@ mod tests {
13521427
);
13531428
}
13541429

1430+
#[test]
1431+
fn head_inserts_queue_gam_attribution_before_guard_and_ad_requests() {
1432+
let targeting_index = GPT_BOOTSTRAP_JS
1433+
.find("gpt.setConfig({ targeting: { ts: 'true' } })")
1434+
.expect("should apply the fixed page-level GAM targeting pair");
1435+
let guard_index = GPT_BOOTSTRAP_JS
1436+
.find("if (ts.adInit) return;")
1437+
.expect("should retain the preinstalled adInit guard");
1438+
let display_index = GPT_BOOTSTRAP_JS
1439+
.find("googletag.display(divId);")
1440+
.expect("should retain the executable GPT display call");
1441+
let refresh_index = GPT_BOOTSTRAP_JS
1442+
.find("googletag.pubads().refresh(slotsNeedingRefresh);")
1443+
.expect("should retain the bounded GPT refresh call");
1444+
1445+
assert!(
1446+
targeting_index < guard_index,
1447+
"should enqueue attribution before the preinstalled adInit guard"
1448+
);
1449+
assert!(
1450+
targeting_index < display_index,
1451+
"should enqueue attribution before the executable display call"
1452+
);
1453+
assert!(
1454+
targeting_index < refresh_index,
1455+
"should enqueue attribution before the executable refresh call"
1456+
);
1457+
}
1458+
13551459
#[test]
13561460
fn head_injector_integration_id() {
13571461
let integration = GptIntegration::new(test_config());

crates/trusted-server-core/src/integrations/gpt_bootstrap.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@
1717
(function () {
1818
if (typeof window === "undefined") return;
1919
var ts = (window.tsjs = window.tsjs || {});
20+
var tag;
21+
22+
if (window.__tsjs_gam_attribution_enabled === true) {
23+
tag = window.googletag = window.googletag || { cmd: [] };
24+
tag.cmd = tag.cmd || [];
25+
tag.cmd.push(function () {
26+
try {
27+
var gpt = window.googletag;
28+
if (gpt && typeof gpt.setConfig === "function") {
29+
// "ts" is the fixed GAM key, not the local window.tsjs alias.
30+
gpt.setConfig({ targeting: { ts: 'true' } });
31+
}
32+
} catch (_) {
33+
// Attribution must not interrupt the existing bootstrap queue.
34+
}
35+
});
36+
}
37+
2038
if (ts.adInit) return;
2139

2240
// Track whether the publisher disabled GPT initial load. Read the effective
@@ -38,7 +56,9 @@
3856
return true;
3957
}
4058

41-
(window.googletag = window.googletag || { cmd: [] }).cmd.push(function () {
59+
tag = tag || (window.googletag = window.googletag || { cmd: [] });
60+
tag.cmd = tag.cmd || [];
61+
tag.cmd.push(function () {
4262
var gpt = window.googletag;
4363
syncInitialLoadDisabled(gpt);
4464
if (

0 commit comments

Comments
 (0)