Rollouts
@evanion/feature 0.1.0 and its patch releases up to 0.1.1, the version on npm. Values on this page were produced by running the source at e53b2d8, a later commit than the 0.1.0 tag, and are not re-executed.
To release a feature to a fraction of your users, you use a rollout. A rule’s rollout holds a feature to a percentage of customers, and inRollout decides whether one customer is in. A customer in the 10% stays in it on every request, process and device.
Stable, Deterministic Bucketing
When rolling out features, you must ensure that a user’s experience does not flicker. @evanion/feature keeps a customer in a rollout from one page to the next, because a customer who loses the new checkout between two pages cannot finish an order and nobody can reproduce the bug they report. It computes a bucket from a seed and a targeting value, using the hash behind the inRollout call Getting Started asked about two customers.
inRollout('cust-0101', 10, 'new-checkout')Produced by @evanion/feature 0.1.0 at e53b2d8. Not re-executed. Run it live on the unreleased page.
The probe calls inRollout(id, 10, 'new-checkout') for any id you type, and the section below derives the bucket behind its answer.
How a Bucket is Derived
To determine membership, the library hashes the seed and the targetingKey (e.g., a customer ID) with MurmurHash3 into a bucket in [0, 1), and a customer is in a rollout when the bucket times 100 is below percent.
This approach provides these guarantees:
- Stability: The result is a pure function of the input. The same user always lands in the same bucket.
- Decorrelation: Because the
seeddefaults to the feature key, a user will land in different buckets for different features. A customer in the new checkout’s 1% is not, for that reason, in express pickup’s 1%. - Monotonicity: The bucket value is independent of the percentage threshold. If you raise a rollout from 10% to 25%, you only add new members to the cohort. No one who already had the feature will suddenly lose it. Lowering it removes the highest buckets first.
cust-0101 sits in the lowest 5% of the new checkout’s range, so it is in at 10% and still in at 25%:
import { , } from '@evanion/feature';
('cust-0101', 'new-checkout'); // -> 0.04385687271133065
('cust-0101', 25, 'new-checkout'); // -> true
('cust-0007', 25, 'new-checkout'); // -> falsecust-0007 below sits near the top of the new checkout’s range and near the bottom of express pickup’s:
import { } from '@evanion/feature';
('cust-0007', 'new-checkout'); // -> 0.912969104712829
('cust-0007', 'express-pickup'); // -> 0.007160091772675514Defining a Rollout
To implement a partial release in @evanion/feature, you add a rollout as one more condition of the rule that carries it. A rule that has both a when list and a rollout matches only if the customer satisfies the conditions AND falls within the bucket range.
import { } from '@evanion/feature';
const = ([
{
: 'gift-cards',
: true,
: [
{
: {
: 25,
: 'targetingKey',
: 'new-checkout',
},
},
],
},
]);
.('gift-cards', { : 'cust-0101' }); // -> true
.('gift-cards', { : 'cust-0007' }); // -> false| Field | Default | Meaning |
|---|---|---|
percent | (Required) | The threshold (0 to 100). |
by | 'targetingKey' | The field in the context used for bucketing. |
seed | The feature key | The salt used for the hash. |
A feature-level seed on the definition replaces the key for every rollout in that feature, and a rule’s rollout.seed replaces it for that rule alone. Set a seed only to correlate two features deliberately, for example to keep a pair of flags rolling out to the same cohort.
Edge Cases and Constraints
When configuring your rollout, be aware of how the bucketing logic handles specific data scenarios:
- Missing Targeting Keys: If the context does not carry the field specified in
by, the rollout is automatically rejected, even at 100%. So a context with no customer id never joins a rollout. - Data Types: Only
stringandnumberare usable as bucketing values. Objects, booleans, or nulls are treated as absent. - Exact Bounds: A
percentof0includes nobody;100includes every context that carries the bucketing field.
import { , } from '@evanion/feature';
const = ([
{
: 'new-checkout',
: true,
: [{ : 'everyone', : { : 100 } }],
},
]);
.('new-checkout', { : 'cust-0042' }); // -> true
.('new-checkout', {}); // -> false
('cust-0101', 0, 'new-checkout'); // -> falseWhy MurmurHash3?
To achieve a uniform distribution, @evanion/feature avoids simple modulus operations (e.g., hash % 100). This is problematic because it only uses the “low bits” of the hash, and a hash such as FNV-1a mixes those bits poorly, which skews the distribution.
Instead, the library uses MurmurHash3’s finalization to mix the entire 32-bit word and takes the bucket from all 32 bits, ensuring a uniform distribution over 2^32 buckets. It also length-prefixes the seed, so a seed and value pair such as (ab, c) can never hash the same as (a, bc), and flags whose keys share a prefix do not share buckets.
Exported Primitives
For snapshot testing or predictive analysis (e.g., “Which 1,000 customers will see this rollout?”), @evanion/feature exports the underlying primitives bucketOf, inRollout and murmur3:
import { , } from '@evanion/feature';
const = ['cust-0007', 'cust-0042', 'cust-0101', 'cust-0113'];
.(() => (, 10, 'new-checkout')); // -> ['cust-0101', 'cust-0113']
('cust-0113', 'new-checkout'); // -> 0.03226795047521591These functions allow you to compute the result of a rollout without creating a full feature store. inRollout(value, percent, seed) is the whole of what a rollout decides once the bucketing value is known, and bucketOf(value, seed) returns the bucket it compares.
Variants splits the customers a feature reaches across named versions, bucketing them the same way under a separate seed.