Skip to content
Draft
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
160 changes: 160 additions & 0 deletions crates/common/src/integrations/gam.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//! GAM (Google Ad Manager) Interceptor Integration
//!
//! This integration forces Prebid creatives to render when GAM doesn't have
//! matching line items configured. It's a client-side only integration that
//! works by intercepting GPT's `slotRenderEnded` event.
//!
//! # Configuration
//!
//! ```toml
//! [integrations.gam]
//! enabled = true
//! bidders = ["mocktioneer"] # Only intercept these bidders, empty = all
//! force_render = false # Force render even if GAM has a line item
//! ```
//!
//! # Environment Variables
//!
//! ```bash
//! TRUSTED_SERVER__INTEGRATIONS__GAM__ENABLED=true
//! TRUSTED_SERVER__INTEGRATIONS__GAM__BIDDERS="mocktioneer,appnexus"
//! TRUSTED_SERVER__INTEGRATIONS__GAM__FORCE_RENDER=false
//! ```

use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::settings::IntegrationConfig;

use super::{IntegrationHeadInjector, IntegrationHtmlContext, IntegrationRegistration};

const GAM_INTEGRATION_ID: &str = "gam";

/// GAM interceptor configuration.
#[derive(Debug, Clone, Default, Deserialize, Serialize, Validate)]
pub struct GamIntegrationConfig {
/// Enable the GAM interceptor. Defaults to false.
#[serde(default)]
pub enabled: bool,

/// Only intercept bids from these bidders. Empty = all bidders.
#[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")]
pub bidders: Vec<String>,

/// Force render Prebid creative even if GAM returned a line item.
#[serde(default)]
pub force_render: bool,
}

impl IntegrationConfig for GamIntegrationConfig {
fn is_enabled(&self) -> bool {
self.enabled
}
}

/// Generate the JavaScript config script tag for GAM integration.
/// Sets window.tsGamConfig which is picked up by the GAM integration on init.
#[must_use]
pub fn gam_config_script_tag(config: &GamIntegrationConfig) -> String {
let bidders_json = if config.bidders.is_empty() {
"[]".to_string()
} else {
format!(
"[{}]",
config
.bidders
.iter()
.map(|b| format!("\"{}\"", b))
.collect::<Vec<_>>()
.join(",")
)
};

format!(
r#"<script>window.tsGamConfig={{enabled:true,bidders:{},forceRender:{}}};</script>"#,
bidders_json, config.force_render
)
}

pub struct GamIntegration {
config: GamIntegrationConfig,
}

impl GamIntegration {
#[must_use]
pub fn new(config: GamIntegrationConfig) -> Self {
Self { config }
}
}

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

fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec<String> {
vec![gam_config_script_tag(&self.config)]
}
}

/// Register the GAM integration if enabled.
#[must_use]
pub fn register(settings: &crate::settings::Settings) -> Option<IntegrationRegistration> {
use std::sync::Arc;

let config: GamIntegrationConfig =
settings.integrations.get_typed(GAM_INTEGRATION_ID).ok()??;

log::info!(
"GAM integration enabled: bidders={:?}, force_render={}",
config.bidders,
config.force_render
);

let integration = Arc::new(GamIntegration::new(config));

Some(
IntegrationRegistration::builder(GAM_INTEGRATION_ID)
.with_head_injector(integration)
.build(),
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn gam_config_script_tag_with_bidders() {
let config = GamIntegrationConfig {
enabled: true,
bidders: vec!["mocktioneer".to_string(), "appnexus".to_string()],
force_render: false,
};
let tag = gam_config_script_tag(&config);
assert!(tag.contains("window.tsGamConfig="));
assert!(tag.contains("enabled:true"));
assert!(tag.contains(r#"bidders:["mocktioneer","appnexus"]"#));
assert!(tag.contains("forceRender:false"));
}

#[test]
fn gam_config_script_tag_empty_bidders() {
let config = GamIntegrationConfig {
enabled: true,
bidders: vec![],
force_render: true,
};
let tag = gam_config_script_tag(&config);
assert!(tag.contains("bidders:[]"));
assert!(tag.contains("forceRender:true"));
}

#[test]
fn gam_config_disabled_by_default() {
let config = GamIntegrationConfig::default();
assert!(!config.enabled);
assert!(config.bidders.is_empty());
assert!(!config.force_render);
}
}
2 changes: 2 additions & 0 deletions crates/common/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod adserver_mock;
pub mod aps;
pub mod datadome;
pub mod didomi;
pub mod gam;
pub mod lockr;
pub mod nextjs;
pub mod permutive;
Expand All @@ -32,5 +33,6 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] {
lockr::register,
didomi::register,
datadome::register,
gam::register,
]
}
30 changes: 30 additions & 0 deletions crates/js/lib/src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,51 @@
// Global configuration storage for the tsjs runtime (logging, debug, etc.).
import { log, LogLevel } from './log';
import type { GamConfig } from './types';

export interface Config {
debug?: boolean;
logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug';
/** GAM interceptor configuration. */
gam?: GamConfig;
[key: string]: unknown;
}

let CONFIG: Config = {};

// Lazy import to avoid circular dependencies - GAM integration may not be present
let setGamConfigFn: ((cfg: GamConfig) => void) | null | undefined = undefined;

function getSetGamConfig(): ((cfg: GamConfig) => void) | null {
if (setGamConfigFn === undefined) {
try {
// Dynamic import path - bundler will include if gam integration is present
// eslint-disable-next-line @typescript-eslint/no-require-imports
const gam = require('../integrations/gam/index');
setGamConfigFn = gam.setGamConfig || null;
} catch {
// GAM integration not available
setGamConfigFn = null;
}
}
return setGamConfigFn ?? null;
}

// Merge publisher-provided config and adjust the log level accordingly.
export function setConfig(cfg: Config): void {
CONFIG = { ...CONFIG, ...cfg };
const debugFlag = cfg.debug;
const l = cfg.logLevel as LogLevel | undefined;
if (typeof l === 'string') log.setLevel(l);
else if (debugFlag === true) log.setLevel('debug');

// Forward GAM config to the GAM integration if present
if (cfg.gam) {
const setGam = getSetGamConfig();
if (setGam) {
setGam(cfg.gam);
}
}

log.info('setConfig:', cfg);
}

Expand Down
10 changes: 10 additions & 0 deletions crates/js/lib/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,13 @@ export interface TsjsApi {
debug(...args: unknown[]): void;
};
}

/** GAM interceptor configuration. */
export interface GamConfig {
/** Enable the GAM interceptor. Defaults to false. */
enabled?: boolean;
/** Only intercept bids from these bidders. Empty array = all bidders. */
bidders?: string[];
/** Force render Prebid creative even if GAM returned a line item. Defaults to false. */
forceRender?: boolean;
}
Loading