Guides
Submit FeedbackJoin Developer Community

Implement a user profile service

Use a User Profile Service to persist information about your users and ensure variation assignments are sticky. For example, if you are working on a backend website, you can create an implementation that reads and saves user profiles from a Redis or memcached store.

In the JavaScript SDK, there is no default implementation. Implementing a User Profile Service is optional and is only necessary if you want to keep variation assignments sticky even when experiment conditions are changed while it is running (for example, audiences, attributes, variation pausing, and traffic distribution). Otherwise, the JavaScript SDK is stateless and rely on deterministic bucketing to return consistent assignments.

If the User Profile Service doesn't bucket a user as you expect, then check whether other features are overriding the bucketing. For more information, see How bucketing works.

Implement a service

Refer to the code samples below to provide your own User Profile Service. It should expose two functions with the following signatures:

  • lookup: Takes a user ID string and returns a user profile matching the schema below.
  • save: Takes a user profile and persists it.

If you want to use the User Profile Service purely for tracking purposes and not sticky bucketing, you can implement only the save method (always return nil from lookup).

The interface of the User Profile Service looks like the following:

// Sample user profile service implementation
const userProfileService = {
  lookup: userId => {
    // Perform user profile lookup
  },
  save: userProfileMap => {
    // Persist user profile
  },
};

var optimizelyClient = optimizely.createInstance({
  datafile,
  userProfileService,
});

An example client-side implementation of a User Profile Service using localStorage looks like the following:

var userProfileService = {
  // Adapter that provides helpers to read and write from localStorage
  localStorageAdapter: {
    UPS_LS_KEY: 'optimizely-ups-data',
    read: function() {
      var UPSDataObject = JSON.parse(localStorage.getItem(this.UPS_LS_KEY) || '{}');
      return UPSDataObject;
    },
    write: function(data) {
      localStorage.setItem(this.UPS_LS_KEY, JSON.stringify(data));
    },
  },
  // Perform user profile lookup
  lookup: function(userId) {        
    return this.localStorageAdapter.read()[userId];
  },
  // Persist user profile
  save: function(userProfileMap) {
    var overwriteData = this.localStorageAdapter.read();
    overwriteData[userProfileMap.user_id] = userProfileMap;
    this.localStorageAdapter.write(overwriteData);
  },
};

// example usage
var optimizelyClientInstance = window.optimizelySdk.createInstance({
  datafile: optimizelyDatafile,
  userProfileService: userProfileService
});

The code example below shows the JSON schema of the user profile object. In the example below, ^[a-zA-Z0-9]+$ is the experiment ID.

{
  "title": "UserProfile",
  "type": "object",
  "properties": {
    "user_id": {
      "type": "string"
    },
    "experiment_bucket_map": {
      "type": "object",
      "patternProperties": {
        "^[a-zA-Z0-9]+$": {
          "type": "object",
          "properties": {
            "variation_id": {
              "type": "string"
            }
          },
          "required": ["variation_id"]
        }
      }
    }
  },
  "required": ["user_id", "experiment_bucket_map"]
}

The SDK uses the User Profile Service you provide to override Optimizely's default bucketing behavior in cases when an experiment assignment has been saved.

The experiment_bucket_map overrides the default bucketing behavior and defines an alternate experiment variation for a given user. Each key in the experiment_bucket_map object corresponds to an experiment override. The experiment ID is the key and the value is an object with a `variation_id property that specifies the desired variation. If there isn't an entry for an experiment, then the default bucketing behavior persists.

When implementing your own User Profile Service, we recommend loading the user profiles into the User Profile Service on initialization and avoiding performing expensive, blocking lookups on the lookup function to minimize the performance impact of incorporating the service.

When implementing in a multi-server or stateless environment, we suggest using this interface with a backend like Cassandra or Redis. You can decide how long you want to keep your sticky bucketing around by configuring these services.

Implement asynchronous user lookups with experiment bucket map attribute

In the JavaScript SDK version 3.0 or newer, you can implement attributes.$opt_experiment_bucket_map to perform asynchronous lookups of users' previous variations. The SDK handles attributes.$opt_experiment_bucket_map the same way it would userProfileService.lookup, and this allows you to do an asynchronous lookup of the experiment bucket map before passing it to the Activate method.

📘

Note

  • attributes.$opt_experiment_bucket_map will always take precedence over an implemented userProfileService.lookup.
  • Because the Javascript SDK is stateless, you must use these attributes anywhere that you call the Activate, Get Variation, or Track methods.

The example below shows how to implement consistent bucketing via attributes.

const userId = 'user1'
// This would come from a DB call
const experimentBucketMap = {
  '123': { // experimentId
    'variation_id': '456', // the variationId
  }
}
const attributes = {
  '$opt_experiment_bucket_map': experimentBucketMap
}
/* The user will always get bucketed into variationid='456' for experiment id='123 */
const result = client.activate("my-experiment", userId, attributes)

You can use the asynchronous service example below to try this functionality in a test environment. If you implement this example in a production environment, be sure to modify UserProfileDB to the correct database.

const optimizely = require('@optimizely/optimizely-sdk')

// This is here only as an example; in a production environment this would be redis or some distributed database
class UserProfileDB {
  constructor() {
    /* Example structure
     * {
     *   user1: {
     *     user_id: 'user1',
     *     experiment_bucket_map: {
     *       '12095834311': { // experimentId
     *         variation_id: '12117244349' // variationId
     *       }
     *     }
     *   }
     * }
     */
    this.db = {}
  }

  async save(user_id, experiment_bucket_map) {
    return new Promise((resolve, reject) => {
      // Use setTimeout to simulate async
      setTimeout(() => {
        this.db[user_id] = { user_id, experiment_bucket_map }
        resolve()
      }, 50)
    })
  }

  async lookup(userId) {
    return new Promise((resolve, reject) => {
      // Use setTimeout to simulate async
      setTimeout(() => {
        let result
        if (this.db[userId] && this.db[userId].experiment_bucket_map) {
          result = this.db[userId].experiment_bucket_map
        }
        resolve(result)
      }, 50)
    })
  }
}

const userDb = new UserProfileDB()

const userProfileService = {
  lookup(userId) {
    // Lookup must be synchronous, so in our case we will not implement this function
  },
  save(userProfileMap) {
    const { user_id, experiment_bucket_map } = userProfileMap
    userDb.save(user_id, experiment_bucket_map)
  }
}

const client = optimizely.createInstance({
  datafile,
  userProfileService,
})

const userId = 'user1'
// Lookup the users experiment_bucket_map
const experimentBucketMap = await userDb.lookup(userId) || {}
const attributes = {
  $opt_experiment_bucket_map: experimentBucketMap
}

const result = client.activate("exp1", userId, attributes)
console.log('got variation', result)

Did this page help you?