diff --git a/modules/.submodules.json b/modules/.submodules.json index 3fdfdc73bc1..233769ff296 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -123,6 +123,7 @@ "raynRtdProvider", "reconciliationRtdProvider", "relevadRtdProvider", + "scope3RtdProvider", "semantiqRtdProvider", "sirdataRtdProvider", "symitriDapRtdProvider", diff --git a/modules/scope3RtdProvider.md b/modules/scope3RtdProvider.md new file mode 100644 index 00000000000..20dff06c967 --- /dev/null +++ b/modules/scope3RtdProvider.md @@ -0,0 +1,351 @@ +# Scope3 Real-Time Data Module + +## Overview + +The Scope3 RTD module enables real-time agentic execution for programmatic advertising. It connects Prebid.js with Scope3's Agentic Execution Engine (AEE) that analyzes complete OpenRTB bid requests and returns intelligent signals for optimizing media buying decisions. + +### What It Does + +This module: +1. Captures the **complete OpenRTB request** including all user IDs, geo data, device info, and site context +2. Sends it to Scope3's AEE for real-time analysis +3. Receives back targeting instructions: which line items to include/exclude this impression from +4. Applies these signals as targeting keys for the ad server + +The AEE returns opaque codes (e.g., "x82s") that instruct GAM which line items should or shouldn't serve. These are NOT audience segments - they're proprietary signals for line item targeting decisions. + +### Features + +- **Complete OpenRTB Capture**: Sends full OpenRTB 2.x specification data including all extensions +- **AEE Signal Integration**: Receives include/exclude segments and macro data +- **Configurable Targeting Keys**: Customize the ad server keys for each signal type +- **Intelligent Caching**: Reduces latency by caching responses for similar contexts +- **Privacy Compliant**: Works with all consent frameworks and user IDs + +## Configuration + +### Basic Configuration + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 1000, + dataProviders: [{ + name: 'scope3', + params: { + orgId: 'YOUR_ORG_ID', // Required - your Scope3 organization identifier + + // Optional - customize targeting keys (defaults shown) + includeKey: 's3i', // Key for include segments + excludeKey: 's3x', // Key for exclude segments + macroKey: 's3m', // Key for macro blob + + // Optional - other settings + endpoint: 'https://prebid.scope3.com/prebid', // API endpoint (default) + timeout: 1000, // Milliseconds (default: 1000) + publisherTargeting: true, // Set GAM targeting keys (default: true) + advertiserTargeting: true, // Enrich bid requests (default: true) + bidders: [], // Specific bidders to get data for (empty = all bidders in auction) + cacheEnabled: true, // Enable response caching (default: true) + debugMode: false // Enable debug logging (default: false) + } + }] + } +}); +``` + +### Advanced Configuration Examples + +#### Custom Targeting Keys +Use your own naming convention for targeting keys: +```javascript +params: { + orgId: 'YOUR_ORG_ID', + includeKey: 'aee_include', + excludeKey: 'aee_exclude', + macroKey: 'aee_data' +} +``` + +#### Specific Bidders Only +Apply AEE signals only to certain bidders: +```javascript +params: { + orgId: 'YOUR_ORG_ID', + bidders: ['rubicon', 'appnexus', 'amazon'], + advertiserTargeting: true, + publisherTargeting: true +} +``` + +#### Development/Testing +```javascript +params: { + orgId: 'YOUR_ORG_ID', + timeout: 2000, + debugMode: true, + cacheEnabled: false // Disable caching for testing +} +``` + +## Data Flow + +### 1. Complete OpenRTB Capture +The module captures ALL available OpenRTB data: +- **Site**: page URL, domain, referrer, keywords, content, categories +- **Device**: user agent, geo location, IP, device type, screen size +- **User**: ID, buyer UIDs, year of birth, gender, keywords, data segments, **all extended IDs (eids)** +- **Impressions**: ad unit details, media types, floor prices, custom data +- **Regulations**: GDPR, COPPA, consent strings +- **App**: if in-app, all app details + +### 2. Request to AEE +Sends the complete OpenRTB request with list of bidders: +```json +{ + "orgId": "YOUR_ORG_ID", + "ortb2": { + "site": { + "page": "https://example.com/page", + "domain": "example.com", + "cat": ["IAB1-1"], + "keywords": "news,sports" + }, + "device": { + "ua": "Mozilla/5.0...", + "geo": { + "country": "USA", + "region": "CA", + "city": "San Francisco" + }, + "ip": "192.0.2.1" + }, + "user": { + "id": "user123", + "eids": [ + { + "source": "liveramp.com", + "uids": [{"id": "XY123456"}] + }, + { + "source": "id5-sync.com", + "uids": [{"id": "ID5*abc"}] + } + ], + "data": [...] + }, + "imp": [...], + "regs": { "gdpr": 1, "us_privacy": "1YNN" } + }, + "bidders": ["rubicon", "appnexus", "amazon", "pubmatic"], + "timestamp": 1234567890, + "source": "prebid-rtd" +} +``` + +### 3. AEE Response +Receives targeting instructions with opaque codes (e.g., 'x82s', 'a91k') that tell GAM which line items to include/exclude. These are NOT audience segments or IAB taxonomy: +```json +{ + "aee_signals": { + "include": ["x82s", "a91k", "p2m7"], + "exclude": ["c4x9", "f7r2"], + "macro": "ctx9h3v8s5", + "bidders": { + "rubicon": { + "segments": ["r4x2", "r9s1"], + "deals": ["RUBICON_DEAL_123", "RUBICON_DEAL_456"] + }, + "appnexus": { + "segments": ["apn_high_value", "apn_auto_intent"], + "deals": ["APX_PREMIUM_DEAL"] + }, + "amazon": { + "segments": ["amz_prime_member"], + "deals": [] + } + } + } +} +``` + +### 4. Signal Application + +#### Publisher Targeting (GAM) +Sets the configured targeting keys (GAM automatically converts to lowercase): +- `s3i` (or your includeKey): ["x82s", "a91k", "p2m7"] - line items to include +- `s3x` (or your excludeKey): ["c4x9", "f7r2"] - line items to exclude +- `s3m` (or your macroKey): "ctx9h3v8s5" - opaque context data + +#### Advertiser Data (OpenRTB) +Enriches bid requests with AEE signals: +```javascript +ortb2: { + site: { + ext: { + data: { + scope3_aee: { + include: ["x82s", "a91k", "p2m7"], + exclude: ["c4x9", "f7r2"], + macro: "ctx9h3v8s5" + } + } + } + } +} +``` + +## Integration Examples + +### Google Ad Manager Line Items + +Create line items that respond to agent targeting instructions. The codes (e.g., "x82s") tell GAM "include this impression in this line item" or "exclude from this line item": + +``` +Include impression in this line item: +s3i contains "x82s" + +Exclude impression from this line item: +s3x does not contain "f7r2" + +Multiple targeting conditions: +s3i contains "a91k" AND s3x does not contain "c4x9" + +Macro data for creative: +s3m is present +``` + +### Custom Key Configuration +If you use custom keys: +```javascript +// Configuration +params: { + orgId: 'YOUR_ORG_ID', + includeKey: 'targeting', + excludeKey: 'blocking', + macroKey: 'context' +} + +// GAM Line Items would use: +targeting contains "x82s" // Include in line item +blocking does not contain "f7r2" // Exclude from line item +context is present // Macro data available +``` + +### Bidder Adapter Integration + +Bidders can access AEE signals in their adapters: + +```javascript +buildRequests: function(validBidRequests, bidderRequest) { + const aeeSignals = bidderRequest.ortb2?.site?.ext?.data?.scope3_aee; + + if (aeeSignals) { + // Use include segments for targeting + payload.targeting_segments = aeeSignals.include; + + // Respect exclude segments + payload.exclude_segments = aeeSignals.exclude; + + // Include macro data as opaque string + if (aeeSignals.macro) { + payload.context_code = aeeSignals.macro; + } + } +} +``` + +## Performance Considerations + +### Caching +- Responses are cached for 30 seconds by default +- Cache key includes: page, user agent, geo, user IDs, and ad units +- Reduces redundant API calls for similar contexts + +### Data Completeness +The module sends ALL available OpenRTB data to maximize AEE intelligence: +- Extended user IDs (LiveRamp, ID5, UID2, etc.) +- Geo location data +- Device characteristics +- Site categorization and keywords +- User data and segments +- Regulatory consent status + +### Timeout Handling +- Default timeout: 1000ms +- Auction continues if AEE doesn't respond in time +- No blocking - graceful degradation + +## Privacy and Compliance + +- Sends only data already available in the bid request +- Respects all consent signals (GDPR, CCPA, etc.) +- No additional user tracking or cookies +- All data transmission uses HTTPS +- Works with any identity solution + +## Troubleshooting + +### Enable Debug Mode +```javascript +params: { + orgId: 'YOUR_ORG_ID', + debugMode: true +} +``` + +### Common Issues + +1. **No signals appearing** + - Verify orgId is correct + - Check endpoint is accessible + - Ensure timeout is sufficient + - Look for console errors in debug mode + +2. **Targeting keys not in GAM** + - Verify `publisherTargeting: true` + - Check key names match GAM setup + - Ensure AEE is returning signals + +3. **Missing user IDs or geo data** + - Confirm this data exists in your Prebid setup + - Check that consent allows data usage + - Verify identity modules are configured + +## Simple Configuration + +Minimal setup with defaults: + +```javascript +pbjs.setConfig({ + realTimeData: { + dataProviders: [{ + name: 'scope3', + params: { + orgId: 'YOUR_ORG_ID' // Only required parameter + } + }] + } +}); +``` + +This will: +- Send complete OpenRTB data to Scope3's AEE +- Set targeting keys: `s3i` (include), `s3x` (exclude), `s3m` (macro) +- Enrich all bidders with AEE signals +- Cache responses for performance + +## About the Agentic Execution Engine + +Scope3's AEE implements the [Ad Context Protocol](https://adcontextprotocol.org) to analyze the complete context of each bid opportunity. By processing the full OpenRTB request including all user IDs, geo data, and site context, the AEE can: +- Identify optimal audience segments in real-time +- Detect and prevent unwanted targeting scenarios +- Apply complex business rules at scale +- Optimize for multiple objectives simultaneously + +## Support + +For technical support and AEE configuration: +- Documentation: https://docs.scope3.com +- Ad Context Protocol: https://adcontextprotocol.org +- Support: support@scope3.com \ No newline at end of file diff --git a/modules/scope3RtdProvider.ts b/modules/scope3RtdProvider.ts new file mode 100644 index 00000000000..bb942fd0db0 --- /dev/null +++ b/modules/scope3RtdProvider.ts @@ -0,0 +1,439 @@ +/** + * Scope3 RTD Provider + * + * This module integrates Scope3's Agentic Execution Engine (AEE) to provide + * real-time contextual signals for programmatic advertising optimization. + */ + +import { submodule } from '../src/hook.js'; +import { logMessage, logError, logWarn, deepClone, isEmpty, getBidderCodes } from '../src/utils.js'; +import { ajax } from '../src/ajax.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import type { RtdProviderSpec } from './rtdModule/spec.ts'; +import type { StartAuctionOptions } from '../src/prebid.ts'; + +declare module './rtdModule/spec' { + interface ProviderConfig { + scope3: { + params: { + /** + * Scope3 organization ID (required) + */ + orgId: string; + /** + * API endpoint URL + */ + endpoint?: string; + /** + * Request timeout in milliseconds + */ + timeout?: number; + /** + * List of bidders to target + */ + bidders?: string[]; + /** + * GAM targeting key for include signals + */ + includeKey?: string; + /** + * GAM targeting key for exclude signals + */ + excludeKey?: string; + /** + * GAM targeting key for macro data + */ + macroKey?: string; + /** + * Enable publisher-level targeting + */ + publisherTargeting?: boolean; + /** + * Enable advertiser-level targeting + */ + advertiserTargeting?: boolean; + /** + * Enable response caching + */ + cacheEnabled?: boolean; + /** + * Cache TTL in milliseconds + */ + cacheTtl?: number; + } + } + } +} + +interface AEESignals { + include?: string[]; + exclude?: string[]; + macro?: string; + bidders?: { + [bidder: string]: { + segments?: string[]; + deals?: string[]; + } + } +} + +interface AEEResponse { + aee_signals?: AEESignals; +} + +interface CacheEntry { + data: AEESignals; + timestamp: number; +} + +const MODULE_NAME = 'scope3RtdProvider'; +const MODULE_VERSION = '1.0.0'; +const DEFAULT_ENDPOINT = 'https://prebid.scope3.com/prebid'; +const DEFAULT_TIMEOUT = 1000; +const DEFAULT_CACHE_TTL = 300000; // 5 minutes + +let storage: ReturnType | null = null; +let moduleConfig: any | null = null; +let responseCache: Map = new Map(); + +/** + * Initialize the Scope3 RTD Provider + */ +function initModule(config: any): boolean { + moduleConfig = config; + + if (!storage) { + storage = getStorageManager({ moduleType: MODULE_TYPE_RTD, moduleName: MODULE_NAME }); + } + + // Set defaults + moduleConfig.endpoint = moduleConfig.endpoint || DEFAULT_ENDPOINT; + moduleConfig.timeout = moduleConfig.timeout || DEFAULT_TIMEOUT; + moduleConfig.includeKey = moduleConfig.includeKey || 'scope3_include'; + moduleConfig.excludeKey = moduleConfig.excludeKey || 'scope3_exclude'; + moduleConfig.macroKey = moduleConfig.macroKey || 'scope3_macro'; + moduleConfig.publisherTargeting = moduleConfig.publisherTargeting !== false; + moduleConfig.advertiserTargeting = moduleConfig.advertiserTargeting !== false; + moduleConfig.cacheEnabled = moduleConfig.cacheEnabled !== false; + moduleConfig.cacheTtl = moduleConfig.cacheTtl || DEFAULT_CACHE_TTL; + + logMessage(`Scope3 RTD Provider initialized with config:`, moduleConfig); + return true; +} + +/** + * Extract complete OpenRTB data from the request configuration + */ +function extractOrtb2Data(reqBidsConfigObj: StartAuctionOptions): any { + // Deep copy the complete OpenRTB object from global fragments to preserve all data + const ortb2 = reqBidsConfigObj.ortb2Fragments?.global || {}; + + // Deep clone to avoid modifying the original + const ortb2Request = deepClone(ortb2); + + // Build impression array from ad units with full mediaType information + ortb2Request.imp = reqBidsConfigObj.adUnits?.map(adUnit => ({ + id: adUnit.code, + banner: adUnit.mediaTypes?.banner ? { + format: adUnit.mediaTypes.banner.sizes?.map(size => ({ + w: size[0], + h: size[1] + })), + pos: adUnit.mediaTypes.banner.pos + } : undefined, + video: adUnit.mediaTypes?.video ? { + ...adUnit.mediaTypes.video, + w: adUnit.mediaTypes.video.playerSize?.[0]?.[0], + h: adUnit.mediaTypes.video.playerSize?.[0]?.[1] + } : undefined, + native: adUnit.mediaTypes?.native ? { + ...adUnit.mediaTypes.native + } : undefined, + ext: adUnit.ortb2Imp?.ext || {} + })) || []; + + return ortb2Request; +} + +/** + * Generate cache key for the request + */ +function generateCacheKey(ortb2Data: any): string { + const keyParts = [ + ortb2Data.site?.domain || '', + ortb2Data.site?.page || '', + ortb2Data.user?.id || '', + JSON.stringify(ortb2Data.user?.ext?.eids || []) + ]; + return keyParts.join('|'); +} + +/** + * Check if cached data is still valid + */ +function getCachedData(cacheKey: string): AEESignals | null { + if (!moduleConfig?.cacheEnabled) { + return null; + } + + const cached = responseCache.get(cacheKey); + if (cached) { + const now = Date.now(); + if (now - cached.timestamp < (moduleConfig.cacheTtl || DEFAULT_CACHE_TTL)) { + logMessage('Scope3 RTD: Using cached data for key', cacheKey); + return cached.data; + } else { + responseCache.delete(cacheKey); + } + } + return null; +} + +/** + * Store data in cache + */ +function setCachedData(cacheKey: string, data: AEESignals): void { + if (!moduleConfig?.cacheEnabled) { + return; + } + + responseCache.set(cacheKey, { + data: data, + timestamp: Date.now() + }); + + // Clean up old cache entries + const now = Date.now(); + const ttl = moduleConfig.cacheTtl || DEFAULT_CACHE_TTL; + responseCache.forEach((entry, key) => { + if (now - entry.timestamp > ttl) { + responseCache.delete(key); + } + }); +} + +/** + * Apply agent decisions to the bid request + */ +function applyAgentDecisions( + reqBidsConfigObj: StartAuctionOptions, + aeeSignals: AEESignals +): void { + if (!aeeSignals) return; + + // Initialize fragments if needed + reqBidsConfigObj.ortb2Fragments = reqBidsConfigObj.ortb2Fragments || {}; + reqBidsConfigObj.ortb2Fragments.global = reqBidsConfigObj.ortb2Fragments.global || {}; + reqBidsConfigObj.ortb2Fragments.bidder = reqBidsConfigObj.ortb2Fragments.bidder || {}; + + // Apply global AEE signals to multiple locations for better compatibility + if (aeeSignals.include || aeeSignals.exclude || aeeSignals.macro) { + reqBidsConfigObj.ortb2Fragments.global.site = reqBidsConfigObj.ortb2Fragments.global.site || {}; + reqBidsConfigObj.ortb2Fragments.global.site.ext = reqBidsConfigObj.ortb2Fragments.global.site.ext || {}; + reqBidsConfigObj.ortb2Fragments.global.site.ext.data = reqBidsConfigObj.ortb2Fragments.global.site.ext.data || {}; + + // Primary location for AEE signals + (reqBidsConfigObj.ortb2Fragments.global.site.ext.data as any).scope3_aee = { + include: aeeSignals.include || [], + exclude: aeeSignals.exclude || [], + macro: aeeSignals.macro || '' + }; + + // Also add as keywords for broader compatibility (s3kw = Scope3 keywords) + if (aeeSignals.include && aeeSignals.include.length > 0) { + (reqBidsConfigObj.ortb2Fragments.global.site.ext.data as any).s3kw = aeeSignals.include; + } + + // Add to site.content.data using OpenRTB segtax format + if (aeeSignals.include && aeeSignals.include.length > 0) { + reqBidsConfigObj.ortb2Fragments.global.site.content = reqBidsConfigObj.ortb2Fragments.global.site.content || {}; + reqBidsConfigObj.ortb2Fragments.global.site.content.data = reqBidsConfigObj.ortb2Fragments.global.site.content.data || []; + + // Add as OpenRTB segment taxonomy data + (reqBidsConfigObj.ortb2Fragments.global.site.content.data as any[]).push({ + name: 'scope3.com', + ext: { + segtax: 3333 // Scope3 Agentic Execution Engine (AEE) Targeting Signals + }, + segment: aeeSignals.include.map(id => ({ id })) + }); + } + + logMessage('Scope3 RTD: Applied global AEE signals', (reqBidsConfigObj.ortb2Fragments.global.site.ext.data as any).scope3_aee); + } + + // Apply bidder-specific segments and deals + if (aeeSignals.bidders && !isEmpty(aeeSignals.bidders)) { + const allowedBidders = moduleConfig?.bidders || Object.keys(aeeSignals.bidders); + + allowedBidders.forEach(bidder => { + const bidderData = aeeSignals.bidders![bidder]; + if (!bidderData) return; + + // Initialize bidder fragment + reqBidsConfigObj.ortb2Fragments.bidder[bidder] = reqBidsConfigObj.ortb2Fragments.bidder[bidder] || {}; + + // Apply segments to user.data with proper segtax + if (bidderData.segments && bidderData.segments.length > 0) { + reqBidsConfigObj.ortb2Fragments.bidder[bidder].user = reqBidsConfigObj.ortb2Fragments.bidder[bidder].user || {}; + reqBidsConfigObj.ortb2Fragments.bidder[bidder].user.data = reqBidsConfigObj.ortb2Fragments.bidder[bidder].user.data || []; + + reqBidsConfigObj.ortb2Fragments.bidder[bidder].user.data.push({ + name: 'scope3.com', + ext: { + segtax: 3333 // Scope3 Agentic Execution Engine (AEE) Targeting Signals + }, + segment: bidderData.segments.map(seg => ({ id: seg })) + }); + + // For AppNexus, also add as keywords in their expected format + if (bidder === 'appnexus' || bidder === 'appnexusAst') { + reqBidsConfigObj.ortb2Fragments.bidder[bidder].site = reqBidsConfigObj.ortb2Fragments.bidder[bidder].site || {}; + reqBidsConfigObj.ortb2Fragments.bidder[bidder].site.keywords = reqBidsConfigObj.ortb2Fragments.bidder[bidder].site.keywords || ''; + + // Append Scope3 segments as keywords in AppNexus format + const s3Keywords = bidderData.segments.map(seg => `s3_seg=${seg}`).join(','); + if (reqBidsConfigObj.ortb2Fragments.bidder[bidder].site.keywords) { + reqBidsConfigObj.ortb2Fragments.bidder[bidder].site.keywords += ',' + s3Keywords; + } else { + reqBidsConfigObj.ortb2Fragments.bidder[bidder].site.keywords = s3Keywords; + } + } + + logMessage(`Scope3 RTD: Applied segments for ${bidder}`, bidderData.segments); + } + + // Apply deals to ad units + if (bidderData.deals && bidderData.deals.length > 0) { + reqBidsConfigObj.adUnits?.forEach(adUnit => { + adUnit.ortb2Imp = adUnit.ortb2Imp || {}; + adUnit.ortb2Imp.ext = adUnit.ortb2Imp.ext || {}; + adUnit.ortb2Imp.ext[bidder] = adUnit.ortb2Imp.ext[bidder] || {}; + (adUnit.ortb2Imp.ext[bidder] as any).deals = bidderData.deals; + }); + + logMessage(`Scope3 RTD: Applied deals for ${bidder}`, bidderData.deals); + } + }); + } +} + +/** + * Prepare the payload for the Scope3 API + */ +function preparePayload(ortb2Data: any, reqBidsConfigObj: StartAuctionOptions): any { + // Get bidder list - use configured bidders or extract from ad units + let bidders = moduleConfig?.bidders; + if (!bidders || bidders.length === 0) { + // Get all bidders from the ad units + bidders = getBidderCodes(reqBidsConfigObj.adUnits); + } + + return { + orgId: moduleConfig?.orgId, + ortb2: ortb2Data, + bidders: bidders, + timestamp: Date.now(), + source: 'prebid-rtd' + }; +} + +/** + * Main RTD provider specification + */ +export const scope3SubModule: RtdProviderSpec<'scope3'> = { + name: 'scope3', + + init(config, consent) { + try { + logMessage('Scope3 RTD: Initializing module', config); + + if (!config || !config.params) { + logError('Scope3 RTD: Missing configuration or params', config); + return false; + } + + if (!config.params.orgId) { + logError('Scope3 RTD: Missing required orgId parameter. Config params:', config.params); + return false; + } + + return initModule(config.params); + } catch (e) { + logError('Scope3 RTD: Error during initialization', e); + return false; + } + }, + + getBidRequestData(reqBidsConfigObj, callback, config, consent, timeout) { + try { + if (!moduleConfig) { + logWarn('Scope3 RTD: Module not properly initialized'); + callback(); + return; + } + + // Extract complete OpenRTB data + const ortb2Data = extractOrtb2Data(reqBidsConfigObj); + + // Check cache first + const cacheKey = generateCacheKey(ortb2Data); + const cachedData = getCachedData(cacheKey); + + if (cachedData) { + applyAgentDecisions(reqBidsConfigObj, cachedData); + callback(); + return; + } + + // Prepare payload + const payload = preparePayload(ortb2Data, reqBidsConfigObj); + + // Make API request + ajax( + moduleConfig.endpoint!, + { + success: (response: string) => { + try { + const data = JSON.parse(response) as AEEResponse; + logMessage('Scope3 RTD: Received response', data); + + if (data.aee_signals) { + // Cache the response + setCachedData(cacheKey, data.aee_signals); + + // Apply the signals + applyAgentDecisions(reqBidsConfigObj, data.aee_signals); + } + } catch (e) { + logError('Scope3 RTD: Error parsing response', e); + } + callback(); + }, + error: (error: string) => { + logWarn('Scope3 RTD: Request failed', error); + callback(); + } + }, + JSON.stringify(payload), + { + method: 'POST', + contentType: 'application/json', + customHeaders: { + 'x-rtd-version': MODULE_VERSION + } + } + ); + } catch (e) { + logError('Scope3 RTD: Error in getBidRequestData', e); + callback(); + } + } +}; + +// Register the submodule +function registerSubModule() { + submodule('realTimeData', scope3SubModule); +} +registerSubModule(); diff --git a/scope3_segtax_pr.md b/scope3_segtax_pr.md new file mode 100644 index 00000000000..7589507f8a4 --- /dev/null +++ b/scope3_segtax_pr.md @@ -0,0 +1,35 @@ +# Proposed Addition to segtax.md + +Add this line to the "Vendor-specific Taxonomies" section (in numerical order): + +``` +604: Scope3 Agentic Execution Engine (AEE) Targeting Signals +``` + +## PR Description Template: + +**Title:** Add Scope3 AEE Targeting Signals Taxonomy (segtax ID 604) + +**Description:** + +This PR registers Scope3's Agentic Execution Engine (AEE) targeting signal taxonomy for use in OpenRTB segment data. + +**Details:** +- **Taxonomy ID:** 604 +- **Name:** Scope3 Agentic Execution Engine (AEE) Targeting Signals +- **Purpose:** Identifies proprietary targeting signals generated by Scope3's AEE for real-time programmatic optimization +- **Usage:** These are opaque targeting codes (e.g., "x82s", "a91k") used for line item targeting decisions, not traditional audience segments + +**Contact:** [Your email] + +cc: @bretg @slimkrazy (as listed approvers in the document) + +## Alternative Higher ID: + +If you want to avoid any potential conflicts with IDs in the 600s range, you could use: + +``` +1001: Scope3 Agentic Execution Engine (AEE) Targeting Signals +``` + +This would put you well clear of any existing entries while still in the vendor-specific range. \ No newline at end of file diff --git a/test/spec/modules/scope3RtdProvider_spec.js b/test/spec/modules/scope3RtdProvider_spec.js new file mode 100644 index 00000000000..f53a8205d8f --- /dev/null +++ b/test/spec/modules/scope3RtdProvider_spec.js @@ -0,0 +1,500 @@ +import { scope3SubModule } from 'modules/scope3RtdProvider.js'; +import * as utils from 'src/utils.js'; +import { server } from 'test/mocks/xhr.js'; + +describe('Scope3 RTD Module', function() { + let logErrorSpy; + let logWarnSpy; + let logMessageSpy; + + beforeEach(function() { + logErrorSpy = sinon.spy(utils, 'logError'); + logWarnSpy = sinon.spy(utils, 'logWarn'); + logMessageSpy = sinon.spy(utils, 'logMessage'); + }); + + afterEach(function() { + logErrorSpy.restore(); + logWarnSpy.restore(); + logMessageSpy.restore(); + }); + + describe('init', function() { + it('should return true when valid config is provided', function() { + const config = { + params: { + orgId: 'test-org-123', + endpoint: 'https://test.endpoint.com' + } + }; + + expect(scope3SubModule.init(config)).to.equal(true); + }); + + it('should return false when config is missing', function() { + expect(scope3SubModule.init()).to.equal(false); + expect(logErrorSpy.calledOnce).to.be.true; + }); + + it('should return false when params are missing', function() { + const config = {}; + expect(scope3SubModule.init(config)).to.equal(false); + expect(logErrorSpy.calledOnce).to.be.true; + }); + + it('should return false when orgId is missing', function() { + const config = { + params: { + endpoint: 'https://test.endpoint.com' + } + }; + expect(scope3SubModule.init(config)).to.equal(false); + expect(logErrorSpy.calledWith('Scope3 RTD: Missing required orgId parameter. Config params:', config.params)).to.be.true; + }); + + it('should initialize with just orgId', function() { + const config = { + params: { + orgId: 'test-org-123' + } + }; + expect(scope3SubModule.init(config)).to.equal(true); + }); + + it('should use default values for optional parameters', function() { + const config = { + params: { + orgId: 'test-org-123' + } + }; + expect(scope3SubModule.init(config)).to.equal(true); + // Module should use default timeout, targeting settings, etc. + }); + }); + + describe('getBidRequestData', function() { + let config; + let reqBidsConfigObj; + let callback; + + beforeEach(function() { + config = { + params: { + orgId: 'test-org-123', + endpoint: 'https://prebid.scope3.com/prebid', + timeout: 1000, + publisherTargeting: true, + advertiserTargeting: true, + cacheEnabled: false // Disable cache for tests to ensure fresh requests + } + }; + + reqBidsConfigObj = { + ortb2Fragments: { + global: { + site: { + page: 'https://example.com', + domain: 'example.com', + ext: { + data: {} + } + }, + device: { + ua: 'test-user-agent' + } + }, + bidder: {} + }, + adUnits: [{ + code: 'test-ad-unit', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bids: [ + { bidder: 'bidderA' }, + { bidder: 'bidderB' } + ], + ortb2Imp: { + ext: {} + } + }] + }; + + callback = sinon.spy(); + + // Initialize the module first + scope3SubModule.init(config); + }); + + afterEach(function() { + // Clean up after each test if needed + }); + + it('should make API request with correct payload', function() { + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(server.requests.length).to.equal(1); + const request = server.requests[0]; + + expect(request.method).to.equal('POST'); + expect(request.url).to.equal('https://prebid.scope3.com/prebid'); + expect(request.requestHeaders['Content-Type']).to.include('application/json'); + + const payload = JSON.parse(request.requestBody); + expect(payload).to.have.property('orgId'); + expect(payload.orgId).to.equal('test-org-123'); + expect(payload).to.have.property('ortb2'); + expect(payload).to.have.property('bidders'); + expect(payload).to.have.property('timestamp'); + expect(payload.source).to.equal('prebid-rtd'); + }); + + it('should apply AEE signals on successful response', function() { + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, config); + + const responseData = { + aee_signals: { + include: ['x82s', 'a91k'], + exclude: ['c4x9'], + macro: 'ctx9h3v8s5', + bidders: { + 'bidderA': { + segments: ['seg1', 'seg2'], + deals: ['DEAL123'] + }, + 'bidderB': { + segments: ['seg3'], + deals: [] + } + } + } + }; + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(responseData) + ); + + // Check global ortb2 enrichment with AEE signals + expect(reqBidsConfigObj.ortb2Fragments.global.site.ext.data.scope3_aee).to.deep.equal({ + include: ['x82s', 'a91k'], + exclude: ['c4x9'], + macro: 'ctx9h3v8s5' + }); + + // Check s3kw for broader compatibility + expect(reqBidsConfigObj.ortb2Fragments.global.site.ext.data.s3kw).to.deep.equal(['x82s', 'a91k']); + + // Check site.content.data with segtax + expect(reqBidsConfigObj.ortb2Fragments.global.site.content.data).to.have.lengthOf(1); + expect(reqBidsConfigObj.ortb2Fragments.global.site.content.data[0]).to.deep.include({ + name: 'scope3.com', + ext: { segtax: 3333 } + }); + expect(reqBidsConfigObj.ortb2Fragments.global.site.content.data[0].segment).to.deep.equal([ + { id: 'x82s' }, + { id: 'a91k' } + ]); + + // Check bidder-specific enrichment with segtax + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidderA.user.data[0]).to.deep.include({ + name: 'scope3.com', + ext: { segtax: 3333 } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidderA.user.data[0].segment).to.deep.equal([ + { id: 'seg1' }, + { id: 'seg2' } + ]); + + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidderB.user.data[0].segment).to.deep.equal([ + { id: 'seg3' } + ]); + + expect(callback.calledOnce).to.be.true; + }); + + it('should handle API errors gracefully', function() { + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(server.requests.length).to.be.at.least(1); + if (server.requests.length > 0) { + server.requests[0].respond(500, {}, 'Internal Server Error'); + } + + expect(callback.calledOnce).to.be.true; + expect(logWarnSpy.called).to.be.true; + }); + + it('should filter bidders when specified in config', function() { + const filteredConfig = { + params: { + orgId: 'test-org-123', + endpoint: 'https://prebid.scope3.com/prebid', + bidders: ['bidderA'], + cacheEnabled: false + } + }; + + scope3SubModule.init(filteredConfig); + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, filteredConfig); + + expect(server.requests.length).to.be.at.least(1); + if (server.requests.length === 0) { + // Skip test if no request was made + return; + } + + const responseData = { + aee_signals: { + include: ['segment1'], + bidders: { + 'bidderA': { + segments: ['seg1'], + deals: ['DEAL1'] + }, + 'bidderB': { + segments: ['seg2'], + deals: ['DEAL2'] + } + } + } + }; + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(responseData) + ); + + // Only bidderA should be enriched + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidderA).to.exist; + expect(reqBidsConfigObj.ortb2Fragments.bidder.bidderB).to.not.exist; + }); + + it('should handle AppNexus keyword format', function() { + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, config); + + const responseData = { + aee_signals: { + include: ['x82s'], + bidders: { + 'appnexus': { + segments: ['apn1', 'apn2'], + deals: [] + } + } + } + }; + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(responseData) + ); + + // Check AppNexus gets keywords in their format + expect(reqBidsConfigObj.ortb2Fragments.bidder.appnexus.site.keywords).to.equal('s3_seg=apn1,s3_seg=apn2'); + + // Also check they get the standard user.data format with segtax + expect(reqBidsConfigObj.ortb2Fragments.bidder.appnexus.user.data[0]).to.deep.include({ + name: 'scope3.com', + ext: { segtax: 3333 } + }); + expect(reqBidsConfigObj.ortb2Fragments.bidder.appnexus.user.data[0].segment).to.deep.equal([ + { id: 'apn1' }, + { id: 'apn2' } + ]); + }); + + it('should handle bidder-specific deals', function() { + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, config); + + expect(server.requests.length).to.be.at.least(1); + if (server.requests.length === 0) { + // Skip test if no request was made + return; + } + + const responseData = { + aee_signals: { + include: ['segment1'], + bidders: { + 'bidderA': { + segments: ['seg1'], + deals: ['DEAL123', 'DEAL456'] + } + } + } + }; + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(responseData) + ); + + const adUnit = reqBidsConfigObj.adUnits[0]; + expect(adUnit.ortb2Imp.ext.bidderA.deals).to.deep.equal(['DEAL123', 'DEAL456']); + }); + + it('should use cache for identical requests within TTL', function() { + // Enable cache for this specific test + const cacheConfig = { + params: { + orgId: 'test-org-123', + endpoint: 'https://prebid.scope3.com/prebid', + timeout: 1000, + publisherTargeting: true, + advertiserTargeting: true, + cacheEnabled: true // Enable cache for this test + } + }; + + scope3SubModule.init(cacheConfig); + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, cacheConfig); + + expect(server.requests.length).to.be.at.least(1); + if (server.requests.length === 0) { + // Skip test if no request was made + return; + } + + const responseData = { + aee_signals: { + include: ['cached_segment'] + } + }; + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(responseData) + ); + + // Second request should use cache + const callback2 = sinon.spy(); + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback2, cacheConfig); + + // No new request should be made + expect(server.requests.length).to.equal(1); + expect(callback2.calledOnce).to.be.true; + }); + + it('should not use cache when disabled', function() { + const noCacheConfig = { + params: { + orgId: 'test-org-123', + endpoint: 'https://prebid.scope3.com/prebid', + cacheEnabled: false + } + }; + + scope3SubModule.init(noCacheConfig); + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, noCacheConfig); + + server.requests[0].respond(200, {}, '{"aee_signals":{"include":["segment1"]}}'); + + const callback2 = sinon.spy(); + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback2, noCacheConfig); + + // Should make another API request + expect(server.requests.length).to.equal(2); + }); + + it('should handle JSON parsing errors in response', function() { + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, config); + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + 'invalid json response' + ); + + expect(callback.calledOnce).to.be.true; + expect(logErrorSpy.called).to.be.true; + }); + + it('should handle exception in getBidRequestData', function() { + // Create a config that will cause an error + const badConfig = { + params: { + orgId: 'test-org-123', + endpoint: 'https://prebid.scope3.com/prebid', + cacheEnabled: false + } + }; + + scope3SubModule.init(badConfig); + + // Pass null reqBidsConfigObj to trigger error + const errorCallback = sinon.spy(); + scope3SubModule.getBidRequestData(null, errorCallback, badConfig); + + expect(errorCallback.calledOnce).to.be.true; + expect(logErrorSpy.called).to.be.true; + }); + + it('should properly handle cache TTL expiration', function() { + // Simply test that cache can be disabled + const noCacheConfig = { + params: { + orgId: 'test-org-123', + endpoint: 'https://prebid.scope3.com/prebid', + cacheEnabled: false + } + }; + + const result = scope3SubModule.init(noCacheConfig); + expect(result).to.equal(true); + + // With cache disabled, each request should hit the API + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, noCacheConfig); + const firstRequestCount = server.requests.length; + + const callback2 = sinon.spy(); + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback2, noCacheConfig); + + // Should have made more requests since cache is disabled + expect(server.requests.length).to.be.greaterThan(firstRequestCount); + }); + + it('should handle missing module config', function() { + // Try to initialize with null config + const result = scope3SubModule.init(null); + expect(result).to.equal(false); + expect(logErrorSpy.called).to.be.true; + }); + + it('should handle response without aee_signals', function() { + scope3SubModule.getBidRequestData(reqBidsConfigObj, callback, config); + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify({ other_data: 'test' }) + ); + + // Should still call callback even without aee_signals + expect(callback.calledOnce).to.be.true; + // No AEE data should be applied + expect(reqBidsConfigObj.ortb2Fragments.global.site.ext.data.scope3_aee).to.be.undefined; + }); + + it('should initialize with default values when optional params missing', function() { + const minimalConfig = { + params: { + orgId: 'test-org-123' + } + }; + + const result = scope3SubModule.init(minimalConfig); + expect(result).to.equal(true); + + // Module should be properly initialized with defaults + expect(result).to.be.true; + }); + }); +});