@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,433 @@
|
|
|
1
|
+
# Targeting DSL
|
|
2
|
+
|
|
3
|
+
How targeting predicates work, why we chose the design we did, and the semantics of each operator.
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [Design goals](#design-goals)
|
|
8
|
+
- [The predicate shape](#the-predicate-shape)
|
|
9
|
+
- [Evaluation semantics](#evaluation-semantics)
|
|
10
|
+
- [Operators](#operators)
|
|
11
|
+
- [The escape hatch](#the-escape-hatch)
|
|
12
|
+
- [Why not a full expression language](#why-not-a-full-expression-language)
|
|
13
|
+
- [Evaluation order](#evaluation-order)
|
|
14
|
+
- [Performance](#performance)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Design goals
|
|
19
|
+
|
|
20
|
+
1. **Expressible enough** to cover 95% of real-world targeting needs without custom code
|
|
21
|
+
2. **Simple enough** to read in a PR without documentation
|
|
22
|
+
3. **Safe** — no code execution, no prototype pollution, no regex ReDoS
|
|
23
|
+
4. **Fast** — O(1) or O(n) evaluation per experiment, never exponential
|
|
24
|
+
5. **Declarative** — JSON-shaped data, diffable in Git
|
|
25
|
+
6. **SSR-deterministic** — pure function of context
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## The predicate shape
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
interface Targeting {
|
|
33
|
+
platform?: Array<"ios" | "android" | "web" | "node">;
|
|
34
|
+
appVersion?: string; // semver range
|
|
35
|
+
locale?: string[]; // IETF language tags
|
|
36
|
+
screenSize?: Array<"small" | "medium" | "large">;
|
|
37
|
+
routes?: string[]; // glob patterns
|
|
38
|
+
userId?: string[] | { hash: "sha256"; mod: number };
|
|
39
|
+
attributes?: Record<string, string | number | boolean>;
|
|
40
|
+
predicate?: (context: VariantContext) => boolean; // escape hatch, code-only
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A `Targeting` object is an **implicit AND** of all specified fields. If no fields are specified, the predicate matches every user.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Evaluation semantics
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
match(targeting, context) =
|
|
52
|
+
platform_match(targeting.platform, context.platform)
|
|
53
|
+
AND appVersion_match(targeting.appVersion, context.appVersion)
|
|
54
|
+
AND locale_match(targeting.locale, context.locale)
|
|
55
|
+
AND screenSize_match(targeting.screenSize, context.screenSize)
|
|
56
|
+
AND routes_match(targeting.routes, context.route)
|
|
57
|
+
AND userId_match(targeting.userId, context.userId)
|
|
58
|
+
AND attributes_match(targeting.attributes, context.attributes)
|
|
59
|
+
AND predicate(context)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each sub-match is:
|
|
63
|
+
|
|
64
|
+
- **True if the field is not specified in targeting** (open by default)
|
|
65
|
+
- **True if the specified predicate matches**
|
|
66
|
+
- **False otherwise**
|
|
67
|
+
|
|
68
|
+
An unspecified field in the context does **not** match a specified targeting field. For example:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
"targeting": { "platform": ["ios"] }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If `context.platform` is `undefined`, this targeting fails to match.
|
|
75
|
+
|
|
76
|
+
### Why implicit AND?
|
|
77
|
+
|
|
78
|
+
Because it's the most common case. 90% of targeting reads as "platform X AND version Y AND route Z". When you need OR, compose multiple experiments or use the `predicate` escape hatch.
|
|
79
|
+
|
|
80
|
+
### Why no explicit OR operator?
|
|
81
|
+
|
|
82
|
+
We considered:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
"targeting": {
|
|
86
|
+
"or": [
|
|
87
|
+
{ "platform": ["ios"] },
|
|
88
|
+
{ "platform": ["android"], "appVersion": ">=2.0.0" }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Rejected because:
|
|
94
|
+
|
|
95
|
+
1. Most users don't need OR
|
|
96
|
+
2. Adds ~300 bytes to the evaluator
|
|
97
|
+
3. Encourages complex configs that should be split into multiple experiments
|
|
98
|
+
4. The `predicate` escape hatch handles the rare cases
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Operators
|
|
103
|
+
|
|
104
|
+
### `platform`
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
platform?: Array<"ios" | "android" | "web" | "node">;
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Set membership. Matches if `context.platform` is in the array.
|
|
111
|
+
|
|
112
|
+
**Values**:
|
|
113
|
+
|
|
114
|
+
- `"ios"` — iOS, iPadOS
|
|
115
|
+
- `"android"` — Android
|
|
116
|
+
- `"web"` — any browser environment (including desktop web, mobile web, PWA)
|
|
117
|
+
- `"node"` — server-side (SSR, edge runtimes)
|
|
118
|
+
|
|
119
|
+
**Implementation**: O(1) lookup.
|
|
120
|
+
|
|
121
|
+
### `appVersion`
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
appVersion?: string; // semver range
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Semver range matching. Matches if `context.appVersion` satisfies the range.
|
|
128
|
+
|
|
129
|
+
**Supported syntax** (subset of npm semver):
|
|
130
|
+
|
|
131
|
+
- Comparators: `=`, `<`, `<=`, `>`, `>=`
|
|
132
|
+
- Caret: `^1.2.0` (>= 1.2.0 < 2.0.0)
|
|
133
|
+
- Tilde: `~1.2.0` (>= 1.2.0 < 1.3.0)
|
|
134
|
+
- Range: `1.2.0 - 2.0.0`
|
|
135
|
+
- Compound: `>=1.0.0 <2.0.0`
|
|
136
|
+
- OR ranges: `>=1.0.0 <2.0.0 || >=3.0.0`
|
|
137
|
+
|
|
138
|
+
**Not supported**:
|
|
139
|
+
|
|
140
|
+
- Prerelease comparisons (`1.2.0-beta.1`)
|
|
141
|
+
- Build metadata (`1.2.0+sha.abc`)
|
|
142
|
+
- `x` wildcards (`1.2.x`)
|
|
143
|
+
|
|
144
|
+
If you need these, use the `predicate` escape hatch.
|
|
145
|
+
|
|
146
|
+
**Implementation**: Hand-rolled parser, ~250 bytes. Linear time.
|
|
147
|
+
|
|
148
|
+
### `locale`
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
locale?: string[]; // IETF language tags
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Matches if `context.locale` matches any entry. Two match modes:
|
|
155
|
+
|
|
156
|
+
- **Exact**: `"en-US"` matches `"en-US"` only
|
|
157
|
+
- **Prefix**: `"en"` matches `"en"`, `"en-US"`, `"en-GB"`, etc.
|
|
158
|
+
|
|
159
|
+
**Implementation**: String prefix comparison.
|
|
160
|
+
|
|
161
|
+
### `screenSize`
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
screenSize?: Array<"small" | "medium" | "large">;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Set membership on pre-bucketed screen sizes. Adapter packages derive the bucket from screen dimensions:
|
|
168
|
+
|
|
169
|
+
- `"small"`: `max(width, height) < 700 px`
|
|
170
|
+
- `"medium"`: `700 ≤ max(width, height) < 1200 px`
|
|
171
|
+
- `"large"`: `max(width, height) ≥ 1200 px`
|
|
172
|
+
|
|
173
|
+
Thresholds are configurable at engine creation.
|
|
174
|
+
|
|
175
|
+
**Why bucket instead of exact pixels?** Buckets keep configs stable across device zooms, responsive changes, and orientation changes. Also makes configs readable ("target small screens") instead of cryptic ("target < 700px").
|
|
176
|
+
|
|
177
|
+
### `routes`
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
routes?: string[]; // glob patterns
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Matches if `context.route` matches any pattern. Supported glob syntax:
|
|
184
|
+
|
|
185
|
+
- Exact: `/about`
|
|
186
|
+
- Wildcard segment: `/blog/*`
|
|
187
|
+
- Wildcard deep: `/docs/**`
|
|
188
|
+
- Parameter: `/user/:id`
|
|
189
|
+
- Trailing slash insensitive
|
|
190
|
+
|
|
191
|
+
**Not supported**:
|
|
192
|
+
|
|
193
|
+
- Character classes: `/[abc]/`
|
|
194
|
+
- Braces: `/foo/{a,b}/`
|
|
195
|
+
- Negation: `/foo/!(bar)/`
|
|
196
|
+
|
|
197
|
+
If you need these, use the `predicate` escape hatch.
|
|
198
|
+
|
|
199
|
+
**Implementation**: Linear-time matcher, ~150 bytes. No regex backtracking risk.
|
|
200
|
+
|
|
201
|
+
### `userId`
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
userId?: string[] | { hash: "sha256"; mod: number };
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Two modes:
|
|
208
|
+
|
|
209
|
+
#### Explicit list
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
"userId": ["alice", "bob", "charlie"]
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Matches if `context.userId` is in the list. Max 10,000 entries. Useful for:
|
|
216
|
+
|
|
217
|
+
- Whitelist betas
|
|
218
|
+
- Internal user testing
|
|
219
|
+
- Specific user debugging
|
|
220
|
+
|
|
221
|
+
#### Hash bucket
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
"userId": { "hash": "sha256", "mod": 10 }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Matches if `sha256(userId) % 100 < mod`. In this example, 10% of users match.
|
|
228
|
+
|
|
229
|
+
**Why sha256?** Uniform distribution and available via Web Crypto API in every runtime. Slightly slower than a simple hash but security-relevant (we don't want attackers to easily guess which users will match).
|
|
230
|
+
|
|
231
|
+
**Performance**: sha256 takes ~1 µs in modern engines. Negligible at the frequency of variant evaluation.
|
|
232
|
+
|
|
233
|
+
### `attributes`
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
attributes?: Record<string, string | number | boolean>;
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Exact-match predicate on `context.attributes`. Every specified key must match exactly.
|
|
240
|
+
|
|
241
|
+
```json
|
|
242
|
+
"attributes": {
|
|
243
|
+
"plan": "premium",
|
|
244
|
+
"region": "us-west",
|
|
245
|
+
"betaOptIn": true
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Matches if:
|
|
250
|
+
|
|
251
|
+
- `context.attributes.plan === "premium"` AND
|
|
252
|
+
- `context.attributes.region === "us-west"` AND
|
|
253
|
+
- `context.attributes.betaOptIn === true`
|
|
254
|
+
|
|
255
|
+
**Implementation**: Linear scan. Keys are validated against the reserved-words list (`__proto__`, `constructor`, `prototype`).
|
|
256
|
+
|
|
257
|
+
**Why no comparison operators?** Because you can't represent "plan != premium" cleanly in JSON without inventing a sub-DSL. The `predicate` escape hatch handles it in ~5 lines of JS.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## The escape hatch
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
targeting: {
|
|
265
|
+
predicate: (context) => context.daysSinceInstall > 7 && context.isPremium
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The `predicate` field is a function that takes the runtime context and returns a boolean. It is:
|
|
270
|
+
|
|
271
|
+
- **Only available in application code**, never in JSON configs
|
|
272
|
+
- **Not validated** — the user is responsible for writing safe code
|
|
273
|
+
- **ANDed with** the other targeting fields
|
|
274
|
+
|
|
275
|
+
This exists because 5% of real-world cases need custom logic:
|
|
276
|
+
|
|
277
|
+
- Time-based targeting (hours since install, days until expiry)
|
|
278
|
+
- Device characteristics beyond screen size (GPU tier, RAM)
|
|
279
|
+
- External data (experiments based on server state)
|
|
280
|
+
- Complex attribute comparisons (numeric thresholds, regex matching)
|
|
281
|
+
|
|
282
|
+
### Why not allow predicates in JSON?
|
|
283
|
+
|
|
284
|
+
Because JSON predicates mean either:
|
|
285
|
+
|
|
286
|
+
1. A full expression language (heavy, security risk)
|
|
287
|
+
2. A limited DSL (inevitably grows a new operator per month)
|
|
288
|
+
3. `eval` of string JS (unacceptable — violates design principle 4)
|
|
289
|
+
|
|
290
|
+
None of these are good. The function escape hatch is the cleanest.
|
|
291
|
+
|
|
292
|
+
### When to use `predicate`
|
|
293
|
+
|
|
294
|
+
- You need AND-OR-NOT combinations
|
|
295
|
+
- You need numeric comparisons
|
|
296
|
+
- You need to read from application state
|
|
297
|
+
- You need to check device capabilities
|
|
298
|
+
|
|
299
|
+
### When NOT to use `predicate`
|
|
300
|
+
|
|
301
|
+
- The built-in operators cover your case (use them, they're cheaper and reviewable in PRs)
|
|
302
|
+
- The logic belongs in the product, not the experiment (refactor)
|
|
303
|
+
- You're trying to implement RBAC (use a real auth system)
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Why not a full expression language
|
|
308
|
+
|
|
309
|
+
We considered shipping a full predicate language like:
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
"targeting": {
|
|
313
|
+
"expr": "platform == 'ios' && appVersion >= '2.0.0' && attributes.plan in ['premium', 'pro']"
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Rejected for these reasons:
|
|
318
|
+
|
|
319
|
+
1. **Parser weight** — a safe expression parser is 2-5 KB gzipped
|
|
320
|
+
2. **Security surface** — parsers have bugs, bugs become CVEs
|
|
321
|
+
3. **Review burden** — expressions in JSON are harder to diff in PRs than structured data
|
|
322
|
+
4. **Diminishing returns** — 95% of cases are covered by the current operators
|
|
323
|
+
5. **The escape hatch** — users with exotic needs already have `predicate`
|
|
324
|
+
|
|
325
|
+
Existing tools that use expression languages (Unleash, LaunchDarkly) pay this price and get complex targeting. We pay nothing and get 95% of the value.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Evaluation order
|
|
330
|
+
|
|
331
|
+
To short-circuit fast, the engine evaluates predicates in this order:
|
|
332
|
+
|
|
333
|
+
1. **`enabled` kill switch** (O(1))
|
|
334
|
+
2. **`startDate` / `endDate`** (O(1))
|
|
335
|
+
3. **`platform`** (O(n), n ≤ 4)
|
|
336
|
+
4. **`screenSize`** (O(n), n ≤ 3)
|
|
337
|
+
5. **`locale`** (O(n))
|
|
338
|
+
6. **`appVersion`** (O(n), n = range tokens)
|
|
339
|
+
7. **`routes`** (O(n × m), n = patterns, m = path segments)
|
|
340
|
+
8. **`attributes`** (O(n))
|
|
341
|
+
9. **`userId`** (O(n) for list; O(hash) for bucket)
|
|
342
|
+
10. **`predicate`** (O(?) — unknown, runs last)
|
|
343
|
+
|
|
344
|
+
Fast rejects first, expensive last.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Performance
|
|
349
|
+
|
|
350
|
+
### Benchmarks (preliminary, to be refined)
|
|
351
|
+
|
|
352
|
+
On a modern laptop (M2 Pro, Node 20):
|
|
353
|
+
|
|
354
|
+
- Full targeting evaluation with all operators: ~2 µs
|
|
355
|
+
- Empty targeting: ~50 ns
|
|
356
|
+
- sha256 user hash bucket: ~1 µs
|
|
357
|
+
- Route glob matching: ~200 ns per pattern
|
|
358
|
+
|
|
359
|
+
At typical app load — 10 experiments, 5-10 hook calls per render — targeting evaluation is well below 100 µs total. Negligible.
|
|
360
|
+
|
|
361
|
+
### Caching
|
|
362
|
+
|
|
363
|
+
The engine caches variant assignments per `(userId, experimentId)` so targeting is re-evaluated only when context changes (e.g., route changes, user logs in).
|
|
364
|
+
|
|
365
|
+
### Hot-path optimization
|
|
366
|
+
|
|
367
|
+
- No allocations on the hot path after warmup
|
|
368
|
+
- No regex in the hot path
|
|
369
|
+
- Integer comparisons for semver, not string comparison
|
|
370
|
+
- Pre-compiled route patterns at config load time
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## Examples
|
|
375
|
+
|
|
376
|
+
### Target iOS users on small screens running the latest version
|
|
377
|
+
|
|
378
|
+
```json
|
|
379
|
+
"targeting": {
|
|
380
|
+
"platform": ["ios"],
|
|
381
|
+
"screenSize": ["small"],
|
|
382
|
+
"appVersion": ">=2.0.0"
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Target 10% of users deterministically
|
|
387
|
+
|
|
388
|
+
```json
|
|
389
|
+
"targeting": {
|
|
390
|
+
"userId": { "hash": "sha256", "mod": 10 }
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Target premium users in Bengali locale
|
|
395
|
+
|
|
396
|
+
```json
|
|
397
|
+
"targeting": {
|
|
398
|
+
"locale": ["bn"],
|
|
399
|
+
"attributes": { "plan": "premium" }
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Time-based targeting (application code)
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
const targeting = {
|
|
407
|
+
platform: ["ios", "android"],
|
|
408
|
+
predicate: (ctx) => {
|
|
409
|
+
const installDate = new Date(ctx.attributes.installDate as string);
|
|
410
|
+
const daysSinceInstall = (Date.now() - installDate.getTime()) / 86400000;
|
|
411
|
+
return daysSinceInstall >= 7 && daysSinceInstall <= 30;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## Future extensions (not in v0.1)
|
|
419
|
+
|
|
420
|
+
- **Rollout curves** — 0% → 50% → 100% over time via `startDate`/`endDate` interpolation
|
|
421
|
+
- **Cohort targeting** — user cohorts defined by custom criteria stored alongside experiments
|
|
422
|
+
- **Multi-variate interactions** — targeting that depends on other experiment assignments
|
|
423
|
+
- **Geolocation targeting** — country/region from IP, via adapter-provided context
|
|
424
|
+
|
|
425
|
+
All of these are post-v1.0 considerations and require API stability first.
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## See also
|
|
430
|
+
|
|
431
|
+
- [`docs/design/config-format.md`](./config-format.md) — the enclosing config format
|
|
432
|
+
- [`API.md`](../../API.md) — the `Targeting` TypeScript interface
|
|
433
|
+
- [`docs/research/bundle-size-analysis.md`](../research/bundle-size-analysis.md) — why we hand-rolled the matchers
|