Targeting & rollouts
Express "show this to the right users" with attribute rules, deterministic percentage rollouts, and per-gate salts. The rules engine, in detail.
A targeting rule on a gate is a predicate of the form attr op value. Multiple rules on a gate are ANDed together. To OR, use the in operator with an array, or split into separate gates and combine in code.
This page is the operational reference for the rules engine: every operator, the bucketing math, and the patterns we've seen work for safe rollouts.
Operators
| Operator | Works on | Example |
|---|---|---|
eq / neq | any | plan eq "pro" |
in / not_in | scalar vs array | country in ["US","CA","MX"] |
gt / gte / lt / lte | numbers | tenure_days gt 30 |
contains | string⊂string, array∋value | roles contains "admin" |
starts_with / ends_with | strings only | email ends_with "@acme.com" |
regex | strings only | email regex "@acme\\.com$" |
version_gte / version_lt | semver strings | app_version version_gte "2.4.0" |
Numeric ops coerce strings that look like numbers — "100" gt 50 is true. They never coerce booleans. regex is anchored only when you anchor it (^ / $); we run it in the SDK, no server round-trip.
Common rule shapes
// Beta cohort — explicit list
{ attr: "user_id", op: "in", value: ["u_1", "u_2", "u_3"] }
// Internal users only
{ attr: "email", op: "regex", value: "@acme\\.com$" }
// Pro plan in supported regions
[
{ attr: "plan", op: "eq", value: "pro" },
{ attr: "country", op: "in", value: ["US","CA","UK"] },
]
// Long-tenured power users
[
{ attr: "tenure_days", op: "gte", value: 60 },
{ attr: "team_size", op: "gte", value: 5 },
]
// Mobile app, version-gated
[
{ attr: "platform", op: "eq", value: "ios" },
{ attr: "app_version", op: "version_gte", value: "2.4.0" },
]The dashboard shape is JSON, so you can copy these straight in — or push them with the CLI:
shipeasy flags rules set new-checkout-flow --json '[
{ "attr": "plan", "op": "eq", "value": "pro" },
{ "attr": "country", "op": "in", "value": ["US","CA","UK"] }
]'Rollout percentages
Rollout is deterministic. The SDK computes:
bucket = murmur3(salt + ":" + (user_id ?? anonymous_id)) % 10000
true = bucket < rolloutPctThat gives you three properties:
- Stable: the same user always gets the same bucket for the same gate. No flicker on reloads, no flicker across servers.
- Independent: changing one gate's rollout doesn't move users on another gate (different salts).
- Fair: bucketing is uniform; small rollouts give a representative sample.
Internally we work in basis points (0..10000), so you can roll out at 0.01% granularity if you really need it.
Rolling out safely
A safe rollout schedule for an unknown-risk change:
0% → enable rule + dogfood with overrides
1% → 1 hour, watch error rate + p95
5% → 4 hours
25% → overnight
50% → 1 day
100% → keep an eye on it for a week, then delete the flagFor a known-low-risk change you can compress this. For anything load-bearing (auth, billing, the data path) — don't. The whole point of a gradual rollout is to find the surprise before it's 100% of your traffic.
Going from 5% → 25% adds users to the bucket — the original 5% is still in. You won't see
a user flap from true back to false as you ramp up. The only way to
reshuffle is to bump the salt.
The salt
Each gate has its own salt. If you ever want to re-shuffle the rollout buckets without changing the percentage (say, to avoid the same 5% always being chosen for every gate you ever roll out), bump the salt:
shipeasy flags update my-feature --salt v2That's rare. The default salt is fine for almost every case. The salt is also why two gates at 50% don't serve the same 50% of users — different salts, different buckets, statistically independent.
Combining rules + rollout
Rules are checked before rollout. Two practical implications:
- "100% of
plan = pro" means all pro users gettrue. The rollout filter doesn't fight the rule. - "5% of
plan = pro" means 5% of pros, not "5% of everyone, then keep only pros". The bucket is computed against the pre-filtered population.
This is what you almost always want. If you ever need "this proportion of the entire user base, but only if they're pro" — that's an experiment and we have purpose-built tools for it.
Bucketing by something other than user_id
Default: bucketing key is user_id ?? anonymous_id. Override per-call:
flags.gate("new-team-ui", user, { bucketBy: "account_id" });Or on the gate itself in the dashboard. Use this in B2B so all teammates of an account see the same variant — half a team on the new UI and half on the old is a confusing user experience and an unanalysable rollout.
Bucketing on email or plan means a user changes bucket whenever the
attribute changes. They'll see the feature flicker on and off. Always bucket on something
stable: user_id, account_id, or anonymous_id.
First-match-wins evaluation
When a gate has multiple rule groups (the dashboard's "OR an additional cohort" affordance), the SDK evaluates them top to bottom and takes the first match. Each group can have its own rollout percentage and bucketing key.
[
{
"match": [{ "attr": "internal", "op": "eq", "value": true }],
"rollout": 10000,
"bucketBy": "user_id"
},
{
"match": [{ "attr": "plan", "op": "eq", "value": "pro" }],
"rollout": 500,
"bucketBy": "account_id"
}
]Reads as: "100% of internal users; 5% of pro accounts (consistent across teammates); everyone else gets the default."
The order matters. If a user is both internal and pro, the first matching group wins — they get the 100% bucket, not the 5% one.
Overrides vs rules
Use rules for cohorts you can describe — "all internal users", "all pro accounts". They scale.
Use overrides for individual user IDs — QA accounts, customers reporting a specific bug, demos. They're the trump card and bypass everything else.
If you find yourself adding many overrides, that's a smell — promote them to a rule keyed on a internal: true or early_access: true attribute and you'll thank yourself when the QA team turns over.
Attributes that travel
Whatever attributes you pass to flags.gate() are also available to:
- Experiments (for assignment and segmentation)
- Dynamic values decoders
- The analysis pipeline (as exposure dimensions)
So define attributes once at the request boundary and pass the same user shape everywhere:
export function buildEvalCtx(req: Request) {
const session = getSession(req);
return {
user_id: session.user_id,
plan: session.plan,
country: geoFromReq(req),
tenure_days: daysSince(session.created_at),
app_version: req.headers.get("x-app-version") ?? "0.0.0",
};
}Debugging a rule
The dashboard's Test evaluator panel takes a JSON context and shows the full evaluation trace — which rule matched, which didn't, what bucket the user landed in. The CLI exposes the same:
shipeasy flags eval new-checkout-flow \
--ctx '{"user_id":"u_4f2a","plan":"pro","country":"US"}'gate new-checkout-flow
result true
trace killswitch=off, enabled=on, override=–, rule[plan=pro]=match,
rule[country in [US,CA,UK]]=match, bucket=2147 < 5000=trueIndispensable when a customer says "I should be in the beta, why am I not?".
Promote to an experiment.
When you want a defensible answer (not just a ramp), turn the gate into an A/B test and let ShipEasy run the statistics.