@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.
@@ -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`