diff --git a/modules/.submodules.json b/modules/.submodules.json index c0e972f3c5d..2037a084f8c 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -105,6 +105,7 @@ "mediafilterRtdProvider", "medianetRtdProvider", "mgidRtdProvider", + "mileRtdProvider", "mobianRtdProvider", "neuwoRtdProvider", "nodalsAiRtdProvider", diff --git a/modules/mileRtdProvider.md b/modules/mileRtdProvider.md new file mode 100644 index 00000000000..e6eb2ba4135 --- /dev/null +++ b/modules/mileRtdProvider.md @@ -0,0 +1,67 @@ +# Mile RTD Provider + +## Overview + +The `mile` RTD provider computes per-slot targeting values through a runtime engine and sets GPT slot targeting used by GAM unified Pricing rules. + +It sets a single targeting key: + +- `mile_rtd` + +The value is provider-specific and is returned by `window[params.runtimeGlobalName].getMileTargetingByAdUnit(...)` + +## When targeting is applied + +Targeting is applied during: + +- `onAuctionInitEvent` +- `onBidResponseEvent` + + +## Key-value mapping + +The runtime engine returns a map keyed by ad unit identifier (slot element ID or ad unit path), and each resolved slot gets: + +- key: `mile_rtd` +- value: `targetingByAdUnit[slotElementId]` or `targetingByAdUnit[adUnitPath]` + +Example runtime response: + +```js +{ + "div-gpt-ad-123": "segA_floorHigh", + "/1234567/homepage/top": "segB_floorMid" +} +``` + +Resulting GPT slot targeting: + +```js +slot.setTargeting("mile_rtd", "segA_floorHigh"); +``` + +## Configuration + +Use the RTD module with provider name `mile`: + +```js +pbjs.setConfig({ + realTimeData: { + dataProviders: [ + { + name: "mile", + waitForIt: false, + params: { + runtimeScriptUrl: "https://cdn.example.com/mile-rtd-runtime.js", + runtimeGlobalName: "mileRtdRuntime", // optional, default shown + }, + }, + ], + }, +}); +``` + +### Params + +- `runtimeScriptUrl` (optional): URL of runtime script to load. +- `runtimeGlobalName` (optional): global object name exposing `getMileTargetingByAdUnit`; defaults to `mileRtdRuntime`. diff --git a/modules/mileRtdProvider.ts b/modules/mileRtdProvider.ts new file mode 100644 index 00000000000..ff398343283 --- /dev/null +++ b/modules/mileRtdProvider.ts @@ -0,0 +1,264 @@ +/** + * Thin Mile RTD client. + * Pulls pre-hashed targeting values from runtime global and merges them into + * ad unit adserver targeting. + */ +import { submodule } from '../src/hook.js'; +import { loadExternalScript } from '../src/adloader.js'; +import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import { auctionManager } from '../src/auctionManager.js'; +import { logError, logInfo } from '../src/utils.js'; +import type { Bid } from '../src/bidfactory.ts'; +import type { AllConsentData } from '../src/consentHandler.ts'; +import type { RTDProviderConfig, RtdProviderSpec } from './rtdModule/spec.ts'; + +type ModuleParams = { + runtimeScriptUrl?: string; + runtimeGlobalName?: string; +}; + +declare module './rtdModule/spec' { + interface ProviderConfig { + mile: { + params?: ModuleParams; + }; + } +} + +type TargetingValue = string | number | boolean | Array; +type TargetingByAdUnit = Record; +type RuntimeContext = { + mode: 'auctionInit' | 'bidResponse'; + bidResponse?: Bid; +}; + +type AuctionSnapshot = { + adUnitCodes: string[]; + [key: string]: unknown; +}; + +type AuctionDetails = AuctionSnapshot & { + adUnits: unknown[]; + bidderRequests: unknown[]; + bidsReceived: unknown[]; +}; + +type AuctionLike = { + getAdUnitCodes?: () => string[]; + getAdUnits?: () => unknown[]; + getBidRequests?: () => unknown[]; + getBidsReceived?: () => unknown[]; +}; + +type AdUnitLike = { + code?: string; + ortb2Imp?: { + ext?: { + gpid?: string; + data?: { + pbadslot?: string; + }; + }; + }; + adserverTargeting?: Record; +}; + +type MileRuntimeEngine = { + getMileTargetingByAdUnit: ( + auctionSnapshot: AuctionSnapshot, + context: RuntimeContext + ) => TargetingByAdUnit | null | Promise; +}; + +type ExtractAuctionSnapshot = (auctionDetails: AuctionDetails) => AuctionSnapshot; + +type MileRuntimeUtils = { + extractAuctionSnapshot?: ExtractAuctionSnapshot; +}; +type MileRtdProviderSpec = RtdProviderSpec<'mile'> & { + onAuctionInitEvent?: (auctionDetails: Partial) => void; + onBidResponseEvent?: ( + bidResponse: Partial, + config: RTDProviderConfig<'mile'>, + userConsent: AllConsentData + ) => void; +}; + +const MODULE_NAME = 'realTimeData'; +const SUBMODULE_NAME = 'mile'; +const TARGETING_KEY = 'mile_rtd'; +const LOG_PREFIX = '[mileRtdProvider]'; +const DEFAULT_ENGINE_GLOBAL = 'mileRtdRuntime'; + +let moduleParams: ModuleParams = {}; +let engineLoadPromise: Promise | null = null; + +function isRuntimeEngine(value: unknown): value is MileRuntimeEngine { + return !!value && typeof (value as MileRuntimeEngine).getMileTargetingByAdUnit === 'function'; +} + +function getRuntimeEngine(): MileRuntimeEngine | null { + const globalName = moduleParams.runtimeGlobalName || DEFAULT_ENGINE_GLOBAL; + const runtimeEngine = (window as unknown as Record)?.[globalName]; + return isRuntimeEngine(runtimeEngine) ? runtimeEngine : null; +} + +export function loadRuntimeScript(): Promise { + if (engineLoadPromise) return engineLoadPromise; + const runtimeScriptUrl = moduleParams.runtimeScriptUrl; + if (!runtimeScriptUrl) return Promise.resolve(false); + + engineLoadPromise = new Promise((resolve) => { + loadExternalScript(runtimeScriptUrl, MODULE_TYPE_RTD, SUBMODULE_NAME, () => { + logInfo(LOG_PREFIX, 'runtime script loaded', runtimeScriptUrl); + resolve(true); + }, undefined, undefined); + }).catch((error: unknown) => { + logError(LOG_PREFIX, 'unable to load runtime script', error); + engineLoadPromise = null; + return false; + }); + return engineLoadPromise; +} + +export function getTargetingFromRuntime( + auctionSnapshot: AuctionSnapshot, + context: RuntimeContext = { mode: 'auctionInit' } +): Promise { + const runtimeEngine = getRuntimeEngine(); + if (!runtimeEngine) { + logInfo(LOG_PREFIX, 'runtime engine missing getMileTargetingByAdUnit()'); + return Promise.resolve(null); + } + try { + return Promise.resolve(runtimeEngine.getMileTargetingByAdUnit(auctionSnapshot, context)); + } catch (error: unknown) { + logError(LOG_PREFIX, 'runtime engine failed while computing targeting', error); + return Promise.resolve(null); + } +} + +function getAdUnitTargetingValue(adUnit: AdUnitLike, targetingByAdUnit: TargetingByAdUnit): TargetingValue | null { + const targetingIdentifiers = [ + adUnit.code, + adUnit.ortb2Imp?.ext?.gpid, + adUnit.ortb2Imp?.ext?.data?.pbadslot, + ]; + + for (const identifier of targetingIdentifiers) { + if (identifier != null && targetingByAdUnit[identifier] != null) { + return targetingByAdUnit[identifier]; + } + } + + return null; +} + +export function setAdUnitTargeting( + targetingByAdUnit: TargetingByAdUnit, + adUnits: AdUnitLike[] = [] +): boolean { + if (!adUnits.length) { + logInfo(LOG_PREFIX, 'auction ad units are unavailable, skipping adserver targeting'); + return false; + } + + let appliedTargeting = false; + adUnits.forEach((adUnit) => { + const targetingValue = getAdUnitTargetingValue(adUnit, targetingByAdUnit); + if (targetingValue == null) return; + + adUnit.adserverTargeting = Object.assign(adUnit.adserverTargeting || {}, { + [TARGETING_KEY]: targetingValue, + }); + appliedTargeting = true; + }); + + return appliedTargeting; +} + +function buildAuctionDetailsFromAuction(auction: AuctionLike | undefined): AuctionDetails { + return { + adUnitCodes: auction?.getAdUnitCodes?.() || [], + adUnits: auction?.getAdUnits?.() || [], + bidderRequests: auction?.getBidRequests?.() || [], + bidsReceived: auction?.getBidsReceived?.() || [], + }; +} + +function extractAuctionSnapshot(auctionDetails: AuctionDetails): AuctionSnapshot { + const extractSnapshot = ((window as unknown as { mileRtdRuntimeUtils?: MileRuntimeUtils }).mileRtdRuntimeUtils)?.extractAuctionSnapshot; + if (typeof extractSnapshot === 'function') { + return extractSnapshot(auctionDetails); + } + return { adUnitCodes: auctionDetails.adUnitCodes || [] }; +} + +function applyRuntimeTargeting( + auctionSnapshot: AuctionSnapshot, + context: RuntimeContext, + adUnits: AdUnitLike[] = [] +): Promise { + return getTargetingFromRuntime(auctionSnapshot, context).then((targetingByAdUnit) => { + if (targetingByAdUnit && Object.keys(targetingByAdUnit).length > 0) { + setAdUnitTargeting(targetingByAdUnit, adUnits); + } + }); +} + +export function onAuctionInitEvent(auctionDetails: Partial = {}): void { + const snapshotDetails = auctionDetails.adUnitCodes?.length + ? (auctionDetails as AuctionDetails) + : buildAuctionDetailsFromAuction( + auctionManager.index.getAuction({ auctionId: auctionManager.getLastAuctionId() }) as AuctionLike + ); + if (!snapshotDetails.adUnitCodes?.length) return; + const auctionSnapshot = extractAuctionSnapshot(snapshotDetails); + applyRuntimeTargeting(auctionSnapshot, { mode: 'auctionInit' }, snapshotDetails.adUnits as AdUnitLike[]); +} + +export function onBidResponseEvent( + bidResponse: Partial, + _config: RTDProviderConfig<'mile'>, + _userConsent: AllConsentData, + auctionDetailsOverride?: Partial +): void { + const adUnitCode = bidResponse?.adUnitCode; + if (!adUnitCode) return; + const auctionDetails = auctionDetailsOverride || buildAuctionDetailsFromAuction(auctionManager.index.getAuction(bidResponse) as AuctionLike); + const auctionSnapshot = extractAuctionSnapshot(auctionDetails as AuctionDetails); + if (!auctionSnapshot.adUnitCodes?.includes(adUnitCode)) { + auctionSnapshot.adUnitCodes = [...(auctionSnapshot.adUnitCodes || []), adUnitCode]; + } + applyRuntimeTargeting( + auctionSnapshot, + { mode: 'bidResponse', bidResponse: bidResponse as Bid }, + auctionDetails.adUnits as AdUnitLike[] + ); +} + +export function init(moduleConfig: RTDProviderConfig<'mile'>): boolean { + moduleParams = moduleConfig?.params || {}; + if (moduleParams.runtimeScriptUrl) { + loadRuntimeScript(); + } else { + logInfo(LOG_PREFIX, 'runtimeScriptUrl not provided; runtime script will not load'); + } + return true; +} + +export const mileRtdSubmodule: MileRtdProviderSpec = { + name: SUBMODULE_NAME, + init, + onAuctionInitEvent, + onBidResponseEvent: (bidResponse, config, userConsent) => onBidResponseEvent(bidResponse, config, userConsent), +}; + +export const __testing__ = { + setModuleParams(params?: ModuleParams): void { + moduleParams = params || {}; + engineLoadPromise = null; + }, +}; + +submodule(MODULE_NAME, mileRtdSubmodule); diff --git a/plugins/eslint/approvedLoadExternalScriptPaths.js b/plugins/eslint/approvedLoadExternalScriptPaths.js index 0eab8545924..2ee1905d03c 100644 --- a/plugins/eslint/approvedLoadExternalScriptPaths.js +++ b/plugins/eslint/approvedLoadExternalScriptPaths.js @@ -33,6 +33,7 @@ const APPROVED_LOAD_EXTERNAL_SCRIPT_PATHS = [ 'modules/optableRtdProvider.js', 'modules/oftmediaRtdProvider.js', 'modules/panxoRtdProvider.js', + 'modules/mileRtdProvider.ts', // UserId Submodules 'modules/justIdSystem.js', 'modules/tncIdSystem.js', diff --git a/test/spec/modules/mileRtdProvider_spec.js b/test/spec/modules/mileRtdProvider_spec.js new file mode 100644 index 00000000000..16d3c71772d --- /dev/null +++ b/test/spec/modules/mileRtdProvider_spec.js @@ -0,0 +1,311 @@ +import { + __testing__, + getTargetingFromRuntime, + init, + loadRuntimeScript, + mileRtdSubmodule, + onAuctionInitEvent, + onBidResponseEvent, + setAdUnitTargeting, +} from "modules/mileRtdProvider.js"; +import { loadExternalScriptStub } from "test/mocks/adloaderStub.js"; + +describe("mile RTD provider", function () { + let sandbox; + + function flushPromises() { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + function createMockAdUnit({ code, gpid, pbadslot, adserverTargeting } = {}) { + return { + code, + ortb2Imp: { + ext: { + gpid, + data: { + pbadslot, + }, + }, + }, + adserverTargeting, + }; + } + + beforeEach(function () { + sandbox = sinon.createSandbox(); + __testing__.setModuleParams({}); + delete window.mileRtdRuntime; + delete window.customRuntimeGlobal; + delete window.mileRtdRuntimeUtils; + }); + + afterEach(function () { + sandbox.restore(); + __testing__.setModuleParams({}); + delete window.mileRtdRuntime; + delete window.customRuntimeGlobal; + delete window.mileRtdRuntimeUtils; + }); + + describe("mileRtdSubmodule", function () { + it("registers with expected name", function () { + expect(mileRtdSubmodule.name).to.equal("mile"); + }); + }); + + describe("init / runtime loading", function () { + it("returns true and does not load script when runtimeScriptUrl is missing", function () { + expect(init({ params: {} })).to.equal(true); + expect(loadExternalScriptStub.called).to.equal(false); + }); + + it("loads runtime script only once when loadRuntimeScript is called repeatedly", async function () { + __testing__.setModuleParams({ + runtimeScriptUrl: "https://cdn.example.com/mile-runtime.js", + }); + + await Promise.all([loadRuntimeScript(), loadRuntimeScript()]); + expect(loadExternalScriptStub.calledOnce).to.equal(true); + expect(loadExternalScriptStub.firstCall.args[0]).to.equal( + "https://cdn.example.com/mile-runtime.js", + ); + }); + + it("init loads runtime script when runtimeScriptUrl is provided", function () { + init({ + params: { + runtimeScriptUrl: "https://cdn.example.com/mile-runtime.js", + }, + }); + + expect(loadExternalScriptStub.calledOnce).to.equal(true); + expect(loadExternalScriptStub.firstCall.args[0]).to.equal( + "https://cdn.example.com/mile-runtime.js", + ); + }); + }); + + describe("getTargetingFromRuntime", function () { + it("resolves null when runtime global is missing", async function () { + const result = await getTargetingFromRuntime({ adUnitCodes: ["ad-1"] }); + expect(result).to.equal(null); + }); + + it("uses default runtime global when available", async function () { + const runtimeResult = { "div-gpt-ad-1": "segment_a" }; + const runtimeStub = sandbox.stub().returns(runtimeResult); + window.mileRtdRuntime = { + getMileTargetingByAdUnit: runtimeStub, + }; + + const snapshot = { adUnitCodes: ["ad-1"] }; + const context = { mode: "auctionInit" }; + const result = await getTargetingFromRuntime(snapshot, context); + + expect(result).to.deep.equal(runtimeResult); + expect(runtimeStub.calledOnceWith(snapshot, context)).to.equal(true); + }); + + it("uses custom runtime global name when configured", async function () { + __testing__.setModuleParams({ runtimeGlobalName: "customRuntimeGlobal" }); + const runtimeResult = { "div-gpt-ad-2": "segment_b" }; + window.customRuntimeGlobal = { + getMileTargetingByAdUnit: sandbox.stub().returns(runtimeResult), + }; + + const result = await getTargetingFromRuntime({ adUnitCodes: ["ad-2"] }); + expect(result).to.deep.equal(runtimeResult); + }); + + it("resolves null when runtime function throws", async function () { + window.mileRtdRuntime = { + getMileTargetingByAdUnit: sandbox + .stub() + .throws(new Error("runtime failed")), + }; + + const result = await getTargetingFromRuntime({ adUnitCodes: ["ad-1"] }); + expect(result).to.equal(null); + }); + }); + + describe("setAdUnitTargeting", function () { + it("returns false when ad units are unavailable", function () { + const result = setAdUnitTargeting({ "div-gpt-ad-1": "value-1" }, []); + expect(result).to.equal(false); + }); + + it("merges mile_rtd targeting using code, gpid, and pbadslot", function () { + const adUnitWithCode = createMockAdUnit({ + code: "div-gpt-ad-1", + gpid: "/123/home/top", + adserverTargeting: { existing: "targeting" }, + }); + const adUnitWithGpid = createMockAdUnit({ + code: "unknown-slot", + gpid: "/123/home/path-only", + }); + const adUnitWithPbadslot = createMockAdUnit({ + code: "unknown-2", + pbadslot: "/123/home/pbadslot-only", + }); + const adUnitWithoutMatch = createMockAdUnit({ + code: "unknown-3", + gpid: "/123/home/no-match", + }); + + const result = setAdUnitTargeting( + { + "div-gpt-ad-1": "segment_a", + "/123/home/path-only": "segment_b", + "/123/home/pbadslot-only": "segment_c", + }, + [ + adUnitWithCode, + adUnitWithGpid, + adUnitWithPbadslot, + adUnitWithoutMatch, + ], + ); + + expect(result).to.equal(true); + expect(adUnitWithCode.adserverTargeting).to.deep.equal({ + existing: "targeting", + mile_rtd: "segment_a", + }); + expect(adUnitWithGpid.adserverTargeting).to.deep.equal({ + mile_rtd: "segment_b", + }); + expect(adUnitWithPbadslot.adserverTargeting).to.deep.equal({ + mile_rtd: "segment_c", + }); + expect(adUnitWithoutMatch.adserverTargeting).to.equal(undefined); + }); + }); + + describe("onAuctionInitEvent", function () { + it("applies runtime targeting even when flooring is not enforced", async function () { + const runtimeStub = sandbox + .stub() + .returns({ "div-gpt-ad-1": "segment_a" }); + window.mileRtdRuntime = { getMileTargetingByAdUnit: runtimeStub }; + + onAuctionInitEvent({ + adUnitCodes: ["ad-1"], + bidderRequests: [ + { bids: [{ floorData: { enforcements: { enforceJS: false } } }] }, + ], + }); + await flushPromises(); + + expect(runtimeStub.called).to.equal(true); + }); + + it("extracts snapshot and applies targeting when flooring is enforced", async function () { + const runtimeStub = sandbox + .stub() + .returns({ "div-gpt-ad-1": "segment_a" }); + const adUnit = createMockAdUnit({ + code: "div-gpt-ad-1", + }); + window.mileRtdRuntime = { getMileTargetingByAdUnit: runtimeStub }; + window.mileRtdRuntimeUtils = { + extractAuctionSnapshot: sandbox + .stub() + .returns({ adUnitCodes: ["ad-1"] }), + }; + onAuctionInitEvent({ + adUnitCodes: ["ad-1"], + adUnits: [adUnit], + bidderRequests: [ + { bids: [{ floorData: { enforcements: { enforceJS: true } } }] }, + ], + }); + await flushPromises(); + + expect( + window.mileRtdRuntimeUtils.extractAuctionSnapshot.calledOnce, + ).to.equal(true); + expect(runtimeStub.calledOnce).to.equal(true); + expect(runtimeStub.firstCall.args[1]).to.deep.equal({ + mode: "auctionInit", + }); + expect(adUnit.adserverTargeting).to.deep.equal({ + mile_rtd: "segment_a", + }); + }); + }); + + describe("onBidResponseEvent", function () { + it("does nothing when bidResponse lacks adUnitCode", async function () { + const runtimeStub = sandbox.stub().returns({}); + window.mileRtdRuntime = { getMileTargetingByAdUnit: runtimeStub }; + + onBidResponseEvent( + {}, + {}, + {}, + { + adUnitCodes: ["ad-1"], + bidderRequests: [ + { bids: [{ floorData: { enforcements: { enforceJS: true } } }] }, + ], + }, + ); + await flushPromises(); + + expect(runtimeStub.called).to.equal(false); + }); + + it("applies runtime targeting even when flooring is not enforced", async function () { + const runtimeStub = sandbox.stub().returns({}); + window.mileRtdRuntime = { getMileTargetingByAdUnit: runtimeStub }; + + onBidResponseEvent( + { adUnitCode: "ad-1" }, + {}, + {}, + { + adUnitCodes: ["ad-1"], + bidderRequests: [ + { bids: [{ floorData: { enforcements: { enforceJS: false } } }] }, + ], + }, + ); + await flushPromises(); + + expect(runtimeStub.called).to.equal(true); + }); + + it("adds adUnitCode to snapshot and calls runtime with bidResponse context", async function () { + const runtimeStub = sandbox.stub().returns({}); + window.mileRtdRuntime = { getMileTargetingByAdUnit: runtimeStub }; + window.mileRtdRuntimeUtils = { + extractAuctionSnapshot: sandbox + .stub() + .returns({ adUnitCodes: ["ad-1"] }), + }; + + const bidResponse = { adUnitCode: "ad-2", requestId: "request-1" }; + onBidResponseEvent( + bidResponse, + {}, + {}, + { + adUnitCodes: ["ad-1"], + bidderRequests: [ + { bids: [{ floorData: { enforcements: { enforceJS: true } } }] }, + ], + }, + ); + await flushPromises(); + + expect(runtimeStub.calledOnce).to.equal(true); + const [snapshot, context] = runtimeStub.firstCall.args; + expect(snapshot.adUnitCodes).to.deep.equal(["ad-1", "ad-2"]); + expect(context.mode).to.equal("bidResponse"); + expect(context.bidResponse).to.equal(bidResponse); + }); + }); +});