These pages document @evanion/feature 0.1.1, the version on npm.
Rollouts
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')Retype the id. The same customer always lands in the same place, and about one in ten is in.
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'); // -> falseWatching a Rollout Widen
The control below holds one flag, new-gallery, rolling out to a share of visitors on strategy-game pages only. A build renders many pages and knows each page’s category, so it answers the Azul page (a family game) before deploy: no visitor of that page can get the new gallery. The Brass page waits, because only a request carries a visitor. Move the slider and the visitors the rollout reaches fill in without anyone dropping out.
Decided before deploy
Set once for every visitor when the site builds.
Brass: Birmingham
Brass: Birmingham is a strategy game, so the rollout can reach its visitors.
New gallery for some visitors
The new gallery waits for the visitor.
Decided when a visitor arrives
Set for each visitor when they arrive.
11 of 48 visitors see the new gallery.
Raising the share only adds visitors; nobody who has the new gallery loses it.
Each dot is a visitor, told apart by their id. Filled means they see the new gallery. The same visitor always gets the same answer.
Tap or hover a dot to see that visitor.
Your flags
createFeatures([
{ key: 'new-gallery', enabled: true,
rules: [{
when: [{ field: 'category', op: 'eq', value: 'strategy' }],
rollout: { percent: 25, by: 'targetingKey' },
}] },
])
// before deploy: one call per page
features.plan({ game, category: 'strategy' })
// when a visitor arrives: one call per request
features.resolve({ game, category, targetingKey: visitor.id })
The first lane is plan() called with the page’s game and category and no visitor. The second is resolve() called once per visitor with the same context and that visitor’s id as the targetingKey.
Why 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.