@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,306 @@
|
|
|
1
|
+
# Time-travel inspector
|
|
2
|
+
|
|
3
|
+
Scrub backwards through variant history to see what was active at any point in a session.
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [Why time travel](#why-time-travel)
|
|
8
|
+
- [What gets recorded](#what-gets-recorded)
|
|
9
|
+
- [The history tab](#the-history-tab)
|
|
10
|
+
- [Programmatic access](#programmatic-access)
|
|
11
|
+
- [Replay mode](#replay-mode)
|
|
12
|
+
- [Storage and size limits](#storage-and-size-limits)
|
|
13
|
+
- [Privacy](#privacy)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Why time travel
|
|
18
|
+
|
|
19
|
+
Debugging variant-related issues is hard because the state changes silently:
|
|
20
|
+
|
|
21
|
+
- A user switches variants via a deep link
|
|
22
|
+
- A crash triggers a rollback
|
|
23
|
+
- Targeting changes when the user navigates
|
|
24
|
+
- A remote config update reassigns everyone
|
|
25
|
+
|
|
26
|
+
Without a history, "why did I see the wrong variant at 3:45pm?" is unanswerable.
|
|
27
|
+
|
|
28
|
+
With time travel, the debug overlay shows a scrollable timeline of every assignment, override, and rollback with timestamps and causes.
|
|
29
|
+
|
|
30
|
+
This is inspired by Redux DevTools' time-travel, but for variant state.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## What gets recorded
|
|
35
|
+
|
|
36
|
+
The engine maintains an in-memory ring buffer of events:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
type HistoryEvent =
|
|
40
|
+
| { type: "configLoaded"; config: ExperimentsConfig; timestamp: number }
|
|
41
|
+
| { type: "contextUpdated"; context: VariantContext; timestamp: number }
|
|
42
|
+
| { type: "assignment"; experimentId: string; variantId: string; reason: AssignmentReason; timestamp: number }
|
|
43
|
+
| { type: "exposure"; experimentId: string; variantId: string; route: string; timestamp: number }
|
|
44
|
+
| { type: "override"; experimentId: string; variantId: string; source: OverrideSource; timestamp: number }
|
|
45
|
+
| { type: "rollback"; experimentId: string; variantId: string; crashCount: number; timestamp: number }
|
|
46
|
+
| { type: "reset"; experimentId?: string; timestamp: number };
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `configLoaded`
|
|
50
|
+
|
|
51
|
+
Fires when the engine loads a new config (from local cache or remote). Includes the full config snapshot.
|
|
52
|
+
|
|
53
|
+
### `contextUpdated`
|
|
54
|
+
|
|
55
|
+
Fires when the context changes (route change, user login, attribute update). Includes the new context.
|
|
56
|
+
|
|
57
|
+
### `assignment`
|
|
58
|
+
|
|
59
|
+
Fires when a user is assigned a variant for the first time. Records the `reason`:
|
|
60
|
+
|
|
61
|
+
- `"targeting-match-default"` — targeting passed, default assignment
|
|
62
|
+
- `"targeting-match-random"` — targeting passed, random assignment
|
|
63
|
+
- `"targeting-match-weighted"` — targeting passed, weighted pick
|
|
64
|
+
- `"targeting-match-sticky"` — targeting passed, sticky hash
|
|
65
|
+
- `"targeting-miss"` — targeting failed, default returned
|
|
66
|
+
- `"kill-switch"` — kill switch on, default returned
|
|
67
|
+
- `"archived"` — experiment archived, default returned
|
|
68
|
+
- `"rollback"` — previously rolled back, default returned
|
|
69
|
+
|
|
70
|
+
### `exposure`
|
|
71
|
+
|
|
72
|
+
Fires when a variant is actually read by application code (`useVariant` called). This is separate from `assignment` — you can be assigned without being exposed (e.g., the component isn't mounted). Useful for deduplication when forwarding to telemetry.
|
|
73
|
+
|
|
74
|
+
### `override`
|
|
75
|
+
|
|
76
|
+
Fires when a manual override is set:
|
|
77
|
+
|
|
78
|
+
- `"user-dev-overlay"` — set via debug overlay
|
|
79
|
+
- `"deep-link"` — set via deep link / QR
|
|
80
|
+
- `"api"` — set via `engine.setVariant()`
|
|
81
|
+
- `"initial-context"` — set via provider props
|
|
82
|
+
|
|
83
|
+
### `rollback`
|
|
84
|
+
|
|
85
|
+
Fires when the rollback threshold is hit. See [`crash-rollback.md`](./crash-rollback.md).
|
|
86
|
+
|
|
87
|
+
### `reset`
|
|
88
|
+
|
|
89
|
+
Fires when assignments are cleared (manually via overlay or programmatically). If `experimentId` is set, it's a targeted reset; otherwise a global reset.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## The history tab
|
|
94
|
+
|
|
95
|
+
In the debug overlay, the "History" tab shows the event stream:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
┌─────────────────────────────────────────────────────┐
|
|
99
|
+
│ History [◀ ▶ ▶▶] │
|
|
100
|
+
├─────────────────────────────────────────────────────┤
|
|
101
|
+
│ 12:34:56.123 assignment │
|
|
102
|
+
│ news-card-layout → pip-thumbnail │
|
|
103
|
+
│ reason: targeting-match-weighted │
|
|
104
|
+
├─────────────────────────────────────────────────────┤
|
|
105
|
+
│ 12:34:58.456 contextUpdated │
|
|
106
|
+
│ route: /feed → /article/abc │
|
|
107
|
+
├─────────────────────────────────────────────────────┤
|
|
108
|
+
│ 12:35:01.789 exposure │
|
|
109
|
+
│ theme → dark │
|
|
110
|
+
├─────────────────────────────────────────────────────┤
|
|
111
|
+
│ 12:35:10.234 rollback │
|
|
112
|
+
│ news-card-layout ← pip-thumbnail (3 crashes) │
|
|
113
|
+
├─────────────────────────────────────────────────────┤
|
|
114
|
+
│ 12:35:10.235 assignment │
|
|
115
|
+
│ news-card-layout → responsive │
|
|
116
|
+
│ reason: rollback │
|
|
117
|
+
└─────────────────────────────────────────────────────┘
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Each row is tappable to see details (stack trace for rollbacks, context snapshot for assignments).
|
|
121
|
+
|
|
122
|
+
### Playback controls
|
|
123
|
+
|
|
124
|
+
- **◀** — jump to start of session
|
|
125
|
+
- **◀** — previous event
|
|
126
|
+
- **⏸** — pause
|
|
127
|
+
- **▶** — next event
|
|
128
|
+
- **▶▶** — jump to now (live tail)
|
|
129
|
+
- **⏯** — auto-play (1 event/sec)
|
|
130
|
+
|
|
131
|
+
In playback mode, the overlay shows the state **as of** the selected event. This doesn't affect the running app — it's a read-only view.
|
|
132
|
+
|
|
133
|
+
### Filter
|
|
134
|
+
|
|
135
|
+
The history tab supports filtering by:
|
|
136
|
+
|
|
137
|
+
- Event type
|
|
138
|
+
- Experiment ID
|
|
139
|
+
- Time range
|
|
140
|
+
|
|
141
|
+
### Export
|
|
142
|
+
|
|
143
|
+
Export the history as JSON for attaching to bug reports:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
[
|
|
147
|
+
{
|
|
148
|
+
"type": "assignment",
|
|
149
|
+
"experimentId": "news-card-layout",
|
|
150
|
+
"variantId": "pip-thumbnail",
|
|
151
|
+
"reason": "targeting-match-weighted",
|
|
152
|
+
"timestamp": 1739500000123
|
|
153
|
+
},
|
|
154
|
+
...
|
|
155
|
+
]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Programmatic access
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const engine = useVariantLabEngine();
|
|
164
|
+
const history = engine.getHistory();
|
|
165
|
+
// Returns all events since session start
|
|
166
|
+
|
|
167
|
+
const filtered = history.filter(
|
|
168
|
+
(e) => e.type === "rollback" || e.type === "override"
|
|
169
|
+
);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Subscribing to new events
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const unsubscribe = engine.onHistoryEvent((event) => {
|
|
176
|
+
console.log("New history event:", event);
|
|
177
|
+
// Forward to Sentry, Datadog, etc.
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Clearing history
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
engine.clearHistory();
|
|
185
|
+
// Does not affect assignments, only the event log
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Replay mode
|
|
191
|
+
|
|
192
|
+
An advanced developer feature: replay a recorded session against a different config.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { replaySession } from "@variantlab/core/replay";
|
|
196
|
+
|
|
197
|
+
const recorded = JSON.parse(await readFile("session.json", "utf8"));
|
|
198
|
+
const newConfig = JSON.parse(await readFile("experiments.v2.json", "utf8"));
|
|
199
|
+
|
|
200
|
+
const result = replaySession(recorded, newConfig);
|
|
201
|
+
// {
|
|
202
|
+
// events: [...],
|
|
203
|
+
// differences: [
|
|
204
|
+
// { experimentId: "foo", old: "a", new: "b" }
|
|
205
|
+
// ]
|
|
206
|
+
// }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Useful for:
|
|
210
|
+
|
|
211
|
+
- "What would this user have seen under the new config?"
|
|
212
|
+
- Regression testing ("do any users change assignment between v1 and v2?")
|
|
213
|
+
- Migration validation
|
|
214
|
+
|
|
215
|
+
Replay mode is pure — it doesn't touch the engine state or any storage.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Storage and size limits
|
|
220
|
+
|
|
221
|
+
### Ring buffer
|
|
222
|
+
|
|
223
|
+
History is stored in-memory only by default. The ring buffer size is:
|
|
224
|
+
|
|
225
|
+
- **500 events** (configurable via engine options)
|
|
226
|
+
- Older events are discarded
|
|
227
|
+
|
|
228
|
+
### Bounded memory
|
|
229
|
+
|
|
230
|
+
Each event is small (~100-300 bytes). 500 events is ~150 KB max. Negligible.
|
|
231
|
+
|
|
232
|
+
### Optional persistence
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
createEngine(config, {
|
|
236
|
+
history: {
|
|
237
|
+
persistent: true,
|
|
238
|
+
maxEvents: 1000,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
When persistent, history is written to Storage on each event. Reading on boot restores the previous session's history. Useful for post-crash diagnosis.
|
|
244
|
+
|
|
245
|
+
### Automatic compression
|
|
246
|
+
|
|
247
|
+
In persistent mode, old events are gzip-compressed every 100 events to save space.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Privacy
|
|
252
|
+
|
|
253
|
+
### What's recorded
|
|
254
|
+
|
|
255
|
+
- Experiment IDs
|
|
256
|
+
- Variant IDs
|
|
257
|
+
- Contexts (platform, app version, route, locale, screen size)
|
|
258
|
+
- User IDs (hashed)
|
|
259
|
+
- Attribute keys and values
|
|
260
|
+
|
|
261
|
+
### What's NOT recorded
|
|
262
|
+
|
|
263
|
+
- Full URLs (only route paths)
|
|
264
|
+
- Full user identifiers (only hashes)
|
|
265
|
+
- PII in attributes (we can't detect this — you're responsible for not putting PII in attributes)
|
|
266
|
+
- Telemetry data
|
|
267
|
+
- Stack traces in production (only in dev)
|
|
268
|
+
|
|
269
|
+
### Redaction
|
|
270
|
+
|
|
271
|
+
For safe export, redact before sharing:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const exported = engine.getHistory().map((event) =>
|
|
275
|
+
redactPII(event, { redactKeys: ["email", "phone"] })
|
|
276
|
+
);
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Production history
|
|
280
|
+
|
|
281
|
+
By default, history is **disabled** in production builds. Enable it explicitly:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
createEngine(config, {
|
|
285
|
+
history: { enabled: true },
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Even when enabled, history is in-memory only unless you explicitly opt-in to persistence. This aligns with the privacy-by-default principle.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Cost
|
|
294
|
+
|
|
295
|
+
- **Bundle size**: ~800 bytes (tree-shaken when unused)
|
|
296
|
+
- **Runtime**: ring buffer append is O(1)
|
|
297
|
+
- **Memory**: < 200 KB for default buffer size
|
|
298
|
+
- **Storage**: 0 unless `persistent: true`
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## See also
|
|
303
|
+
|
|
304
|
+
- [`debug-overlay.md`](./debug-overlay.md) — where the history tab lives
|
|
305
|
+
- [`crash-rollback.md`](./crash-rollback.md) — rollback events in the history
|
|
306
|
+
- [`API.md`](../../API.md) — `getHistory`, `onHistoryEvent`
|