@variantlab/core 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1209 -39
- package/docs/API.md +692 -0
- package/docs/ARCHITECTURE.md +430 -0
- package/docs/CONTRIBUTING.md +264 -0
- package/docs/ROADMAP.md +292 -0
- package/docs/SECURITY.md +323 -0
- package/docs/design/api-philosophy.md +347 -0
- package/docs/design/config-format.md +442 -0
- package/docs/design/design-principles.md +212 -0
- package/docs/design/targeting-dsl.md +433 -0
- package/docs/features/codegen.md +351 -0
- package/docs/features/crash-rollback.md +399 -0
- package/docs/features/debug-overlay.md +328 -0
- package/docs/features/hmac-signing.md +330 -0
- package/docs/features/killer-features.md +308 -0
- package/docs/features/multivariate.md +339 -0
- package/docs/features/qr-sharing.md +372 -0
- package/docs/features/targeting.md +481 -0
- package/docs/features/time-travel.md +306 -0
- package/docs/features/value-experiments.md +487 -0
- package/docs/phases/phase-2-expansion.md +307 -0
- package/docs/phases/phase-3-ecosystem.md +289 -0
- package/docs/phases/phase-4-advanced.md +306 -0
- package/docs/phases/phase-5-v1-stable.md +350 -0
- package/docs/research/bundle-size-analysis.md +279 -0
- package/docs/research/competitors.md +327 -0
- package/docs/research/framework-ssr-quirks.md +394 -0
- package/docs/research/naming-rationale.md +238 -0
- package/docs/research/origin-story.md +179 -0
- package/docs/research/security-threats.md +312 -0
- package/package.json +2 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# Targeting
|
|
2
|
+
|
|
3
|
+
How variantlab decides whether a user is eligible for an experiment. This doc is a user-facing guide; the formal semantics are in [`docs/design/targeting-dsl.md`](../design/targeting-dsl.md).
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [Mental model](#mental-model)
|
|
8
|
+
- [The VariantContext](#the-variantcontext)
|
|
9
|
+
- [Targeting fields](#targeting-fields)
|
|
10
|
+
- [Targeting by platform](#targeting-by-platform)
|
|
11
|
+
- [Targeting by app version](#targeting-by-app-version)
|
|
12
|
+
- [Targeting by locale](#targeting-by-locale)
|
|
13
|
+
- [Targeting by screen size](#targeting-by-screen-size)
|
|
14
|
+
- [Targeting by route](#targeting-by-route)
|
|
15
|
+
- [Targeting by user ID](#targeting-by-user-id)
|
|
16
|
+
- [Targeting by custom attributes](#targeting-by-custom-attributes)
|
|
17
|
+
- [The predicate escape hatch](#the-predicate-escape-hatch)
|
|
18
|
+
- [Combining targeting rules](#combining-targeting-rules)
|
|
19
|
+
- [How to debug targeting](#how-to-debug-targeting)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Mental model
|
|
24
|
+
|
|
25
|
+
Targeting answers one question: **"Is this user eligible for this experiment?"**
|
|
26
|
+
|
|
27
|
+
If the answer is no, the user sees the default variant. If yes, the assignment logic (random, sticky-hash, weighted) picks a variant.
|
|
28
|
+
|
|
29
|
+
The targeting predicate is a pure function of the `VariantContext`. Given the same context, the same experiment always targets the same way. This is what makes SSR work.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## The VariantContext
|
|
34
|
+
|
|
35
|
+
The context is the input to every targeting decision. It is populated by the framework adapter automatically, but can be overridden:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
interface VariantContext {
|
|
39
|
+
platform?: "ios" | "android" | "web" | "node";
|
|
40
|
+
appVersion?: string; // "2.3.1"
|
|
41
|
+
locale?: string; // "bn-BD"
|
|
42
|
+
screenSize?: "small" | "medium" | "large";
|
|
43
|
+
route?: string; // "/feed"
|
|
44
|
+
userId?: string; // hashed internally
|
|
45
|
+
attributes?: Record<string, string | number | boolean>;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Auto-populated fields
|
|
50
|
+
|
|
51
|
+
Each adapter fills in what it can:
|
|
52
|
+
|
|
53
|
+
- **`@variantlab/react-native`**: platform, appVersion (from `expo-constants` or `react-native-device-info`), locale, screenSize, route
|
|
54
|
+
- **`@variantlab/next`**: platform (`web` or `node`), locale, route
|
|
55
|
+
- **`@variantlab/vue`**: platform (`web`), locale
|
|
56
|
+
- **`@variantlab/core`**: nothing — you provide everything
|
|
57
|
+
|
|
58
|
+
### Manually setting context
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
<VariantLabProvider
|
|
62
|
+
initialContext={{
|
|
63
|
+
userId: "user-123",
|
|
64
|
+
attributes: { plan: "premium", betaOptIn: true },
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<App />
|
|
68
|
+
</VariantLabProvider>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Merging: manual context is merged over auto-populated context. Manual wins on conflict.
|
|
72
|
+
|
|
73
|
+
### Updating context at runtime
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const engine = useVariantLabEngine();
|
|
77
|
+
engine.updateContext({ userId: await getUserId() });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Every experiment is re-evaluated on context change. Assignments are cached per `(userId, experimentId)` so targeting runs once per user per experiment unless context changes.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Targeting fields
|
|
85
|
+
|
|
86
|
+
All fields optional. If no targeting is specified, **every user matches**.
|
|
87
|
+
|
|
88
|
+
When multiple fields are specified, they are **ANDed together**. For OR semantics, use the `predicate` escape hatch or split into multiple experiments.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Targeting by platform
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
"targeting": { "platform": ["ios", "android"] }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Matches users whose `context.platform` is in the list.
|
|
99
|
+
|
|
100
|
+
**Use cases**:
|
|
101
|
+
|
|
102
|
+
- Mobile-only features
|
|
103
|
+
- Platform-specific rollouts (iOS-first launches)
|
|
104
|
+
- Excluding SSR (no `node` in the list)
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Targeting by app version
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
"targeting": { "appVersion": ">=2.0.0" }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Matches users whose `context.appVersion` satisfies the semver range.
|
|
115
|
+
|
|
116
|
+
### Supported ranges
|
|
117
|
+
|
|
118
|
+
- `1.2.3` (exact)
|
|
119
|
+
- `>=1.2.3`
|
|
120
|
+
- `^1.2.0` (1.2.0 <= v < 2.0.0)
|
|
121
|
+
- `~1.2.0` (1.2.0 <= v < 1.3.0)
|
|
122
|
+
- `1.2.0 - 1.5.0`
|
|
123
|
+
- `>=1.0.0 <2.0.0 || >=3.0.0`
|
|
124
|
+
|
|
125
|
+
### Common patterns
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
// Only users on the latest version
|
|
129
|
+
"appVersion": ">=2.5.0"
|
|
130
|
+
|
|
131
|
+
// Exclude a broken version
|
|
132
|
+
"appVersion": ">=2.0.0 <2.3.5 || >=2.3.7"
|
|
133
|
+
|
|
134
|
+
// New feature behind a minimum version
|
|
135
|
+
"appVersion": ">=3.0.0"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Reading the version
|
|
139
|
+
|
|
140
|
+
Adapters auto-populate from:
|
|
141
|
+
|
|
142
|
+
- React Native: `Constants.expoConfig?.version` or `DeviceInfo.getVersion()`
|
|
143
|
+
- Next.js: user-supplied (no built-in way)
|
|
144
|
+
- Web: user-supplied via build-time env var (e.g., `process.env.NEXT_PUBLIC_APP_VERSION`)
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Targeting by locale
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
"targeting": { "locale": ["en", "bn"] }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Matches if `context.locale` matches any entry. Two match modes:
|
|
155
|
+
|
|
156
|
+
- **Prefix** (recommended): `"en"` matches `"en"`, `"en-US"`, `"en-GB"`
|
|
157
|
+
- **Exact**: `"en-US"` matches only `"en-US"`
|
|
158
|
+
|
|
159
|
+
### Use cases
|
|
160
|
+
|
|
161
|
+
- Language-specific experiments
|
|
162
|
+
- Regional rollouts
|
|
163
|
+
- Translation A/B tests
|
|
164
|
+
|
|
165
|
+
### Example
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"id": "casual-bengali-copy",
|
|
170
|
+
"targeting": { "locale": ["bn"] },
|
|
171
|
+
"variants": [
|
|
172
|
+
{ "id": "formal", "value": "অনুগ্রহ করে লগইন করুন" },
|
|
173
|
+
{ "id": "casual", "value": "লগইন করুন" }
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Targeting by screen size
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
"targeting": { "screenSize": ["small"] }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Matches devices in the specified buckets:
|
|
187
|
+
|
|
188
|
+
- `"small"` — `max(width, height) < 700 px`
|
|
189
|
+
- `"medium"` — `700 ≤ max(width, height) < 1200 px`
|
|
190
|
+
- `"large"` — `max(width, height) ≥ 1200 px`
|
|
191
|
+
|
|
192
|
+
### Why buckets?
|
|
193
|
+
|
|
194
|
+
- Stable across zoom and orientation changes
|
|
195
|
+
- Readable in configs ("small" vs "< 700px")
|
|
196
|
+
- Easy to target exact device classes
|
|
197
|
+
|
|
198
|
+
### Customizing thresholds
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
createEngine(config, {
|
|
202
|
+
screenSize: {
|
|
203
|
+
smallMax: 640,
|
|
204
|
+
mediumMax: 1024,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Use cases
|
|
210
|
+
|
|
211
|
+
- Card layout experiments (the Drishtikon origin story)
|
|
212
|
+
- Responsive UI tests
|
|
213
|
+
- Touch target size variations
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Targeting by route
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
"targeting": { "routes": ["/feed", "/article/*"] }
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Matches if `context.route` matches any glob pattern.
|
|
224
|
+
|
|
225
|
+
### Glob syntax
|
|
226
|
+
|
|
227
|
+
- `/about` — exact
|
|
228
|
+
- `/blog/*` — single segment wildcard
|
|
229
|
+
- `/docs/**` — multi-segment wildcard
|
|
230
|
+
- `/user/:id` — parameter (matches any single segment)
|
|
231
|
+
|
|
232
|
+
### Trailing slash insensitive
|
|
233
|
+
|
|
234
|
+
`/about` matches both `/about` and `/about/`.
|
|
235
|
+
|
|
236
|
+
### Use cases
|
|
237
|
+
|
|
238
|
+
- Feature flags scoped to specific screens
|
|
239
|
+
- Performance experiments only on heavy pages
|
|
240
|
+
- Debug overlay filtering
|
|
241
|
+
|
|
242
|
+
### How the route is read
|
|
243
|
+
|
|
244
|
+
Framework adapters auto-populate the route:
|
|
245
|
+
|
|
246
|
+
- **Expo Router**: via `usePathname` or `useRouter().pathname`
|
|
247
|
+
- **React Navigation**: via `useRoute()` + config parser
|
|
248
|
+
- **Next.js App Router**: via `usePathname` from `next/navigation`
|
|
249
|
+
- **Next.js Pages Router**: via `useRouter().pathname`
|
|
250
|
+
- **Remix**: via `useLocation().pathname`
|
|
251
|
+
- **Nuxt**: via `useRoute().path`
|
|
252
|
+
- **SvelteKit**: via `$page.url.pathname`
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Targeting by user ID
|
|
257
|
+
|
|
258
|
+
Two modes.
|
|
259
|
+
|
|
260
|
+
### Explicit list
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
"targeting": { "userId": ["alice", "bob", "charlie"] }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Matches if `context.userId` is in the list exactly.
|
|
267
|
+
|
|
268
|
+
**Max 10,000 entries.** Larger lists slow down config loading.
|
|
269
|
+
|
|
270
|
+
**Use cases**: beta whitelists, internal testing, VIP features.
|
|
271
|
+
|
|
272
|
+
### Hash bucket
|
|
273
|
+
|
|
274
|
+
```json
|
|
275
|
+
"targeting": { "userId": { "hash": "sha256", "mod": 10 } }
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Matches if `sha256(userId) % 100 < mod`. In this example, 10% of users match.
|
|
279
|
+
|
|
280
|
+
**Use cases**: percentage rollouts, deterministic subsetting.
|
|
281
|
+
|
|
282
|
+
**Why sha256?** Uniform distribution and available everywhere via Web Crypto API.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Targeting by custom attributes
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
"targeting": {
|
|
290
|
+
"attributes": {
|
|
291
|
+
"plan": "premium",
|
|
292
|
+
"region": "us-west",
|
|
293
|
+
"betaOptIn": true
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Every specified key must match exactly. Values can be strings, numbers, or booleans.
|
|
299
|
+
|
|
300
|
+
### Setting attributes
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
<VariantLabProvider
|
|
304
|
+
initialContext={{
|
|
305
|
+
attributes: {
|
|
306
|
+
plan: user.subscription,
|
|
307
|
+
region: user.region,
|
|
308
|
+
betaOptIn: user.betaOptIn,
|
|
309
|
+
signupDaysAgo: daysSince(user.createdAt),
|
|
310
|
+
},
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### No operators
|
|
316
|
+
|
|
317
|
+
Attribute matching is exact equality only. For greater-than, less-than, pattern matching, or OR logic, use the `predicate` escape hatch.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## The predicate escape hatch
|
|
322
|
+
|
|
323
|
+
For complex targeting that doesn't fit the declarative DSL, provide a function:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
const engine = createEngine(config, {
|
|
327
|
+
experimentOverrides: {
|
|
328
|
+
"new-feature": {
|
|
329
|
+
targeting: {
|
|
330
|
+
platform: ["ios", "android"],
|
|
331
|
+
predicate: (ctx) =>
|
|
332
|
+
typeof ctx.attributes?.signupDaysAgo === "number" &&
|
|
333
|
+
ctx.attributes.signupDaysAgo >= 7 &&
|
|
334
|
+
ctx.attributes.signupDaysAgo <= 30,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Constraints
|
|
342
|
+
|
|
343
|
+
- **Code-only** — cannot be specified in JSON
|
|
344
|
+
- **Pure** — no side effects, no async
|
|
345
|
+
- **Fast** — runs on every evaluation; keep it O(1)
|
|
346
|
+
- **ANDed** — combined with the declarative fields
|
|
347
|
+
|
|
348
|
+
### When to use it
|
|
349
|
+
|
|
350
|
+
- Numeric comparisons
|
|
351
|
+
- Time-based rules
|
|
352
|
+
- Dependency on external state
|
|
353
|
+
- OR / NOT logic
|
|
354
|
+
|
|
355
|
+
### When NOT to use it
|
|
356
|
+
|
|
357
|
+
- When the declarative operators cover your case (use them — they're debuggable in PRs)
|
|
358
|
+
- For large logic blocks (refactor into the app, use a simpler flag)
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Combining targeting rules
|
|
363
|
+
|
|
364
|
+
All fields are implicit AND:
|
|
365
|
+
|
|
366
|
+
```json
|
|
367
|
+
"targeting": {
|
|
368
|
+
"platform": ["ios"],
|
|
369
|
+
"appVersion": ">=2.0.0",
|
|
370
|
+
"locale": ["en"],
|
|
371
|
+
"screenSize": ["small"],
|
|
372
|
+
"routes": ["/feed"],
|
|
373
|
+
"attributes": { "plan": "premium" }
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
This matches: iOS users on app v2.0.0+, English locale, small screens, on `/feed`, with a premium plan.
|
|
378
|
+
|
|
379
|
+
### Getting OR logic
|
|
380
|
+
|
|
381
|
+
Split into multiple experiments:
|
|
382
|
+
|
|
383
|
+
```json
|
|
384
|
+
// Two experiments with the same variants but different targeting
|
|
385
|
+
{
|
|
386
|
+
"id": "new-checkout-premium",
|
|
387
|
+
"targeting": { "attributes": { "plan": "premium" } },
|
|
388
|
+
"variants": [...]
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
"id": "new-checkout-pro",
|
|
392
|
+
"targeting": { "attributes": { "plan": "pro" } },
|
|
393
|
+
"variants": [...]
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Or use the `predicate`:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
targeting: {
|
|
401
|
+
predicate: (ctx) =>
|
|
402
|
+
ctx.attributes?.plan === "premium" ||
|
|
403
|
+
ctx.attributes?.plan === "pro",
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Mutually exclusive experiments
|
|
408
|
+
|
|
409
|
+
Use the `mutex` field:
|
|
410
|
+
|
|
411
|
+
```json
|
|
412
|
+
{ "id": "card-layout-a", "mutex": "card-layout", ... },
|
|
413
|
+
{ "id": "card-layout-b", "mutex": "card-layout", ... }
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
A user who matches both is assigned to exactly one, deterministically hashed by experiment ID.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## How to debug targeting
|
|
421
|
+
|
|
422
|
+
### Debug overlay
|
|
423
|
+
|
|
424
|
+
The overlay shows three groups:
|
|
425
|
+
|
|
426
|
+
1. **Active** — user is enrolled
|
|
427
|
+
2. **Targeted but not enrolled** — targeting passed but assignment chose default (e.g., due to weights)
|
|
428
|
+
3. **Not targeted** — targeting failed, showing which field caused the failure
|
|
429
|
+
|
|
430
|
+
Each card has an "Explain" button showing the evaluation trace:
|
|
431
|
+
|
|
432
|
+
```
|
|
433
|
+
news-card-layout:
|
|
434
|
+
platform: ios ✅ (matched)
|
|
435
|
+
appVersion: >=2.0.0 ✅ (2.3.1 satisfies)
|
|
436
|
+
screenSize: small ❌ (got "large")
|
|
437
|
+
→ FAIL: screenSize
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### CLI
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
variantlab eval experiments.json \
|
|
444
|
+
--context '{"platform":"ios","appVersion":"2.0.0"}' \
|
|
445
|
+
--experiment news-card-layout
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Outputs the full evaluation trace.
|
|
449
|
+
|
|
450
|
+
### Engine method
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
const engine = createEngine(config);
|
|
454
|
+
const explanation = engine.explain("news-card-layout", context);
|
|
455
|
+
console.log(explanation);
|
|
456
|
+
// { matched: false, reason: "screenSize", ... }
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## Performance
|
|
462
|
+
|
|
463
|
+
Targeting evaluation is hot-path code. We budget ~2 µs per experiment on modern hardware.
|
|
464
|
+
|
|
465
|
+
Optimizations:
|
|
466
|
+
|
|
467
|
+
- Kill-switch check before any targeting (O(1))
|
|
468
|
+
- Platform check second (O(1) set lookup)
|
|
469
|
+
- Cheap checks before expensive ones
|
|
470
|
+
- Assignment cache by `(userId, experimentId)` to avoid re-evaluation
|
|
471
|
+
- Pre-compiled route globs at config load time
|
|
472
|
+
|
|
473
|
+
At 100 experiments and 10 hooks per render, targeting is well under 1 ms of frame budget. Not a concern.
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## See also
|
|
478
|
+
|
|
479
|
+
- [`targeting-dsl.md`](../design/targeting-dsl.md) — formal semantics
|
|
480
|
+
- [`config-format.md`](../design/config-format.md) — field reference
|
|
481
|
+
- [`debug-overlay.md`](./debug-overlay.md) — live debugging
|