@anyreplay/browser 0.1.4 → 0.2.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/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
- /** Recording stops after this many consecutive transport failures. */
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
- export { type AnyReplayOptions, ConfigError, DEFAULTS, EVENT_NAME_PATTERN, MAX_PROPERTIES_BYTES, NAVIGATE_TAG, type RecordedEvent, type RecorderHandle, type RecorderStatus, type ResolvedOptions, type SessionMeta, TRACK_TAG, type TrackPayload, Transport, createRecorder, forgetVisitor, hashToUnitInterval, isSampledIn, resolveIdentity, resolveOptions, safeStorage, uuidv4, validateTrack };
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 };