Skip to content

Commit b91937c

Browse files
authored
Add ab_test_override config (#214)
1 parent a482104 commit b91937c

3 files changed

Lines changed: 200 additions & 4 deletions

File tree

lib/config.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import type { CMPApiConfig, Consent } from "./core/regs/consent";
33

44
type Experiment = "tokenize-v2" | "targeting-cascade";
55

6+
type MatcherOverride = {
7+
id: string;
8+
rank: number;
9+
};
10+
11+
type ABTestConfig = {
12+
id: string;
13+
trafficPercentage: number;
14+
matcher_override?: MatcherOverride[];
15+
};
16+
617
type InitConsent = {
718
// A "cmpapi" configuration indicating that consent should be gathered from CMP apis.
819
cmpapi?: CMPApiConfig;
@@ -39,6 +50,8 @@ type InitConfig = {
3950
initTargeting?: boolean;
4051
// (Defaults to 'optable_cache_targeting') Cache Key used to store 'targeting' response
4152
optableCacheTargeting?: string;
53+
// AB test configuration to define testing configurations
54+
abTests?: ABTestConfig[];
4255
};
4356

4457
type ResolvedConfig = {
@@ -56,6 +69,7 @@ type ResolvedConfig = {
5669
sessionID: string;
5770
skipEnrichment?: boolean;
5871
initTargeting?: boolean;
72+
abTests?: ABTestConfig[];
5973
};
6074

6175
const DCN_DEFAULTS = {
@@ -88,6 +102,7 @@ function getConfig(init: InitConfig): ResolvedConfig {
88102
sessionID: init.sessionID ?? generateSessionID(),
89103
skipEnrichment: init.skipEnrichment,
90104
initTargeting: init.initTargeting,
105+
abTests: init.abTests,
91106
};
92107

93108
if (init.consent?.static) {
@@ -110,5 +125,5 @@ function generateSessionID(): string {
110125
.replace(/=+$/g, "");
111126
}
112127

113-
export type { InitConsent, CMPApiConfig, InitConfig, ResolvedConfig };
128+
export type { InitConsent, CMPApiConfig, InitConfig, ResolvedConfig, ABTestConfig, MatcherOverride };
114129
export { getConfig, DCN_DEFAULTS, generateSessionID };

lib/edge/targeting.test.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,147 @@ describe("TargetingKeyValues", () => {
4444
});
4545
});
4646
});
47+
48+
describe("determineABTest", () => {
49+
// Mock Math.random to control the random bucket selection
50+
let originalMathRandom;
51+
52+
beforeEach(() => {
53+
originalMathRandom = Math.random;
54+
});
55+
56+
afterEach(() => {
57+
Math.random = originalMathRandom;
58+
});
59+
60+
test("returns null when no abTests provided", () => {
61+
const { determineABTest } = require("./targeting");
62+
expect(determineABTest()).toBeNull();
63+
expect(determineABTest(null)).toBeNull();
64+
expect(determineABTest([])).toBeNull();
65+
});
66+
67+
test("returns null when traffic percentage sum exceeds 100%", () => {
68+
const { determineABTest } = require("./targeting");
69+
const abTests = [
70+
{ id: "test1", trafficPercentage: 60 },
71+
{ id: "test2", trafficPercentage: 50 },
72+
];
73+
74+
// Mock console.error to capture the error message
75+
const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
76+
77+
expect(determineABTest(abTests)).toBeNull();
78+
expect(consoleSpy).toHaveBeenCalledWith("AB Test Config Error: Traffic Percentage Sum Exceeds 100%");
79+
80+
consoleSpy.mockRestore();
81+
});
82+
83+
test("returns correct test based on random bucket", () => {
84+
const { determineABTest } = require("./targeting");
85+
const abTests = [
86+
{ id: "test1", trafficPercentage: 30 },
87+
{ id: "test2", trafficPercentage: 40 },
88+
{ id: "test3", trafficPercentage: 20 },
89+
];
90+
91+
// Test bucket 0-29 (first test)
92+
Math.random = jest.fn().mockReturnValue(0.15); // bucket = 15
93+
expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 30 });
94+
95+
// Test bucket 30-69 (second test)
96+
Math.random = jest.fn().mockReturnValue(0.5); // bucket = 50
97+
expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 40 });
98+
99+
// Test bucket 70-89 (third test)
100+
Math.random = jest.fn().mockReturnValue(0.8); // bucket = 80
101+
expect(determineABTest(abTests)).toEqual({ id: "test3", trafficPercentage: 20 });
102+
103+
// Test bucket 90-99 (no test selected)
104+
Math.random = jest.fn().mockReturnValue(0.95); // bucket = 95
105+
expect(determineABTest(abTests)).toBeNull();
106+
});
107+
108+
test("handles single test configuration", () => {
109+
const { determineABTest } = require("./targeting");
110+
const abTests = [{ id: "single-test", trafficPercentage: 50 }];
111+
112+
// Test bucket 0-49 (test selected)
113+
Math.random = jest.fn().mockReturnValue(0.25); // bucket = 25
114+
expect(determineABTest(abTests)).toEqual({ id: "single-test", trafficPercentage: 50 });
115+
116+
// Test bucket 50-99 (no test selected)
117+
Math.random = jest.fn().mockReturnValue(0.75); // bucket = 75
118+
expect(determineABTest(abTests)).toBeNull();
119+
});
120+
121+
test("handles edge cases with 0% traffic", () => {
122+
const { determineABTest } = require("./targeting");
123+
const abTests = [
124+
{ id: "test1", trafficPercentage: 0 },
125+
{ id: "test2", trafficPercentage: 100 },
126+
];
127+
128+
// Any bucket should return test2 since test1 has 0% traffic
129+
Math.random = jest.fn().mockReturnValue(0.5); // bucket = 50
130+
expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 100 });
131+
});
132+
133+
test("handles tests with matcher_override", () => {
134+
const { determineABTest } = require("./targeting");
135+
const abTests = [
136+
{
137+
id: "test1",
138+
trafficPercentage: 50,
139+
matcher_override: [{ id: "override1", rank: 1 }],
140+
},
141+
{
142+
id: "test2",
143+
trafficPercentage: 50,
144+
matcher_override: [{ id: "override2", rank: 2 }],
145+
},
146+
];
147+
148+
Math.random = jest.fn().mockReturnValue(0.25); // bucket = 25
149+
expect(determineABTest(abTests)).toEqual({
150+
id: "test1",
151+
trafficPercentage: 50,
152+
matcher_override: [{ id: "override1", rank: 1 }],
153+
});
154+
});
155+
156+
test("handles boundary conditions", () => {
157+
const { determineABTest } = require("./targeting");
158+
const abTests = [
159+
{ id: "test1", trafficPercentage: 50 },
160+
{ id: "test2", trafficPercentage: 50 },
161+
];
162+
163+
// Test exact boundary at 50
164+
Math.random = jest.fn().mockReturnValue(0.5); // bucket = 50
165+
expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 50 });
166+
167+
// Test just below boundary
168+
Math.random = jest.fn().mockReturnValue(0.499); // bucket = 49
169+
expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 50 });
170+
});
171+
172+
test("handles floating point precision issues", () => {
173+
const { determineABTest } = require("./targeting");
174+
const abTests = [
175+
{ id: "test1", trafficPercentage: 33.33 },
176+
{ id: "test2", trafficPercentage: 33.33 },
177+
{ id: "test3", trafficPercentage: 33.34 },
178+
];
179+
180+
// Test cumulative calculation with floating point
181+
Math.random = jest.fn().mockReturnValue(0.333); // bucket = 33
182+
expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 33.33 });
183+
184+
Math.random = jest.fn().mockReturnValue(0.666); // bucket = 66
185+
expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 33.33 });
186+
187+
Math.random = jest.fn().mockReturnValue(0.999); // bucket = 99
188+
expect(determineABTest(abTests)).toEqual({ id: "test3", trafficPercentage: 33.34 });
189+
});
190+
});

lib/edge/targeting.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ResolvedConfig } from "../config";
1+
import type { ResolvedConfig, ABTestConfig, MatcherOverride } from "../config";
22
import { fetch } from "../core/network";
33
import { LocalStorage } from "../core/storage";
44
import * as ortb2 from "iab-openrtb/v26";
@@ -34,10 +34,47 @@ type TargetingResponse = {
3434
resolved_ids?: string[];
3535
};
3636

37+
// Determine which A/B test (if any) should be used for this request
38+
function determineABTest(abTests?: ABTestConfig[]): ABTestConfig | null {
39+
if (!abTests || abTests.length === 0) {
40+
return null;
41+
}
42+
43+
// Skip A/B testing if traffic percentage sum exceeds 100%
44+
const totalTrafficPercentage = abTests.reduce((sum, test) => sum + test.trafficPercentage, 0);
45+
if (totalTrafficPercentage > 100) {
46+
console.error(`AB Test Config Error: Traffic Percentage Sum Exceeds 100%`);
47+
return null;
48+
}
49+
50+
// Simple random number 0-99
51+
const bucket = Math.floor(Math.random() * 100);
52+
let cumulative = 0;
53+
for (const test of abTests) {
54+
cumulative += test.trafficPercentage;
55+
if (bucket < cumulative) {
56+
return test;
57+
}
58+
}
59+
return null;
60+
}
61+
3762
async function Targeting(config: ResolvedConfig, req: TargetingRequest): Promise<TargetingResponse> {
3863
const searchParams = new URLSearchParams();
3964
req.ids.forEach((id) => searchParams.append("id", id));
4065
req.hids.forEach((id) => searchParams.append("hid", id));
66+
const abTest = determineABTest(config.abTests);
67+
if (abTest) {
68+
searchParams.append("ab_test_id", abTest.id);
69+
if (abTest.matcher_override) {
70+
// Sort by rank to ensure proper order
71+
const sortedOverrides = [...abTest.matcher_override].sort((a, b) => a.rank - b.rank);
72+
sortedOverrides.forEach((override) => {
73+
searchParams.append("matcher_override", override.id);
74+
});
75+
}
76+
}
77+
4178
const path = "/v2/targeting?" + searchParams.toString();
4279

4380
const response: TargetingResponse = await fetch(path, config, {
@@ -116,6 +153,6 @@ function TargetingKeyValues(tdata: TargetingResponse | null): TargetingKeyValues
116153
return result;
117154
}
118155

119-
export { Targeting, TargetingFromCache, TargetingClearCache, PrebidORTB2, TargetingKeyValues };
156+
export { Targeting, TargetingFromCache, TargetingClearCache, PrebidORTB2, TargetingKeyValues, determineABTest };
120157
export default Targeting;
121-
export type { TargetingResponse, TargetingRequest };
158+
export type { TargetingResponse, TargetingRequest, ABTestConfig, MatcherOverride };

0 commit comments

Comments
 (0)