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

Use attributes for sticky bucketing #179

Merged
merged 9 commits into from
Nov 8, 2018
Merged

Use attributes for sticky bucketing #179

merged 9 commits into from
Nov 8, 2018

Conversation

jordangarcia
Copy link
Contributor

@jordangarcia jordangarcia commented Oct 23, 2018

Use attributes for sticky bucketing

This PR allows customers to pass in $opt_experiment_bucket_map to attributes. This will take priority over userProfileService.lookup when making sticky bucketing decisions.

Example

// without userProfileService
const client = optimizelySDK.createInstance({
  datafile,
});

const attributes = {
  $opt_experiment_bucket_map: {
    "1": { // abtest experiment_id
      variation_id: "11" // abtest variation1 variation_id
    },
    "2": { // feature test experiment_id
      variation_id: "21" // feature test variation where feature is enabled
    }
  }
}

const variation = client.activate('abtest', 'user1', attributes)
// result will always be variation_id = "11"
const isEnabled = client.isFeatureEnabled('featuretest', 'user1', attributes)
// result will always be true

This allows sticky bucketing to be achieved without using the userProfileService and allows sticky bucketing to be done synchronously. However most implementations the lookup for attributes will be done synchronously and will still use userProfileService.save to persist sticky bucketing information.

Example

const fs = require('fs');
const datafile = require('./datafile.json')
const optimizely = require('./packages/optimizely-sdk/lib/index.node.js')

const EXPERIMENT_KEY = 'local_abtest'
const userProfileService ={
  lookup: function(userId) {
    return null
  },
  save: function(res) {
    let profiles = {}
    try {
      profiles = JSON.parse(fs.readFileSync('userProfiles.json'))
    } catch(e) {}

    let toWrite = {}
    if (profiles[res.user_id]) {
      // merge experiment_bucket_map -> dont override since there could be other sticky bucketing info
      toWrite = profiles[res.user_id]
      const existingBucketMap = toWrite.experiment_bucket_map
      toWrite.experiment_bucket_map = {
        ...existingBucketMap,
        ...res.experiment_bucket_map,
      }
    } else {
      toWrite = res;
    }

    console.log('writing', toWrite);
    profiles[res.user_id] = toWrite
    fs.writeFileSync('userProfiles.json', JSON.stringify(profiles, null, '  '), 'utf8')
  }
}
const client = optimizely.createInstance({
  datafile,
  userProfileService,
});


/**
 * Async example
 */
for (let i = 0; i < 10; i++) {
  // do some async lookup before activating optimizely
  fs.readFile('./userProfiles.json', (err, contents) => {
    if (err) {
      return;
    }

    const userId = `user-${i}`
    const profiles = JSON.parse(contents)
    const userProfile = profiles[userId] || {}
    const attributes = {
      $opt_experiment_bucket_map: userProfile.experiment_bucket_map
    }

    var res1 = client.isFeatureEnabled('f1', userId, attributes);
    var res2 = client.activate('local_abtest', userId, attributes);
    console.log(`userId=${userId} -> ${res1} | ${res2}\n`)
  })
}

@coveralls
Copy link

coveralls commented Oct 31, 2018

Coverage Status

Coverage increased (+0.06%) to 97.321% when pulling 1b8c5e8 on jordan/sticky into 8af7eb2 on master.

@jordangarcia jordangarcia changed the title WIP Fix userProfileService Use attributes for sticky bucketing Oct 31, 2018
@jordangarcia jordangarcia removed the wip label Oct 31, 2018
Copy link
Contributor

@mjc1283 mjc1283 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, only requesting changes for missing var and updating doc comments.

@@ -61,6 +63,7 @@ function DecisionService(options) {
* @return {string|null} the variation the user is bucketed into.
*/
DecisionService.prototype.getVariation = function(experimentKey, userId, attributes) {
attributes = attributes || {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move down inside __resolveExperimentBucketMap, or closer to where it's needed?

Leaving it here makes it harder to merge this into the 2.x branch because this change might affect audience evaluation in that branch. I think we want to release this as a 2.x minor version.

if (!this.userProfileService) {
return;
}

try {
userProfile.experiment_bucket_map[experiment.id] = {
variation_id: variation.id,
newBucketMap = fns.cloneDeep(experimentBucketMap);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need var here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, seems like lint should catch something like this and error hard.

assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"variation\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.');
});

it('should respect ignore attributes for a different experiment id', function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: delete 'respect'?

assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"control\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.');
});

it('should use attributes when the userProfileLookup returns null', function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the title of this test seems like it should be swapped with the title of the following one.

packages/optimizely-sdk/lib/core/decision_service/index.js Outdated Show resolved Hide resolved
@@ -236,20 +246,25 @@ DecisionService.prototype.__getUserProfile = function(userId) {
* @param {Object} experiment
* @param {Object} variation
*/
DecisionService.prototype.__saveUserProfile = function(userProfile, experiment, variation) {
DecisionService.prototype.__saveUserProfile = function(experiment, variation, userId, experimentBucketMap) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: another doc comment changed

Copy link
Contributor

@aliabbasrizvi aliabbasrizvi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikeng13 we should have a compat test for this only targeted at JS SDK

@@ -136,6 +136,7 @@ exports.CONTROL_ATTRIBUTES = {
BOT_FILTERING: '$opt_bot_filtering',
BUCKETING_ID: '$opt_bucketing_id',
USER_AGENT: '$opt_user_agent',
STICKY_BUCKETING_KEY: '$opt_experiment_bucket_map',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. Alphabetize.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is really a nit

Copy link
Contributor

@mikeproeng37 mikeproeng37 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty straightforward! Thanks for doing this.

* @return {Object} the stored variation or null if the user profile does not have one for the given experiment
*/
DecisionService.prototype.__getStoredVariation = function(experiment, userProfile) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description of the function is no longer accurate. Not sure it was very accurate in the first place either because it wasn't really "stored"

Copy link
Contributor

@tylerbrandt tylerbrandt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to make sure this extension to the API is actually what we want to do (ref: discussion in doc/slack)

@tylerbrandt tylerbrandt dismissed their stale review November 7, 2018 22:52

Discussed IRL. I still have reservations because of how it makes something complex (distributed syncing) appear simple, but still require the customer to do that legwork, but if this is what we need to do then we need to do it. My understanding is this isn't required in other SDKs since it's much easier to write blocking I/O.

@mikeproeng37 mikeproeng37 merged commit 42eb08e into master Nov 8, 2018
@mikeproeng37 mikeproeng37 deleted the jordan/sticky branch January 14, 2019 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants