@loxel.dev/pharos-browser 0.6.0

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,480 @@
1
+ # @loxel.dev/pharos-browser — technical details
2
+
3
+ How the client behaves underneath the API, and the full export reference.
4
+ You do not need any of this to use the package — `README.md` is the guide.
5
+ This is for reading a replay that looks wrong, auditing what leaves the
6
+ browser, or wiring your own transport.
7
+
8
+ ## How a burst becomes one window, and when a trigger produces none
9
+
10
+ De-duplication keeps the **first** trigger's `kind` and `name`. A `TypeError`
11
+ followed half a second later by a manual capture ships as one
12
+ `kind: "exception", occurrences: 2` window, and the manual kind is not recorded
13
+ anywhere. That is the de-duplication rule, not an oversight: one upload per
14
+ burst, described by the trigger whose pre-roll explains it.
15
+
16
+ A trigger does **not** guarantee a window. Two shapes fail closed — a buffer
17
+ with no extractable snapshot, and a window whose events all postdate the error
18
+ (possible when memory pressure has evicted everything older). Neither produces
19
+ a window, a stat or a degradation record, but **since `0.5.0` neither is
20
+ silent**: `startReplayUpload` counts both into the `droppedWindows` it reports
21
+ to Pharos, and `RecorderOptions.onWindowDropped` is the hook if you are wiring
22
+ your own transport. A recorder started with a bare `startRecording({sink})`
23
+ and no `onWindowDropped` still drops them without a trace.
24
+
25
+ ## Why rrweb is pinned exactly
26
+
27
+ rrweb is pinned to **exactly `2.1.1`, with no caret** — deliberately. A replay
28
+ recorder's *fidelity* and its *privacy behaviour* both change with the version:
29
+ what rrweb serializes, which nodes it routes through `maskTextFn`, and which
30
+ attributes it invents (`rr_dataURL` and friends) are all version-specific, and
31
+ this package's privacy wiring is audited against that exact build. Widening the
32
+ range would let a `bun install` change what a recorder sends. Bump it
33
+ deliberately, re-run the leak test, and treat the result as a reviewable change.
34
+
35
+ ## Buffer ceiling and graceful degradation
36
+
37
+ **Limits it enforces on itself.** The buffer has a hard **30 MB ceiling that
38
+ wins over the configured pre-roll**: on a heavy page the buffer holds less
39
+ history than requested and records that it did. That ceiling counts
40
+ **serialized bytes** — the size the events would occupy on the wire — and is
41
+ **not** a heap budget: the retained JavaScript objects behind them cost several
42
+ times that, so read `30 MB` as a bound on captured data, never as "the recorder
43
+ uses 30 MB of memory". `PerformanceObserver` long-task entries back off
44
+ checkpoint frequency first and pointer sampling second, recovering gradually.
45
+ Every transition is listed in the window's `degraded` array with its reason, so
46
+ a sparse replay is explained rather than looking like a bug.
47
+
48
+ ## Flushing on unload
49
+
50
+ **Unload.** A post-roll is a timer, and an unloading page does not run timers.
51
+ The recorder listens for `pagehide` and for `visibilitychange` to `hidden` and
52
+ flushes the in-flight window immediately, marking it `trigger.unloaded: true`.
53
+ An error moments before the user navigates away is the most interesting kind
54
+ there is; without this it would be the one kind that never shipped. Note the
55
+ cost: `hidden` also means a plain tab switch, so `unloaded: true` marks "this
56
+ window was cut short", **not** "the user left" — the session can carry on and
57
+ produce more windows afterwards.
58
+
59
+ ## The wire format, IndexedDB rest, and droppedWindows
60
+
61
+ **What goes on the wire.** The window is serialized to JSON, gzipped with
62
+ `CompressionStream` (falling back to uncompressed, flagged `compressed: false`,
63
+ on browsers that lack it), and sent as a **JSON envelope with the blob
64
+ base64-encoded** — `{sessionId, trigger, degraded, config, compressed,
65
+ droppedWindows, blob}` — not as `application/octet-stream`. The envelope form
66
+ is required because raw bytes have nowhere to carry any of those other fields.
67
+ **Base64 inflates the payload by about 33%**, and the client's own window limit
68
+ (`MAX_WINDOW_BYTES`) is *derived* from the server's transport cap with that
69
+ inflation accounted for, so a window this client agrees to send is one the
70
+ transport can actually deliver. A window over the limit is **rejected, never
71
+ truncated** — a truncated gzip stream is not a shorter replay, it is an
72
+ unreadable one — and it is counted in `droppedWindows`. **This holds on both
73
+ paths**: the live POST and the unload-time write to IndexedDB apply the same
74
+ comparison to the same bytes.
75
+
76
+ **Replay data rests in the user's browser, in IndexedDB.** When a trigger is
77
+ flushed by the page-lifecycle path — `pagehide`, or `visibilitychange` to
78
+ `hidden` — the window is not POSTed: `sendBeacon` and `fetch(keepalive)` both
79
+ cap at **64 KiB** by browser design (the Fetch specification returns a network
80
+ error past that), and a replay window is megabytes. So it is written to an
81
+ IndexedDB database named `pharos-replay`.
82
+
83
+ **Only `pagehide` genuinely *cannot* send.** `hidden` also means a plain tab
84
+ switch (see the flush note above), and that page is fully alive with a working
85
+ `fetch` — so a persisted window is **drained and POSTed as soon as the tab
86
+ becomes `visible` again**, not on the next page load. That turns the tab-switch
87
+ case from "delayed until the user reloads your site" — which for a long-lived
88
+ SPA can mean *never*, and the window then expires unsent — into "delayed by
89
+ seconds". A `pagehide` window really is delayed until the next
90
+ `startReplayUpload()`, and nothing can do better: the page is gone.
91
+
92
+ Three properties of that rest, each enforced and tested:
93
+
94
+ - it is **consent-bound** — `startReplayUpload()`'s `stop()` deletes every
95
+ persisted window, not just the in-memory buffer, so withdrawing consent
96
+ leaves nothing of the user's anywhere. (A recorder started with a bare
97
+ `startRecording({sink})` clears only its buffer; `stop()` there knows
98
+ nothing about IndexedDB. Call `clearPersisted()` yourself if you wire your
99
+ own transport.)
100
+ - it is **never read or uploaded until the application opts in again** — the
101
+ drain runs on `startReplayUpload()`, which is the consent event; and
102
+ - **the 24-hour local expiry (`LOCAL_TTL_MS`) is enforced on the next visit,
103
+ not by a clock.** A record older than 24 hours is deleted **unsent** by the
104
+ first `startReplayUpload()` or the first `persistWindow()` after it lapses.
105
+ **Nothing runs while the page does not**: if the user never returns to your
106
+ site, the record stays on their device until the browser evicts the origin's
107
+ storage. There is no browser mechanism that can do better — a page that
108
+ never executes cannot delete anything — so read this as "it is never
109
+ *uploaded* after 24 hours, and it is removed the next time your site runs",
110
+ which is what the code actually provides.
111
+
112
+ **An oversized window is never persisted either.** The same
113
+ `MAX_WINDOW_BYTES` gate as the live path applies before the write, because a
114
+ window the server is certain to refuse would otherwise sit on the user's disk
115
+ and be retried on every load for 24 hours, failing every time. It is dropped
116
+ and counted instead.
117
+
118
+ If IndexedDB is unavailable or refuses the write, the window is lost and
119
+ counted; it is never queued in memory across a navigation.
120
+
121
+ **`droppedWindows` — what the number actually means.** It is a **cumulative
122
+ per-session count** (the server merges it with `max(existing, incoming)`, so a
123
+ delta would under-count). **Cumulative means across page loads**, not within
124
+ one: the running total lives in `sessionStorage` next to the durable session
125
+ id and is seeded from there on every `startReplayUpload()`, so a tab that
126
+ reloads three times during an incident, losing two windows each time, reports
127
+ `6` and not `2`. It is stored **with the session id it belongs to** and is
128
+ discarded if they disagree, so a host that passes a fresh `sessionId` per page
129
+ load gets a per-load count rather than another session's losses. `stop()`
130
+ clears it along with the id — a pre-withdrawal count cannot ride out on a
131
+ post-withdrawal upload.
132
+
133
+ It counts every window this session lost:
134
+ oversized windows (on either path), failed or refused POSTs, IndexedDB quota
135
+ evictions and other local-storage failures, and the two B1 fail-closed drops
136
+ above. It does **not** count a window that simply *expired* locally after 24
137
+ hours — that is the retention the user was promised working, not data lost to
138
+ a fault. It does **not** count buffer segment evictions
139
+ under memory pressure — those *shorten* a window rather than losing one, and
140
+ they are already reported separately in the window's `degraded` array with
141
+ their own count. **`createUploadSink` used on its own counts only its own
142
+ delivery failures**; `startReplayUpload` is what makes the number complete.
143
+
144
+ **What the server can and cannot do with it.** Nothing. The bytes are
145
+ compressed by this client and **stored opaque** — Loxel exposes no `inflate`
146
+ binding today, so the Pharos server cannot decompress an upload. (The runtime
147
+ *does* link zlib — `Archive.targz_*` — so an inflate intrinsic is a modest
148
+ addition rather than an impossibility; the constraint is the current binding
149
+ surface, not the platform.) **The decision to store opaque bytes stands
150
+ regardless**, and its consequence is stated plainly because it is what the
151
+ design chose: **there is no server-side validation or audit of uploaded replay
152
+ content.** Only slice B3's console, in the browser, inflates a window.
153
+
154
+ ## Per-application replay configuration
155
+
156
+ **Per-application replay configuration has no admin surface yet.** The TTL and
157
+ session cap below are per-application columns an operator sets **by direct
158
+ SQL**; there is no admin API or console screen for them, so in practice every
159
+ application currently gets the platform default (14 days, 500 sessions). The
160
+ bootstrap response and the ingest path already read whatever is stored, so
161
+ closing this is a UI/route addition, not a data-model change.
162
+
163
+ ## Server-side storage notes
164
+
165
+ Two storage facts worth knowing if you are reading the server side (both
166
+ documented in full in
167
+ [the Pharos README](../../README.md#session-replay-privacy-contract) and the
168
+ `loxel-mongo` README):
169
+
170
+ - `loxel-mongo`'s GridFS **deliberately deviates from the GridFS
171
+ specification** by writing `expiresAt` onto every chunk, because a TTL index
172
+ on `fs.files` does **not** cascade to `fs.chunks` — without the deviation,
173
+ expiry would delete the metadata and strand every chunk forever.
174
+ - **MongoDB's TTL monitor expires only BSON dates.** A field stored as a
175
+ Number never expires, silently, so every `expiresAt` in this pipeline is a
176
+ BSON date and nothing may "simplify" one into an epoch number.
177
+
178
+ ## API
179
+
180
+ - `PharosBrowserClient.init(config): Promise<PharosBrowserClient>` — bootstraps
181
+ and opens the live stream. `config.context` reserved fields
182
+ (`contextKey`/`application`/`release`/`sessionId`) are split from
183
+ `attributes` on the wire automatically; put anything else you want to
184
+ target on into `attributes`.
185
+ - `flag<T>(key, defaultValue): T` — synchronous map lookup, no network call,
186
+ no evaluation. Returns `defaultValue` for any key not present in the
187
+ current flags map (unknown key, or a `server_side`-only flag the bootstrap
188
+ endpoint never sent).
189
+ - `on(event, cb): () => void` — subscribe to `"change"` (a new flags payload
190
+ was applied — fires exactly once per accepted server push) or `"error"`
191
+ (stream/bootstrap failure). Returns an unsubscribe function.
192
+ - `identify(context): Promise<void>` — re-bootstraps as a new context and
193
+ reopens the stream against the fresh `streamToken`. **This is a full HTTP
194
+ round trip, not a local re-key** — call it on meaningful identity changes
195
+ (login, plan change), not per-render.
196
+ - `captureException(err, opts?): void` — reports one exception; see
197
+ "Error reporting" above. `opts` is `{site?: string, attributes?: Record<string, unknown>}`.
198
+ Never throws; a failed POST is reported via `on("error")`. A no-op after
199
+ `close()`.
200
+ - `attachReplay(handle): void` — routes this client's error paths into a
201
+ running recorder (see "Session replay" above). **Throws if a different
202
+ recorder is already attached** — rrweb records into one process-wide session,
203
+ so a second `startRecording()` silently takes the first one over, and this is
204
+ the one place a client can see both. Call `detachReplay()` first to swap
205
+ deliberately; re-attaching the same handle is a no-op.
206
+ - `detachReplay(): void` — stops routing errors into the recorder. Does **not**
207
+ stop the recorder: call `handle.stop()` for that (which also discards the
208
+ buffer). A no-op after `close()`, like
209
+ `captureException()` and `identify()`: `attachReplay()` after `close()`
210
+ registers nothing.
211
+ - `close(): void` — closes the stream, cancels any pending reconnect, and
212
+ unregisters auto-capture listeners. Terminal; construct a new client to
213
+ resume. Calling `identify()` after `close()` is a no-op (no fetch, no
214
+ `"change"`) rather than a half-resume.
215
+ - `init()`/`identify()` **reject (throw) on a failed bootstrap call**
216
+ (non-2xx HTTP status or a network error) — callers should `try`/`catch`
217
+ around them. This is distinct from the `on("error")` stream events, which
218
+ cover the live SSE connection after a successful bootstrap.
219
+
220
+ ### Replay exports
221
+
222
+ The package root also exports the recorder entry points described under
223
+ "Session replay". Everything below is the whole replay surface — if it is not
224
+ here, it is internal.
225
+
226
+ Exports marked **`/replay`** come from `@loxel.dev/pharos-browser/replay`;
227
+ everything else is on the package root. That line is where `rrweb` enters a
228
+ bundle — see [Bundle size](#bundle-size).
229
+
230
+ - **`/replay`** `startRecording(options): RecorderHandle` — starts a recorder. `options` is
231
+ `{sink, onWindowDropped?, preRollMs?, postRollMs?, maxBytes?, pointerHz?}`
232
+ plus injectable seams for tests (`now`, `scheduleIdle`,
233
+ `setTimer`/`clearTimer`, `target`, `lifecycleTarget`, `doc`). The handle is
234
+ `{config, stop(), trigger(source), flushNow(), stats()}`. For uploading to
235
+ Pharos, prefer `startReplayUpload` below.
236
+ - `resolveConfig(opts): ResolvedRecorderConfig` — the clamping the recorder
237
+ applies to its options, exposed so you can see what a configuration will
238
+ actually become before starting. `handle.config` is the same object for a
239
+ running recorder.
240
+ - `MAX_BUFFER_BYTES` — the 30 MB serialized-size ceiling. Configuration can
241
+ lower it; nothing raises it.
242
+ - `RECORDER_DEFAULTS` — `{preRollMs: 30_000, postRollMs: 10_000, pointerHz: 20}`.
243
+ - `ACTIVATION_EVENT_TAG` — `"pharos-activation"`, the tag on the custom rrweb
244
+ event carrying the activation mode. Match on the constant, not the string.
245
+ - `beginsWithSnapshot(events): boolean` — true when an event array opens with a
246
+ Meta or FullSnapshot event, i.e. is replayable on its own. A sink can assert
247
+ it; a window from this recorder always satisfies it.
248
+ - **`/replay`** `startReplayUpload(options): ReplayUploadHandle` — a recorder wired to
249
+ Pharos (see "Uploading to Pharos" above). `options` is every
250
+ `startRecording` option except `sink`/`onWindowDropped`, plus
251
+ `{endpoint, appKey, fetchImpl?, compress?, sessionId?, indexedDB?,
252
+ sessionStorage?}`. The handle is a `RecorderHandle` plus `sessionId`,
253
+ `drops()` and `settled()`.
254
+ - `PharosBrowserClient.startReplay(options?): Promise<ReplayUploadHandle | null>`
255
+ — the same thing, with `endpoint`, `appKey` and `sessionId` taken from the
256
+ client, and `attachReplay` already called. **Prefer this**: it is the only
257
+ form in which replay and errors cannot end up under two different session
258
+ ids, and the only one that keeps `rrweb` out of your bundle until it runs
259
+ (it loads the recorder through a dynamic `import()`). Resolves to `null`
260
+ after `close()`; rejects only if the chunk cannot be loaded.
261
+ - `createUploadSink(options): Sink` — the live-POST sink alone, for an
262
+ application wiring its own recorder. **Counts only its own delivery
263
+ failures** in `droppedWindows` unless you inject a shared `DropLedger`.
264
+ - `encodeWindow(window)` — JSON + gzip, returning `{body, compressed}`.
265
+ - `postEnvelope(body, meta, target)` / `envelopeMetaFor(...)` — the one
266
+ implementation of the wire format, exported so a custom transport reuses it
267
+ rather than re-deriving it.
268
+ - `MAX_WINDOW_BYTES` — the largest decoded window this client will send,
269
+ derived from the server's transport cap and base64's 4/3 inflation.
270
+ - `persistWindow` / `drainPersisted` / `clearPersisted` / `LOCAL_TTL_MS` —
271
+ the IndexedDB rest described above. `clearPersisted` is the consent
272
+ primitive; `startReplayUpload`'s `stop()` calls it for you. Both
273
+ `persistWindow` and `drainPersisted` delete records older than
274
+ `LOCAL_TTL_MS` as they go — that is the only thing that enforces the local
275
+ expiry, so neither running means nothing expires.
276
+ - `quotaDroppedWindows()` / `resetQuotaDroppedWindows()` — the quota-eviction
277
+ count on `persistWindow`'s *default* ledger, for callers that inject none.
278
+ - `createDropLedger()` — the cumulative `droppedWindows` ledger, if you are
279
+ assembling the pieces yourself.
280
+ - **`/replay`** `resolveSessionId(explicit, storage)` / `SESSION_STORAGE_KEY` — the durable
281
+ session-id rule, exported so a host can see or pre-seed it.
282
+ `SESSION_DROPS_KEY` is where the session's cumulative `droppedWindows` rides
283
+ alongside it, scoped to that id.
284
+ - Types: `ActivationMode`, `DegradationReason`, `DegradationRecord`,
285
+ `DropLedger`, `DropReason`, `PostTarget`, `RecorderHandle`,
286
+ `RecorderOptions`, `RecorderStats`, `ReplayUploadHandle`,
287
+ `ReplayUploadOptions`, `ReplayWindow`, `ResolvedRecorderConfig`, `Sink`,
288
+ `TriggerInfo`, `TriggerKind`, `TriggerSource`, `UploadEnvelopeMeta`,
289
+ `UploadOptions`, `WindowDropReason`.
290
+
291
+ ## Stream resilience
292
+
293
+ The browser's native `EventSource` already retries transient drops on its
294
+ own. The one case it cannot recover from is an **expired stream session**
295
+ (the server holds a 10-minute token TTL, refreshed only at connect): the
296
+ browser sees repeated errors and keeps retrying the same dead token forever.
297
+ When `EventSource` settles into its terminal `CLOSED` state, this client
298
+ re-bootstraps once (short fixed backoff) to mint a fresh token and reconnect.
299
+ It does not attempt a queue or exponential ramp — if the server is down, the
300
+ next successful `identify()`/reconnect will resync from scratch, and `flag()`
301
+ keeps serving the last-known values in the meantime.
302
+
303
+ Only payloads with `version >= current` are applied; stale (older-version)
304
+ pushes are ignored. An equal-version push (e.g. the replay a fresh stream
305
+ sends on connect) is still applied and still fires `change`.
306
+
307
+ ## Wiring a recorder yourself
308
+
309
+ ```typescript
310
+ import { PharosBrowserClient } from "@loxel.dev/pharos-browser";
311
+ import { startRecording } from "@loxel.dev/pharos-browser/replay";
312
+
313
+ const client = await PharosBrowserClient.init({ /* … */ });
314
+
315
+ const recorder = startRecording({
316
+ // B1 ships no network code. The sink receives a replayable window; slice B2
317
+ // replaces this with a real uploader.
318
+ sink: (window) => console.log("replay window", window.events.length),
319
+ preRollMs: 30_000, // default
320
+ postRollMs: 10_000, // default
321
+ pointerHz: 20, // default
322
+ });
323
+
324
+ // Route this client's errors into the recorder, so an error flushes a window.
325
+ client.attachReplay(recorder);
326
+
327
+ // Consent withdrawn: capture stops and the buffer is DISCARDED. Detach first —
328
+ // the client keeps the handle it was given, and `attachReplay` refuses a second
329
+ // recorder while one is attached, stopped or not.
330
+ client.detachReplay();
331
+ recorder.stop();
332
+ ```
333
+
334
+ ### Uploading to Pharos
335
+
336
+ `startReplayUpload` is the entry point that turns the recorder into a
337
+ recorder-with-a-destination. It starts a recorder, routes its windows to
338
+ Pharos, drains anything a previous page load left behind, and makes `stop()` a
339
+ total delete.
340
+
341
+ ```typescript
342
+ import { PharosBrowserClient } from "@loxel.dev/pharos-browser";
343
+ import { startReplayUpload } from "@loxel.dev/pharos-browser/replay";
344
+
345
+ const client = await PharosBrowserClient.init({ /* … */ });
346
+
347
+ const recorder = startReplayUpload({
348
+ endpoint: "https://pharos-ops.app/api/v1/client/replay",
349
+ appKey: "phc_…", // the same CLIENT key the rest of the SDK uses
350
+ sessionId: myAppSessionId, // optional — see "Sessions" below
351
+ // every startRecording() option is accepted too
352
+ preRollMs: 30_000,
353
+ postRollMs: 10_000,
354
+ });
355
+
356
+ client.attachReplay(recorder);
357
+
358
+ // Consent withdrawn.
359
+ client.detachReplay();
360
+ recorder.stop(); // buffer discarded AND IndexedDB cleared
361
+ await recorder.settled(); // optional: wait for that clear to finish
362
+ ```
363
+
364
+ **Sessions.** Windows group server-side by `sessionId`. Pass your own if your
365
+ application already has a session concept — replay then groups the same way
366
+ your errors and flags do. Otherwise a durable id is kept in `sessionStorage`
367
+ under `pharos-replay-session`, so **a reload or a same-tab navigation stays one
368
+ session** rather than minting a new one per page load. `stop()` removes it, so
369
+ recording again after a consent withdrawal is a *new* session, never a
370
+ continuation of the one the user withdrew from.
371
+
372
+ **`sessionId`: replay and errors are joined by it, or not at all.**
373
+ `PharosContext.sessionId` is what rides the errors envelope and what the
374
+ server stores on an error row. `startReplayUpload` mints **its own** unless you
375
+ pass one. **If they differ, an error and the replay window captured around that
376
+ same error cannot be joined** — which is precisely the correlation the replay
377
+ console exists to perform. Two ways not to get this wrong:
378
+
379
+ - **`await client.startReplay(options)`** — starts a recorder wired to that
380
+ client's base URL, client key and `PharosContext.sessionId`, and attaches it.
381
+ One id by construction; nothing to remember. It is **async**: it loads the
382
+ recorder on demand, so you never import `/replay` yourself and never pay for
383
+ rrweb unless you call it.
384
+ - `client.attachReplay(handle)` **warns** (`console.warn`, once) when the
385
+ handle's `sessionId` disagrees with the context's. It does not repair it:
386
+ windows already uploaded under the other id cannot be re-keyed from here,
387
+ and only you can say which id is authoritative.
388
+
389
+ With no `PharosContext.sessionId` at all, replay falls back to its durable
390
+ per-tab id and errors carry none — consistent, and nothing to join on either
391
+ side.
392
+
393
+ ## Full version history
394
+
395
+ - `0.6.0` — **published to npm as `@loxel.dev/pharos-browser`, and rrweb is no
396
+ longer bundled into every consumer.** Two changes, one release.
397
+
398
+ **Packaging.** Adds an `exports` map, `main`/`module`/`types`, `files`,
399
+ repository metadata and `publishConfig.access: public`, and a real build
400
+ (`bun build` emits `dist/`, with `rrweb` left external; `tsc -p
401
+ tsconfig.build.json` emits declarations beside it). Before this the package
402
+ pointed `module`/`types` straight at TypeScript source with no `exports`
403
+ map, so a bare specifier could not be resolved from outside the monorepo at
404
+ all — the reason `apps/alvita/frontend/vite.config.ts` carries an alias to
405
+ the source file. Driven by the first out-of-repo consumer, `qute-code`,
406
+ whose Docker build context cannot reach that directory. (The package was
407
+ `@pharos/browser` while it was monorepo-only.)
408
+
409
+ **BREAKING — the recorder moved off the root entry.** `startRecording`,
410
+ `startReplayUpload`, `resolveSessionId`, `SESSION_STORAGE_KEY` and
411
+ `SESSION_DROPS_KEY` are now imported from `@loxel.dev/pharos-browser/replay`,
412
+ and **`client.startReplay()` returns a promise** — it dynamic-imports the
413
+ recorder. Everything else, including the whole privacy surface and the
414
+ rrweb-free upload pieces, is unchanged on the root.
415
+
416
+ Why: `rrweb` is ~63 kB gzipped and was reachable from the root entry, so it
417
+ landed in every consumer's bundle whether or not anything recorded —
418
+ measured at **+204 kB raw / +63 kB gzip** on an application with replay
419
+ switched off. A dynamic import rather than a static one keeps the chunk in
420
+ the deployment, so enabling replay stays a configuration change rather than
421
+ a redeploy; it simply stops being downloaded by applications that never use
422
+ it. A test now asserts the root does **not** re-export the recorder, because
423
+ adding that back is a one-line "convenience" that silently undoes all of it.
424
+
425
+ The README's "zero runtime dependencies" claim was also stale and is
426
+ corrected: `rrweb` has been a dependency since the recorder shipped.
427
+ - `0.5.2` — **stops shipping most checkpoint segments twice** (#793 follow-up).
428
+ A segment shipped by the unload path while still OPEN — which a
429
+ `visibilitychange` to hidden does on an ordinary tab switch the page
430
+ survives — then closed normally and was shipped AGAIN, in full, under the
431
+ same `trigger.at`: measured on real data, nearly every segment reached the
432
+ server twice, one `unloaded: true` and one `unloaded: false`, roughly
433
+ doubling ingest and storage for always-on recording. `onSegmentClosed` now
434
+ suppresses the close when the segment has not grown, and ships it when it
435
+ has (the closed segment is a superset, and the server supersedes the partial
436
+ copy). No API change; no export added or removed. The server half —
437
+ idempotence on `(applicationId, sessionId, checkpoint, trigger.at)` — is in
438
+ `infrastructure/persistence/replay_store.lox`, and is the half that is
439
+ load-bearing: a window persisted to IndexedDB and drained on a later page
440
+ load can arrive after a fresher copy, which no client can suppress.
441
+ (Always-on recording itself shipped in #873 without a version entry; this
442
+ is the first bump since.)
443
+ - `0.5.1` — replay-upload correctness fixes (slice B2 review, #793).
444
+ `droppedWindows` is now cumulative **across page loads**, carried in
445
+ `sessionStorage` beside the durable session id and scoped to it — the
446
+ per-page-load count it used to send was silently reduced to the worst single
447
+ load by the server's `max()` merge. A window persisted by a **tab switch**
448
+ is drained and POSTed when the tab becomes visible again instead of waiting
449
+ for a full page load. Adds `PharosBrowserClient.startReplay()` and a
450
+ `console.warn` from `attachReplay` when replay and errors would be reported
451
+ under different session ids. No existing behaviour is removed; every
452
+ `0.5.0` export is unchanged.
453
+ - `0.5.0` — session-replay upload (slice B2, #793): `startReplayUpload()` —
454
+ gzip + a base64 JSON envelope to `POST /api/v1/client/replay`, IndexedDB
455
+ rest for windows triggered during unload (drained on the next start, 24 h
456
+ local TTL) and a `stop()` that clears it, a durable per-tab `sessionId`, and
457
+ one cumulative `droppedWindows` covering delivery failures, quota evictions
458
+ and B1's two fail-closed window drops, plus local-storage failures. Adds
459
+ `createUploadSink`,
460
+ `encodeWindow`, `postEnvelope`, `envelopeMetaFor`, `MAX_WINDOW_BYTES`,
461
+ `persistWindow`/`drainPersisted`/`clearPersisted`/`LOCAL_TTL_MS`,
462
+ `createDropLedger`, `resolveSessionId`, and `RecorderOptions.onWindowDropped`
463
+ (additive; without it B1's drops stay silent as before). No existing
464
+ behaviour changes.
465
+ - `0.4.0` — session-replay recorder (slice B1, #793): `startRecording()` with
466
+ a pluggable sink, rrweb 2.1.1 driven entirely from the privacy policy, a
467
+ checkpoint-segmented ring buffer with a 30 MB ceiling, activation-mode
468
+ capture with no key identity, error-driven windows with de-duplication, an
469
+ unload flush on `pagehide`/`visibilitychange`, and self-degradation under
470
+ long tasks. Adds `attachReplay()`/`detachReplay()` to `PharosBrowserClient`.
471
+ No network code — upload lands in slice B2.
472
+ - `0.3.0` — session-replay privacy contract (#793): `pharos-exclude`/
473
+ `pharos-mask` markers, field-sensitivity detection, and attribute/URL/
474
+ `style` scrubbing, plus `collectRecordedStrings` for verifying what a
475
+ recorder would send. `collectRecordedStrings`, `masksValue`, and
476
+ `scrubAttribute` change observable behavior.
477
+ - `0.2.0` — error reporting: `captureException`, auto-capture of `window`
478
+ `"error"`/`"unhandledrejection"` events (default on, `init({errors: false})`
479
+ to opt out), and `parseStack` (V8 + Firefox stack-trace parsing).
480
+ - `0.1.0` — initial release: bootstrap + SSE stream, `flag`/`on`/`identify`/`close`.