@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.
- package/README.md +360 -0
- package/TECHNICAL-DETAILS.md +480 -0
- package/dist/index-bc4bw3ba.js +844 -0
- package/dist/index-c3taa3cg.js +9 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +504 -0
- package/dist/privacy/attributes.d.ts +12 -0
- package/dist/privacy/collect.d.ts +8 -0
- package/dist/privacy/markers.d.ts +7 -0
- package/dist/privacy/mask.d.ts +2 -0
- package/dist/privacy/policy.d.ts +27 -0
- package/dist/privacy/sensitivity.d.ts +1 -0
- package/dist/replay/budget.d.ts +49 -0
- package/dist/replay/buffer.d.ts +155 -0
- package/dist/replay/config.d.ts +190 -0
- package/dist/replay/drops.d.ts +46 -0
- package/dist/replay/index.d.ts +2 -0
- package/dist/replay/index.js +8 -0
- package/dist/replay/interactions.d.ts +48 -0
- package/dist/replay/persist.d.ts +75 -0
- package/dist/replay/privacy-hooks.d.ts +101 -0
- package/dist/replay/recorder.d.ts +20 -0
- package/dist/replay/triggers.d.ts +48 -0
- package/dist/replay/upload.d.ts +194 -0
- package/dist/replay/wire.d.ts +132 -0
- package/dist/stack.d.ts +8 -0
- package/dist/wire-992wvzs1.js +926 -0
- package/package.json +62 -0
|
@@ -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`.
|