@newtalaria/browser 0.1.1 → 0.1.5

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: options.inlineStylesheet,
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,9 +13292,12 @@ 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,
13297
+ inlineStylesheet: options.inlineStylesheet ?? false,
13134
13298
  blockSelector: options.blockSelector ?? "",
13135
13299
  userId: options.userId,
13300
+ tags: options.tags,
13136
13301
  disableDefaultIntegrations: options.disableDefaultIntegrations ?? false
13137
13302
  };
13138
13303
  }
@@ -13140,6 +13305,12 @@ function clamp01(n2) {
13140
13305
  if (Number.isNaN(n2)) return 0;
13141
13306
  return Math.min(1, Math.max(0, n2));
13142
13307
  }
13308
+ function normalizeErrorAfterMs(n2) {
13309
+ if (n2 === void 0 || Number.isNaN(n2) || n2 < 0) {
13310
+ return ERROR_REPLAY_AFTER_MS;
13311
+ }
13312
+ return Math.floor(n2);
13313
+ }
13143
13314
  function levelToEventType(level) {
13144
13315
  if (level === "fatal" || level === "error") return "error";
13145
13316
  if (level === "warning") return "warning";
@@ -13150,6 +13321,10 @@ function isTerminalReplayLimitError(error) {
13150
13321
  const msg = error instanceof Error ? error.message : String(error);
13151
13322
  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
13323
  }
13324
+ function isOversizedSegmentError(error) {
13325
+ const msg = error instanceof Error ? error.message : String(error);
13326
+ return msg.includes("segment exceeds max compressed size") || msg.includes("segment exceeds max uncompressed size") || msg.includes("HTTP 400") && msg.includes("ApiValidationException");
13327
+ }
13153
13328
  var TalariaClient = class {
13154
13329
  constructor() {
13155
13330
  this.options = null;
@@ -13164,11 +13339,22 @@ var TalariaClient = class {
13164
13339
  this.startedOnServer = false;
13165
13340
  this.finishedOnServer = false;
13166
13341
  this.segmentIndex = 0;
13342
+ this.uploadedCompressedBytes = 0;
13343
+ /** Wall clock when continuous upload began (session or error promote). */
13344
+ this.uploadStartedAtMs = null;
13345
+ /** Absolute deadline for error-clip mode (not extended by later errors). */
13346
+ this.errorClipDeadlineMs = null;
13167
13347
  this.flushTimer = null;
13168
13348
  this.errorClipTimer = null;
13349
+ this.maxDurationTimer = null;
13169
13350
  this.uploadChain = Promise.resolve();
13170
13351
  this.closed = false;
13171
13352
  this.teardowns = [];
13353
+ /**
13354
+ * Replay id that just finished with segments — kept briefly so capture can
13355
+ * attach it to the error event after budget/timer finish resets buffer mode.
13356
+ */
13357
+ this.linkableReplayId = null;
13172
13358
  }
13173
13359
  init(options) {
13174
13360
  if (this.options) {
@@ -13186,11 +13372,17 @@ var TalariaClient = class {
13186
13372
  this.finishedOnServer = false;
13187
13373
  this.startedOnServer = false;
13188
13374
  this.segmentIndex = 0;
13375
+ this.uploadedCompressedBytes = 0;
13376
+ this.uploadStartedAtMs = null;
13377
+ this.errorClipDeadlineMs = null;
13378
+ this.linkableReplayId = null;
13189
13379
  this.buffer = new SegmentBuffer();
13380
+ this.uploadChain = Promise.resolve();
13190
13381
  this.sessionSampled = Math.random() < this.options.replaysSessionSampleRate;
13191
13382
  this.uploadEnabled = this.sessionSampled;
13192
13383
  this.recorder = startRecorder({
13193
13384
  maskAllInputs: this.options.maskAllInputs,
13385
+ inlineStylesheet: this.options.inlineStylesheet,
13194
13386
  blockSelector: this.options.blockSelector,
13195
13387
  onEvent: (event) => this.onRrwebEvent(event)
13196
13388
  });
@@ -13213,13 +13405,15 @@ var TalariaClient = class {
13213
13405
  });
13214
13406
  }
13215
13407
  if (this.uploadEnabled) {
13408
+ this.markUploadStarted();
13216
13409
  void this.enqueueUpload(async () => {
13217
13410
  await this.ensureStarted({ keepalive: false });
13218
13411
  });
13219
13412
  }
13220
13413
  }
13221
13414
  getReplayId() {
13222
- return this.uploadEnabled ? this.replayId : null;
13415
+ if (this.uploadEnabled && this.replayId) return this.replayId;
13416
+ return this.linkableReplayId;
13223
13417
  }
13224
13418
  async captureException(error, context) {
13225
13419
  const err = error instanceof Error ? error : new Error(typeof error === "string" ? error : "Unknown error");
@@ -13248,6 +13442,14 @@ var TalariaClient = class {
13248
13442
  }
13249
13443
  await this.enqueueUpload(async () => {
13250
13444
  if (!this.uploadEnabled || this.closed) return;
13445
+ if (this.isPastErrorClipDeadline() && !opts?.finish) {
13446
+ await this.runEndErrorClip({ reason: "error_clip_deadline" });
13447
+ return;
13448
+ }
13449
+ if (this.isPastMaxDuration() && !opts?.finish) {
13450
+ await this.runStopForMaxDuration({ keepalive });
13451
+ return;
13452
+ }
13251
13453
  await this.ensureStarted({ keepalive });
13252
13454
  await this.uploadPendingSegments({ keepalive });
13253
13455
  if (opts?.finish && this.uploadEnabled) {
@@ -13259,14 +13461,19 @@ var TalariaClient = class {
13259
13461
  this.resetToBufferMode();
13260
13462
  } else {
13261
13463
  this.uploadEnabled = false;
13464
+ this.clearMaxDurationTimer();
13262
13465
  }
13263
13466
  }
13264
13467
  });
13265
13468
  }
13469
+ /**
13470
+ * Stop recording, flush while still open, then fully reset so `init()` can
13471
+ * run again (React Strict Mode remount).
13472
+ */
13266
13473
  async close() {
13267
- if (this.closed) return;
13268
- this.closed = true;
13474
+ if (this.closed && !this.options) return;
13269
13475
  this.clearErrorClipTimer();
13476
+ this.clearMaxDurationTimer();
13270
13477
  if (this.flushTimer) {
13271
13478
  clearInterval(this.flushTimer);
13272
13479
  this.flushTimer = null;
@@ -13279,17 +13486,45 @@ var TalariaClient = class {
13279
13486
  } catch {
13280
13487
  }
13281
13488
  }
13282
- if (this.uploadEnabled) {
13283
- await this.flush({ reason: "close", finish: true });
13489
+ if (this.uploadEnabled && this.options && this.transport) {
13490
+ try {
13491
+ await this.flush({ reason: "close", finish: true });
13492
+ } catch (error) {
13493
+ console.warn("@newtalaria/browser: close flush failed", error);
13494
+ }
13284
13495
  }
13496
+ this.options = null;
13497
+ this.transport = null;
13498
+ this.replayId = null;
13499
+ this.sessionId = null;
13500
+ this.sessionSampled = false;
13501
+ this.uploadEnabled = false;
13502
+ this.startedOnServer = false;
13503
+ this.finishedOnServer = false;
13504
+ this.segmentIndex = 0;
13505
+ this.uploadedCompressedBytes = 0;
13506
+ this.uploadStartedAtMs = null;
13507
+ this.errorClipDeadlineMs = null;
13508
+ this.buffer.clear();
13509
+ this.uploadChain = Promise.resolve();
13510
+ this.closed = false;
13511
+ this.linkableReplayId = null;
13285
13512
  }
13286
13513
  onRrwebEvent(event) {
13287
- if (this.closed) return;
13514
+ if (this.closed || !this.options) return;
13288
13515
  this.buffer.push(event);
13289
13516
  if (!this.uploadEnabled) {
13290
13517
  this.buffer.trimRing();
13291
13518
  return;
13292
13519
  }
13520
+ if (this.isPastErrorClipDeadline()) {
13521
+ void this.flush({ reason: "error_clip_deadline" });
13522
+ return;
13523
+ }
13524
+ if (this.isPastMaxDuration()) {
13525
+ void this.flush({ reason: "max_duration" });
13526
+ return;
13527
+ }
13293
13528
  if (this.buffer.shouldFlushBySize()) {
13294
13529
  void this.flush({ reason: "size" });
13295
13530
  }
@@ -13303,14 +13538,15 @@ var TalariaClient = class {
13303
13538
  if (!this.uploadEnabled) {
13304
13539
  if (Math.random() < this.options.replaysOnErrorSampleRate) {
13305
13540
  this.uploadEnabled = true;
13541
+ this.checkoutFullSnapshot();
13542
+ this.markUploadStarted();
13306
13543
  this.scheduleErrorClipEnd();
13307
13544
  }
13308
13545
  } else {
13309
13546
  this.scheduleErrorClipEnd();
13310
13547
  }
13311
13548
  }
13312
- const attachReplay = this.uploadEnabled;
13313
- if (attachReplay) {
13549
+ if (this.uploadEnabled) {
13314
13550
  try {
13315
13551
  await this.flush({ reason: "capture", keepalive: args.keepalive });
13316
13552
  } catch (error) {
@@ -13318,9 +13554,12 @@ var TalariaClient = class {
13318
13554
  throw error;
13319
13555
  }
13320
13556
  }
13321
- const tags = { ...args.context?.tags ?? {} };
13557
+ const tags = {
13558
+ ...this.options.tags ?? {},
13559
+ ...args.context?.tags ?? {}
13560
+ };
13322
13561
  const extra = args.context?.extra;
13323
- const replayId = attachReplay ? this.replayId : null;
13562
+ const replayId = this.uploadEnabled && this.segmentIndex > 0 ? this.replayId : this.linkableReplayId;
13324
13563
  try {
13325
13564
  await ingestEvent(this.transport, {
13326
13565
  message: args.message,
@@ -13339,18 +13578,96 @@ var TalariaClient = class {
13339
13578
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
13340
13579
  keepalive: args.keepalive
13341
13580
  });
13581
+ if (replayId && replayId === this.linkableReplayId) {
13582
+ this.linkableReplayId = null;
13583
+ }
13342
13584
  } catch (error) {
13343
13585
  console.warn("@newtalaria/browser: event ingest failed", error);
13344
13586
  throw error;
13345
13587
  }
13346
13588
  }
13589
+ markUploadStarted() {
13590
+ if (this.uploadStartedAtMs == null) {
13591
+ this.uploadStartedAtMs = Date.now();
13592
+ }
13593
+ if (!this.sessionSampled && this.options && this.options.replaysErrorAfterMs > 0 && this.errorClipDeadlineMs == null) {
13594
+ this.errorClipDeadlineMs = computeErrorClipDeadlineMs(
13595
+ this.uploadStartedAtMs,
13596
+ this.options.replaysErrorAfterMs
13597
+ );
13598
+ }
13599
+ this.scheduleMaxDurationStop();
13600
+ }
13601
+ isPastMaxDuration() {
13602
+ if (this.uploadStartedAtMs == null) return false;
13603
+ return Date.now() - this.uploadStartedAtMs >= MAX_REPLAY_DURATION_MS;
13604
+ }
13605
+ isPastErrorClipDeadline() {
13606
+ if (this.sessionSampled || this.errorClipDeadlineMs == null) return false;
13607
+ return Date.now() >= this.errorClipDeadlineMs;
13608
+ }
13609
+ isErrorClipMode() {
13610
+ return !this.sessionSampled && !!this.options && this.options.replaysErrorAfterMs > 0;
13611
+ }
13612
+ scheduleMaxDurationStop() {
13613
+ if (this.closed || this.uploadStartedAtMs == null) return;
13614
+ this.clearMaxDurationTimer();
13615
+ const remaining = Math.max(
13616
+ 0,
13617
+ MAX_REPLAY_DURATION_MS - (Date.now() - this.uploadStartedAtMs)
13618
+ );
13619
+ this.maxDurationTimer = setTimeout(() => {
13620
+ this.maxDurationTimer = null;
13621
+ if (!this.uploadEnabled || this.closed) return;
13622
+ void this.enqueueUpload(async () => {
13623
+ if (!this.uploadEnabled || this.closed) return;
13624
+ await this.runStopForMaxDuration({ keepalive: false });
13625
+ });
13626
+ }, remaining);
13627
+ }
13628
+ clearMaxDurationTimer() {
13629
+ if (this.maxDurationTimer) {
13630
+ clearTimeout(this.maxDurationTimer);
13631
+ this.maxDurationTimer = null;
13632
+ }
13633
+ }
13634
+ async runStopForMaxDuration(opts) {
13635
+ if (!this.uploadEnabled) return;
13636
+ await this.ensureStarted({ keepalive: opts.keepalive });
13637
+ await this.uploadPendingSegments({ keepalive: opts.keepalive });
13638
+ await this.finishOnServer({
13639
+ keepalive: opts.keepalive,
13640
+ reason: "max_duration"
13641
+ });
13642
+ if (this.sessionSampled) {
13643
+ this.uploadEnabled = false;
13644
+ this.clearMaxDurationTimer();
13645
+ if (this.flushTimer) {
13646
+ clearInterval(this.flushTimer);
13647
+ this.flushTimer = null;
13648
+ }
13649
+ } else {
13650
+ this.resetToBufferMode();
13651
+ }
13652
+ }
13653
+ /**
13654
+ * Schedule end of the cheap error clip against a fixed absolute deadline.
13655
+ * Subsequent errors re-arm the timer but never push the wall later.
13656
+ */
13347
13657
  scheduleErrorClipEnd() {
13348
- if (this.sessionSampled || this.closed) return;
13658
+ if (this.sessionSampled || this.closed || !this.options) return;
13659
+ const afterMs = this.options.replaysErrorAfterMs;
13660
+ if (afterMs <= 0) return;
13661
+ if (this.errorClipDeadlineMs == null) {
13662
+ const started = this.uploadStartedAtMs ?? Date.now();
13663
+ this.errorClipDeadlineMs = computeErrorClipDeadlineMs(started, afterMs);
13664
+ }
13665
+ const remaining = Math.max(0, this.errorClipDeadlineMs - Date.now());
13349
13666
  this.clearErrorClipTimer();
13350
13667
  this.errorClipTimer = setTimeout(() => {
13351
13668
  this.errorClipTimer = null;
13352
- void this.endErrorClip();
13353
- }, ERROR_REPLAY_AFTER_MS);
13669
+ void this.endErrorClip({ reason: "error_clip" });
13670
+ }, remaining);
13354
13671
  }
13355
13672
  clearErrorClipTimer() {
13356
13673
  if (this.errorClipTimer) {
@@ -13359,30 +13676,61 @@ var TalariaClient = class {
13359
13676
  }
13360
13677
  }
13361
13678
  /** Flush trailing post-error window, finish replay, return to ring buffer. */
13362
- async endErrorClip() {
13679
+ async endErrorClip(opts) {
13363
13680
  if (this.closed || this.sessionSampled || !this.uploadEnabled || !this.options || !this.transport) {
13364
13681
  return;
13365
13682
  }
13366
13683
  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();
13684
+ await this.runEndErrorClip(opts);
13685
+ });
13686
+ }
13687
+ async runEndErrorClip(opts) {
13688
+ if (!this.uploadEnabled || this.sessionSampled) return;
13689
+ await this.ensureStarted({ keepalive: false });
13690
+ await this.uploadPendingSegments({ keepalive: false });
13691
+ await this.finishOnServer({
13692
+ keepalive: false,
13693
+ reason: opts?.reason ?? "error_clip"
13372
13694
  });
13695
+ this.resetToBufferMode();
13373
13696
  }
13374
13697
  /** After an error clip (or terminal limit), buffer locally until the next error. */
13375
13698
  resetToBufferMode() {
13376
13699
  this.clearErrorClipTimer();
13700
+ this.clearMaxDurationTimer();
13377
13701
  this.uploadEnabled = false;
13378
13702
  this.startedOnServer = false;
13379
13703
  this.finishedOnServer = false;
13380
13704
  this.segmentIndex = 0;
13705
+ this.uploadedCompressedBytes = 0;
13706
+ this.uploadStartedAtMs = null;
13707
+ this.errorClipDeadlineMs = null;
13381
13708
  this.replayId = createId();
13382
- this.buffer.trimRing();
13709
+ this.buffer.clear();
13710
+ this.checkoutFullSnapshot();
13711
+ }
13712
+ /** Emit a checkout FullSnapshot into the ring buffer (sync via rrweb emit). */
13713
+ checkoutFullSnapshot() {
13714
+ this.recorder?.takeFullSnapshot();
13715
+ }
13716
+ /**
13717
+ * Ensure segment 0 begins at Meta+FullSnapshot. Orphan increments alone
13718
+ * produce a blank rrweb player.
13719
+ */
13720
+ prepareBufferForNewReplay() {
13721
+ if (this.segmentIndex !== 0) return;
13722
+ if (!this.buffer.hasFullSnapshot()) {
13723
+ this.checkoutFullSnapshot();
13724
+ }
13725
+ if (!this.buffer.trimToFullSnapshot()) {
13726
+ console.warn(
13727
+ "@newtalaria/browser: replay has no FullSnapshot; player may show a blank screen"
13728
+ );
13729
+ }
13383
13730
  }
13384
13731
  stopUploadingAfterLimit() {
13385
13732
  this.clearErrorClipTimer();
13733
+ this.clearMaxDurationTimer();
13386
13734
  this.uploadEnabled = false;
13387
13735
  if (this.sessionSampled) {
13388
13736
  if (this.flushTimer) {
@@ -13433,13 +13781,61 @@ var TalariaClient = class {
13433
13781
  keepalive: opts.keepalive
13434
13782
  });
13435
13783
  this.startedOnServer = true;
13784
+ this.markUploadStarted();
13436
13785
  }
13437
13786
  async uploadPendingSegments(opts) {
13438
13787
  if (!this.options || !this.transport || !this.replayId) return;
13439
13788
  if (!this.startedOnServer || this.finishedOnServer) return;
13789
+ this.prepareBufferForNewReplay();
13440
13790
  while (this.buffer.length > 0) {
13441
- const events = this.buffer.takeAll();
13442
- if (events.length === 0) break;
13791
+ if (this.isPastMaxDuration()) {
13792
+ break;
13793
+ }
13794
+ if (this.isPastErrorClipDeadline()) {
13795
+ break;
13796
+ }
13797
+ if (this.segmentIndex >= this.maxSegmentsAllowed()) {
13798
+ console.warn(
13799
+ "@newtalaria/browser: replay upload stopped (max segments)",
13800
+ { segmentIndex: this.segmentIndex }
13801
+ );
13802
+ await this.finishOnServer({
13803
+ keepalive: opts.keepalive,
13804
+ reason: "max_segments"
13805
+ });
13806
+ this.stopUploadingAfterLimit();
13807
+ break;
13808
+ }
13809
+ if (this.isErrorClipMode() && isErrorClipBudgetExhausted({
13810
+ segmentIndex: this.segmentIndex,
13811
+ uploadedCompressedBytes: this.uploadedCompressedBytes
13812
+ })) {
13813
+ console.warn(
13814
+ "@newtalaria/browser: replay upload stopped (error clip budget)",
13815
+ {
13816
+ segmentIndex: this.segmentIndex,
13817
+ uploadedCompressedBytes: this.uploadedCompressedBytes
13818
+ }
13819
+ );
13820
+ await this.finishOnServer({
13821
+ keepalive: opts.keepalive,
13822
+ reason: "error_clip_budget"
13823
+ });
13824
+ this.stopUploadingAfterLimit();
13825
+ break;
13826
+ }
13827
+ const fitted = await this.takeFittedSegment();
13828
+ if (!fitted) break;
13829
+ const { events, gzip } = fitted;
13830
+ if (this.isErrorClipMode() && this.uploadedCompressedBytes + gzip.length > MAX_ERROR_CLIP_COMPRESSED_BYTES && this.segmentIndex > 0) {
13831
+ this.buffer.clear();
13832
+ await this.finishOnServer({
13833
+ keepalive: opts.keepalive,
13834
+ reason: "error_clip_budget"
13835
+ });
13836
+ this.stopUploadingAfterLimit();
13837
+ break;
13838
+ }
13443
13839
  const startedAt = new Date(events[0].timestamp);
13444
13840
  const endedAt = new Date(events[events.length - 1].timestamp);
13445
13841
  const index2 = this.segmentIndex;
@@ -13450,9 +13846,11 @@ var TalariaClient = class {
13450
13846
  events,
13451
13847
  startedAt,
13452
13848
  endedAt,
13849
+ gzip,
13453
13850
  keepalive: opts.keepalive
13454
13851
  });
13455
13852
  this.segmentIndex = index2 + 1;
13853
+ this.uploadedCompressedBytes += gzip.length;
13456
13854
  } catch (error) {
13457
13855
  if (isTerminalReplayLimitError(error)) {
13458
13856
  console.warn(
@@ -13466,6 +13864,19 @@ var TalariaClient = class {
13466
13864
  this.stopUploadingAfterLimit();
13467
13865
  break;
13468
13866
  }
13867
+ if (isOversizedSegmentError(error)) {
13868
+ const plan = planOversizedRetry(events);
13869
+ if (plan.action === "drop") {
13870
+ console.warn(
13871
+ "@newtalaria/browser: dropping rrweb event rejected as oversized",
13872
+ { type: events[0]?.type }
13873
+ );
13874
+ continue;
13875
+ }
13876
+ this.buffer.prepend(plan.right);
13877
+ this.buffer.prepend(plan.left);
13878
+ continue;
13879
+ }
13469
13880
  this.buffer.prepend(events);
13470
13881
  console.warn("@newtalaria/browser: replays/ingestSegment failed", error);
13471
13882
  break;
@@ -13473,9 +13884,44 @@ var TalariaClient = class {
13473
13884
  if (opts.keepalive) break;
13474
13885
  }
13475
13886
  }
13887
+ maxSegmentsAllowed() {
13888
+ if (this.sessionSampled) return MAX_SEGMENTS_PER_REPLAY;
13889
+ if (this.options && this.options.replaysErrorAfterMs <= 0) {
13890
+ return MAX_SEGMENTS_PER_REPLAY;
13891
+ }
13892
+ return MAX_SEGMENTS_ERROR_CLIP;
13893
+ }
13894
+ /**
13895
+ * Pull the largest event prefix that gzips under the target compressed size.
13896
+ * Drops a single event that can never fit.
13897
+ */
13898
+ async takeFittedSegment() {
13899
+ const chunk = this.buffer.takeByEstimatedBytes(SEGMENT_SIZE_BYTES);
13900
+ if (chunk.length === 0) return null;
13901
+ const fitted = await fitCompressedPrefix(
13902
+ chunk,
13903
+ compressReplayEvents,
13904
+ TARGET_COMPRESSED_SEGMENT_BYTES,
13905
+ MAX_COMPRESSED_SEGMENT_BYTES
13906
+ );
13907
+ if (!fitted) {
13908
+ console.warn(
13909
+ "@newtalaria/browser: dropping rrweb event that exceeds max segment size",
13910
+ { type: chunk[0]?.type }
13911
+ );
13912
+ return this.takeFittedSegment();
13913
+ }
13914
+ if (fitted.remainder.length > 0) {
13915
+ this.buffer.prepend(fitted.remainder);
13916
+ }
13917
+ return { events: fitted.events, gzip: fitted.gzip };
13918
+ }
13476
13919
  async finishOnServer(opts) {
13477
13920
  if (!this.transport || !this.replayId) return;
13478
13921
  if (!this.startedOnServer || this.finishedOnServer) return;
13922
+ if (this.segmentIndex > 0) {
13923
+ this.linkableReplayId = this.replayId;
13924
+ }
13479
13925
  try {
13480
13926
  await finishReplay(this.transport, {
13481
13927
  replayId: this.replayId,
@@ -13513,9 +13959,17 @@ var Talaria = {
13513
13959
  };
13514
13960
  var index_default = Talaria;
13515
13961
  export {
13962
+ MAX_COMPRESSED_SEGMENT_BYTES,
13963
+ MAX_ERROR_CLIP_COMPRESSED_BYTES,
13964
+ MAX_SEGMENTS_ERROR_CLIP,
13965
+ TARGET_COMPRESSED_SEGMENT_BYTES,
13516
13966
  Talaria,
13517
13967
  TalariaClient,
13518
- index_default as default
13968
+ computeErrorClipDeadlineMs,
13969
+ index_default as default,
13970
+ fitCompressedPrefix,
13971
+ isErrorClipBudgetExhausted,
13972
+ planOversizedRetry
13519
13973
  };
13520
13974
  /*! Bundled license information:
13521
13975