From f431f730fed7c79b442d377866477aa262d888d2 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 08:13:06 -0500 Subject: [PATCH] Exclude GAM paths from Prebid refresh auctions --- .../src/integrations/prebid.rs | 187 +++++++++- .../lib/src/integrations/prebid/index.ts | 34 +- .../test/integrations/prebid/index.test.ts | 188 ++++++++++ docs/guide/integrations/prebid.md | 82 +++-- ...6-07-24-prebid-refresh-gam-path-opt-out.md | 195 +++++++++++ ...-prebid-refresh-gam-path-opt-out-design.md | 331 ++++++++++++++++++ trusted-server.example.toml | 3 + 7 files changed, 996 insertions(+), 24 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md create mode 100644 docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index a1b40281d..ecaf89055 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -260,6 +260,13 @@ pub struct PrebidIntegrationConfig { /// manages both lists explicitly. #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] pub client_side_bidders: Vec, + /// GAM ad-unit-path suffixes excluded from Trusted Server refresh auctions. + /// + /// Matching is exact and case-sensitive. Excluded slots still refresh through + /// GAM, but are not included in synthetic Prebid refresh ad units. + #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] + #[validate(custom(function = "validate_excluded_gam_ad_unit_path_suffixes"))] + pub excluded_gam_ad_unit_path_suffixes: Vec, /// Compatibility sugar for per-bidder, per-zone param overrides. /// /// This preserves the natural `bidder -> zone -> params` config shape for @@ -348,6 +355,58 @@ impl IntegrationConfig for PrebidIntegrationConfig { } } +fn excluded_gam_ad_unit_path_suffix_validation_error(message: &'static str) -> ValidationError { + let mut error = ValidationError::new("invalid_gam_ad_unit_path_suffix"); + error.message = Some(message.into()); + error +} + +fn validate_excluded_gam_ad_unit_path_suffix(value: &str) -> Result<(), ValidationError> { + if value.trim() != value { + return Err(excluded_gam_ad_unit_path_suffix_validation_error( + "excluded_gam_ad_unit_path_suffixes entries must not have surrounding whitespace", + )); + } + + if value.is_empty() { + return Err(excluded_gam_ad_unit_path_suffix_validation_error( + "excluded_gam_ad_unit_path_suffixes entries must not be empty", + )); + } + + if !value.starts_with('/') { + return Err(excluded_gam_ad_unit_path_suffix_validation_error( + "excluded_gam_ad_unit_path_suffixes entries must start with '/'", + )); + } + + if value == "/" { + return Err(excluded_gam_ad_unit_path_suffix_validation_error( + "excluded_gam_ad_unit_path_suffixes entries must identify a non-root path suffix", + )); + } + + Ok(()) +} + +fn validate_excluded_gam_ad_unit_path_suffixes(values: &[String]) -> Result<(), ValidationError> { + for value in values { + validate_excluded_gam_ad_unit_path_suffix(value)?; + } + + Ok(()) +} + +fn canonicalize_excluded_gam_ad_unit_path_suffixes(config: &mut PrebidIntegrationConfig) { + let mut canonical = Vec::with_capacity(config.excluded_gam_ad_unit_path_suffixes.len()); + for suffix in std::mem::take(&mut config.excluded_gam_ad_unit_path_suffixes) { + if !canonical.contains(&suffix) { + canonical.push(suffix); + } + } + config.excluded_gam_ad_unit_path_suffixes = canonical; +} + /// Validate enabled Prebid config using the same startup-only checks as runtime registration. /// /// # Errors @@ -357,11 +416,12 @@ impl IntegrationConfig for PrebidIntegrationConfig { pub fn validate_config_for_startup( settings: &Settings, ) -> Result, Report> { - let Some(config) = + let Some(mut config) = settings.integration_config::(PREBID_INTEGRATION_ID)? else { return Ok(None); }; + canonicalize_excluded_gam_ad_unit_path_suffixes(&mut config); BidParamOverrideEngine::try_from_config(&config)?; validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?; Ok(Some(config)) @@ -879,11 +939,12 @@ fn escape_html_attr(value: &str) -> String { fn build( settings: &Settings, ) -> Result>, Report> { - let Some(config) = + let Some(mut config) = settings.integration_config::(PREBID_INTEGRATION_ID)? else { return Ok(None); }; + canonicalize_excluded_gam_ad_unit_path_suffixes(&mut config); validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?; @@ -1013,6 +1074,8 @@ impl IntegrationHeadInjector for PrebidIntegration { bidders: &'a [String], #[serde(skip_serializing_if = "<[String]>::is_empty")] client_side_bidders: &'a [String], + #[serde(skip_serializing_if = "<[String]>::is_empty")] + excluded_gam_ad_unit_path_suffixes: &'a [String], } let payload = InjectedPrebidClientConfig { @@ -1021,6 +1084,7 @@ impl IntegrationHeadInjector for PrebidIntegration { debug: self.config.debug, bidders: &self.config.bidders, client_side_bidders: &self.config.client_side_bidders, + excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes, }; // Escape ` string; + getAdUnitPath?: () => string; getTargeting?: (key: string) => string[]; clearTargeting?: (key?: string) => RefreshGptSlot; getSizes?: () => unknown[]; @@ -458,6 +461,25 @@ function serverSideBidderParamsForRefresh( return params; } +function isExcludedFromRefreshAuction( + slot: RefreshGptSlot, + excludedGamAdUnitPathSuffixes: Set +): boolean { + if (excludedGamAdUnitPathSuffixes.size === 0) return false; + + try { + const adUnitPath = slot.getAdUnitPath?.(); + return ( + typeof adUnitPath === 'string' && + [...excludedGamAdUnitPathSuffixes].some((suffix) => adUnitPath.endsWith(suffix)) + ); + } catch { + // GPT path metadata is optional for this optimization. If it is unavailable, + // preserve normal refresh-auction behavior rather than suppressing demand. + return false; + } +} + function clearRefreshTargeting(slot: RefreshGptSlot): void { if (typeof slot.clearTargeting !== 'function') return; @@ -768,7 +790,17 @@ export function installRefreshHandler(timeoutMs = 1500): void { targetSlots.forEach(clearRefreshTargeting); - const adUnits = targetSlots.map((slot) => { + const excludedGamAdUnitPathSuffixes = new Set( + getInjectedConfig()?.excludedGamAdUnitPathSuffixes ?? [] + ); + const auctionSlots = targetSlots.filter( + (slot) => !isExcludedFromRefreshAuction(slot, excludedGamAdUnitPathSuffixes) + ); + if (!auctionSlots.length) { + return originalRefresh(targetSlots, opts); + } + + const adUnits = auctionSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); const zone = injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 726f40b49..104c5deb1 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -854,11 +854,13 @@ describe('prebid/installRefreshHandler', () => { mockPbjs.adUnits = []; (window as any).tsjs = undefined; delete (window as any).googletag; + delete (window as any).__tsjs_prebid; }); afterEach(() => { (window as any).tsjs = undefined; delete (window as any).googletag; + delete (window as any).__tsjs_prebid; }); it('builds refresh ad units from injected slot metadata', () => { @@ -1352,6 +1354,192 @@ describe('prebid/installRefreshHandler', () => { expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); }); + it('passes an explicitly excluded path directly to GPT after clearing stale targeting', () => { + const originalRefresh = vi.fn(); + const clearTargeting = vi.fn(); + const gptSlot = { + getSlotElementId: vi.fn(() => 'div-ad-tracking'), + getAdUnitPath: vi.fn(() => '/123/trackingonly'), + getTargeting: vi.fn(() => []), + clearTargeting, + }; + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => [gptSlot]), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__tsjs_prebid = { + excludedGamAdUnitPathSuffixes: ['/trackingonly'], + }; + const options = { changeCorrelator: false }; + + installRefreshHandler(750); + pubads.refresh([gptSlot], options); + + expect(mockRequestBids).not.toHaveBeenCalled(); + expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); + expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); + expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(originalRefresh).toHaveBeenCalledWith([gptSlot], options); + }); + + it('passes an all-excluded global refresh directly to GPT', () => { + const originalRefresh = vi.fn(); + const trackingSlot = { + getSlotElementId: vi.fn(() => 'div-ad-tracking'), + getAdUnitPath: vi.fn(() => '/123/trackingonly'), + getTargeting: vi.fn(() => []), + clearTargeting: vi.fn(), + }; + const measurementSlot = { + getSlotElementId: vi.fn(() => 'div-ad-measurement'), + getAdUnitPath: vi.fn(() => '/123/measurement-only'), + getTargeting: vi.fn(() => []), + clearTargeting: vi.fn(), + }; + const targetSlots = [trackingSlot, measurementSlot]; + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => targetSlots), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__tsjs_prebid = { + excludedGamAdUnitPathSuffixes: ['/trackingonly', '/measurement-only'], + }; + const options = { changeCorrelator: false }; + + installRefreshHandler(750); + pubads.refresh(undefined, options); + + expect(mockRequestBids).not.toHaveBeenCalled(); + expect(trackingSlot.clearTargeting).toHaveBeenCalled(); + expect(measurementSlot.clearTargeting).toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith(targetSlots, options); + }); + + it('auctions eligible slots and refreshes every slot in a mixed global refresh', () => { + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const originalRefresh = vi.fn(); + const displaySlot = { + getSlotElementId: vi.fn(() => 'div-ad-display'), + getAdUnitPath: vi.fn(() => '/123/content'), + getTargeting: vi.fn(() => []), + clearTargeting: vi.fn(), + }; + const trackingSlot = { + getSlotElementId: vi.fn(() => 'div-ad-tracking'), + getAdUnitPath: vi.fn(() => '/123/trackingonly'), + getTargeting: vi.fn(() => []), + clearTargeting: vi.fn(), + }; + const targetSlots = [displaySlot, trackingSlot]; + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => targetSlots), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__tsjs_prebid = { + excludedGamAdUnitPathSuffixes: ['/trackingonly'], + }; + + installRefreshHandler(750); + pubads.refresh(); + + expect(displaySlot.clearTargeting).toHaveBeenCalled(); + expect(trackingSlot.clearTargeting).toHaveBeenCalled(); + expect(mockRequestBids).toHaveBeenCalledWith( + expect.objectContaining({ + adUnits: [expect.objectContaining({ code: 'div-ad-display' })], + }) + ); + expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-display']); + expect(originalRefresh).toHaveBeenCalledWith(targetSlots, undefined); + + delete (mockPbjs as any).setTargetingForGPTAsync; + }); + + it.each([ + ['a missing path getter', {}], + ['a non-string path', { getAdUnitPath: vi.fn(() => 123) }], + [ + 'a throwing path getter', + { + getAdUnitPath: vi.fn(() => { + throw new Error('path unavailable'); + }), + }, + ], + ])('fails open to an auction for %s', (_description, pathBehavior) => { + const originalRefresh = vi.fn(); + const gptSlot = { + getSlotElementId: vi.fn(() => 'div-ad-display'), + getTargeting: vi.fn(() => []), + ...pathBehavior, + }; + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => [gptSlot]), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__tsjs_prebid = { + excludedGamAdUnitPathSuffixes: ['/trackingonly'], + }; + + installRefreshHandler(750); + pubads.refresh([gptSlot]); + + expect(mockRequestBids).toHaveBeenCalled(); + expect(originalRefresh).not.toHaveBeenCalled(); + }); + + it.each(['/123/TrackingOnly', '/123/trackingonly/'])( + 'uses literal case-sensitive suffix matching for %s', + (adUnitPath) => { + const originalRefresh = vi.fn(); + const gptSlot = { + getSlotElementId: vi.fn(() => 'div-ad-display'), + getAdUnitPath: vi.fn(() => adUnitPath), + getTargeting: vi.fn(() => []), + }; + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => [gptSlot]), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__tsjs_prebid = { + excludedGamAdUnitPathSuffixes: ['/trackingonly'], + }; + + installRefreshHandler(750); + pubads.refresh([gptSlot]); + + expect(mockRequestBids).toHaveBeenCalled(); + expect(originalRefresh).not.toHaveBeenCalled(); + } + ); + it('passes the adInit internal refresh straight to GPT without a client-side auction', () => { const originalRefresh = vi.fn(); const clearTargeting = vi.fn(); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 4bc73c0ce..03eb7c9f5 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -32,6 +32,10 @@ external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" # being routed through the server-side auction. client_side_bidders = ["rubicon"] +# Keep matching GAM inventory out of Trusted Server's Prebid refresh auctions. +# GAM still refreshes these slots. +excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] + # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] @@ -64,27 +68,28 @@ set = { placementId = "_s2sHeaderPlacement" } ### Configuration Options -| Field | Type | Default | Description | -| -------------------------- | ------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | Boolean | `true` | Enable Prebid integration | -| `server_url` | String | Required | Prebid Server endpoint URL | -| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | -| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | -| `external_bundle_url` | String | Required when enabled | Absolute HTTPS URL of the generated external Prebid bundle, proxied through `/integrations/prebid/bundle.js`; its host must be listed in `proxy.allowed_domains` | -| `external_bundle_sha256` | String | `None` | Optional 64-character hex SHA-256 used for versioned first-party URLs, immutable cache headers, and `sha256:` ETags | -| `external_bundle_sri` | String | `None` | Optional Subresource Integrity metadata added to the same-origin bundle script tag when configured | -| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | -| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | -| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | -| `debug` | Boolean | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`; surfaces debug metadata in auction responses) | -| `test_mode` | Boolean | `false` | Set the OpenRTB `test: 1` flag so bidders treat the auction as non-billable test traffic. Separate from `debug` to avoid suppressing real demand | -| `debug_query_params` | String | `None` | Extra query params appended for debugging | -| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side. See [Client-Side Bidders](#client-side-bidders) | -| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | -| `bundle.adapters` | Array[String] | Required for `ts prebid bundle` | Prebid.js bidder adapter modules imported into the generated external browser bundle | -| `bundle.user_id_modules` | Array[String] | Generator default preset when omitted | Prebid User ID modules imported into the generated external browser bundle | +| Field | Type | Default | Description | +| ------------------------------------ | ------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | Boolean | `true` | Enable Prebid integration | +| `server_url` | String | Required | Prebid Server endpoint URL | +| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | +| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | +| `external_bundle_url` | String | Required when enabled | Absolute HTTPS URL of the generated external Prebid bundle, proxied through `/integrations/prebid/bundle.js`; its host must be listed in `proxy.allowed_domains` | +| `external_bundle_sha256` | String | `None` | Optional 64-character hex SHA-256 used for versioned first-party URLs, immutable cache headers, and `sha256:` ETags | +| `external_bundle_sri` | String | `None` | Optional Subresource Integrity metadata added to the same-origin bundle script tag when configured | +| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | +| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | +| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | +| `debug` | Boolean | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`; surfaces debug metadata in auction responses) | +| `test_mode` | Boolean | `false` | Set the OpenRTB `test: 1` flag so bidders treat the auction as non-billable test traffic. Separate from `debug` to avoid suppressing real demand | +| `debug_query_params` | String | `None` | Extra query params appended for debugging | +| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side. See [Client-Side Bidders](#client-side-bidders) | +| `excluded_gam_ad_unit_path_suffixes` | Array[String] | `[]` | Exact, case-sensitive GAM ad-unit-path suffixes excluded from Trusted Server's Prebid refresh auction; matching slots still refresh through GAM | +| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| `bundle.adapters` | Array[String] | Required for `ts prebid bundle` | Prebid.js bidder adapter modules imported into the generated external browser bundle | +| `bundle.user_id_modules` | Array[String] | Generator default preset when omitted | Prebid User ID modules imported into the generated external browser bundle | ## External Bundle Generation @@ -322,6 +327,41 @@ set = { placementId = "_s2sHeaderPlacement", keep = "server" } TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_s2sHeaderPlacement","keep":"server"}}]' ``` +## Refresh Auction GAM-Path Opt-Out + +Use `excluded_gam_ad_unit_path_suffixes` when a GAM slot must refresh for an +impression or measurement purpose but must not participate in Trusted Server's +Prebid refresh auction: + +```toml +[integrations.prebid] +excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] +``` + +Trusted Server reads each refreshed GPT slot's `getAdUnitPath()` and compares it to +the configured suffixes with an exact, case-sensitive `endsWith()` match. A matching +slot is omitted from the synthetic Prebid refresh ad units, but it remains in the +original GPT refresh call. In a mixed global refresh, normal display slots still +auction and receive refreshed Prebid targeting while excluded slots still refresh in +GAM. + +Each suffix must be a non-empty slash-prefixed path with no surrounding whitespace. +The root suffix (`"/"`) is rejected, as are suffixes without a leading slash; exact +duplicates are injected once. Matching is literal: paths are not case-normalized or +slash-normalized. Use a specific terminal GAM path segment, not a broad size rule or +div ID. + +If GPT does not expose `getAdUnitPath()` for a slot or the getter fails, Trusted +Server fails open and runs the normal refresh auction. The option affects only this +Trusted Server GPT-refresh wrapper; it does not block direct publisher Prebid, +APS, or other auction flows. + +The external Prebid bundle and the injected Trusted Server configuration must be +rolled out together. Regenerate and upload the bundle with `ts prebid bundle`, then +deploy the configuration containing the suffix list and its corresponding bundle +URL/hash/SRI metadata. A new configuration paired with an old cached external bundle +cannot apply the browser filter. + ## Client-Side Bidders Some Prebid.js bid adapters do not work well through Prebid Server (e.g. Magnite/Rubicon). The `client_side_bidders` config field lets you keep these bidders running natively in the browser while routing all other bidders through the server-side auction. diff --git a/docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md b/docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md new file mode 100644 index 000000000..a35cab40c --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md @@ -0,0 +1,195 @@ +# Prebid Refresh GAM-Path Opt-Out Implementation Plan + +**Design:** `docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md` + +**Goal:** Let operators exclude selected GAM ad-unit-path suffixes from Trusted +Server's Prebid refresh auctions without suppressing the corresponding GAM refresh. + +## Scope and constraints + +- Do not change publisher source, slot div IDs, or GAM configuration. +- Do not edit `dist` output, minified assets, or an externally hosted Prebid bundle + by hand. `build-prebid-external.mjs`/`ts prebid bundle` are the supported build + path. +- The mechanism is literal, case-sensitive GAM-path suffix matching; it is not a + size-based rule and does not add a div-ID fallback. +- Preserve the current `adInitRefreshInProgress` direct pass-through before all new + work, including targeting cleanup. +- Use only fictional paths and hostnames in checked-in tests and documentation. + +## Files + +**Modify:** + +- `crates/trusted-server-core/src/integrations/prebid.rs` +- `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- `trusted-server.example.toml` +- `docs/guide/integrations/prebid.md` + +**Update as needed for this work record only:** + +- `docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md` +- this plan + +## Task 1 — Add and inject canonical Prebid configuration + +**Files:** `crates/trusted-server-core/src/integrations/prebid.rs` + +1. Add `excluded_gam_ad_unit_path_suffixes: Vec` to + `PrebidIntegrationConfig`, defaulting to an empty vector. Initialize it in + `base_config()` and every other explicit `PrebidIntegrationConfig` literal. +2. Implement one shared validation/canonicalization path used by both `build()` and + `validate_config_for_startup()`: + - reject leading/trailing whitespace; + - reject empty/whitespace-only values; + - require a leading `/`; + - reject `/` itself; + - retain every other value literally; + - deduplicate exact valid entries, preserving first declaration order. + Put schema-level validation on the field so typed settings parsing produces a + useful configuration error, and retain the normalizer so the runtime payload is + canonical rather than merely valid. +3. Extend the local `InjectedPrebidClientConfig` with a borrowed + `excluded_gam_ad_unit_path_suffixes` field. Use the existing camel-case serde + convention and omit it when empty, producing + `excludedGamAdUnitPathSuffixes` only for a non-empty list. +4. Add Rust tests next to the existing Prebid tests: + - omitted field defaults to empty; + - valid values parse and duplicate values inject once in first-occurrence order; + - empty, whitespace-padded, missing-leading-slash, and `/` inputs are rejected; + - the head injector includes the expected camel-case JSON when configured and + omits it by default. +5. Run focused Prebid tests while iterating, then run the target-matched Rust test + commands required for the changed core configuration: + + ```bash + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + ``` + +## Task 2 — Filter only refresh-auction slots in the browser + +**Files:** `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` + +1. Add `excludedGamAdUnitPathSuffixes?: string[]` to `InjectedPrebidConfig` and + `getAdUnitPath?: () => string` to `RefreshGptSlot`. +2. Add a small private predicate that receives a slot and configured suffix set. It + must call `getAdUnitPath()` only when it exists, catch a getter exception, require + a string result, and use `path.endsWith(suffix)` for any configured suffix. A + missing, non-string, empty, or throwing getter returns `false` (fail open). +3. In `installRefreshHandler()`, leave the + `window.tsjs?.adInitRefreshInProgress` branch byte-for-byte behaviorally first. + Keep target-slot resolution unchanged. +4. Keep `targetSlots.forEach(clearRefreshTargeting)` before filtering. Build + `auctionSlots` from `targetSlots` by applying the new predicate. +5. When `auctionSlots` is empty, call `originalRefresh(targetSlots, opts)` + immediately. Do not call `pbjs.requestBids()` or + `pbjs.setTargetingForGPTAsync()`. +6. Otherwise, build synthetic ad units and `refreshAdUnitCodes` from + `auctionSlots` only. In the bids-back handler, target only those eligible codes, + then call `originalRefresh(targetSlots, opts)` so mixed refreshes retain excluded + slots in the GAM list. +7. Do not alter `TS_REFRESH_TARGETING_KEYS`, size fallback logic, injected-slot + lookup, bidder-parameter recovery, or client-side-bid recovery except to ensure + they run only for eligible auction slots. + +## Task 3 — Cover refresh behavior with Vitest + +**Files:** `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + +Add tests in the existing `prebid/installRefreshHandler` suite. Make the mocked +`requestBids` invoke its callback synchronously only where post-auction behavior is +being asserted. + +| Case | Assertions | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Normal explicit slot | Nonmatching path still creates an ad unit, requests bids, scopes targeting to its code, and refreshes after callback. | +| Explicit excluded slot | Each TS/Prebid key is cleared; no Prebid request/targeting call; original GPT refresh receives the same slot list and options immediately. | +| All-excluded global refresh | `getSlots()` supplies the concrete list; all targets are cleaned; no Prebid call; GPT receives the complete concrete list and options. | +| Mixed global refresh | Both slots are cleaned; only the normal slot occurs in ad units and scoped targeting; GPT receives both slots after callback. | +| Missing getter | Slot is auctioned and wrapper does not throw. | +| Throwing or non-string getter | Slot is auctioned and wrapper does not throw. | +| Literal semantics | Case mismatch and trailing-slash mismatch do not exclude. | +| Regression | Existing `adInitRefreshInProgress` direct-pass-through test and normal client-side bidder/param-recovery tests remain unchanged and green. | + +Run the JS suite after each change and format it: + +```bash +cd crates/trusted-server-js/lib +npx vitest run +npm run format +``` + +## Task 4 — Document the operator contract + +**Files:** `trusted-server.example.toml`, `docs/guide/integrations/prebid.md` + +1. Add a commented example of + `excluded_gam_ad_unit_path_suffixes = ["/trackingonly"]` to the example TOML. +2. Add the field to the Prebid guide's configuration table and explain: + - it excludes only Trusted Server's GPT-refresh Prebid auction; + - GAM still refreshes the matching slot; + - matching is case-sensitive literal suffix matching via `getAdUnitPath()`; + - invalid/empty/root suffixes are rejected and a missing getter fails open; + - use a specific terminal path rather than a size or div-ID rule; + - mixed global refreshes still auction normal slots. +3. Include the external-bundle/config rollout dependency and direct-Prebid/APS + non-goals. Do not add real inventory names, production domains, or tracking IDs. +4. Format docs: + + ```bash + cd docs && npm run format + ``` + +## Task 5 — Final validation and rollout verification + +1. Inspect the complete diff; verify it contains no generated bundle edits and no + publisher-source changes. +2. Run project gates relevant to the changed Rust, TypeScript, and docs surfaces: + + ```bash + cargo fmt --all -- --check + cargo clippy-fastly + cargo clippy-axum + cargo clippy-cloudflare + cargo clippy-cloudflare-wasm + cargo clippy-spin-native + cargo clippy-spin-wasm + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cd crates/trusted-server-js/lib && npx vitest run && npm run format + cd docs && npm run format + ``` + +3. Regenerate the external browser bundle through the supported `ts prebid bundle` + workflow, upload the generated artifact, and update the operator-managed + URL/hash/SRI configuration. Deploy that bundle together with the Trusted Server + application/config carrying the suffix list. +4. On a controlled staging page, use browser request instrumentation to verify: + - the injected browser config contains the expected suffixes; + - a matching path still produces a GAM request/impression but no refresh + `/auction` request; + - a normal display slot in a mixed global refresh does produce `/auction`, gets + scoped refreshed targeting, and GAM refreshes both slots; + - `disableInitialLoad()`/initial Trusted Server loading still uses the existing + `adInitRefreshInProgress` direct GPT path. +5. Record deployed bundle hash, configuration version, test page, and observations + in the implementation handoff. Do not treat an unstable production page as the + sole verification environment. + +## Acceptance criteria + +- With no new configuration, refresh behavior is unchanged. +- A configured matching GAM path is never included in a synthetic refresh auction, + but remains in the GPT refresh call. +- An all-excluded refresh produces no `requestBids()` or targeting call. +- A mixed refresh auctions and targets only eligible slots while refreshing every + requested GPT slot. +- Stale Trusted Server/Prebid targeting is cleared from every target slot, and the + initial-load bypass remains untouched. +- The deployed external bundle and injected configuration are version-compatible. diff --git a/docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md b/docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md new file mode 100644 index 000000000..cd8a13006 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md @@ -0,0 +1,331 @@ +# Prebid Refresh GAM-Path Opt-Out Design + +**Date:** 2026-07-24 · **Status:** Proposed + +**Scope:** Exclude selected GAM inventory from Trusted Server's Prebid refresh auctions while preserving GAM refreshes. + +## 1. Problem statement + +Trusted Server wraps publisher calls to `googletag.pubads().refresh()` and creates a +fresh synthetic Prebid ad unit for every refreshed GPT slot. That is correct for +display inventory, but it also sends tracking-only GAM slots to `/auction`. The +production motivating case is a hidden 1×1 slot whose GAM ad-unit path ends in +`/trackingonly`; its element ID is unstable and cannot be used as configuration. + +Operators need a configuration-only way to identify that inventory by its supported +GAM ad-unit path and prevent **this refresh wrapper** from running a Trusted Server +or native-Prebid refresh auction for it. GPT must still refresh the slot so GAM can +record the tracking impression. Publishers must not change component source. + +The successful 2026-07-23 browser capture established that GPT exposes +`slot.getAdUnitPath()` and returned `/88059007/autoblog/trackingonly` for the +tracking slot. The capture also showed that the current wrapper included that slot +in a `/auction` payload as a `[1, 1]` banner. The later 2026-07-24 failed probe is +not a stable production test environment and is not used as behavioral evidence. + +## 2. Goals and non-goals + +### Goals + +- Add an operator-configured, suffix-based GAM-path exclusion mechanism. +- Support explicit `refresh([slot], options)` and bare/global `refresh(options)` + calls. +- Keep excluded slots in the final GPT refresh list and preserve refresh options. +- Continue refreshing and auctioning non-excluded display slots, including in mixed + global refreshes. +- Clear stale Trusted Server/Prebid targeting from every target slot before its GAM + refresh, including excluded slots. +- Preserve the existing `adInitRefreshInProgress` direct-GAM bypass exactly. + +### Non-goals + +- Filtering by size, especially a broad 1×1 rule. +- Matching or configuring div IDs, ad-slot IDs, CSS selectors, labels, or arbitrary + GPT targeting. +- Suppressing GAM requests, GAM impressions, slot definitions, or initial ad loads. +- Changing unrelated publisher Prebid, APS, or direct `/auction` flows. +- Editing generated or minified Prebid bundles directly. +- Changing Prebid adapter selection, bid-param overrides, or GAM line-item setup. + +## 3. Current call flow + +1. `installPrebidNpm()` registers the `trustedServer` Prebid adapter and wraps + `pbjs.requestBids()` so server-side bidder requests flow through it + (`crates/trusted-server-js/lib/src/integrations/prebid/index.ts:514-669`). +2. The adapter's `buildRequests()` serializes auction inputs and returns a POST to + `auctionEndpoint`, which defaults to `/auction` + (`index.ts:524-535`). +3. `installRefreshHandler()` wraps `googletag.pubads().refresh()` after GPT loads + (`index.ts:726-818`). The wrapper: + - immediately passes through `adInitRefreshInProgress` requests; + - resolves explicit slots, or `pubads.getSlots()` for bare refreshes; + - clears `ts_initial`, `hb_pb`, `hb_bidder`, `hb_adid`, `hb_cache_host`, and + `hb_cache_path` from every resolved slot (`index.ts:43-50, 461-467`); + - creates one synthetic refresh ad unit per target slot; + - calls `pbjs.requestBids()`; + - scopes `setTargetingForGPTAsync()` to the synthetic codes; and + - calls the original GPT refresh after bids return. +4. The refresh-slot type currently represents only element ID, targeting cleanup, + and sizes (`index.ts:260-265`). It does not expose GAM path metadata. +5. Rust reads `integrations.prebid` into `PrebidIntegrationConfig` + (`crates/trusted-server-core/src/integrations/prebid.rs:203-343`) and injects + browser config into `window.__tsjs_prebid` through the head injector + (`prebid.rs:1006-1041`). The injected TypeScript shape is declared at + `index.ts:62-87`. + +## 4. Configuration contract + +### 4.1 Operator API + +Add this optional field to `[integrations.prebid]`: + +```toml +[integrations.prebid] +# Keep GAM tracking inventory out of Trusted Server's Prebid refresh auctions. +# GPT still refreshes these slots. +excluded_gam_ad_unit_path_suffixes = ["/trackingonly", "/measurement-only"] +``` + +The field is an array because publishers may have multiple tracking-only paths and +because selecting one suffix must not preclude a future suffix. Its default is `[]`. +An omitted or empty array preserves today's behavior: every resolved refresh slot is +auction-eligible. + +The configuration is intentionally in the existing Prebid integration rather than +in GPT configuration: it controls whether the **Prebid refresh auction** runs and +is serialized with the rest of the Prebid browser configuration. + +### 4.2 Validation and canonicalization + +Validate enabled Prebid configuration at startup and in `ts config validate` using +a shared normalization helper before constructing `PrebidIntegration`: + +| Input | Result | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Omitted or `[]` | Valid; no exclusions. | +| A suffix with leading/trailing whitespace | Reject; do not silently alter a path matcher. | +| Empty or whitespace-only suffix | Reject. | +| Suffix not beginning with `/` | Reject. | +| `/` | Reject; it would opt every slash-prefixed GAM path out and is not an inventory-specific suffix. | +| Any other slash-prefixed string | Valid literal suffix; trailing slashes, repeated slashes, and case are not normalized. | +| Exact duplicate valid strings | Retain the first occurrence and remove later duplicates before injection. | + +The normalized list preserves first-occurrence declaration order. There is no +case-folding, path parsing, URL decoding, slash collapsing, or trailing-slash +normalization. This produces an auditable literal match and avoids accidental +cross-inventory matching. + +The Rust schema remains a `Vec` with `#[serde(default)]`; the implementation +adds a custom validator plus one reusable normalizer so invalid values fail typed +configuration validation and a valid duplicate list has one canonical runtime form. +`build()` and `validate_config_for_startup()` must both use the normalizer, so the +runtime and validation command cannot disagree. Existing Rust config literals must +set the new field to `Vec::new()`. + +### 4.3 Browser injection + +Add an optional camel-case property to the serialized head-injected payload and to +`InjectedPrebidConfig`: + +```ts +interface InjectedPrebidConfig { + // existing fields + excludedGamAdUnitPathSuffixes?: string[] +} +``` + +For a non-empty normalized list the server injects: + +```html + +``` + +For `[]`, omit the property with `skip_serializing_if`, matching the existing +`clientSideBidders` convention. A browser that receives no property treats it as an +empty list. This makes upgrade and rollback backwards compatible: old configuration +has no behavior change; an older external bundle safely ignores the extra injected +property; and a newer bundle with old configuration has no exclusions. + +## 5. Matching and refresh behavior + +### 5.1 Match predicate + +Extend `RefreshGptSlot` with: + +```ts +getAdUnitPath?: () => string; +``` + +At each publisher refresh, derive a `Set` from +`getInjectedConfig()?.excludedGamAdUnitPathSuffixes ?? []`. A slot is excluded only +when all of the following hold: + +1. The normalized set is non-empty. +2. `slot.getAdUnitPath` is a function. +3. Calling it returns a string. +4. The returned GAM path `endsWith()` at least one configured suffix, using exact, + case-sensitive JavaScript string comparison. + +Do not derive paths from the element ID or injected `adSlots` metadata. Do not use +`getSizes()` as a fallback. A missing getter, a non-string return value, an empty +path, or a getter that throws is **fail-open**: the slot remains auction-eligible. +The implementation catches only the getter failure around that call; it neither +suppresses the GPT refresh nor broadens an exclusion because telemetry is absent. + +A matching path is excluded only from the synthetic refresh auction. It is not +removed from GPT's target list. + +### 5.2 Required algorithm + +Keep the existing `adInitRefreshInProgress` check as the first branch, before slot +resolution, targeting cleanup, and path inspection: + +```text +if adInitRefreshInProgress: + originalRefresh(slots, options) + return + +targetSlots = explicit slots, or pubads.getSlots() for bare refresh +if targetSlots is empty: + originalRefresh(slots, options) + return + +clear TS/Prebid refresh-targeting keys from every target slot +auctionSlots = targetSlots excluding suffix-matched slots + +if auctionSlots is empty: + originalRefresh(targetSlots, options) + return + +adUnits = synthetic refresh ad units for auctionSlots only +pbjs.requestBids({ adUnits, timeout, bidsBackHandler }) +bidsBackHandler: + pbjs.setTargetingForGPTAsync(auction-slot codes only) + originalRefresh(targetSlots, options) +``` + +Build candidate codes, recover publisher bidder params, and recover client-side bids +only for `auctionSlots`; excluded slots must not be represented in `adUnits` at all. +The existing scoped targeting behavior therefore continues to affect only eligible +slots. + +### 5.3 Refresh sequences + +| Call and slot set | Prebid behavior | GPT behavior | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `refresh([normal], options)` | Auction `normal`, target its synthetic code after bids return. | Refresh `[normal]` with the same options after the callback. | +| `refresh([excluded], options)` | Clear TS/Prebid keys; do not call `requestBids()` or `setTargetingForGPTAsync()`. | Immediately refresh `[excluded]` with the same options. | +| Bare `refresh(options)`; all slots excluded | Resolve `pubads.getSlots()`, clear their TS/Prebid keys, then make no Prebid calls. | Immediately refresh the resolved complete slot list with the same options. | +| Bare `refresh(options)`; mixed normal and excluded slots | Clear every target slot; auction and target only normal slots. | After the callback, refresh the complete resolved list, including excluded slots. | +| Any refresh while `adInitRefreshInProgress` is true | No cleanup, match, auction, or targeting. | Directly pass through the original `slots` and options unchanged. | +| Missing/throwing `getAdUnitPath()` | Treat the slot as normal and auction it. | Existing post-auction refresh behavior. | + +Passing the resolved list in the all-excluded and mixed cases is deliberate: it is +the same concrete list used for cleanup and makes the final GAM refresh list +explicit. The original options object is passed through unchanged. + +### 5.4 Targeting and initial-load invariants + +The cleanup step remains before filtering and is limited to the existing +`TS_REFRESH_TARGETING_KEYS`. It removes stale Trusted Server/Prebid winner data +from excluded slots so GAM cannot serve using an obsolete header-bid winner, while +preserving GAM path metadata and every unrelated publisher targeting key. + +`adInitRefreshInProgress` continues to bypass cleanup and auctioning directly. This +preserves `disableInitialLoad()` and the initial Trusted Server targeting handoff: +that one internal refresh must deliver already-applied targeting to GAM instead of +being converted into a client-side refresh auction. + +## 6. Implementation areas + +| File | Planned change | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Add config field, validation/canonicalization, head-injected camel-case array, and Rust tests. | +| `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` | Add injected config/type support, guarded path matcher, and filter `targetSlots` into `auctionSlots` after cleanup. | +| `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Add explicit/global/mixed/fail-open refresh tests. | +| `trusted-server.example.toml` | Add a commented fictional configuration example. | +| `docs/guide/integrations/prebid.md` | Document the field, exact matcher semantics, and GAM-preservation caveat. | +| `docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md` | Update only if implementation exposes a necessary design correction. | +| `docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md` | Mark implementation evidence/status only if project practice requires it. | + +No generated `dist` file, minified external bundle, or publisher source file is a +source-of-truth edit target. + +## 7. Test matrix + +### Rust configuration and injection + +- Default/omitted field yields `Vec::new()` and omits + `excludedGamAdUnitPathSuffixes` from injected JSON. +- A valid array parses, normalizes exact duplicates to one entry in declaration + order, and injects the expected camel-case array. +- Empty, whitespace-padded, whitespace-only, missing-leading-slash, and `/` values + fail enabled Prebid configuration validation with field-specific errors. +- Existing Prebid config/head-injector tests continue to pass with the new empty + field initialized in helper literals. + +### Browser refresh wrapper + +- Normal explicit slot with a nonmatching path still calls `requestBids()`, creates + its ad unit, scopes targeting to its code, and refreshes it after the callback. +- Explicit matching slot clears each existing TS/Prebid key, does not call + `requestBids()` or `setTargetingForGPTAsync()`, and immediately calls original + GPT refresh with the exact slot array and options. +- Global all-excluded slots resolve through `getSlots()`, clear every target, make + no Prebid calls, and refresh the complete resolved list with options. +- Global mixed slots clear both categories, auction only eligible slots, scope + targeting to eligible synthetic codes, and refresh the complete list after bids + return. +- Missing `getAdUnitPath`, non-string path, and a throwing getter each fail open to + the normal auction path without throwing from the wrapper. +- Case mismatch and a trailing-slash mismatch do not exclude, proving literal + case-sensitive suffix behavior. +- Existing `adInitRefreshInProgress` test still proves direct pass-through without + cleanup or auction; existing normal refresh and client-side-bid recovery tests + remain green. + +## 8. External bundle and browser verification + +`crates/trusted-server-js/lib/build-prebid-external.mjs` is the supported source +build path for the immutable external Prebid bundle; `build-all.mjs` intentionally +does not build Prebid. Implementers must change TypeScript source and regenerate a +new external bundle through the supported `ts prebid bundle` workflow (or its +underlying supported generator), not edit a generated/minified asset. + +Roll out the application/config and the regenerated external bundle together: + +1. Build and test the source change. +2. Generate and upload the new external bundle. +3. Update the operator bundle URL/hash/SRI metadata as required by the existing + bundle workflow and deploy the Trusted Server application/config containing the + suffix list. +4. Verify the first-party bundle URL resolves to the new bytes and the injected + `window.__tsjs_prebid.excludedGamAdUnitPathSuffixes` has the expected values. +5. In browser instrumentation, verify a matching slot calls GPT refresh without a + corresponding Trusted Server refresh `/auction` request, while a normal display + slot in the same global refresh still produces `/auction` and receives refreshed + Prebid targeting. +6. Verify GAM records the excluded slot's request/impression. Use a controlled + staging page or harness rather than relying on the unstable production host. + +A config-only deployment with an old cached external bundle cannot apply the browser +filter; a bundle-only deployment without the injected configuration remains a no-op. + +## 9. Operational caveats and risks + +- The exclusion is limited to Trusted Server's wrapper around GPT refresh. It does + not block a publisher's unrelated direct `pbjs.requestBids()`, APS calls, direct + `/auction` use, or any other auction wrapper. +- The feature relies on GPT's supported `getAdUnitPath()` API. A missing or throwing + getter deliberately fails open, which may continue auctioning a tracking slot + rather than risk silently suppressing display inventory. +- Literal suffix matching can be over-broad if an operator chooses a generic suffix + such as `/only`; use a unique terminal GAM path segment and validate on a staging + page. `/` is rejected, but other overly broad valid values remain an operator + responsibility. +- Excluded slots have only Trusted Server/Prebid targeting cleared; unrelated GAM + targeting and GAM request behavior are intentionally untouched. +- Browser code and injected config must reach the same deployed page. Cache/version + rollout mistakes are the primary operational risk. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 26d95d681..08f924bf5 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -45,6 +45,9 @@ timeout_ms = 1000 bidders = [] debug = false client_side_bidders = [] +# Keep selected GAM inventory out of Trusted Server's Prebid refresh auctions. +# Matching slots still refresh through GAM. +# excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] # Runtime bundle metadata. Set these after running `ts prebid bundle` and uploading the bundle. # external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" # external_bundle_sha256 = ""