@anyreplay/browser 0.1.4 → 0.2.1
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 +20 -2
- package/dist/index.cjs +1188 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +599 -97
- package/dist/index.d.ts +599 -97
- package/dist/index.js +1172 -135
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,335 @@
|
|
|
1
|
+
interface RecordedEvent {
|
|
2
|
+
type: number;
|
|
3
|
+
timestamp: number;
|
|
4
|
+
data?: unknown;
|
|
5
|
+
}
|
|
6
|
+
interface SessionMeta {
|
|
7
|
+
startedAt: number;
|
|
8
|
+
url?: string;
|
|
9
|
+
lang?: string;
|
|
10
|
+
userAgent?: string;
|
|
11
|
+
screenWidth?: number;
|
|
12
|
+
screenHeight?: number;
|
|
13
|
+
referrer?: string;
|
|
14
|
+
/** `maxSessionsPerMonth`, sent only with chunk 0 of a new session. */
|
|
15
|
+
sessionCap?: number;
|
|
16
|
+
/** What this SDK records on — always `web` for this one. */
|
|
17
|
+
platform?: string;
|
|
18
|
+
/** This SDK, so a session says which release recorded it. */
|
|
19
|
+
sdk?: {
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
};
|
|
23
|
+
/** Only from a wrapper that knows the app (`ShellInfo` in recorder.ts). */
|
|
24
|
+
appVersion?: string;
|
|
25
|
+
deviceModel?: string;
|
|
26
|
+
}
|
|
27
|
+
interface ChunkFlags {
|
|
28
|
+
hasError?: boolean;
|
|
29
|
+
hasRageClick?: boolean;
|
|
30
|
+
pageCount?: number;
|
|
31
|
+
}
|
|
32
|
+
interface TransportContext {
|
|
33
|
+
projectKey: string;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
visitorId: string;
|
|
36
|
+
/** The first chunk number to use. Non-zero when a page resumes a session. */
|
|
37
|
+
initialSeq?: number;
|
|
38
|
+
}
|
|
39
|
+
interface TransportHooks {
|
|
40
|
+
onStopped?: (reason: string) => void;
|
|
41
|
+
onSent?: (seq: number, eventCount: number) => void;
|
|
42
|
+
/** Called with the next free chunk number before a request using the current one leaves. */
|
|
43
|
+
onSeqReserved?: (nextSeq: number) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Called when events had to be dropped and the recording needs a fresh
|
|
46
|
+
* full snapshot to make sense again. The recorder answers by taking one
|
|
47
|
+
* (`record.takeFullSnapshot`); until it arrives, DOM changes are not kept,
|
|
48
|
+
* since they would describe nodes the server never saw.
|
|
49
|
+
*/
|
|
50
|
+
onResyncNeeded?: () => void;
|
|
51
|
+
/** Debug-only explanations: an event dropped, a body limit lowered. */
|
|
52
|
+
onWarning?: (message: string) => void;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Ships buffered events to ingest.
|
|
56
|
+
*
|
|
57
|
+
* Three properties matter more than throughput here, because this code runs
|
|
58
|
+
* inside somebody else's product:
|
|
59
|
+
*
|
|
60
|
+
* - It must never throw into the host page.
|
|
61
|
+
* - It must never keep retrying a request the server has rejected on its
|
|
62
|
+
* merits (a bad key, a disallowed origin) — that is an infinite loop.
|
|
63
|
+
* - It must not lose the session to a network that is merely away: a train
|
|
64
|
+
* tunnel, a laptop lid, a flaky café connection. It backs off and waits
|
|
65
|
+
* instead of giving up, and keeps a bounded buffer meanwhile.
|
|
66
|
+
*
|
|
67
|
+
* And one property of every body it sends: under ingest's size limit. An
|
|
68
|
+
* outage leaves up to 2000 events waiting; they go out as a run of chunks of
|
|
69
|
+
* at most 200 events and about 256 KB each, in order, never as one body ingest
|
|
70
|
+
* would refuse.
|
|
71
|
+
*/
|
|
72
|
+
declare class Transport {
|
|
73
|
+
private readonly options;
|
|
74
|
+
private readonly context;
|
|
75
|
+
private readonly hooks;
|
|
76
|
+
private buffer;
|
|
77
|
+
/** The chunk being delivered: built, numbered, maybe already attempted. */
|
|
78
|
+
private pending;
|
|
79
|
+
/** The next unused chunk number. */
|
|
80
|
+
private seq;
|
|
81
|
+
private consecutiveFailures;
|
|
82
|
+
private consecutiveRefusals;
|
|
83
|
+
private state;
|
|
84
|
+
private timer;
|
|
85
|
+
private retryTimer;
|
|
86
|
+
private pendingMeta;
|
|
87
|
+
private flags;
|
|
88
|
+
private inFlight;
|
|
89
|
+
private staleRecoveries;
|
|
90
|
+
/** Our own backoff after a failure. Cleared when the network comes back. */
|
|
91
|
+
private backoffUntil;
|
|
92
|
+
/** What the server asked for in `Retry-After`. Not cleared by anything but time. */
|
|
93
|
+
private serverWaitUntil;
|
|
94
|
+
/** Largest body this transport will build; lowered by a `413`. */
|
|
95
|
+
private bodyLimit;
|
|
96
|
+
private targetBytes;
|
|
97
|
+
/**
|
|
98
|
+
* Set when events were dropped and a full snapshot has been asked for:
|
|
99
|
+
* DOM changes are not kept until it arrives.
|
|
100
|
+
*/
|
|
101
|
+
private awaitingSnapshot;
|
|
102
|
+
/** Serialised events, so a retry or a re-split does not stringify a snapshot twice. */
|
|
103
|
+
private readonly serialised;
|
|
104
|
+
constructor(options: ResolvedOptions, context: TransportContext, hooks?: TransportHooks);
|
|
105
|
+
get isStopped(): boolean;
|
|
106
|
+
/** Events not yet delivered, including a chunk waiting to be retried. */
|
|
107
|
+
get bufferedCount(): number;
|
|
108
|
+
/** The number the next undelivered chunk will carry — the count of chunks delivered, for a fresh session. */
|
|
109
|
+
get sentChunks(): number;
|
|
110
|
+
/** The most events kept while deliveries fail. Older ones are dropped first. */
|
|
111
|
+
private get bufferLimit();
|
|
112
|
+
/** Attaches metadata to the next chunk. Sent once, with the session's first chunk. */
|
|
113
|
+
setMeta(meta: SessionMeta): void;
|
|
114
|
+
mergeFlags(flags: ChunkFlags): void;
|
|
115
|
+
start(): void;
|
|
116
|
+
stop(reason: string): void;
|
|
117
|
+
push(event: RecordedEvent): void;
|
|
118
|
+
/**
|
|
119
|
+
* The network is back (`online`), or the page is visible again: try now
|
|
120
|
+
* rather than at the end of the backoff. A server's `Retry-After` still
|
|
121
|
+
* holds — it was not about our connection.
|
|
122
|
+
*/
|
|
123
|
+
resume(): void;
|
|
124
|
+
/**
|
|
125
|
+
* Sends whatever is buffered, as as many chunks as it takes. Safe to call
|
|
126
|
+
* when there is nothing to send, and while waiting out a failure, when it
|
|
127
|
+
* does nothing. `force` skips our own backoff (an explicit `flush()` from
|
|
128
|
+
* the page) but never the server's `Retry-After`.
|
|
129
|
+
*/
|
|
130
|
+
flush(force?: boolean): Promise<void>;
|
|
131
|
+
/**
|
|
132
|
+
* Builds the next chunk from the front of the buffer: at most
|
|
133
|
+
* `maxEventsPerChunk` events and `target` bytes, or one event alone up to
|
|
134
|
+
* `limit`. An event too large for any chunk is dropped here — it would be
|
|
135
|
+
* refused, and refused again, and hold up everything behind it. For a
|
|
136
|
+
* beacon (`forBeacon`) it is left where it is instead: an ordinary request
|
|
137
|
+
* can still carry what a beacon cannot.
|
|
138
|
+
*/
|
|
139
|
+
private nextChunk;
|
|
140
|
+
/**
|
|
141
|
+
* An event that no chunk can carry. A DOM change or a custom event is
|
|
142
|
+
* dropped, and the DOM is re-sent whole if the dropped one described it.
|
|
143
|
+
* A full snapshot that alone exceeds the limit is a page this transport
|
|
144
|
+
* cannot record — re-taking it would only be refused again — so recording
|
|
145
|
+
* stops instead of uploading a snapshot every few seconds forever.
|
|
146
|
+
*/
|
|
147
|
+
private dropOversized;
|
|
148
|
+
private serialise;
|
|
149
|
+
/** Everything in a chunk's body except its events, as a JSON object. */
|
|
150
|
+
private envelope;
|
|
151
|
+
/**
|
|
152
|
+
* The body, assembled from the events' cached JSON rather than by
|
|
153
|
+
* stringifying them again: a retry resends exactly the bytes the first
|
|
154
|
+
* attempt sent, which is also what lets ingest read it as a retry.
|
|
155
|
+
*/
|
|
156
|
+
private body;
|
|
157
|
+
/** One request, and what to do next. */
|
|
158
|
+
private send;
|
|
159
|
+
/**
|
|
160
|
+
* `413`: this ingest takes less than we thought. The chunk was not stored,
|
|
161
|
+
* so its events go back to the front of the queue and are re-cut under a
|
|
162
|
+
* lower limit; its number is not reused, so no number ever carries two
|
|
163
|
+
* different sets of events. A chunk of one event that is still too large is
|
|
164
|
+
* dropped by `nextChunk` on the way back out.
|
|
165
|
+
*/
|
|
166
|
+
private shrink;
|
|
167
|
+
/**
|
|
168
|
+
* Counts a failed delivery and schedules the next attempt: 2 s, 4 s, 8 s …
|
|
169
|
+
* up to a minute, each with jitter so a thousand tabs that lost the same
|
|
170
|
+
* Wi-Fi do not all come back in the same second. The chunk stays pending,
|
|
171
|
+
* to be resent under the same number.
|
|
172
|
+
*
|
|
173
|
+
* Recording does not stop over this unless the page asked for it with
|
|
174
|
+
* `maxConsecutiveFailures`: the buffer exists for exactly this, and a
|
|
175
|
+
* phone in a tunnel for half a minute must not end the visit's recording.
|
|
176
|
+
*/
|
|
177
|
+
private registerFailure;
|
|
178
|
+
/**
|
|
179
|
+
* The attempt at the end of a backoff. It skips the backoff check itself:
|
|
180
|
+
* a timer may fire a fraction of a millisecond "early" against `Date.now`,
|
|
181
|
+
* and refusing then would leave nothing scheduled until the next tick.
|
|
182
|
+
*/
|
|
183
|
+
private scheduleRetry;
|
|
184
|
+
/**
|
|
185
|
+
* Keeps the buffer bounded during a long outage. Older events go first — a
|
|
186
|
+
* replay missing a stretch is still useful; a crashed page is not. A chunk
|
|
187
|
+
* waiting to be retried is the oldest of all, and goes whole: sending part
|
|
188
|
+
* of it under its number could clash with an attempt that did arrive.
|
|
189
|
+
*/
|
|
190
|
+
private dropOldest;
|
|
191
|
+
/**
|
|
192
|
+
* After events were dropped: DOM changes left before the next full snapshot
|
|
193
|
+
* may describe nodes whose creation was dropped, and a replay applying them
|
|
194
|
+
* breaks. They go, and when the buffer holds no snapshot to start again
|
|
195
|
+
* from, the recorder is asked for one. Custom events, errors and the like
|
|
196
|
+
* do not depend on the DOM and stay.
|
|
197
|
+
*/
|
|
198
|
+
private afterGap;
|
|
199
|
+
/**
|
|
200
|
+
* Last-gasp send when the page is going away or hidden.
|
|
201
|
+
*
|
|
202
|
+
* `fetch` is cancelled once the document unloads, so the tail of every
|
|
203
|
+
* session would be lost. `sendBeacon` hands the payload to the browser, which
|
|
204
|
+
* delivers it after the page is gone — but only up to 64 KB in flight, so
|
|
205
|
+
* the tail goes as a run of small chunks, oldest first, until the browser
|
|
206
|
+
* says it has no room. What it refuses stays buffered for the page's next
|
|
207
|
+
* flush, should there be one (a hidden tab that comes back, a page restored
|
|
208
|
+
* from the back/forward cache).
|
|
209
|
+
*
|
|
210
|
+
* For a tab that is only hidden, and so likely to come back, nothing goes
|
|
211
|
+
* while deliveries are failing or the server asked us to wait: a beacon
|
|
212
|
+
* "queued" while offline is usually a beacon lost, where the buffer and the
|
|
213
|
+
* backoff would have delivered it. On `pagehide` (`final`), the last
|
|
214
|
+
* chance there is, it goes regardless.
|
|
215
|
+
*/
|
|
216
|
+
flushWithBeacon(final?: boolean): boolean;
|
|
217
|
+
/** Hands one chunk to the browser. False when its beacon queue is full. */
|
|
218
|
+
private beacon;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Making a recording carry the files a page loaded from inside an app.
|
|
223
|
+
*
|
|
224
|
+
* A website's replay loads its stylesheets, fonts and images from the site,
|
|
225
|
+
* later, in the reviewer's browser: the snapshot records `https://shop.com/
|
|
226
|
+
* logo.png` and the dashboard asks shop.com for it. That is `'reference'`, and
|
|
227
|
+
* it is right for anything on a public origin.
|
|
228
|
+
*
|
|
229
|
+
* A page inside an app has no such origin. Capacitor serves the app from
|
|
230
|
+
* `capacitor://localhost` (iOS) or `https://localhost` (Android), Cordova from
|
|
231
|
+
* `app://localhost`, `https://localhost` or `file://`, Ionic's older shells
|
|
232
|
+
* from `ionic://localhost`, Tauri from `tauri://localhost`, Electron often from
|
|
233
|
+
* `file://`. Those addresses mean "inside this phone" — no reviewer can load
|
|
234
|
+
* them — so the replay came out as bare HTML.
|
|
235
|
+
*
|
|
236
|
+
* `'inline'` makes the recording carry what it needs instead:
|
|
237
|
+
*
|
|
238
|
+
* - **Stylesheets** were already inside it: rrweb copies every readable
|
|
239
|
+
* stylesheet into the snapshot as text (`inlineStylesheet`, on in both
|
|
240
|
+
* modes), and an app's own CSS is always readable.
|
|
241
|
+
* - **Images** (PNG, JPEG, GIF, WebP) are uploaded once per project by
|
|
242
|
+
* content hash through the same routes the native SDKs use (contract §9),
|
|
243
|
+
* at most 3 MB each, and the session says which file stood at which URL.
|
|
244
|
+
* - **Fonts** (WOFF2, WOFF, TTF, OTF) and **SVG images** cannot be uploaded —
|
|
245
|
+
* ingest stores raster images only — so they travel inside the recording as
|
|
246
|
+
* `data:` URLs, each in its own event, under a per-file cap and a per-page
|
|
247
|
+
* budget that keep every chunk inside the transport's 256 KB target.
|
|
248
|
+
*
|
|
249
|
+
* In both cases the recording keeps the original URL wherever the page used
|
|
250
|
+
* it, and an `anyreplay.asset` custom event maps that URL to the uploaded
|
|
251
|
+
* hash or to the inline data. The dashboard's player rewrites those URLs
|
|
252
|
+
* before it builds the replay. Nothing in the DOM snapshot is changed, so a
|
|
253
|
+
* recording made with this on replays exactly as before in a player that does
|
|
254
|
+
* not know about it — unstyled, as it always was.
|
|
255
|
+
*
|
|
256
|
+
* Only the app's own files are read: URLs under the page's own origin (for
|
|
257
|
+
* `file://`, under the page's own folder). Capacitor's and Cordova's bridges
|
|
258
|
+
* to the device's files (`/_capacitor_file_/…`, `/__cdvfile_…`) are excluded
|
|
259
|
+
* by path, because what they serve is the user's photos and downloads, not
|
|
260
|
+
* the app. Anything that fails — a fetch, an upload, a cap — leaves that one
|
|
261
|
+
* file as it was: missing from the replay, and nothing else.
|
|
262
|
+
*/
|
|
263
|
+
type AssetMode = 'reference' | 'inline';
|
|
264
|
+
/** The custom event that maps a URL the page used to the file that was there. */
|
|
265
|
+
declare const ASSET_TAG = "anyreplay.asset";
|
|
266
|
+
/** `{ url, sha256 }` for an uploaded image, `{ url, data }` for a file carried inline. */
|
|
267
|
+
type AssetPayload = {
|
|
268
|
+
url: string;
|
|
269
|
+
sha256: string;
|
|
270
|
+
} | {
|
|
271
|
+
url: string;
|
|
272
|
+
data: string;
|
|
273
|
+
};
|
|
274
|
+
interface AssetLimits {
|
|
275
|
+
/** An image larger than this is not uploaded (ingest's own limit). */
|
|
276
|
+
maxImageBytes: number;
|
|
277
|
+
/** A font larger than this is not carried inline. */
|
|
278
|
+
maxFontBytes: number;
|
|
279
|
+
/** An SVG larger than this is not carried inline. */
|
|
280
|
+
maxSvgBytes: number;
|
|
281
|
+
/** All inline files of one page together, before base64. */
|
|
282
|
+
inlineBudgetBytes: number;
|
|
283
|
+
/** Distinct URLs looked at per page; anything after is left as it is. */
|
|
284
|
+
maxUrls: number;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The caps, and why each is what it is.
|
|
288
|
+
*
|
|
289
|
+
* A font of 160 KB is 214 KB as base64: alone in its chunk with the envelope
|
|
290
|
+
* it stays under the transport's 256 KB target, so no chunk is ever built
|
|
291
|
+
* around something ingest could refuse. Almost every WOFF2 an app ships is
|
|
292
|
+
* well under it; an icon font or a CJK font that is not is left out, and the
|
|
293
|
+
* replay falls back to the next font in the stack. One megabyte across a page
|
|
294
|
+
* is about six fonts and a few icons, which is more than an app uses, and
|
|
295
|
+
* small enough that a page with a hundred SVG icons does not turn its first
|
|
296
|
+
* minute into a download.
|
|
297
|
+
*/
|
|
298
|
+
declare const ASSET_LIMITS: AssetLimits;
|
|
299
|
+
interface LocationLike {
|
|
300
|
+
protocol: string;
|
|
301
|
+
host: string;
|
|
302
|
+
hostname: string;
|
|
303
|
+
pathname: string;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* What `assets` is when the page does not say.
|
|
307
|
+
*
|
|
308
|
+
* `'inline'` for any scheme that is not `http:` or `https:` — `capacitor:`,
|
|
309
|
+
* `ionic:`, `app:`, `tauri:`, `file:` and whatever a shell invents next. No
|
|
310
|
+
* website is served that way, so nobody outside the device could load the
|
|
311
|
+
* page's own files.
|
|
312
|
+
*
|
|
313
|
+
* `'reference'` for every `http(s)` page, `localhost` included. A developer
|
|
314
|
+
* trying their website on `http://localhost:3000` should not upload its
|
|
315
|
+
* images into the project's quota — the replay of a page on their own machine
|
|
316
|
+
* loads fine on that machine. The shells that serve an app from an
|
|
317
|
+
* `http(s)://localhost` address (Capacitor and Cordova on Android, Tauri on
|
|
318
|
+
* Windows) say `'inline'` themselves: `@anyreplay/capacitor`,
|
|
319
|
+
* `@anyreplay/cordova` and `@anyreplay/tauri` pass it, and so does
|
|
320
|
+
* `@anyreplay/electron`.
|
|
321
|
+
*/
|
|
322
|
+
declare function defaultAssetMode(loc?: LocationLike | undefined): AssetMode;
|
|
323
|
+
/**
|
|
324
|
+
* The start every URL of the app's own files shares, or null when there is
|
|
325
|
+
* nothing to look for.
|
|
326
|
+
*
|
|
327
|
+
* `scheme://host/` for anything with a host. For `file:` it is the page's own
|
|
328
|
+
* folder: `file://` alone would match every file on the disk, and an Electron
|
|
329
|
+
* app showing a document the user opened must not upload that document.
|
|
330
|
+
*/
|
|
331
|
+
declare function localAssetPrefix(loc?: LocationLike | undefined): string | null;
|
|
332
|
+
|
|
1
333
|
interface AnyReplayOptions {
|
|
2
334
|
/** Public project key from the dashboard. */
|
|
3
335
|
projectKey: string;
|
|
@@ -51,8 +383,44 @@ interface AnyReplayOptions {
|
|
|
51
383
|
maxEventsPerChunk?: number;
|
|
52
384
|
/** Flush at least this often, in milliseconds. */
|
|
53
385
|
flushIntervalMs?: number;
|
|
54
|
-
/**
|
|
386
|
+
/**
|
|
387
|
+
* Stop recording after this many failed deliveries in a row (offline, a
|
|
388
|
+
* timeout, `429`, `5xx`). `0`, the default, never stops: failed chunks are
|
|
389
|
+
* kept (up to ten chunks' worth of events, oldest dropped first) and
|
|
390
|
+
* retried with a backoff of 2 s doubling to a minute, and at once when the
|
|
391
|
+
* browser comes back online or the tab becomes visible.
|
|
392
|
+
*/
|
|
55
393
|
maxConsecutiveFailures?: number;
|
|
394
|
+
/**
|
|
395
|
+
* Record uncaught exceptions and unhandled rejections, with their stacks.
|
|
396
|
+
*
|
|
397
|
+
* On by default: a replay of a page that broke is worth far more with the
|
|
398
|
+
* error beside it, and an exception is the page's own diagnostic rather than
|
|
399
|
+
* anything a visitor typed. The message, the error name and the stack are
|
|
400
|
+
* recorded; secrets, addresses and card-shaped numbers are taken out of all
|
|
401
|
+
* three first (see `diagnostics.ts`).
|
|
402
|
+
*/
|
|
403
|
+
recordErrors?: boolean;
|
|
404
|
+
/**
|
|
405
|
+
* Record `console.error` and `console.warn` calls.
|
|
406
|
+
*
|
|
407
|
+
* On by default, and the one stream here with no predictable contents: a
|
|
408
|
+
* console line is whatever the page chose to print, which on some sites
|
|
409
|
+
* includes data about the person using it. The same redaction runs over
|
|
410
|
+
* every line, but a page that prints something it would not want a
|
|
411
|
+
* teammate to read should turn this off. `log`, `info` and `debug` are
|
|
412
|
+
* never recorded at any setting.
|
|
413
|
+
*/
|
|
414
|
+
recordConsole?: boolean;
|
|
415
|
+
/**
|
|
416
|
+
* Record one entry per `fetch`/`XMLHttpRequest`: method, URL, status code
|
|
417
|
+
* and duration.
|
|
418
|
+
*
|
|
419
|
+
* On by default. **No request or response body is ever recorded**, and no
|
|
420
|
+
* headers — not a sampled copy, not a truncated one. The URL is redacted
|
|
421
|
+
* before it is kept. Requests to the ingest endpoint are skipped.
|
|
422
|
+
*/
|
|
423
|
+
recordNetwork?: boolean;
|
|
56
424
|
/**
|
|
57
425
|
* The most sessions this project may record per calendar month (UTC).
|
|
58
426
|
*
|
|
@@ -62,17 +430,81 @@ interface AnyReplayOptions {
|
|
|
62
430
|
* wins. Positive integer, at most 1,000,000.
|
|
63
431
|
*/
|
|
64
432
|
maxSessionsPerMonth?: number;
|
|
433
|
+
/**
|
|
434
|
+
* The app this page runs inside, when it runs inside one: the bundle id or
|
|
435
|
+
* package name of a Capacitor, Cordova or Electron app
|
|
436
|
+
* (`com.example.shop`).
|
|
437
|
+
*
|
|
438
|
+
* Leave it out on a website. An app's web view has no real web origin —
|
|
439
|
+
* `capacitor://localhost`, `https://localhost`, `file://` — so a project
|
|
440
|
+
* that lists allowed domains refuses it; listing the same id under the
|
|
441
|
+
* project's allowed apps, and passing it here, lets it record. Sent in
|
|
442
|
+
* every request body, never as a header: a header would cost a CORS
|
|
443
|
+
* preflight per request, and `sendBeacon` cannot send one.
|
|
444
|
+
*
|
|
445
|
+
* A filter, not a secret. Ingest ignores it on a request from a real
|
|
446
|
+
* website, so it cannot open a web project's allow-list, and anyone holding
|
|
447
|
+
* the public key can send any id from outside a browser.
|
|
448
|
+
*/
|
|
449
|
+
appId?: string;
|
|
450
|
+
/**
|
|
451
|
+
* Whether the recording refers to the page's images and fonts by URL, or
|
|
452
|
+
* carries them.
|
|
453
|
+
*
|
|
454
|
+
* `'reference'` records URLs, and the replay loads each file from where the
|
|
455
|
+
* page did: right for a website, whose files anyone can load later.
|
|
456
|
+
* `'inline'` is for a page whose files nobody else can load — an app's web
|
|
457
|
+
* view (`capacitor://localhost`, `https://localhost`, `file://`) or a
|
|
458
|
+
* desktop shell. Images the app ships are uploaded once per project by
|
|
459
|
+
* content hash (PNG, JPEG, GIF, WebP, up to 3 MB each), and its fonts and
|
|
460
|
+
* SVGs travel inside the recording (up to 160 KB per font, 48 KB per SVG and
|
|
461
|
+
* 1 MB per page), so the replay looks like the app instead of bare HTML.
|
|
462
|
+
* Stylesheets are copied into the recording in both modes.
|
|
463
|
+
*
|
|
464
|
+
* Leave it out and it is `'inline'` when the page is served from anything
|
|
465
|
+
* other than `http:`/`https:`, or from `localhost`, `127.0.0.1`, `[::1]` or a
|
|
466
|
+
* `*.localhost` host; `'reference'` everywhere else. Only files under the
|
|
467
|
+
* page's own origin (for `file:`, its own folder) are ever read, and never
|
|
468
|
+
* the device-file bridges Capacitor and Cordova serve user files through.
|
|
469
|
+
* See `assets.ts`.
|
|
470
|
+
*/
|
|
471
|
+
assets?: AssetMode;
|
|
65
472
|
debug?: boolean;
|
|
66
473
|
}
|
|
67
|
-
interface ResolvedOptions extends Required<Omit<AnyReplayOptions, 'ingestUrl' | 'maxSessionsPerMonth'>> {
|
|
474
|
+
interface ResolvedOptions extends Required<Omit<AnyReplayOptions, 'ingestUrl' | 'maxSessionsPerMonth' | 'appId' | 'assets'>> {
|
|
475
|
+
/** Always decided: the page's choice, or what `defaultAssetMode` reads from its address. */
|
|
476
|
+
assets: AssetMode;
|
|
68
477
|
ingestUrl: string;
|
|
69
478
|
maxSessionsPerMonth?: number;
|
|
479
|
+
appId?: string;
|
|
70
480
|
}
|
|
71
|
-
declare const DEFAULTS: Omit<ResolvedOptions, 'projectKey'>;
|
|
481
|
+
declare const DEFAULTS: Omit<ResolvedOptions, 'projectKey' | 'assets'>;
|
|
72
482
|
declare class ConfigError extends Error {
|
|
73
483
|
}
|
|
74
484
|
declare function resolveOptions(input: AnyReplayOptions): ResolvedOptions;
|
|
75
485
|
|
|
486
|
+
/**
|
|
487
|
+
* What an SDK that wraps this one says about the app the page runs in.
|
|
488
|
+
*
|
|
489
|
+
* Not an option: a website never has one. `@anyreplay/capacitor`,
|
|
490
|
+
* `@anyreplay/cordova` and the desktop shells pass it as `createRecorder`'s
|
|
491
|
+
* second argument, so a session names the SDK that was installed and the
|
|
492
|
+
* platform it runs on (contract §2.3–§2.5) while the recording itself — rrweb,
|
|
493
|
+
* masking, transport — stays this package's, unchanged (contract §4).
|
|
494
|
+
*/
|
|
495
|
+
interface ShellInfo {
|
|
496
|
+
/** One of the web family's platforms: `capacitor`, `cordova`, `electron`, `tauri`. */
|
|
497
|
+
platform: string;
|
|
498
|
+
/** The wrapper's own npm name and version. */
|
|
499
|
+
sdk: {
|
|
500
|
+
name: string;
|
|
501
|
+
version: string;
|
|
502
|
+
};
|
|
503
|
+
/** The app's user-visible version (`CFBundleShortVersionString`, `versionName`), ≤ 40. */
|
|
504
|
+
appVersion?: string;
|
|
505
|
+
/** The hardware model (`iPhone15,2`, `Pixel 8`), ≤ 64. */
|
|
506
|
+
deviceModel?: string;
|
|
507
|
+
}
|
|
76
508
|
type RecorderStatus = 'idle' | 'awaiting-consent' | 'recording' | 'sampled-out' | 'stopped';
|
|
77
509
|
interface IdentifyTraits {
|
|
78
510
|
userId?: string;
|
|
@@ -92,10 +524,19 @@ interface RecorderHandle {
|
|
|
92
524
|
* own handlers.
|
|
93
525
|
*/
|
|
94
526
|
track: (name: string, properties?: Record<string, unknown>) => void;
|
|
527
|
+
/**
|
|
528
|
+
* Records an error the page caught itself — a failed checkout handled with a
|
|
529
|
+
* message, an error another tracker saw first — as the same `anyreplay.error`
|
|
530
|
+
* event an uncaught one becomes: redacted by the same rules, counted against
|
|
531
|
+
* the same ceiling, and setting the session's `hasError`. Accepts anything
|
|
532
|
+
* that can be thrown. Ignored with `recordErrors: false`. Before recording
|
|
533
|
+
* starts it is held like `track` and still flags the session once it does.
|
|
534
|
+
*/
|
|
535
|
+
trackError: (error: unknown) => void;
|
|
95
536
|
stop: () => void;
|
|
96
537
|
flush: () => Promise<void>;
|
|
97
538
|
}
|
|
98
|
-
declare function createRecorder(rawOptions: AnyReplayOptions): RecorderHandle;
|
|
539
|
+
declare function createRecorder(rawOptions: AnyReplayOptions, shell?: ShellInfo): RecorderHandle;
|
|
99
540
|
|
|
100
541
|
/**
|
|
101
542
|
* FNV-1a. Small, fast, and good enough to spread visitor ids uniformly across
|
|
@@ -111,98 +552,6 @@ declare function hashToUnitInterval(input: string): number;
|
|
|
111
552
|
*/
|
|
112
553
|
declare function isSampledIn(visitorId: string, sampleRate: number): boolean;
|
|
113
554
|
|
|
114
|
-
interface RecordedEvent {
|
|
115
|
-
type: number;
|
|
116
|
-
timestamp: number;
|
|
117
|
-
data?: unknown;
|
|
118
|
-
}
|
|
119
|
-
interface SessionMeta {
|
|
120
|
-
startedAt: number;
|
|
121
|
-
url?: string;
|
|
122
|
-
lang?: string;
|
|
123
|
-
userAgent?: string;
|
|
124
|
-
screenWidth?: number;
|
|
125
|
-
screenHeight?: number;
|
|
126
|
-
referrer?: string;
|
|
127
|
-
/** `maxSessionsPerMonth`, sent only with chunk 0 of a new session. */
|
|
128
|
-
sessionCap?: number;
|
|
129
|
-
}
|
|
130
|
-
interface ChunkFlags {
|
|
131
|
-
hasError?: boolean;
|
|
132
|
-
hasRageClick?: boolean;
|
|
133
|
-
pageCount?: number;
|
|
134
|
-
}
|
|
135
|
-
interface TransportContext {
|
|
136
|
-
projectKey: string;
|
|
137
|
-
sessionId: string;
|
|
138
|
-
visitorId: string;
|
|
139
|
-
/** The first chunk number to use. Non-zero when a page resumes a session. */
|
|
140
|
-
initialSeq?: number;
|
|
141
|
-
}
|
|
142
|
-
interface TransportHooks {
|
|
143
|
-
onStopped?: (reason: string) => void;
|
|
144
|
-
onSent?: (seq: number, eventCount: number) => void;
|
|
145
|
-
/** Called with the next free chunk number before a request using the current one leaves. */
|
|
146
|
-
onSeqReserved?: (nextSeq: number) => void;
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Ships buffered events to ingest.
|
|
150
|
-
*
|
|
151
|
-
* Three properties matter more than throughput here, because this code runs
|
|
152
|
-
* inside somebody else's product:
|
|
153
|
-
*
|
|
154
|
-
* - It must never throw into the host page.
|
|
155
|
-
* - It must never keep retrying a request the server has rejected on its
|
|
156
|
-
* merits (a bad key, a disallowed origin) — that is an infinite loop.
|
|
157
|
-
* - It must not lose the tail of a session when the tab closes.
|
|
158
|
-
*/
|
|
159
|
-
declare class Transport {
|
|
160
|
-
private readonly options;
|
|
161
|
-
private readonly context;
|
|
162
|
-
private readonly hooks;
|
|
163
|
-
private buffer;
|
|
164
|
-
private seq;
|
|
165
|
-
private consecutiveFailures;
|
|
166
|
-
private state;
|
|
167
|
-
private timer;
|
|
168
|
-
private pendingMeta;
|
|
169
|
-
private flags;
|
|
170
|
-
private inFlight;
|
|
171
|
-
/** The chunk number of the last request that left, to tell a retry from a first attempt. */
|
|
172
|
-
private lastAttemptedSeq;
|
|
173
|
-
private staleRecoveries;
|
|
174
|
-
constructor(options: ResolvedOptions, context: TransportContext, hooks?: TransportHooks);
|
|
175
|
-
get isStopped(): boolean;
|
|
176
|
-
get bufferedCount(): number;
|
|
177
|
-
get sentChunks(): number;
|
|
178
|
-
/** Attaches metadata to the next chunk. Sent once, with the session's first chunk. */
|
|
179
|
-
setMeta(meta: SessionMeta): void;
|
|
180
|
-
mergeFlags(flags: ChunkFlags): void;
|
|
181
|
-
start(): void;
|
|
182
|
-
stop(reason: string): void;
|
|
183
|
-
push(event: RecordedEvent): void;
|
|
184
|
-
private body;
|
|
185
|
-
/** Sends whatever is buffered. Safe to call when there is nothing to send. */
|
|
186
|
-
flush(): Promise<void>;
|
|
187
|
-
/**
|
|
188
|
-
* Puts a failed batch back at the front of the queue.
|
|
189
|
-
*
|
|
190
|
-
* Bounded on purpose: a long offline period must not grow the buffer until
|
|
191
|
-
* the tab runs out of memory. Older events are dropped first — a replay
|
|
192
|
-
* missing its beginning is still useful; a crashed page is not.
|
|
193
|
-
*/
|
|
194
|
-
private requeue;
|
|
195
|
-
private registerFailure;
|
|
196
|
-
/**
|
|
197
|
-
* Last-gasp send when the page is going away.
|
|
198
|
-
*
|
|
199
|
-
* `fetch` is cancelled once the document unloads, so the tail of every
|
|
200
|
-
* session would be lost. `sendBeacon` hands the payload to the browser, which
|
|
201
|
-
* delivers it after the page is gone.
|
|
202
|
-
*/
|
|
203
|
-
flushWithBeacon(): boolean;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
555
|
/** RFC 4122 v4, formatted. Ingest requires a UUID for the session id. */
|
|
207
556
|
declare function uuidv4(): string;
|
|
208
557
|
interface Storage {
|
|
@@ -272,4 +621,157 @@ interface TrackPayload {
|
|
|
272
621
|
*/
|
|
273
622
|
declare function validateTrack(name: unknown, properties: unknown): TrackPayload | string;
|
|
274
623
|
|
|
275
|
-
|
|
624
|
+
/**
|
|
625
|
+
* What a developer opens a replay to find out: what broke.
|
|
626
|
+
*
|
|
627
|
+
* Three streams, each a reserved custom-event tag inside the rrweb stream so
|
|
628
|
+
* they carry the recording's own clock and replay in order with the clicks
|
|
629
|
+
* that caused them:
|
|
630
|
+
*
|
|
631
|
+
* - `anyreplay.error` — uncaught exceptions and unhandled rejections.
|
|
632
|
+
* - `anyreplay.console` — `console.error` and `console.warn` calls.
|
|
633
|
+
* - `anyreplay.network` — one entry per fetch/XHR, metadata only.
|
|
634
|
+
*
|
|
635
|
+
* `anyreplay.error` and `anyreplay.network` are a contract that already has a
|
|
636
|
+
* reader: `packages/insights/src/rrweb.ts` has been looking for those tags,
|
|
637
|
+
* and the `js_error`, `error_click` and `network_error` detectors have been
|
|
638
|
+
* waiting for them. The payload field names here are the ones that file reads
|
|
639
|
+
* (`message`; `url`, `status`, `method`) — renaming one silently empties three
|
|
640
|
+
* signals, so they are pinned by a test in both packages.
|
|
641
|
+
*
|
|
642
|
+
* ## What never leaves the page
|
|
643
|
+
*
|
|
644
|
+
* No request or response bodies, no request headers, no cookies. A URL is
|
|
645
|
+
* recorded, so a URL is redacted first: a query value whose key names a
|
|
646
|
+
* secret, anything shaped like an email address, a long opaque token and
|
|
647
|
+
* anything that passes the card-number check are replaced before the entry is
|
|
648
|
+
* built. The same redaction runs over console messages, which are arbitrary
|
|
649
|
+
* strings a page chose to print and so the least predictable thing here.
|
|
650
|
+
*
|
|
651
|
+
* ## Why the caps are low
|
|
652
|
+
*
|
|
653
|
+
* A page in a render loop can print thousands of warnings a second, and a
|
|
654
|
+
* dashboard polling four endpoints makes a request a second for an hour. The
|
|
655
|
+
* value of all of it is in the first few of each kind near the moment
|
|
656
|
+
* something went wrong, so each stream has a per-session ceiling and drops
|
|
657
|
+
* quietly once it is reached. Nothing here grows without bound, and nothing
|
|
658
|
+
* here throws: every entry point is wrapped, because this code runs inside
|
|
659
|
+
* the page's own `fetch` and its own `console`.
|
|
660
|
+
*/
|
|
661
|
+
/** Contract with `packages/insights/src/rrweb.ts` and `apps/ingest/src/custom-events.ts`. */
|
|
662
|
+
declare const ERROR_TAG = "anyreplay.error";
|
|
663
|
+
declare const CONSOLE_TAG = "anyreplay.console";
|
|
664
|
+
declare const NETWORK_TAG = "anyreplay.network";
|
|
665
|
+
/**
|
|
666
|
+
* Per-session ceilings.
|
|
667
|
+
*
|
|
668
|
+
* Errors are the point of the feature and the rarest, so they get the most
|
|
669
|
+
* room relative to how often they happen; network is the chattiest and so is
|
|
670
|
+
* capped hardest per unit of value.
|
|
671
|
+
*/
|
|
672
|
+
declare const LIMITS: {
|
|
673
|
+
readonly error: 100;
|
|
674
|
+
readonly console: 200;
|
|
675
|
+
readonly network: 500;
|
|
676
|
+
};
|
|
677
|
+
declare const REDACTED = "[redacted]";
|
|
678
|
+
/**
|
|
679
|
+
* Removes from free text the three things that turn up in it by accident:
|
|
680
|
+
* an address, a token, a card number.
|
|
681
|
+
*
|
|
682
|
+
* Deliberately blunt. This is applied to strings a page printed or a URL it
|
|
683
|
+
* requested, where there is no structure to reason about and the cost of a
|
|
684
|
+
* false positive — a redacted order id — is far below the cost of a miss.
|
|
685
|
+
*/
|
|
686
|
+
declare function redactText(text: string): string;
|
|
687
|
+
/**
|
|
688
|
+
* A URL with its secrets taken out, kept absolute so the host is still visible.
|
|
689
|
+
*
|
|
690
|
+
* Query values are replaced by key first — `?token=abc` is a secret even when
|
|
691
|
+
* `abc` is three letters — and then the whole thing goes through the free-text
|
|
692
|
+
* redaction, which catches an address or a token sitting in the path. The hash
|
|
693
|
+
* is dropped: it never reaches a server, and on a hash-routed app it is the
|
|
694
|
+
* one part most likely to hold an id.
|
|
695
|
+
*/
|
|
696
|
+
declare function redactUrl(raw: string): string;
|
|
697
|
+
interface ErrorPayload {
|
|
698
|
+
/** Read by the insights timeline. Redacted and clipped. */
|
|
699
|
+
message: string;
|
|
700
|
+
/** `Error`, `TypeError`, … when the thrown value was an Error. */
|
|
701
|
+
name?: string;
|
|
702
|
+
/** Redacted and clipped; absent when the thrown value carried none. */
|
|
703
|
+
stack?: string;
|
|
704
|
+
/** How it reached us, which is how severe it is: a rejection nobody handled is not a thrown error. */
|
|
705
|
+
kind: 'error' | 'rejection';
|
|
706
|
+
/** Where it was thrown, when the browser said. Already a redacted URL. */
|
|
707
|
+
source?: string;
|
|
708
|
+
line?: number;
|
|
709
|
+
column?: number;
|
|
710
|
+
}
|
|
711
|
+
interface ConsolePayload {
|
|
712
|
+
level: 'error' | 'warn';
|
|
713
|
+
/** The arguments, joined and redacted. */
|
|
714
|
+
message: string;
|
|
715
|
+
}
|
|
716
|
+
interface NetworkPayload {
|
|
717
|
+
/** Read by the insights timeline. A redacted absolute URL. */
|
|
718
|
+
url: string;
|
|
719
|
+
/** Read by the insights timeline. Upper case. */
|
|
720
|
+
method: string;
|
|
721
|
+
/** Read by the insights timeline. 0 when the request never got a response. */
|
|
722
|
+
status: number;
|
|
723
|
+
/** Wall-clock milliseconds from call to settled, rounded. */
|
|
724
|
+
durationMs: number;
|
|
725
|
+
/** Which API the page used, which is all the panel needs to group them. */
|
|
726
|
+
initiator: 'fetch' | 'xhr';
|
|
727
|
+
/** Present and true when the request failed outright (network error, CORS, abort). */
|
|
728
|
+
failed?: boolean;
|
|
729
|
+
}
|
|
730
|
+
/** The thrown value, described however it arrived — pages throw strings. */
|
|
731
|
+
declare function errorPayload(kind: ErrorPayload['kind'], value: unknown, where?: {
|
|
732
|
+
source?: unknown;
|
|
733
|
+
line?: unknown;
|
|
734
|
+
column?: unknown;
|
|
735
|
+
}): ErrorPayload;
|
|
736
|
+
declare function consolePayload(level: ConsolePayload['level'], args: readonly unknown[]): ConsolePayload;
|
|
737
|
+
interface DiagnosticsOptions {
|
|
738
|
+
/** Emit uncaught errors and unhandled rejections. */
|
|
739
|
+
errors: boolean;
|
|
740
|
+
/** Emit `console.error` and `console.warn`. */
|
|
741
|
+
console: boolean;
|
|
742
|
+
/** Emit one entry per fetch/XHR. */
|
|
743
|
+
network: boolean;
|
|
744
|
+
/** Where entries go. Called with a reserved tag and a payload. */
|
|
745
|
+
emit: (tag: string, payload: unknown) => void;
|
|
746
|
+
/** Called the first time an error or a failed request is seen, so the session is flagged. */
|
|
747
|
+
onError: () => void;
|
|
748
|
+
/** Ingest's own endpoint, so the recorder never records itself. */
|
|
749
|
+
ingestUrl: string;
|
|
750
|
+
}
|
|
751
|
+
interface DiagnosticsHandle {
|
|
752
|
+
/** Puts back every patched global, in reverse order. Safe to call twice. */
|
|
753
|
+
stop: () => void;
|
|
754
|
+
/**
|
|
755
|
+
* An error the page caught itself (`trackError`): redacted, counted against
|
|
756
|
+
* the same per-session ceiling and flagging the session exactly as an
|
|
757
|
+
* uncaught one would.
|
|
758
|
+
*/
|
|
759
|
+
error: (value: unknown) => void;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Patches `console`, `window` and the two request APIs, and gives back the
|
|
763
|
+
* single function that undoes all of it.
|
|
764
|
+
*
|
|
765
|
+
* Patching is the only way to see any of this from a library, so the rules
|
|
766
|
+
* are strict: the original is captured once and always called, its return
|
|
767
|
+
* value is always passed through untouched, and everything this adds sits in
|
|
768
|
+
* a `try` so that a bug here cannot break a page's error handling or its
|
|
769
|
+
* network layer. Requests to the ingest endpoint are skipped — recording the
|
|
770
|
+
* recorder is noise that also grows with itself.
|
|
771
|
+
*/
|
|
772
|
+
declare function startDiagnostics(options: DiagnosticsOptions): DiagnosticsHandle;
|
|
773
|
+
|
|
774
|
+
declare const SDK_NAME = "@anyreplay/browser";
|
|
775
|
+
declare const SDK_VERSION: string;
|
|
776
|
+
|
|
777
|
+
export { ASSET_LIMITS, ASSET_TAG, type AnyReplayOptions, type AssetLimits, type AssetMode, type AssetPayload, CONSOLE_TAG, ConfigError, type ConsolePayload, DEFAULTS, type DiagnosticsHandle, ERROR_TAG, EVENT_NAME_PATTERN, type ErrorPayload, LIMITS, MAX_PROPERTIES_BYTES, NAVIGATE_TAG, NETWORK_TAG, type NetworkPayload, REDACTED, type RecordedEvent, type RecorderHandle, type RecorderStatus, type ResolvedOptions, SDK_NAME, SDK_VERSION, type SessionMeta, type ShellInfo, TRACK_TAG, type TrackPayload, Transport, consolePayload, createRecorder, defaultAssetMode, errorPayload, forgetVisitor, hashToUnitInterval, isSampledIn, localAssetPrefix, redactText, redactUrl, resolveIdentity, resolveOptions, safeStorage, startDiagnostics, uuidv4, validateTrack };
|