Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

a/b testing framework baked in to the ID5 user id module #6076

Merged
Merged
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
47 changes: 39 additions & 8 deletions modules/id5IdSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,58 @@ export const id5IdSubmodule = {
* decode the stored id value for passing to bid requests
* @function decode
* @param {(Object|string)} value
* @param {SubmoduleConfig|undefined} config
* @returns {(Object|undefined)}
*/
decode(value) {
let uid;
decode(value, config) {
let universalUid;
let linkType = 0;

if (value && typeof value.universal_uid === 'string') {
uid = value.universal_uid;
universalUid = value.universal_uid;
linkType = value.link_type || linkType;
} else {
return undefined;
}

return {
'id5id': {
'uid': uid,
'ext': {
'linkType': linkType
// check for A/B testing configuration and hide ID if in Control Group
let abConfig = (config && config.params && config.params.abTesting) || { enabled: false };
let controlGroup = false;
if (
abConfig.enabled === true &&
(!utils.isNumber(abConfig.controlGroupPct) ||
abConfig.controlGroupPct < 0 ||
abConfig.controlGroupPct > 1)
) {
// A/B Testing is enabled, but configured improperly, so skip A/B testing
utils.logError('User ID - ID5 submodule: A/B Testing controlGroupPct must be a number >= 0 and <= 1! Skipping A/B Testing');
} else if (
abConfig.enabled === true &&
Math.random() < abConfig.controlGroupPct
) {
// A/B Testing is enabled and user is in the Control Group, so do not share the ID5 ID
utils.logInfo('User ID - ID5 submodule: A/B Testing Enabled - request is in the Control Group, so the ID5 ID is NOT exposed');
universalUid = linkType = 0;
controlGroup = true;
} else if (abConfig.enabled === true) {
// A/B Testing is enabled but user is not in the Control Group, so ID5 ID is shared
utils.logInfo('User ID - ID5 submodule: A/B Testing Enabled - request is NOT in the Control Group, so the ID5 ID is exposed');
}

let responseObj = {
id5id: {
uid: universalUid,
ext: {
linkType: linkType
}
}
};

if (abConfig.enabled === true) {
utils.deepSetValue(responseObj, 'id5id.ext.abTestingControlGroup', controlGroup);
}

return responseObj;
},

/**
Expand Down
21 changes: 17 additions & 4 deletions modules/id5IdSystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@ The following configuration parameters are available:
pbjs.setConfig({
userSync: {
userIds: [{
name: "id5Id",
name: 'id5Id',
params: {
partner: 173, // change to the Partner Number you received from ID5
pd: "MT1iNTBjY..." // optional, see table below for a link to how to generate this
pd: 'MT1iNTBjY...', // optional, see table below for a link to how to generate this
abTesting: { // optional
enabled: true, // false by default
controlGroupPct: 0.1 // valid values are 0.0 - 1.0 (inclusive)
}
},
storage: {
type: "html5", // "html5" is the required storage type
name: "id5id", // "id5id" is the required storage name
type: 'html5', // "html5" is the required storage type
name: 'id5id', // "id5id" is the required storage name
expires: 90, // storage lasts for 90 days
refreshInSeconds: 8*3600 // refresh ID every 8 hours to ensure it's fresh
}
Expand All @@ -46,10 +50,19 @@ pbjs.setConfig({
| params.partner | Required | Number | This is the ID5 Partner Number obtained from registering with ID5. | `173` |
| params.pd | Optional | String | Publisher-supplied data used for linking ID5 IDs across domains. See [our documentation](https://wiki.id5.io/x/BIAZ) for details on generating the string. Omit the parameter or leave as an empty string if no data to supply | `"MT1iNTBjY..."` |
| params.provider | Optional | String | An identifier provided by ID5 to technology partners who manage Prebid setups on behalf of publishers. Reach out to [ID5](mailto:prebid@id5.io) if you have questions about this parameter | `pubmatic-identity-hub` |
| params.abTesting | Optional | Object | Allows publishers to easily run an A/B Test. If enabled and the user is in the Control Group, the ID5 ID will NOT be exposed to bid adapters for that request | Disabled by default |
| params.abTesting.enabled | Optional | Boolean | Set this to `true` to turn on this feature | `true` or `false` |
| params.abTesting.controlGroupPct | Optional | Number | Must be a number between `0.0` and `1.0` (inclusive) and is used to determine the percentage of requests that fall into the control group (and thus not exposing the ID5 ID). For example, a value of `0.20` will result in 20% of requests without an ID5 ID and 80% with an ID. | `0.1` |
| storage | Required | Object | Storage settings for how the User ID module will cache the ID5 ID locally | |
| storage.type | Required | String | This is where the results of the user ID will be stored. ID5 **requires** `"html5"`. | `"html5"` |
| storage.name | Required | String | The name of the local storage where the user ID will be stored. ID5 **requires** `"id5id"`. | `"id5id"` |
| storage.expires | Optional | Integer | How long (in days) the user ID information will be stored. ID5 recommends `90`. | `90` |
| storage.refreshInSeconds | Optional | Integer | How many seconds until the ID5 ID will be refreshed. ID5 strongly recommends 8 hours between refreshes | `8*3600` |

**ATTENTION:** As of Prebid.js v4.14.0, ID5 requires `storage.type` to be `"html5"` and `storage.name` to be `"id5id"`. Using other values will display a warning today, but in an upcoming release, it will prevent the ID5 module from loading. This change is to ensure the ID5 module in Prebid.js interoperates properly with the [ID5 API](https://github.com/id5io/id5-api.js) and to reduce the size of publishers' first-party cookies that are sent to their web servers. If you have any questions, please reach out to us at [prebid@id5.io](mailto:prebid@id5.io).

### A Note on A/B Testing

Publishers may want to test the value of the ID5 ID with their downstream partners. While there are various ways to do this, A/B testing is a standard approach. Instead of publishers manually enabling or disabling the ID5 User ID Module based on their control group settings (which leads to fewer calls to ID5, reducing our ability to recognize the user), we have baked this in to our module directly.

To turn on A/B Testing, simply edit the configuration (see above table) to enable it and set what percentage of requests you would like to set for the control group. The control group is the set of requests where an ID5 ID will not be exposed in to bid adapters or in the various user id functions available on the `pbjs` global. An additional value of `ext.abTestingControlGroup` will be set to `true` or `false` that can be used to inform reporting systems that the request was in the control group or not. It's important to note that the control group is request based, and not user based. In other words, from one page view to another, a user may be in or out of the control group.
3 changes: 2 additions & 1 deletion modules/userId/eids.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ userIdAsEids = [
atype: 1
},
ext: {
linkType: 2
linkType: 2,
abTestingControlGroup: false
}]
},

Expand Down
157 changes: 151 additions & 6 deletions test/spec/modules/id5IdSystem_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,19 @@ describe('ID5 ID System', function() {
const ID5_NB_STORAGE_NAME = nbCacheName(ID5_TEST_PARTNER_ID);
const ID5_STORED_ID = 'storedid5id';
const ID5_STORED_SIGNATURE = '123456';
const ID5_STORED_LINK_TYPE = 1;
const ID5_STORED_OBJ = {
'universal_uid': ID5_STORED_ID,
'signature': ID5_STORED_SIGNATURE
'signature': ID5_STORED_SIGNATURE,
'link_type': ID5_STORED_LINK_TYPE
};
const ID5_RESPONSE_ID = 'newid5id';
const ID5_RESPONSE_SIGNATURE = 'abcdef';
const ID5_RESPONSE_LINK_TYPE = 2;
const ID5_JSON_RESPONSE = {
'universal_uid': ID5_RESPONSE_ID,
'signature': ID5_RESPONSE_SIGNATURE,
'link_type': 0
'link_type': ID5_RESPONSE_LINK_TYPE
};

function getId5FetchConfig(storageName = ID5_STORAGE_NAME, storageType = 'html5') {
Expand Down Expand Up @@ -268,7 +271,7 @@ describe('ID5 ID System', function() {
source: ID5_SOURCE,
uids: [{ id: ID5_STORED_ID, atype: 1 }],
ext: {
linkType: 0
linkType: ID5_STORED_LINK_TYPE
}
});
});
Expand Down Expand Up @@ -360,13 +363,155 @@ describe('ID5 ID System', function() {
});

describe('Decode stored object', function() {
const expectedDecodedObject = { id5id: { uid: ID5_STORED_ID, ext: { linkType: 0 } } };
const expectedDecodedObject = { id5id: { uid: ID5_STORED_ID, ext: { linkType: ID5_STORED_LINK_TYPE } } };

it('should properly decode from a stored object', function() {
expect(id5IdSubmodule.decode(ID5_STORED_OBJ)).to.deep.equal(expectedDecodedObject);
expect(id5IdSubmodule.decode(ID5_STORED_OBJ, getId5FetchConfig())).to.deep.equal(expectedDecodedObject);
});
it('should return undefined if passed a string', function() {
expect(id5IdSubmodule.decode('somestring')).to.eq(undefined);
expect(id5IdSubmodule.decode('somestring', getId5FetchConfig())).to.eq(undefined);
});
});

describe('A/B Testing', function() {
const expectedDecodedObjectWithIdAbOff = { id5id: { uid: ID5_STORED_ID, ext: { linkType: ID5_STORED_LINK_TYPE } } };
const expectedDecodedObjectWithIdAbOn = { id5id: { uid: ID5_STORED_ID, ext: { linkType: ID5_STORED_LINK_TYPE, abTestingControlGroup: false } } };
const expectedDecodedObjectWithoutIdAbOn = { id5id: { uid: 0, ext: { linkType: 0, abTestingControlGroup: true } } };
let testConfig;

beforeEach(function() {
testConfig = getId5FetchConfig();
});

describe('Configuration Validation', function() {
let logErrorSpy;
let logInfoSpy;

beforeEach(function() {
logErrorSpy = sinon.spy(utils, 'logError');
logInfoSpy = sinon.spy(utils, 'logInfo');
});
afterEach(function() {
logErrorSpy.restore();
logInfoSpy.restore();
});

// A/B Testing ON, but invalid config
let testInvalidAbTestingConfigsWithError = [
{ enabled: true },
{ enabled: true, controlGroupPct: 2 },
{ enabled: true, controlGroupPct: -1 },
{ enabled: true, controlGroupPct: 'a' },
{ enabled: true, controlGroupPct: true }
];
testInvalidAbTestingConfigsWithError.forEach((testAbTestingConfig) => {
it('should error if config is invalid, and always return an ID', function () {
testConfig.params.abTesting = testAbTestingConfig;
let decoded = id5IdSubmodule.decode(ID5_STORED_OBJ, testConfig);
expect(decoded).to.deep.equal(expectedDecodedObjectWithIdAbOn);
sinon.assert.calledOnce(logErrorSpy);
});
});

// A/B Testing OFF, with invalid config (ignore)
let testInvalidAbTestingConfigsWithoutError = [
{ enabled: false, controlGroupPct: -1 },
{ enabled: false, controlGroupPct: 2 },
{ enabled: false, controlGroupPct: 'a' },
{ enabled: false, controlGroupPct: true }
];
testInvalidAbTestingConfigsWithoutError.forEach((testAbTestingConfig) => {
it('should not error if config is invalid but A/B testing is off, and always return an ID', function () {
testConfig.params.abTesting = testAbTestingConfig;
let decoded = id5IdSubmodule.decode(ID5_STORED_OBJ, testConfig);
expect(decoded).to.deep.equal(expectedDecodedObjectWithIdAbOff);
sinon.assert.notCalled(logErrorSpy);
sinon.assert.notCalled(logInfoSpy);
});
});

// A/B Testing ON, with valid config
let testValidConfigs = [
{ enabled: true, controlGroupPct: 0 },
{ enabled: true, controlGroupPct: 0.5 },
{ enabled: true, controlGroupPct: 1 }
];
testValidConfigs.forEach((testAbTestingConfig) => {
it('should not error if config is valid', function () {
testConfig.params.abTesting = testAbTestingConfig;
id5IdSubmodule.decode(ID5_STORED_OBJ, testConfig);
sinon.assert.notCalled(logErrorSpy);
sinon.assert.calledOnce(logInfoSpy);
});
});
});

describe('A/B Testing Config is not Set', function() {
let randStub;

beforeEach(function() {
randStub = sinon.stub(Math, 'random').callsFake(function() {
return 0;
});
});
afterEach(function () {
randStub.restore();
});

it('should expose ID when A/B config is not set', function () {
let decoded = id5IdSubmodule.decode(ID5_STORED_OBJ, testConfig);
expect(decoded).to.deep.equal(expectedDecodedObjectWithIdAbOff);
});

it('should expose ID when A/B config is empty', function () {
testConfig.params.abTesting = { };

let decoded = id5IdSubmodule.decode(ID5_STORED_OBJ, testConfig);
expect(decoded).to.deep.equal(expectedDecodedObjectWithIdAbOff);
});
});

describe('A/B Testing Config is Set', function() {
let randStub;

beforeEach(function() {
randStub = sinon.stub(Math, 'random').callsFake(function() {
return 0.25;
});
});
afterEach(function () {
randStub.restore();
});

it('should expose ID when A/B testing is off', function () {
testConfig.params.abTesting = {
enabled: false,
controlGroupPct: 0.5
};

let decoded = id5IdSubmodule.decode(ID5_STORED_OBJ, testConfig);
expect(decoded).to.deep.equal(expectedDecodedObjectWithIdAbOff);
});

it('should expose ID when not in control group', function () {
testConfig.params.abTesting = {
enabled: true,
controlGroupPct: 0.1
};

let decoded = id5IdSubmodule.decode(ID5_STORED_OBJ, testConfig);
expect(decoded).to.deep.equal(expectedDecodedObjectWithIdAbOn);
});

it('should not expose ID when in control group', function () {
testConfig.params.abTesting = {
enabled: true,
controlGroupPct: 0.5
};

let decoded = id5IdSubmodule.decode(ID5_STORED_OBJ, testConfig);
expect(decoded).to.deep.equal(expectedDecodedObjectWithoutIdAbOn);
});
});
});
});