Abstracting the Optimizely SDK — Part 1: Simplifying the Interface

In this two-part series we’ll explore the evolution of implementing the Optimizely SDK within our Java applications and the abstractions we’ve built along the way.

The Data Platform team at Optimizely owns the processing pipelines for ingestion and aggregation of the billions of events received from our customer’s experiment and personalization clickstream. The systems we build and operate run almost exclusively within the JVM and our in house applications are primarily Java.

In our query processing stages of the stack, we continuously utilize the Optimizely Java SDK to measure and validate changes to our schema, topology and processing engines. Inserting experimentation into our development process has become paramount to delivering with confidence and increasing velocity.

While the Optimizely SDK provides all the APIs required to experiment with various features, we quickly found ourselves building high-level abstractions adapting the SDK to a strongly-typed environment and minimizing the amount of code need to install/uninstall an experiment. In this article we’ll describe two major hurdles, handling user state in a multithreaded application and making the Optimizely SDK available anywhere within the code base.

While these examples are specific to Java and JVM based application the concepts can be applied to any platform.

Handling State

The native Optimizely APIs are stateless and require the calling application to supply a full context of the user and their attributes so that the attribution and targeting logic can be appropriately applied. Just about every call to the SDK accepts some user identifier (user id) and optionally a set of attributes.

The user id is used for deterministic bucketing based on a consistent hashing scheme within the SDK, but is also required for our processing pipeline to satisfy experience attribution and uniqueness for results reporting. The attribute arguments, modeled as a Map<String, String> in Java is used for targeting experiences within the SDK and for segmentation on the reporting side.

Maintaining state throughout the lifecycle of an HTTP request can become complex since we need the same state to be available at the top of the call stack as the bottom of the call stack. This becomes even more problematic when the originating request additionally spawns async worker threads where subsequent calls to the SDK might be made.

This problem sounds familiar and is handled in a variety of ways across frameworks, typically as some form of a request object that can be enriched. Luckily, we already had a mechanism for perpetuating this degree of request state through our use of Mapped Diagnostic Context (MDC) that we’ve adopted previously for comprehensive logging and aggregation.

In short, MDC is a thread local Map<String, String> that can be used for enriching your Java logs with metadata, such as request id or customer id. The benefit, especially in a highly concurrent environment like a web application, is that logs can be easily grouped together by these attributes to untangle all the interwoven log lines and reconstruct the logs of a single request.

By leveraging the existing MDC we implemented for logging and adding some experiment specific context we were able to solve our experimentation consistency issue with a basic SDK wrapper.

We first defined a simple interface for our application to consume the API:

public interface OptimizelyClient {
String OPTIMIZELY_END_USER_ID_KEY = "optimizelyEndUserId";
Variation activate(String experimentKey);
void track(String eventKey);
}

And our MDC based implementation looks like:

public class OptimizelyMDCClient implements OptimizelyClient {
private final Optimizely optimizely;
    public OptimizelyMDCClient(Optimizely optimizely) {
this.optimizely = optimizely;
}
    @Override
public Variation activate(String experimentKey) {
return optimizely.activate(experimentKey, getUserId(), getAttributes());
}
    @Override
public Variation track(String eventKey) {
return optimizely.track(eventKey, getUserId(), getAttributes());
}
    private String getUserId() {
return MDC.get(OptimizelyClient.OPTIMIZELY_END_USER_ID_KEY);
}
    private String getAttributes() {
return MDC.getCopyOfContextMap();
}
}

Now we just need to populate the MDC at the top of the request, and all calls to the Optimizely SDK will have a consistent userId and attribute set. And since we were already using MDC for logging we were already copying the context into each spawned thread as well, so there were no gaps in the state across threads.

Optimizely Singleton

This served us well for a while until we noticed another pattern that started to emerge as we began using the SDK deeper and deeper into the call stack. We had to keep punching holes into our constructors and builders to pass the OptimizelyClient from implementation to implementation.

To address this issue we again turned to logging as inspiration for a solution via a factory class. In true Java form, we construct a singleton and use a lazy holder pattern to instantiate Optimizely once the first time the factory is accessed.

public class OptimizelyClientFactory {
    public static OptimizelyClient getClient() {
return LazyHolder.INSTANCE;
}
    private static class LazyHolder {
static final OptimizelyClient INSTANCE = new OptimizelyClient(...);
}
}

With this in place we no longer have to explicitly declare Optimizely in our class constructors and where we want to leverage the SDK and now we can start experimenting anywhere with something like:

OptimizelyClient optimizelyClient = OptimizelyClientFactory.getClient();
Variation variation = optimizelyClient.activate("exp_1");
MyInterface myInterface;
switch (variation.getKey()) {
case "first Implementation":
myInterface = new FirstImplementation();
break;
case "secondImplementation":
myInterface = new SecondImplementation();
break;
default:
myInterface = new DefaultImplementation();
}
myInterface.run();
optimizelyClient.track("success");

In a follow-up post we’ll tackle the control flow patterns like the one above that experimentation tends to perpetuate and instead implement a lightweight annotation framework and provide instances of our classes directly from the OptimizelyClient.