@newtalaria/browser 0.1.1 → 0.1.4

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.js CHANGED
@@ -38,8 +38,18 @@ var ServerpodTransport = class {
38
38
  });
39
39
  if (!response.ok) {
40
40
  const text = await response.text().catch(() => "");
41
+ let detail = text.slice(0, 400);
42
+ try {
43
+ const parsed = JSON.parse(text);
44
+ const className = parsed.className ?? parsed.exception;
45
+ const message = parsed.message;
46
+ if (className || message) {
47
+ detail = [className, message].filter(Boolean).join(": ");
48
+ }
49
+ } catch {
50
+ }
41
51
  throw new Error(
42
- `Talaria ${endpoint}/${method} failed: HTTP ${response.status}${text ? ` \u2014 ${text.slice(0, 200)}` : ""}`
52
+ `Talaria ${endpoint}/${method} failed: HTTP ${response.status}${detail ? ` \u2014 ${detail}` : ""}`
43
53
  );
44
54
  }
45
55
  const contentType = response.headers.get("content-type") ?? "";
@@ -101,56 +111,6 @@ function toServerpodByteData(bytes) {
101
111
  return `decode('${b64}', 'base64')`;
102
112
  }
103
113
 
104
- // src/transport/replays.ts
105
- async function startReplay(transport, params) {
106
- const input2 = {
107
- __className__: "StartReplayInput",
108
- replayId: params.replayId,
109
- environment: params.environment
110
- };
111
- if (params.sessionId) input2.sessionId = params.sessionId;
112
- if (params.url) input2.url = params.url;
113
- if (params.userId) input2.userId = params.userId;
114
- return transport.call(
115
- "replays",
116
- "start",
117
- { input: input2 },
118
- { keepalive: params.keepalive }
119
- );
120
- }
121
- async function ingestReplaySegment(transport, params) {
122
- const json = JSON.stringify(params.events);
123
- const compressed = await gzipBytes(new TextEncoder().encode(json));
124
- const input2 = {
125
- __className__: "IngestReplaySegmentInput",
126
- replayId: params.replayId,
127
- segmentIndex: params.segmentIndex,
128
- gzipBytes: toServerpodByteData(compressed),
129
- eventCount: params.events.length,
130
- startedAt: params.startedAt.toISOString(),
131
- endedAt: params.endedAt.toISOString()
132
- };
133
- return transport.call(
134
- "replays",
135
- "ingestSegment",
136
- { input: input2 },
137
- { keepalive: params.keepalive }
138
- );
139
- }
140
- async function finishReplay(transport, params) {
141
- const input2 = {
142
- __className__: "FinishReplayInput",
143
- replayId: params.replayId
144
- };
145
- if (params.reason) input2.reason = params.reason;
146
- return transport.call(
147
- "replays",
148
- "finish",
149
- { input: input2 },
150
- { keepalive: params.keepalive }
151
- );
152
- }
153
-
154
114
  // src/utils/estimate.ts
155
115
  function estimateJsonBytes(value) {
156
116
  try {
@@ -161,10 +121,20 @@ function estimateJsonBytes(value) {
161
121
  }
162
122
 
163
123
  // src/replay/segment_buffer.ts
164
- var SEGMENT_SIZE_BYTES = 1e5;
124
+ var SEGMENT_SIZE_BYTES = 4e5;
125
+ var MAX_COMPRESSED_SEGMENT_BYTES = 512 * 1024;
126
+ var TARGET_COMPRESSED_SEGMENT_BYTES = MAX_COMPRESSED_SEGMENT_BYTES - 32 * 1024;
127
+ var MAX_SEGMENTS_PER_REPLAY = 200;
128
+ var MAX_SEGMENTS_ERROR_CLIP = 12;
129
+ var MAX_ERROR_CLIP_COMPRESSED_BYTES = 2 * 1024 * 1024;
130
+ var ERROR_CLIP_FLUSH_SLACK_MS = 5e3;
165
131
  var SEGMENT_FLUSH_MS = 5e3;
166
132
  var RING_BUFFER_MS = 6e4;
133
+ var RING_BUFFER_MAX_BYTES = 15e5;
167
134
  var ERROR_REPLAY_AFTER_MS = 15e3;
135
+ var MAX_REPLAY_DURATION_MS = 5 * 60 * 1e3;
136
+ var RRWEB_FULL_SNAPSHOT = 2;
137
+ var RRWEB_META = 4;
168
138
  var SegmentBuffer = class {
169
139
  constructor() {
170
140
  this.events = [];
@@ -180,8 +150,31 @@ var SegmentBuffer = class {
180
150
  this.events.push(event);
181
151
  this.pendingBytes += estimateJsonBytes(event);
182
152
  }
183
- /** Drop events older than the ring window (buffer-only mode). */
184
- trimRing(now = Date.now()) {
153
+ hasFullSnapshot() {
154
+ return this.events.some((e) => e.type === RRWEB_FULL_SNAPSHOT);
155
+ }
156
+ /**
157
+ * Drop leading events until Meta+FullSnapshot (or FullSnapshot alone).
158
+ * Returns false if no FullSnapshot is present (replay would paint blank).
159
+ */
160
+ trimToFullSnapshot() {
161
+ const fullIdx = this.events.findIndex((e) => e.type === RRWEB_FULL_SNAPSHOT);
162
+ if (fullIdx < 0) return false;
163
+ let start = fullIdx;
164
+ if (fullIdx > 0 && this.events[fullIdx - 1].type === RRWEB_META) {
165
+ start = fullIdx - 1;
166
+ }
167
+ if (start > 0) {
168
+ this.events.splice(0, start);
169
+ this.recomputeBytes();
170
+ }
171
+ return true;
172
+ }
173
+ /**
174
+ * Drop events older than the ring window and/or over the byte cap
175
+ * (buffer-only mode). Prefer keeping the newest events.
176
+ */
177
+ trimRing(now = Date.now(), maxBytes = RING_BUFFER_MAX_BYTES) {
185
178
  const cutoff = now - RING_BUFFER_MS;
186
179
  let remove = 0;
187
180
  while (remove < this.events.length && this.events[remove].timestamp < cutoff) {
@@ -191,22 +184,55 @@ var SegmentBuffer = class {
191
184
  this.events.splice(0, remove);
192
185
  this.recomputeBytes();
193
186
  }
187
+ while (this.events.length > 1 && this.pendingBytes > maxBytes) {
188
+ this.events.shift();
189
+ this.recomputeBytes();
190
+ }
194
191
  }
195
192
  shouldFlushBySize() {
196
193
  return this.pendingBytes >= SEGMENT_SIZE_BYTES;
197
194
  }
195
+ /**
196
+ * Take a prefix of events whose estimated JSON size is ~maxBytes.
197
+ * Always returns at least one event when the buffer is non-empty (caller
198
+ * must handle a single oversized event).
199
+ */
200
+ takeByEstimatedBytes(maxBytes) {
201
+ if (this.events.length === 0) return [];
202
+ const batch = [];
203
+ let batchBytes = 0;
204
+ while (this.events.length > 0) {
205
+ const next = this.events[0];
206
+ const nextBytes = estimateJsonBytes(next);
207
+ if (batch.length > 0 && batchBytes + nextBytes > maxBytes) {
208
+ break;
209
+ }
210
+ this.events.shift();
211
+ batch.push(next);
212
+ batchBytes += nextBytes;
213
+ if (batch.length === 1 && nextBytes >= maxBytes) {
214
+ break;
215
+ }
216
+ }
217
+ this.recomputeBytes();
218
+ return batch;
219
+ }
198
220
  takeAll() {
199
221
  const batch = this.events;
200
222
  this.events = [];
201
223
  this.pendingBytes = 0;
202
224
  return batch;
203
225
  }
204
- /** Re-queue events at the front (failed upload retry). */
226
+ /** Re-queue events at the front (failed upload retry / bisect remainder). */
205
227
  prepend(events) {
206
228
  if (events.length === 0) return;
207
229
  this.events = events.concat(this.events);
208
230
  this.recomputeBytes();
209
231
  }
232
+ clear() {
233
+ this.events = [];
234
+ this.pendingBytes = 0;
235
+ }
210
236
  peekTimes() {
211
237
  if (this.events.length === 0) return null;
212
238
  const first = this.events[0];
@@ -221,6 +247,114 @@ var SegmentBuffer = class {
221
247
  }
222
248
  };
223
249
 
250
+ // src/transport/replays.ts
251
+ async function compressReplayEvents(events) {
252
+ const json = JSON.stringify(events);
253
+ return gzipBytes(new TextEncoder().encode(json));
254
+ }
255
+ async function startReplay(transport, params) {
256
+ const input2 = {
257
+ __className__: "StartReplayInput",
258
+ replayId: params.replayId,
259
+ environment: params.environment
260
+ };
261
+ if (params.sessionId) input2.sessionId = params.sessionId;
262
+ if (params.url) input2.url = params.url;
263
+ if (params.userId) input2.userId = params.userId;
264
+ return transport.call(
265
+ "replays",
266
+ "start",
267
+ { input: input2 },
268
+ { keepalive: params.keepalive }
269
+ );
270
+ }
271
+ async function ingestReplaySegment(transport, params) {
272
+ const compressed = params.gzip ?? await compressReplayEvents(params.events);
273
+ if (compressed.length > MAX_COMPRESSED_SEGMENT_BYTES) {
274
+ throw new Error(
275
+ `Talaria replays/ingestSegment failed: HTTP 400 \u2014 segment exceeds max compressed size (${compressed.length} > ${MAX_COMPRESSED_SEGMENT_BYTES})`
276
+ );
277
+ }
278
+ const input2 = {
279
+ __className__: "IngestReplaySegmentInput",
280
+ replayId: params.replayId,
281
+ segmentIndex: params.segmentIndex,
282
+ gzipBytes: toServerpodByteData(compressed),
283
+ eventCount: params.events.length,
284
+ startedAt: params.startedAt.toISOString(),
285
+ endedAt: params.endedAt.toISOString()
286
+ };
287
+ return transport.call(
288
+ "replays",
289
+ "ingestSegment",
290
+ { input: input2 },
291
+ { keepalive: params.keepalive }
292
+ );
293
+ }
294
+ async function finishReplay(transport, params) {
295
+ const input2 = {
296
+ __className__: "FinishReplayInput",
297
+ replayId: params.replayId
298
+ };
299
+ if (params.reason) input2.reason = params.reason;
300
+ return transport.call(
301
+ "replays",
302
+ "finish",
303
+ { input: input2 },
304
+ { keepalive: params.keepalive }
305
+ );
306
+ }
307
+
308
+ // src/replay/fit_segment.ts
309
+ function computeErrorClipDeadlineMs(uploadStartedAtMs, afterMs, slackMs = ERROR_CLIP_FLUSH_SLACK_MS) {
310
+ return uploadStartedAtMs + afterMs + slackMs;
311
+ }
312
+ function isErrorClipBudgetExhausted(opts) {
313
+ const maxSegments = opts.maxSegments ?? MAX_SEGMENTS_ERROR_CLIP;
314
+ const maxBytes = opts.maxBytes ?? MAX_ERROR_CLIP_COMPRESSED_BYTES;
315
+ return opts.segmentIndex >= maxSegments || opts.uploadedCompressedBytes >= maxBytes;
316
+ }
317
+ function planOversizedRetry(events) {
318
+ if (events.length <= 1) {
319
+ return { action: "drop" };
320
+ }
321
+ const mid = Math.ceil(events.length / 2);
322
+ return {
323
+ action: "bisect",
324
+ left: events.slice(0, mid),
325
+ right: events.slice(mid)
326
+ };
327
+ }
328
+ async function fitCompressedPrefix(events, compress, targetBytes = TARGET_COMPRESSED_SEGMENT_BYTES, hardCap = MAX_COMPRESSED_SEGMENT_BYTES) {
329
+ if (events.length === 0) return null;
330
+ let remaining = events;
331
+ while (remaining.length > 0) {
332
+ let lo = 1;
333
+ let hi = remaining.length;
334
+ let best = null;
335
+ while (lo <= hi) {
336
+ const mid = Math.floor((lo + hi) / 2);
337
+ const prefix = remaining.slice(0, mid);
338
+ const gzip = await compress(prefix);
339
+ if (gzip.length <= targetBytes && gzip.length <= hardCap) {
340
+ best = { events: prefix, gzip };
341
+ lo = mid + 1;
342
+ } else {
343
+ hi = mid - 1;
344
+ }
345
+ }
346
+ if (best) {
347
+ return {
348
+ events: best.events,
349
+ gzip: best.gzip,
350
+ remainder: remaining.slice(best.events.length)
351
+ };
352
+ }
353
+ remaining = remaining.slice(1);
354
+ }
355
+ return null;
356
+ }
357
+
224
358
  // ../../node_modules/rrweb/dist/rrweb.js
225
359
  var __defProp = Object.defineProperty;
226
360
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
@@ -12976,13 +13110,27 @@ function redactUrl(raw) {
12976
13110
  }
12977
13111
  }
12978
13112
  function defaultBlockSelector(extra) {
12979
- const parts = ["[data-talaria-mask]", ".talaria-block"];
13113
+ const parts = [
13114
+ "[data-talaria-mask]",
13115
+ ".talaria-block",
13116
+ // Heavy docs surfaces (syntax-highlighted code) blow up FullSnapshots.
13117
+ "pre",
13118
+ "code"
13119
+ ];
12980
13120
  if (extra?.trim()) parts.push(extra.trim());
12981
13121
  return parts.join(", ");
12982
13122
  }
12983
13123
 
12984
13124
  // src/replay/recorder.ts
12985
13125
  function startRecorder(options) {
13126
+ if (typeof document === "undefined") {
13127
+ return {
13128
+ stop: () => {
13129
+ },
13130
+ takeFullSnapshot: () => {
13131
+ }
13132
+ };
13133
+ }
12986
13134
  const stopFn = record({
12987
13135
  emit(event) {
12988
13136
  options.onEvent(event);
@@ -12993,11 +13141,25 @@ function startRecorder(options) {
12993
13141
  },
12994
13142
  blockSelector: defaultBlockSelector(options.blockSelector),
12995
13143
  recordCanvas: false,
12996
- collectFonts: false
13144
+ collectFonts: false,
13145
+ inlineStylesheet: false,
13146
+ // Shrink DOM snapshots on heavy sites (docs / marketing).
13147
+ slimDOMOptions: "all",
13148
+ sampling: {
13149
+ mousemove: 150,
13150
+ scroll: 200,
13151
+ input: "last"
13152
+ }
12997
13153
  });
12998
13154
  return {
12999
13155
  stop: () => {
13000
13156
  stopFn?.();
13157
+ },
13158
+ takeFullSnapshot: () => {
13159
+ try {
13160
+ record.takeFullSnapshot(true);
13161
+ } catch {
13162
+ }
13001
13163
  }
13002
13164
  };
13003
13165
  }
@@ -13130,6 +13292,7 @@ function resolveOptions(options) {
13130
13292
  release: options.release,
13131
13293
  replaysSessionSampleRate: clamp01(options.replaysSessionSampleRate ?? 0),
13132
13294
  replaysOnErrorSampleRate: clamp01(options.replaysOnErrorSampleRate ?? 1),
13295
+ replaysErrorAfterMs: normalizeErrorAfterMs(options.replaysErrorAfterMs),
13133
13296
  maskAllInputs: options.maskAllInputs ?? true,
13134
13297
  blockSelector: options.blockSelector ?? "",
13135
13298
  userId: options.userId,
@@ -13140,6 +13303,12 @@ function clamp01(n2) {
13140
13303
  if (Number.isNaN(n2)) return 0;
13141
13304
  return Math.min(1, Math.max(0, n2));
13142
13305
  }
13306
+ function normalizeErrorAfterMs(n2) {
13307
+ if (n2 === void 0 || Number.isNaN(n2) || n2 < 0) {
13308
+ return ERROR_REPLAY_AFTER_MS;
13309
+ }
13310
+ return Math.floor(n2);
13311
+ }
13143
13312
  function levelToEventType(level) {
13144
13313
  if (level === "fatal" || level === "error") return "error";
13145
13314
  if (level === "warning") return "warning";
@@ -13150,6 +13319,10 @@ function isTerminalReplayLimitError(error) {
13150
13319
  const msg = error instanceof Error ? error.message : String(error);
13151
13320
  return msg.includes("replay exceeds max segment count") || msg.includes("replay exceeds max total size") || msg.includes("replay exceeds max duration") || msg.includes("Replay has expired");
13152
13321
  }
13322
+ function isOversizedSegmentError(error) {
13323
+ const msg = error instanceof Error ? error.message : String(error);
13324
+ return msg.includes("segment exceeds max compressed size") || msg.includes("segment exceeds max uncompressed size") || msg.includes("HTTP 400") && msg.includes("ApiValidationException");
13325
+ }
13153
13326
  var TalariaClient = class {
13154
13327
  constructor() {
13155
13328
  this.options = null;
@@ -13164,11 +13337,22 @@ var TalariaClient = class {
13164
13337
  this.startedOnServer = false;
13165
13338
  this.finishedOnServer = false;
13166
13339
  this.segmentIndex = 0;
13340
+ this.uploadedCompressedBytes = 0;
13341
+ /** Wall clock when continuous upload began (session or error promote). */
13342
+ this.uploadStartedAtMs = null;
13343
+ /** Absolute deadline for error-clip mode (not extended by later errors). */
13344
+ this.errorClipDeadlineMs = null;
13167
13345
  this.flushTimer = null;
13168
13346
  this.errorClipTimer = null;
13347
+ this.maxDurationTimer = null;
13169
13348
  this.uploadChain = Promise.resolve();
13170
13349
  this.closed = false;
13171
13350
  this.teardowns = [];
13351
+ /**
13352
+ * Replay id that just finished with segments — kept briefly so capture can
13353
+ * attach it to the error event after budget/timer finish resets buffer mode.
13354
+ */
13355
+ this.linkableReplayId = null;
13172
13356
  }
13173
13357
  init(options) {
13174
13358
  if (this.options) {
@@ -13186,7 +13370,12 @@ var TalariaClient = class {
13186
13370
  this.finishedOnServer = false;
13187
13371
  this.startedOnServer = false;
13188
13372
  this.segmentIndex = 0;
13373
+ this.uploadedCompressedBytes = 0;
13374
+ this.uploadStartedAtMs = null;
13375
+ this.errorClipDeadlineMs = null;
13376
+ this.linkableReplayId = null;
13189
13377
  this.buffer = new SegmentBuffer();
13378
+ this.uploadChain = Promise.resolve();
13190
13379
  this.sessionSampled = Math.random() < this.options.replaysSessionSampleRate;
13191
13380
  this.uploadEnabled = this.sessionSampled;
13192
13381
  this.recorder = startRecorder({
@@ -13213,13 +13402,15 @@ var TalariaClient = class {
13213
13402
  });
13214
13403
  }
13215
13404
  if (this.uploadEnabled) {
13405
+ this.markUploadStarted();
13216
13406
  void this.enqueueUpload(async () => {
13217
13407
  await this.ensureStarted({ keepalive: false });
13218
13408
  });
13219
13409
  }
13220
13410
  }
13221
13411
  getReplayId() {
13222
- return this.uploadEnabled ? this.replayId : null;
13412
+ if (this.uploadEnabled && this.replayId) return this.replayId;
13413
+ return this.linkableReplayId;
13223
13414
  }
13224
13415
  async captureException(error, context) {
13225
13416
  const err = error instanceof Error ? error : new Error(typeof error === "string" ? error : "Unknown error");
@@ -13248,6 +13439,14 @@ var TalariaClient = class {
13248
13439
  }
13249
13440
  await this.enqueueUpload(async () => {
13250
13441
  if (!this.uploadEnabled || this.closed) return;
13442
+ if (this.isPastErrorClipDeadline() && !opts?.finish) {
13443
+ await this.runEndErrorClip({ reason: "error_clip_deadline" });
13444
+ return;
13445
+ }
13446
+ if (this.isPastMaxDuration() && !opts?.finish) {
13447
+ await this.runStopForMaxDuration({ keepalive });
13448
+ return;
13449
+ }
13251
13450
  await this.ensureStarted({ keepalive });
13252
13451
  await this.uploadPendingSegments({ keepalive });
13253
13452
  if (opts?.finish && this.uploadEnabled) {
@@ -13259,14 +13458,19 @@ var TalariaClient = class {
13259
13458
  this.resetToBufferMode();
13260
13459
  } else {
13261
13460
  this.uploadEnabled = false;
13461
+ this.clearMaxDurationTimer();
13262
13462
  }
13263
13463
  }
13264
13464
  });
13265
13465
  }
13466
+ /**
13467
+ * Stop recording, flush while still open, then fully reset so `init()` can
13468
+ * run again (React Strict Mode remount).
13469
+ */
13266
13470
  async close() {
13267
- if (this.closed) return;
13268
- this.closed = true;
13471
+ if (this.closed && !this.options) return;
13269
13472
  this.clearErrorClipTimer();
13473
+ this.clearMaxDurationTimer();
13270
13474
  if (this.flushTimer) {
13271
13475
  clearInterval(this.flushTimer);
13272
13476
  this.flushTimer = null;
@@ -13279,17 +13483,45 @@ var TalariaClient = class {
13279
13483
  } catch {
13280
13484
  }
13281
13485
  }
13282
- if (this.uploadEnabled) {
13283
- await this.flush({ reason: "close", finish: true });
13486
+ if (this.uploadEnabled && this.options && this.transport) {
13487
+ try {
13488
+ await this.flush({ reason: "close", finish: true });
13489
+ } catch (error) {
13490
+ console.warn("@newtalaria/browser: close flush failed", error);
13491
+ }
13284
13492
  }
13493
+ this.options = null;
13494
+ this.transport = null;
13495
+ this.replayId = null;
13496
+ this.sessionId = null;
13497
+ this.sessionSampled = false;
13498
+ this.uploadEnabled = false;
13499
+ this.startedOnServer = false;
13500
+ this.finishedOnServer = false;
13501
+ this.segmentIndex = 0;
13502
+ this.uploadedCompressedBytes = 0;
13503
+ this.uploadStartedAtMs = null;
13504
+ this.errorClipDeadlineMs = null;
13505
+ this.buffer.clear();
13506
+ this.uploadChain = Promise.resolve();
13507
+ this.closed = false;
13508
+ this.linkableReplayId = null;
13285
13509
  }
13286
13510
  onRrwebEvent(event) {
13287
- if (this.closed) return;
13511
+ if (this.closed || !this.options) return;
13288
13512
  this.buffer.push(event);
13289
13513
  if (!this.uploadEnabled) {
13290
13514
  this.buffer.trimRing();
13291
13515
  return;
13292
13516
  }
13517
+ if (this.isPastErrorClipDeadline()) {
13518
+ void this.flush({ reason: "error_clip_deadline" });
13519
+ return;
13520
+ }
13521
+ if (this.isPastMaxDuration()) {
13522
+ void this.flush({ reason: "max_duration" });
13523
+ return;
13524
+ }
13293
13525
  if (this.buffer.shouldFlushBySize()) {
13294
13526
  void this.flush({ reason: "size" });
13295
13527
  }
@@ -13303,14 +13535,15 @@ var TalariaClient = class {
13303
13535
  if (!this.uploadEnabled) {
13304
13536
  if (Math.random() < this.options.replaysOnErrorSampleRate) {
13305
13537
  this.uploadEnabled = true;
13538
+ this.checkoutFullSnapshot();
13539
+ this.markUploadStarted();
13306
13540
  this.scheduleErrorClipEnd();
13307
13541
  }
13308
13542
  } else {
13309
13543
  this.scheduleErrorClipEnd();
13310
13544
  }
13311
13545
  }
13312
- const attachReplay = this.uploadEnabled;
13313
- if (attachReplay) {
13546
+ if (this.uploadEnabled) {
13314
13547
  try {
13315
13548
  await this.flush({ reason: "capture", keepalive: args.keepalive });
13316
13549
  } catch (error) {
@@ -13320,7 +13553,7 @@ var TalariaClient = class {
13320
13553
  }
13321
13554
  const tags = { ...args.context?.tags ?? {} };
13322
13555
  const extra = args.context?.extra;
13323
- const replayId = attachReplay ? this.replayId : null;
13556
+ const replayId = this.uploadEnabled && this.segmentIndex > 0 ? this.replayId : this.linkableReplayId;
13324
13557
  try {
13325
13558
  await ingestEvent(this.transport, {
13326
13559
  message: args.message,
@@ -13339,18 +13572,96 @@ var TalariaClient = class {
13339
13572
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
13340
13573
  keepalive: args.keepalive
13341
13574
  });
13575
+ if (replayId && replayId === this.linkableReplayId) {
13576
+ this.linkableReplayId = null;
13577
+ }
13342
13578
  } catch (error) {
13343
13579
  console.warn("@newtalaria/browser: event ingest failed", error);
13344
13580
  throw error;
13345
13581
  }
13346
13582
  }
13583
+ markUploadStarted() {
13584
+ if (this.uploadStartedAtMs == null) {
13585
+ this.uploadStartedAtMs = Date.now();
13586
+ }
13587
+ if (!this.sessionSampled && this.options && this.options.replaysErrorAfterMs > 0 && this.errorClipDeadlineMs == null) {
13588
+ this.errorClipDeadlineMs = computeErrorClipDeadlineMs(
13589
+ this.uploadStartedAtMs,
13590
+ this.options.replaysErrorAfterMs
13591
+ );
13592
+ }
13593
+ this.scheduleMaxDurationStop();
13594
+ }
13595
+ isPastMaxDuration() {
13596
+ if (this.uploadStartedAtMs == null) return false;
13597
+ return Date.now() - this.uploadStartedAtMs >= MAX_REPLAY_DURATION_MS;
13598
+ }
13599
+ isPastErrorClipDeadline() {
13600
+ if (this.sessionSampled || this.errorClipDeadlineMs == null) return false;
13601
+ return Date.now() >= this.errorClipDeadlineMs;
13602
+ }
13603
+ isErrorClipMode() {
13604
+ return !this.sessionSampled && !!this.options && this.options.replaysErrorAfterMs > 0;
13605
+ }
13606
+ scheduleMaxDurationStop() {
13607
+ if (this.closed || this.uploadStartedAtMs == null) return;
13608
+ this.clearMaxDurationTimer();
13609
+ const remaining = Math.max(
13610
+ 0,
13611
+ MAX_REPLAY_DURATION_MS - (Date.now() - this.uploadStartedAtMs)
13612
+ );
13613
+ this.maxDurationTimer = setTimeout(() => {
13614
+ this.maxDurationTimer = null;
13615
+ if (!this.uploadEnabled || this.closed) return;
13616
+ void this.enqueueUpload(async () => {
13617
+ if (!this.uploadEnabled || this.closed) return;
13618
+ await this.runStopForMaxDuration({ keepalive: false });
13619
+ });
13620
+ }, remaining);
13621
+ }
13622
+ clearMaxDurationTimer() {
13623
+ if (this.maxDurationTimer) {
13624
+ clearTimeout(this.maxDurationTimer);
13625
+ this.maxDurationTimer = null;
13626
+ }
13627
+ }
13628
+ async runStopForMaxDuration(opts) {
13629
+ if (!this.uploadEnabled) return;
13630
+ await this.ensureStarted({ keepalive: opts.keepalive });
13631
+ await this.uploadPendingSegments({ keepalive: opts.keepalive });
13632
+ await this.finishOnServer({
13633
+ keepalive: opts.keepalive,
13634
+ reason: "max_duration"
13635
+ });
13636
+ if (this.sessionSampled) {
13637
+ this.uploadEnabled = false;
13638
+ this.clearMaxDurationTimer();
13639
+ if (this.flushTimer) {
13640
+ clearInterval(this.flushTimer);
13641
+ this.flushTimer = null;
13642
+ }
13643
+ } else {
13644
+ this.resetToBufferMode();
13645
+ }
13646
+ }
13647
+ /**
13648
+ * Schedule end of the cheap error clip against a fixed absolute deadline.
13649
+ * Subsequent errors re-arm the timer but never push the wall later.
13650
+ */
13347
13651
  scheduleErrorClipEnd() {
13348
- if (this.sessionSampled || this.closed) return;
13652
+ if (this.sessionSampled || this.closed || !this.options) return;
13653
+ const afterMs = this.options.replaysErrorAfterMs;
13654
+ if (afterMs <= 0) return;
13655
+ if (this.errorClipDeadlineMs == null) {
13656
+ const started = this.uploadStartedAtMs ?? Date.now();
13657
+ this.errorClipDeadlineMs = computeErrorClipDeadlineMs(started, afterMs);
13658
+ }
13659
+ const remaining = Math.max(0, this.errorClipDeadlineMs - Date.now());
13349
13660
  this.clearErrorClipTimer();
13350
13661
  this.errorClipTimer = setTimeout(() => {
13351
13662
  this.errorClipTimer = null;
13352
- void this.endErrorClip();
13353
- }, ERROR_REPLAY_AFTER_MS);
13663
+ void this.endErrorClip({ reason: "error_clip" });
13664
+ }, remaining);
13354
13665
  }
13355
13666
  clearErrorClipTimer() {
13356
13667
  if (this.errorClipTimer) {
@@ -13359,30 +13670,61 @@ var TalariaClient = class {
13359
13670
  }
13360
13671
  }
13361
13672
  /** Flush trailing post-error window, finish replay, return to ring buffer. */
13362
- async endErrorClip() {
13673
+ async endErrorClip(opts) {
13363
13674
  if (this.closed || this.sessionSampled || !this.uploadEnabled || !this.options || !this.transport) {
13364
13675
  return;
13365
13676
  }
13366
13677
  await this.enqueueUpload(async () => {
13367
- if (!this.uploadEnabled || this.sessionSampled) return;
13368
- await this.ensureStarted({ keepalive: false });
13369
- await this.uploadPendingSegments({ keepalive: false });
13370
- await this.finishOnServer({ keepalive: false, reason: "error_clip" });
13371
- this.resetToBufferMode();
13678
+ await this.runEndErrorClip(opts);
13679
+ });
13680
+ }
13681
+ async runEndErrorClip(opts) {
13682
+ if (!this.uploadEnabled || this.sessionSampled) return;
13683
+ await this.ensureStarted({ keepalive: false });
13684
+ await this.uploadPendingSegments({ keepalive: false });
13685
+ await this.finishOnServer({
13686
+ keepalive: false,
13687
+ reason: opts?.reason ?? "error_clip"
13372
13688
  });
13689
+ this.resetToBufferMode();
13373
13690
  }
13374
13691
  /** After an error clip (or terminal limit), buffer locally until the next error. */
13375
13692
  resetToBufferMode() {
13376
13693
  this.clearErrorClipTimer();
13694
+ this.clearMaxDurationTimer();
13377
13695
  this.uploadEnabled = false;
13378
13696
  this.startedOnServer = false;
13379
13697
  this.finishedOnServer = false;
13380
13698
  this.segmentIndex = 0;
13699
+ this.uploadedCompressedBytes = 0;
13700
+ this.uploadStartedAtMs = null;
13701
+ this.errorClipDeadlineMs = null;
13381
13702
  this.replayId = createId();
13382
- this.buffer.trimRing();
13703
+ this.buffer.clear();
13704
+ this.checkoutFullSnapshot();
13705
+ }
13706
+ /** Emit a checkout FullSnapshot into the ring buffer (sync via rrweb emit). */
13707
+ checkoutFullSnapshot() {
13708
+ this.recorder?.takeFullSnapshot();
13709
+ }
13710
+ /**
13711
+ * Ensure segment 0 begins at Meta+FullSnapshot. Orphan increments alone
13712
+ * produce a blank rrweb player.
13713
+ */
13714
+ prepareBufferForNewReplay() {
13715
+ if (this.segmentIndex !== 0) return;
13716
+ if (!this.buffer.hasFullSnapshot()) {
13717
+ this.checkoutFullSnapshot();
13718
+ }
13719
+ if (!this.buffer.trimToFullSnapshot()) {
13720
+ console.warn(
13721
+ "@newtalaria/browser: replay has no FullSnapshot; player may show a blank screen"
13722
+ );
13723
+ }
13383
13724
  }
13384
13725
  stopUploadingAfterLimit() {
13385
13726
  this.clearErrorClipTimer();
13727
+ this.clearMaxDurationTimer();
13386
13728
  this.uploadEnabled = false;
13387
13729
  if (this.sessionSampled) {
13388
13730
  if (this.flushTimer) {
@@ -13433,13 +13775,61 @@ var TalariaClient = class {
13433
13775
  keepalive: opts.keepalive
13434
13776
  });
13435
13777
  this.startedOnServer = true;
13778
+ this.markUploadStarted();
13436
13779
  }
13437
13780
  async uploadPendingSegments(opts) {
13438
13781
  if (!this.options || !this.transport || !this.replayId) return;
13439
13782
  if (!this.startedOnServer || this.finishedOnServer) return;
13783
+ this.prepareBufferForNewReplay();
13440
13784
  while (this.buffer.length > 0) {
13441
- const events = this.buffer.takeAll();
13442
- if (events.length === 0) break;
13785
+ if (this.isPastMaxDuration()) {
13786
+ break;
13787
+ }
13788
+ if (this.isPastErrorClipDeadline()) {
13789
+ break;
13790
+ }
13791
+ if (this.segmentIndex >= this.maxSegmentsAllowed()) {
13792
+ console.warn(
13793
+ "@newtalaria/browser: replay upload stopped (max segments)",
13794
+ { segmentIndex: this.segmentIndex }
13795
+ );
13796
+ await this.finishOnServer({
13797
+ keepalive: opts.keepalive,
13798
+ reason: "max_segments"
13799
+ });
13800
+ this.stopUploadingAfterLimit();
13801
+ break;
13802
+ }
13803
+ if (this.isErrorClipMode() && isErrorClipBudgetExhausted({
13804
+ segmentIndex: this.segmentIndex,
13805
+ uploadedCompressedBytes: this.uploadedCompressedBytes
13806
+ })) {
13807
+ console.warn(
13808
+ "@newtalaria/browser: replay upload stopped (error clip budget)",
13809
+ {
13810
+ segmentIndex: this.segmentIndex,
13811
+ uploadedCompressedBytes: this.uploadedCompressedBytes
13812
+ }
13813
+ );
13814
+ await this.finishOnServer({
13815
+ keepalive: opts.keepalive,
13816
+ reason: "error_clip_budget"
13817
+ });
13818
+ this.stopUploadingAfterLimit();
13819
+ break;
13820
+ }
13821
+ const fitted = await this.takeFittedSegment();
13822
+ if (!fitted) break;
13823
+ const { events, gzip } = fitted;
13824
+ if (this.isErrorClipMode() && this.uploadedCompressedBytes + gzip.length > MAX_ERROR_CLIP_COMPRESSED_BYTES && this.segmentIndex > 0) {
13825
+ this.buffer.clear();
13826
+ await this.finishOnServer({
13827
+ keepalive: opts.keepalive,
13828
+ reason: "error_clip_budget"
13829
+ });
13830
+ this.stopUploadingAfterLimit();
13831
+ break;
13832
+ }
13443
13833
  const startedAt = new Date(events[0].timestamp);
13444
13834
  const endedAt = new Date(events[events.length - 1].timestamp);
13445
13835
  const index2 = this.segmentIndex;
@@ -13450,9 +13840,11 @@ var TalariaClient = class {
13450
13840
  events,
13451
13841
  startedAt,
13452
13842
  endedAt,
13843
+ gzip,
13453
13844
  keepalive: opts.keepalive
13454
13845
  });
13455
13846
  this.segmentIndex = index2 + 1;
13847
+ this.uploadedCompressedBytes += gzip.length;
13456
13848
  } catch (error) {
13457
13849
  if (isTerminalReplayLimitError(error)) {
13458
13850
  console.warn(
@@ -13466,6 +13858,19 @@ var TalariaClient = class {
13466
13858
  this.stopUploadingAfterLimit();
13467
13859
  break;
13468
13860
  }
13861
+ if (isOversizedSegmentError(error)) {
13862
+ const plan = planOversizedRetry(events);
13863
+ if (plan.action === "drop") {
13864
+ console.warn(
13865
+ "@newtalaria/browser: dropping rrweb event rejected as oversized",
13866
+ { type: events[0]?.type }
13867
+ );
13868
+ continue;
13869
+ }
13870
+ this.buffer.prepend(plan.right);
13871
+ this.buffer.prepend(plan.left);
13872
+ continue;
13873
+ }
13469
13874
  this.buffer.prepend(events);
13470
13875
  console.warn("@newtalaria/browser: replays/ingestSegment failed", error);
13471
13876
  break;
@@ -13473,9 +13878,44 @@ var TalariaClient = class {
13473
13878
  if (opts.keepalive) break;
13474
13879
  }
13475
13880
  }
13881
+ maxSegmentsAllowed() {
13882
+ if (this.sessionSampled) return MAX_SEGMENTS_PER_REPLAY;
13883
+ if (this.options && this.options.replaysErrorAfterMs <= 0) {
13884
+ return MAX_SEGMENTS_PER_REPLAY;
13885
+ }
13886
+ return MAX_SEGMENTS_ERROR_CLIP;
13887
+ }
13888
+ /**
13889
+ * Pull the largest event prefix that gzips under the target compressed size.
13890
+ * Drops a single event that can never fit.
13891
+ */
13892
+ async takeFittedSegment() {
13893
+ const chunk = this.buffer.takeByEstimatedBytes(SEGMENT_SIZE_BYTES);
13894
+ if (chunk.length === 0) return null;
13895
+ const fitted = await fitCompressedPrefix(
13896
+ chunk,
13897
+ compressReplayEvents,
13898
+ TARGET_COMPRESSED_SEGMENT_BYTES,
13899
+ MAX_COMPRESSED_SEGMENT_BYTES
13900
+ );
13901
+ if (!fitted) {
13902
+ console.warn(
13903
+ "@newtalaria/browser: dropping rrweb event that exceeds max segment size",
13904
+ { type: chunk[0]?.type }
13905
+ );
13906
+ return this.takeFittedSegment();
13907
+ }
13908
+ if (fitted.remainder.length > 0) {
13909
+ this.buffer.prepend(fitted.remainder);
13910
+ }
13911
+ return { events: fitted.events, gzip: fitted.gzip };
13912
+ }
13476
13913
  async finishOnServer(opts) {
13477
13914
  if (!this.transport || !this.replayId) return;
13478
13915
  if (!this.startedOnServer || this.finishedOnServer) return;
13916
+ if (this.segmentIndex > 0) {
13917
+ this.linkableReplayId = this.replayId;
13918
+ }
13479
13919
  try {
13480
13920
  await finishReplay(this.transport, {
13481
13921
  replayId: this.replayId,
@@ -13513,9 +13953,17 @@ var Talaria = {
13513
13953
  };
13514
13954
  var index_default = Talaria;
13515
13955
  export {
13956
+ MAX_COMPRESSED_SEGMENT_BYTES,
13957
+ MAX_ERROR_CLIP_COMPRESSED_BYTES,
13958
+ MAX_SEGMENTS_ERROR_CLIP,
13959
+ TARGET_COMPRESSED_SEGMENT_BYTES,
13516
13960
  Talaria,
13517
13961
  TalariaClient,
13518
- index_default as default
13962
+ computeErrorClipDeadlineMs,
13963
+ index_default as default,
13964
+ fitCompressedPrefix,
13965
+ isErrorClipBudgetExhausted,
13966
+ planOversizedRetry
13519
13967
  };
13520
13968
  /*! Bundled license information:
13521
13969