Skip to content

Commit 97ff651

Browse files
Price Floors: Handle USER IDs (prebid#13732)
* Add support for user ID tiers in price floors module - Implement `resolveTierUserIds` function to check for user ID tier matches in bid requests. - Enhance floor selection logic to consider user ID tiers when determining the applicable floor. - Introduce tests for user ID tier functionality to ensure correct behavior. * Add validation for user ID tier fields and track valid fields in a global set * Refactor user ID tier field validation logic for improved clarity and performance
1 parent cd6ea87 commit 97ff651

File tree

2 files changed

+279
-3
lines changed

2 files changed

+279
-3
lines changed

modules/priceFloors.ts

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ const SYN_FIELD = Symbol();
5959
export const allowedFields = [SYN_FIELD, 'gptSlot', 'adUnitCode', 'size', 'domain', 'mediaType'] as const;
6060
type DefaultField = { [K in (typeof allowedFields)[number]]: K extends string ? K : never}[(typeof allowedFields)[number]];
6161

62+
/**
63+
* @summary Global set to track valid userId tier fields
64+
*/
65+
const validUserIdTierFields = new Set<string>();
66+
67+
/**
68+
* @summary Checks if a field is a valid user ID tier field (userId.tierName)
69+
* A field is only considered valid if it appears in the validUserIdTierFields set,
70+
* which is populated during config validation based on explicitly configured userIds.
71+
* Fields will be rejected if they're not in the configured set, even if they follow the userId.tierName format.
72+
*/
73+
function isUserIdTierField(field: string): boolean {
74+
if (typeof field !== 'string') return false;
75+
76+
// Simply check if the field exists in our configured userId tier fields set
77+
return validUserIdTierFields.has(field);
78+
}
79+
6280
/**
6381
* @summary This is a flag to indicate if a AJAX call is processing for a floors request
6482
*/
@@ -103,7 +121,33 @@ const getHostname = (() => {
103121
}
104122
})();
105123

106-
// First look into bidRequest!
124+
/**
125+
* @summary Check if a bidRequest contains any user IDs from the specified tiers
126+
* Returns an object with keys like 'userId.tierName' with boolean values (0/1)
127+
*/
128+
export function resolveTierUserIds(tiers, bidRequest) {
129+
if (!tiers || !bidRequest?.userIdAsEid?.length) {
130+
return {};
131+
}
132+
133+
// Get all available EID sources from the bidRequest (single pass)
134+
const availableSources = bidRequest.userIdAsEid.reduce((acc: Set<string>, eid: { source?: string }) => {
135+
if (eid?.source) {
136+
acc.add(eid.source);
137+
}
138+
return acc;
139+
}, new Set());
140+
141+
// For each tier, check if any of its sources are available
142+
return Object.entries(tiers).reduce((result, [tierName, sources]) => {
143+
const hasAnyIdFromTier = Array.isArray(sources) &&
144+
sources.some(source => availableSources.has(source));
145+
146+
result[`userId.${tierName}`] = hasAnyIdFromTier ? 1 : 0;
147+
return result;
148+
}, {});
149+
}
150+
107151
function getGptSlotFromAdUnit(adUnitId, {index = auctionManager.index} = {}) {
108152
const adUnit = index.getAdUnit({adUnitId});
109153
const isGam = deepAccess(adUnit, 'ortb2Imp.ext.data.adserver.name') === 'gam';
@@ -133,9 +177,25 @@ export const fieldMatchingFunctions = {
133177
*/
134178
function enumeratePossibleFieldValues(floorFields, bidObject, responseObject) {
135179
if (!floorFields.length) return [];
180+
181+
// Get userId tier values if needed
182+
let userIdTierValues = {};
183+
const userIdFields = floorFields.filter(isUserIdTierField);
184+
if (userIdFields.length > 0 && _floorsConfig.userIds) {
185+
userIdTierValues = resolveTierUserIds(_floorsConfig.userIds, bidObject);
186+
}
187+
136188
// generate combination of all exact matches and catch all for each field type
137189
return floorFields.reduce((accum, field) => {
138-
const exactMatch = fieldMatchingFunctions[field](bidObject, responseObject) || '*';
190+
let exactMatch: string;
191+
// Handle userId tier fields
192+
if (isUserIdTierField(field)) {
193+
exactMatch = String(userIdTierValues[field] ?? '*');
194+
} else {
195+
// Standard fields use the field matching functions
196+
exactMatch = fieldMatchingFunctions[field](bidObject, responseObject) || '*';
197+
}
198+
139199
// storing exact matches as lowerCase since we want to compare case insensitively
140200
accum.push(exactMatch === '*' ? ['*'] : [exactMatch.toLowerCase(), '*']);
141201
return accum;
@@ -481,7 +541,7 @@ export function continueAuction(hookConfig) {
481541

482542
function validateSchemaFields(fields) {
483543
if (Array.isArray(fields) && fields.length > 0) {
484-
if (fields.every(field => allowedFields.includes(field))) {
544+
if (fields.every(field => allowedFields.includes(field) || isUserIdTierField(field))) {
485545
return true;
486546
} else {
487547
logError(`${MODULE_NAME}: Fields received do not match allowed fields`);
@@ -768,6 +828,13 @@ export type FloorsConfig = Pick<Schema1FloorData, 'skipRate' | 'floorProvider'>
768828
* The Price Floors Module will take the greater of floorMin and the matched rule CPM when evaluating getFloor() and enforcing floors.
769829
*/
770830
floorMin?: number;
831+
/**
832+
* Configuration for user ID tiers. Each tier is an array of EID sources
833+
* that will be matched against available EIDs in the bid request.
834+
*/
835+
userIds?: {
836+
[tierName: string]: string[];
837+
};
771838
enforcement?: Pick<Schema2FloorData['modelGroups'][0], 'noFloorSignalBidders'> & {
772839
/**
773840
* If set to true (the default), the Price Floors Module will provide floors to bid adapters for bid request
@@ -830,6 +897,7 @@ export function handleSetFloorsConfig(config) {
830897
'floorProvider', floorProvider => deepAccess(config, 'data.floorProvider', floorProvider),
831898
'endpoint', endpoint => endpoint || {},
832899
'skipRate', () => !isNaN(deepAccess(config, 'data.skipRate')) ? config.data.skipRate : config.skipRate || 0,
900+
'userIds', validateUserIdsConfig,
833901
'enforcement', enforcement => pick(enforcement || {}, [
834902
'enforceJS', enforceJS => enforceJS !== false, // defaults to true
835903
'enforcePBS', enforcePBS => enforcePBS === true, // defaults to false
@@ -1085,3 +1153,31 @@ registerOrtbProcessor({type: IMP, name: 'bidfloor', fn: setOrtbImpBidFloor});
10851153
registerOrtbProcessor({type: IMP, name: 'extBidfloor', fn: setGranularBidfloors, priority: -10})
10861154
registerOrtbProcessor({type: IMP, name: 'extPrebidFloors', fn: setImpExtPrebidFloors, dialects: [PBS], priority: -1});
10871155
registerOrtbProcessor({type: REQUEST, name: 'extPrebidFloors', fn: setOrtbExtPrebidFloors, dialects: [PBS]});
1156+
1157+
/**
1158+
* Validate userIds config: must be an object with array values
1159+
* Also populates the validUserIdTierFields set with field names in the format "userId.tierName"
1160+
*/
1161+
function validateUserIdsConfig(userIds: Record<string, unknown>): Record<string, unknown> {
1162+
if (!userIds || typeof userIds !== 'object') return {};
1163+
1164+
// Clear the previous set of valid tier fields
1165+
validUserIdTierFields.clear();
1166+
1167+
// Check if userIds is an object with array values
1168+
const invalidKey = Object.entries(userIds).some(([tierName, value]) => {
1169+
if (!Array.isArray(value)) {
1170+
return true;
1171+
}
1172+
// Add the tier field to the validUserIdTierFields set
1173+
validUserIdTierFields.add(`userId.${tierName}`);
1174+
return false;
1175+
});
1176+
1177+
if (invalidKey) {
1178+
validUserIdTierFields.clear();
1179+
return {};
1180+
}
1181+
1182+
return userIds;
1183+
}

test/spec/modules/priceFloors_spec.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isFloorsDataValid,
1414
addBidResponseHook,
1515
fieldMatchingFunctions,
16+
resolveTierUserIds,
1617
allowedFields, parseFloorData, normalizeDefault, getFloorDataFromAdUnits, updateAdUnitsForAuction, createFloorsDataForAuction
1718
} from 'modules/priceFloors.js';
1819
import * as events from 'src/events.js';
@@ -2520,3 +2521,182 @@ describe('setting null as rule value', () => {
25202521
expect(exposedAdUnits[0].bids[0].getFloor(inputParams)).to.deep.equal(null);
25212522
});
25222523
})
2524+
2525+
describe('Price Floors User ID Tiers', function() {
2526+
let sandbox;
2527+
let logErrorStub;
2528+
2529+
beforeEach(function() {
2530+
sandbox = sinon.createSandbox();
2531+
logErrorStub = sandbox.stub(utils, 'logError');
2532+
});
2533+
2534+
afterEach(function() {
2535+
sandbox.restore();
2536+
});
2537+
2538+
describe('resolveTierUserIds', function() {
2539+
it('returns empty object when no tiers provided', function() {
2540+
const bidRequest = {
2541+
userIdAsEid: [
2542+
{ source: 'liveintent.com', uids: [{ id: 'test123' }] },
2543+
{ source: 'sharedid.org', uids: [{ id: 'test456' }] }
2544+
]
2545+
};
2546+
const result = resolveTierUserIds(null, bidRequest);
2547+
expect(result).to.deep.equal({});
2548+
});
2549+
2550+
it('returns empty object when no userIdAsEid in bidRequest', function() {
2551+
const tiers = {
2552+
tierOne: ['liveintent.com', 'sharedid.org'],
2553+
tierTwo: ['pairid.com']
2554+
};
2555+
const result = resolveTierUserIds(tiers, { userIdAsEid: [] });
2556+
expect(result).to.deep.equal({});
2557+
});
2558+
2559+
it('correctly identifies tier matches for present EIDs', function() {
2560+
const tiers = {
2561+
tierOne: ['liveintent.com', 'sharedid.org'],
2562+
tierTwo: ['pairid.com']
2563+
};
2564+
2565+
const bidRequest = {
2566+
userIdAsEid: [
2567+
{ source: 'liveintent.com', uids: [{ id: 'test123' }] },
2568+
{ source: 'sharedid.org', uids: [{ id: 'test456' }] }
2569+
]
2570+
};
2571+
2572+
const result = resolveTierUserIds(tiers, bidRequest);
2573+
expect(result).to.deep.equal({
2574+
'userId.tierOne': 1,
2575+
'userId.tierTwo': 0
2576+
});
2577+
});
2578+
2579+
it('handles multiple tiers correctly', function() {
2580+
const tiers = {
2581+
tierOne: ['liveintent.com'],
2582+
tierTwo: ['pairid.com'],
2583+
tierThree: ['sharedid.org']
2584+
};
2585+
2586+
const bidRequest = {
2587+
userIdAsEid: [
2588+
{ source: 'sharedid.org', uids: [{ id: 'test456' }] }
2589+
]
2590+
};
2591+
2592+
const result = resolveTierUserIds(tiers, bidRequest);
2593+
expect(result).to.deep.equal({
2594+
'userId.tierOne': 0,
2595+
'userId.tierTwo': 0,
2596+
'userId.tierThree': 1
2597+
});
2598+
});
2599+
});
2600+
2601+
describe('Floor selection with user ID tiers', function() {
2602+
const mockFloorData = {
2603+
skipRate: 0,
2604+
enforcement: {},
2605+
data: {
2606+
currency: 'USD',
2607+
skipRate: 0,
2608+
schema: {
2609+
fields: ['mediaType', 'userId.tierOne', 'userId.tierTwo'],
2610+
delimiter: '|'
2611+
},
2612+
values: {
2613+
'banner|1|0': 1.0,
2614+
'banner|0|1': 0.5,
2615+
'banner|0|0': 0.1,
2616+
'banner|1|1': 2.0
2617+
}
2618+
}
2619+
};
2620+
2621+
const mockBidRequest = {
2622+
mediaType: 'banner',
2623+
userIdAsEid: [
2624+
{ source: 'liveintent.com', uids: [{ id: 'test123' }] }
2625+
]
2626+
};
2627+
2628+
beforeEach(function() {
2629+
// Set up floors config with userIds
2630+
handleSetFloorsConfig({
2631+
enabled: true,
2632+
userIds: {
2633+
tierOne: ['liveintent.com', 'sharedid.org'],
2634+
tierTwo: ['pairid.com']
2635+
}
2636+
});
2637+
});
2638+
2639+
it('selects correct floor based on userId tiers', function() {
2640+
// User has tierOne ID but not tierTwo
2641+
const result = getFirstMatchingFloor(
2642+
mockFloorData.data,
2643+
mockBidRequest,
2644+
{ mediaType: 'banner' }
2645+
);
2646+
2647+
expect(result.matchingFloor).to.equal(1.0);
2648+
});
2649+
2650+
it('selects correct floor when different userId tier is present', function() {
2651+
const bidRequest = {
2652+
...mockBidRequest,
2653+
userIdAsEid: [
2654+
{ source: 'pairid.com', uids: [{ id: 'test123' }] }
2655+
]
2656+
};
2657+
2658+
const result = getFirstMatchingFloor(
2659+
mockFloorData.data,
2660+
bidRequest,
2661+
{ mediaType: 'banner' }
2662+
);
2663+
2664+
expect(result.matchingFloor).to.equal(0.5);
2665+
});
2666+
2667+
it('selects correct floor when no userId tiers are present', function() {
2668+
const bidRequest = {
2669+
...mockBidRequest,
2670+
userIdAsEid: [
2671+
{ source: 'unknown.com', uids: [{ id: 'test123' }] }
2672+
]
2673+
};
2674+
2675+
const result = getFirstMatchingFloor(
2676+
mockFloorData.data,
2677+
bidRequest,
2678+
{ mediaType: 'banner' }
2679+
);
2680+
2681+
expect(result.matchingFloor).to.equal(0.1);
2682+
});
2683+
2684+
it('selects correct floor when both userId tiers are present', function() {
2685+
const bidRequest = {
2686+
...mockBidRequest,
2687+
userIdAsEid: [
2688+
{ source: 'liveintent.com', uids: [{ id: 'test123' }] },
2689+
{ source: 'pairid.com', uids: [{ id: 'test456' }] }
2690+
]
2691+
};
2692+
2693+
const result = getFirstMatchingFloor(
2694+
mockFloorData.data,
2695+
bidRequest,
2696+
{ mediaType: 'banner' }
2697+
);
2698+
2699+
expect(result.matchingFloor).to.equal(2.0);
2700+
});
2701+
});
2702+
});

0 commit comments

Comments
 (0)