Release 1.4.0, September 2024
1 Overview
Variant server’s functionality can be extended through the use of the server-side extension service provider interface (SPI). It provides Java bindings for custom code to be directly executed by the server process. This custom code implements business logic which Variant server delegated to at pre-defined points in the experiments’ lifecycles. Two types of custom event handlers are supported:
- Lifecycle hooks are handlers for lifecycle events with respect to a user session. Two types of events are raised: when a session is about to be qualified for an experiment and when a session is about to be targeted for an experiment. A lifecycle hook provides a callback method which which is posted by Variant server whenever a pertinent lifecycle event is raised. A hook listens to a particular lifecycle event type by implementing the
post()
method of the appropriate interface. - Trace event flushers handle the egest of trace events into external storage, like a database or an event queue. Each experiment schema can have its own event flusher.
Both the hooks and the flushers are configured in the experiment schema. The next two chapters explain their semantics and configuration, and Chapter 4 provides details on how to write them.
2 Lifecycle Hooks
2.1 QualificationLifecycleHook
QualificationLifecycleHook
listens to QualificationLifecycleEvent
, which is raised by Variant when a user session must be (re)qualified for a experiment. The event posts all eligible hooks whose experiment matches that of triggering event. Used to (dis)qualify a user session for the triggering experiment. For example, a custom qualification hook may disqualify registered users from an experiment designed to boost user registration.
If no custom qualification hooks were provided in the schema, or if none of them returned a non-empty Optional
, Variant posts a built-in default qualification hook, which always returns a result. The default qualification hook qualifies all sessions, except if the triggering experiment is implicitly concurrent with this session’s already targeted live experiments. This default provides the safety guarantee that a session will not be targeted for two implicitly concurrent experiments independently. For more information, refer to the Variant Experiment Server User Guide.
2.2 TargetingLifecycleHook
TargetingLifecycleHook
listens to TargetingLifecycleEvent
, which is raised when a Variant session must be (re)targeted for a experiment. The event posts all eligible hooks whose state and experiment match those of the triggering event. For example, a custom targeting hook can provide an implementation of an advanced Bandit algorithm, minimizing traffic to losing experiences.
If no custom targeting hooks were provided in the schema, or if none of them returned a non-empty Optional
, Variant posts a built-in default targeting hook which targets randomly and uniformly between all the triggering experiment’s experiences defined on the the triggering state.
2.3 Schema Definition and Instantiation
A hook definition may appear in the experiment schema in one of three places. The placement of the hook definition in the schema determines its scope:
- Schema-scoped hooks are defined at the root level. These hooks are applicable to all states and all experiments in the schema.
- State-scoped hooks are defined with their state definitions. These hooks are applicable to the containing state only.
- Experiment-scoped hooks are defined with their experiment definitions. These hooks are applicable to the containing experiment only.
The schema definition of a hook in any scope has the following three components
Key | Type | Required | Comment | Default |
---|---|---|---|---|
class | any-string | Yes | The fully qualified name of the Java class implementing the hook. | |
name | name-string | No | This hook’s name. | The simple (unqualified) class name. |
init | fragment | No | Any YAML value. | None |
Example:
name: minimal_schema
...
hooks:
- class: mycompany.variant.RecaptchaQualificationHook # Schema-scoped hook
init: [USERID1 USERID2 USERID3]
...
states:
- name: signup_page
hooks:
- class: mycompany.variant.SignupTargetingHook # State-scoped hook
...
experiments:
- name: my_new_feature
hooks:
- class: mycompany.variant.MyNewFeatureQualificationHook # Experiment-scoped hook
The implementing class must be added to the server’s classpath to be discoverable. The way to accomplish that is to copy the compiled JAR file (with its dependencies) to the server’s /spi
directory at the server startup time.
2.4 Hook Instantiation and Chaining
By contract, an implementation must provide at least one of these constructors:
- If no
init
property is given in the hook definition, Variant server will first look for a nullary constructor. If absent, Variant server will look for a constructor that takes a single parameter of typeYamlNode<T>
and invoke it with thenull
value. - If an init property is given, Variant server will look for a constructor that takes a single parameter of type
YamlNode<T>
and pass it the parsed YAML value.
Variant server instantiates a single instance for each hook defined in the schema and reuses it for all eligible events in a highly-concurrent environment. The application programmer must ensure that
- The hooks’
post()
method is thread safe. - The hook class has no mutable state.
In any scope, any number of hooks can be defined. If more than one lifecycle hook is eligible to be posted by a lifecycle event at runtime, they form a hook chain. Hooks on the chain are posted one at a time in a strictly defined order: first the experiment-scoped hooks, then the state-scoped hooks, and lastly the schema-scoped hooks. Within each scope, hooks are posted in the ordinal order, i.e. the order in which they are defined in the schema.
The hooks are posted serially, until a hook’s post()
method returns a non-empty Optional
. If no custom hooks have been defined for a lifecycle event, or all returned an empty Optional
, the default built-in hook for the event is posted, which is guaranteed to return a non-empty result.
3 Trace Event Flushers
Trace events are generated by user sessions, as they navigate the host application’s state graph. Trace events can be triggered implicitly by Variant or explicitly by the host application. In either case, the host application can attach attributes to these events, to aid in the downstream analysis. Newly generated trace events are placed in in-memory trace event buffers, whose capacity can be configured with the variant.event.writer.buffer.size
config key. Periodically, Variant server flushes these events to external storage by passing them to an event flusher.
Only one flusher is supported per schema. It is configured at the root level with the following keys:
Key | Type | Required | Comment | Default |
---|---|---|---|---|
class | any-string | Yes | The fully qualified name of the Java class implementing the flusher. | |
name | name-string | No | This flusher’s name. | The simple (unqualified) class name. |
init | fragment | No | Any YAML value. | None |
Example:
name: example_schema
flusher:
class: com.variant.spi.stdlib.flush.TraceEventFlusherCsv
init:
header: true
file: /tmp/variant-events.csv
An implementation must implement the TraceEventFlusher
interface and provide at least one of these constructors:
- If no
init
property is given in the hook definition, Variant server will first look for a nullary constructor. If absent, Variant server will look for a constructor that takes a single parameter of typeYamlNode<T>
and invoke it with thenull
value. - If an init property is given, Variant server will look for a constructor that takes a single parameter of type
YamlNode<T>
and pass it the parsed YAML value.
Variant server instantiates a single instance of the flusher and reuses it for all trace events generated by the schema in a highly-concurrent environment. The application programmer must ensure that
- The
flush()
method is thread safe. - The flusher class has no mutable state.
Variant server maintains a single, asynchronous, multithreaded event writer, which serves all schemas. It is configured with the variant.event.writer.
* config keys. Event writer groups trace events by schema which produced them and turns them over to that schema’s event flusher by calling its flush()
method.
Each call to the flush()
method is scheduled to be executed asynchronously and concurrently with other similar calls by a dedicated thread pool, whose size is configured by the variant.event.writer.buffer.flush.pool.size
config property. Buffers are passed to the thread pool as soon as they fill up or the variant.event.writer.max.delay
number of seconds has elapsed since the time when the oldest event in the buffer was triggered.
If no event flusher is configured in the schema, the system wide default is used, as configured by the variant.event.flusher.*
properties.
A number of pre-built trace event flushers come with the server as part of the standard extension library. They are discussed in Section 5.
4 Developing for the Extension SPI
Download the extension SPI library from the Download page.
Create a new Java project in your IDE, add this JAR file to the classpath and you are set. You must package your project as a JAR. To add the packaged JAR file to Variant server’s runtime classpath, copy it (and its dependencies) into the server’s spi/
directory.
Alternatively, but with the same ultimate result, — you may clone the standard extension public repository into a local workspace, remove the source files, change the groupId, artifactId and the version to suit your environment,— and you have a working shell of a brand new Variant SPI development project.
Note, that a running Variant server loads all hook and flusher classes from the class path only once, when they are first encountered. Therefore, replacing classes in the server’s spi/
directory will not have any effect (and may even lead to unexpected behavior) until the server is restarted. Always restart your Variant server when you redeploy your custom SPI classes.
5 The Standard Extension Library
TBD