@newtalaria/browser 0.1.0 → 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,9 +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;
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;
167
138
  var SegmentBuffer = class {
168
139
  constructor() {
169
140
  this.events = [];
@@ -179,8 +150,31 @@ var SegmentBuffer = class {
179
150
  this.events.push(event);
180
151
  this.pendingBytes += estimateJsonBytes(event);
181
152
  }
182
- /** Drop events older than the ring window (buffer-only mode). */
183
- 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) {
184
178
  const cutoff = now - RING_BUFFER_MS;
185
179
  let remove = 0;
186
180
  while (remove < this.events.length && this.events[remove].timestamp < cutoff) {
@@ -190,22 +184,55 @@ var SegmentBuffer = class {
190
184
  this.events.splice(0, remove);
191
185
  this.recomputeBytes();
192
186
  }
187
+ while (this.events.length > 1 && this.pendingBytes > maxBytes) {
188
+ this.events.shift();
189
+ this.recomputeBytes();
190
+ }
193
191
  }
194
192
  shouldFlushBySize() {
195
193
  return this.pendingBytes >= SEGMENT_SIZE_BYTES;
196
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
+ }
197
220
  takeAll() {
198
221
  const batch = this.events;
199
222
  this.events = [];
200
223
  this.pendingBytes = 0;
201
224
  return batch;
202
225
  }
203
- /** Re-queue events at the front (failed upload retry). */
226
+ /** Re-queue events at the front (failed upload retry / bisect remainder). */
204
227
  prepend(events) {
205
228
  if (events.length === 0) return;
206
229
  this.events = events.concat(this.events);
207
230
  this.recomputeBytes();
208
231
  }
232
+ clear() {
233
+ this.events = [];
234
+ this.pendingBytes = 0;
235
+ }
209
236
  peekTimes() {
210
237
  if (this.events.length === 0) return null;
211
238
  const first = this.events[0];
@@ -220,6 +247,114 @@ var SegmentBuffer = class {
220
247
  }
221
248
  };
222
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
+
223
358
  // ../../node_modules/rrweb/dist/rrweb.js
224
359
  var __defProp = Object.defineProperty;
225
360
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
@@ -12975,13 +13110,27 @@ function redactUrl(raw) {
12975
13110
  }
12976
13111
  }
12977
13112
  function defaultBlockSelector(extra) {
12978
- 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
+ ];
12979
13120
  if (extra?.trim()) parts.push(extra.trim());
12980
13121
  return parts.join(", ");
12981
13122
  }
12982
13123
 
12983
13124
  // src/replay/recorder.ts
12984
13125
  function startRecorder(options) {
13126
+ if (typeof document === "undefined") {
13127
+ return {
13128
+ stop: () => {
13129
+ },
13130
+ takeFullSnapshot: () => {
13131
+ }
13132
+ };
13133
+ }
12985
13134
  const stopFn = record({
12986
13135
  emit(event) {
12987
13136
  options.onEvent(event);
@@ -12992,11 +13141,25 @@ function startRecorder(options) {
12992
13141
  },
12993
13142
  blockSelector: defaultBlockSelector(options.blockSelector),
12994
13143
  recordCanvas: false,
12995
- 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
+ }
12996
13153
  });
12997
13154
  return {
12998
13155
  stop: () => {
12999
13156
  stopFn?.();
13157
+ },
13158
+ takeFullSnapshot: () => {
13159
+ try {
13160
+ record.takeFullSnapshot(true);
13161
+ } catch {
13162
+ }
13000
13163
  }
13001
13164
  };
13002
13165
  }
@@ -13129,6 +13292,7 @@ function resolveOptions(options) {
13129
13292
  release: options.release,
13130
13293
  replaysSessionSampleRate: clamp01(options.replaysSessionSampleRate ?? 0),
13131
13294
  replaysOnErrorSampleRate: clamp01(options.replaysOnErrorSampleRate ?? 1),
13295
+ replaysErrorAfterMs: normalizeErrorAfterMs(options.replaysErrorAfterMs),
13132
13296
  maskAllInputs: options.maskAllInputs ?? true,
13133
13297
  blockSelector: options.blockSelector ?? "",
13134
13298
  userId: options.userId,
@@ -13139,12 +13303,26 @@ function clamp01(n2) {
13139
13303
  if (Number.isNaN(n2)) return 0;
13140
13304
  return Math.min(1, Math.max(0, n2));
13141
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
+ }
13142
13312
  function levelToEventType(level) {
13143
13313
  if (level === "fatal" || level === "error") return "error";
13144
13314
  if (level === "warning") return "warning";
13145
13315
  if (level === "debug") return "debug";
13146
13316
  return "info";
13147
13317
  }
13318
+ function isTerminalReplayLimitError(error) {
13319
+ const msg = error instanceof Error ? error.message : String(error);
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");
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
+ }
13148
13326
  var TalariaClient = class {
13149
13327
  constructor() {
13150
13328
  this.options = null;
@@ -13153,14 +13331,28 @@ var TalariaClient = class {
13153
13331
  this.sessionId = null;
13154
13332
  this.recorder = null;
13155
13333
  this.buffer = new SegmentBuffer();
13334
+ /** Continuous upload for the whole page session (session sample hit). */
13335
+ this.sessionSampled = false;
13156
13336
  this.uploadEnabled = false;
13157
13337
  this.startedOnServer = false;
13158
13338
  this.finishedOnServer = false;
13159
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;
13160
13345
  this.flushTimer = null;
13346
+ this.errorClipTimer = null;
13347
+ this.maxDurationTimer = null;
13161
13348
  this.uploadChain = Promise.resolve();
13162
13349
  this.closed = false;
13163
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;
13164
13356
  }
13165
13357
  init(options) {
13166
13358
  if (this.options) {
@@ -13178,9 +13370,14 @@ var TalariaClient = class {
13178
13370
  this.finishedOnServer = false;
13179
13371
  this.startedOnServer = false;
13180
13372
  this.segmentIndex = 0;
13373
+ this.uploadedCompressedBytes = 0;
13374
+ this.uploadStartedAtMs = null;
13375
+ this.errorClipDeadlineMs = null;
13376
+ this.linkableReplayId = null;
13181
13377
  this.buffer = new SegmentBuffer();
13182
- const sessionSampled = Math.random() < this.options.replaysSessionSampleRate;
13183
- this.uploadEnabled = sessionSampled;
13378
+ this.uploadChain = Promise.resolve();
13379
+ this.sessionSampled = Math.random() < this.options.replaysSessionSampleRate;
13380
+ this.uploadEnabled = this.sessionSampled;
13184
13381
  this.recorder = startRecorder({
13185
13382
  maskAllInputs: this.options.maskAllInputs,
13186
13383
  blockSelector: this.options.blockSelector,
@@ -13205,13 +13402,15 @@ var TalariaClient = class {
13205
13402
  });
13206
13403
  }
13207
13404
  if (this.uploadEnabled) {
13405
+ this.markUploadStarted();
13208
13406
  void this.enqueueUpload(async () => {
13209
13407
  await this.ensureStarted({ keepalive: false });
13210
13408
  });
13211
13409
  }
13212
13410
  }
13213
13411
  getReplayId() {
13214
- return this.uploadEnabled ? this.replayId : null;
13412
+ if (this.uploadEnabled && this.replayId) return this.replayId;
13413
+ return this.linkableReplayId;
13215
13414
  }
13216
13415
  async captureException(error, context) {
13217
13416
  const err = error instanceof Error ? error : new Error(typeof error === "string" ? error : "Unknown error");
@@ -13239,16 +13438,39 @@ var TalariaClient = class {
13239
13438
  return;
13240
13439
  }
13241
13440
  await this.enqueueUpload(async () => {
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
+ }
13242
13450
  await this.ensureStarted({ keepalive });
13243
13451
  await this.uploadPendingSegments({ keepalive });
13244
- if (opts?.finish) {
13245
- await this.finishOnServer({ keepalive, reason: opts.reason ?? "pagehide" });
13452
+ if (opts?.finish && this.uploadEnabled) {
13453
+ await this.finishOnServer({
13454
+ keepalive,
13455
+ reason: opts.reason ?? "pagehide"
13456
+ });
13457
+ if (!this.sessionSampled) {
13458
+ this.resetToBufferMode();
13459
+ } else {
13460
+ this.uploadEnabled = false;
13461
+ this.clearMaxDurationTimer();
13462
+ }
13246
13463
  }
13247
13464
  });
13248
13465
  }
13466
+ /**
13467
+ * Stop recording, flush while still open, then fully reset so `init()` can
13468
+ * run again (React Strict Mode remount).
13469
+ */
13249
13470
  async close() {
13250
- if (this.closed) return;
13251
- this.closed = true;
13471
+ if (this.closed && !this.options) return;
13472
+ this.clearErrorClipTimer();
13473
+ this.clearMaxDurationTimer();
13252
13474
  if (this.flushTimer) {
13253
13475
  clearInterval(this.flushTimer);
13254
13476
  this.flushTimer = null;
@@ -13261,17 +13483,45 @@ var TalariaClient = class {
13261
13483
  } catch {
13262
13484
  }
13263
13485
  }
13264
- if (this.uploadEnabled) {
13265
- 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
+ }
13266
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;
13267
13509
  }
13268
13510
  onRrwebEvent(event) {
13269
- if (this.closed) return;
13511
+ if (this.closed || !this.options) return;
13270
13512
  this.buffer.push(event);
13271
13513
  if (!this.uploadEnabled) {
13272
13514
  this.buffer.trimRing();
13273
13515
  return;
13274
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
+ }
13275
13525
  if (this.buffer.shouldFlushBySize()) {
13276
13526
  void this.flush({ reason: "size" });
13277
13527
  }
@@ -13281,13 +13531,19 @@ var TalariaClient = class {
13281
13531
  throw new Error("@newtalaria/browser: call Talaria.init() first");
13282
13532
  }
13283
13533
  const isErrorLike = args.level === "error" || args.level === "fatal";
13284
- if (isErrorLike && !this.uploadEnabled) {
13285
- if (Math.random() < this.options.replaysOnErrorSampleRate) {
13286
- this.uploadEnabled = true;
13534
+ if (isErrorLike && !this.sessionSampled) {
13535
+ if (!this.uploadEnabled) {
13536
+ if (Math.random() < this.options.replaysOnErrorSampleRate) {
13537
+ this.uploadEnabled = true;
13538
+ this.checkoutFullSnapshot();
13539
+ this.markUploadStarted();
13540
+ this.scheduleErrorClipEnd();
13541
+ }
13542
+ } else {
13543
+ this.scheduleErrorClipEnd();
13287
13544
  }
13288
13545
  }
13289
- const attachReplay = this.uploadEnabled;
13290
- if (attachReplay) {
13546
+ if (this.uploadEnabled) {
13291
13547
  try {
13292
13548
  await this.flush({ reason: "capture", keepalive: args.keepalive });
13293
13549
  } catch (error) {
@@ -13297,7 +13553,7 @@ var TalariaClient = class {
13297
13553
  }
13298
13554
  const tags = { ...args.context?.tags ?? {} };
13299
13555
  const extra = args.context?.extra;
13300
- const replayId = attachReplay ? this.replayId : null;
13556
+ const replayId = this.uploadEnabled && this.segmentIndex > 0 ? this.replayId : this.linkableReplayId;
13301
13557
  try {
13302
13558
  await ingestEvent(this.transport, {
13303
13559
  message: args.message,
@@ -13316,11 +13572,169 @@ var TalariaClient = class {
13316
13572
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
13317
13573
  keepalive: args.keepalive
13318
13574
  });
13575
+ if (replayId && replayId === this.linkableReplayId) {
13576
+ this.linkableReplayId = null;
13577
+ }
13319
13578
  } catch (error) {
13320
13579
  console.warn("@newtalaria/browser: event ingest failed", error);
13321
13580
  throw error;
13322
13581
  }
13323
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
+ */
13651
+ scheduleErrorClipEnd() {
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());
13660
+ this.clearErrorClipTimer();
13661
+ this.errorClipTimer = setTimeout(() => {
13662
+ this.errorClipTimer = null;
13663
+ void this.endErrorClip({ reason: "error_clip" });
13664
+ }, remaining);
13665
+ }
13666
+ clearErrorClipTimer() {
13667
+ if (this.errorClipTimer) {
13668
+ clearTimeout(this.errorClipTimer);
13669
+ this.errorClipTimer = null;
13670
+ }
13671
+ }
13672
+ /** Flush trailing post-error window, finish replay, return to ring buffer. */
13673
+ async endErrorClip(opts) {
13674
+ if (this.closed || this.sessionSampled || !this.uploadEnabled || !this.options || !this.transport) {
13675
+ return;
13676
+ }
13677
+ await this.enqueueUpload(async () => {
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"
13688
+ });
13689
+ this.resetToBufferMode();
13690
+ }
13691
+ /** After an error clip (or terminal limit), buffer locally until the next error. */
13692
+ resetToBufferMode() {
13693
+ this.clearErrorClipTimer();
13694
+ this.clearMaxDurationTimer();
13695
+ this.uploadEnabled = false;
13696
+ this.startedOnServer = false;
13697
+ this.finishedOnServer = false;
13698
+ this.segmentIndex = 0;
13699
+ this.uploadedCompressedBytes = 0;
13700
+ this.uploadStartedAtMs = null;
13701
+ this.errorClipDeadlineMs = null;
13702
+ this.replayId = createId();
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
+ }
13724
+ }
13725
+ stopUploadingAfterLimit() {
13726
+ this.clearErrorClipTimer();
13727
+ this.clearMaxDurationTimer();
13728
+ this.uploadEnabled = false;
13729
+ if (this.sessionSampled) {
13730
+ if (this.flushTimer) {
13731
+ clearInterval(this.flushTimer);
13732
+ this.flushTimer = null;
13733
+ }
13734
+ } else {
13735
+ this.resetToBufferMode();
13736
+ }
13737
+ }
13324
13738
  installGlobalHandlers() {
13325
13739
  if (typeof window === "undefined") return;
13326
13740
  const onError = (event) => {
@@ -13361,13 +13775,61 @@ var TalariaClient = class {
13361
13775
  keepalive: opts.keepalive
13362
13776
  });
13363
13777
  this.startedOnServer = true;
13778
+ this.markUploadStarted();
13364
13779
  }
13365
13780
  async uploadPendingSegments(opts) {
13366
13781
  if (!this.options || !this.transport || !this.replayId) return;
13367
13782
  if (!this.startedOnServer || this.finishedOnServer) return;
13783
+ this.prepareBufferForNewReplay();
13368
13784
  while (this.buffer.length > 0) {
13369
- const events = this.buffer.takeAll();
13370
- 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
+ }
13371
13833
  const startedAt = new Date(events[0].timestamp);
13372
13834
  const endedAt = new Date(events[events.length - 1].timestamp);
13373
13835
  const index2 = this.segmentIndex;
@@ -13378,10 +13840,37 @@ var TalariaClient = class {
13378
13840
  events,
13379
13841
  startedAt,
13380
13842
  endedAt,
13843
+ gzip,
13381
13844
  keepalive: opts.keepalive
13382
13845
  });
13383
13846
  this.segmentIndex = index2 + 1;
13847
+ this.uploadedCompressedBytes += gzip.length;
13384
13848
  } catch (error) {
13849
+ if (isTerminalReplayLimitError(error)) {
13850
+ console.warn(
13851
+ "@newtalaria/browser: replay upload stopped (server limit)",
13852
+ error
13853
+ );
13854
+ await this.finishOnServer({
13855
+ keepalive: opts.keepalive,
13856
+ reason: "limit"
13857
+ });
13858
+ this.stopUploadingAfterLimit();
13859
+ break;
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
+ }
13385
13874
  this.buffer.prepend(events);
13386
13875
  console.warn("@newtalaria/browser: replays/ingestSegment failed", error);
13387
13876
  break;
@@ -13389,9 +13878,44 @@ var TalariaClient = class {
13389
13878
  if (opts.keepalive) break;
13390
13879
  }
13391
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
+ }
13392
13913
  async finishOnServer(opts) {
13393
13914
  if (!this.transport || !this.replayId) return;
13394
13915
  if (!this.startedOnServer || this.finishedOnServer) return;
13916
+ if (this.segmentIndex > 0) {
13917
+ this.linkableReplayId = this.replayId;
13918
+ }
13395
13919
  try {
13396
13920
  await finishReplay(this.transport, {
13397
13921
  replayId: this.replayId,
@@ -13429,9 +13953,17 @@ var Talaria = {
13429
13953
  };
13430
13954
  var index_default = Talaria;
13431
13955
  export {
13956
+ MAX_COMPRESSED_SEGMENT_BYTES,
13957
+ MAX_ERROR_CLIP_COMPRESSED_BYTES,
13958
+ MAX_SEGMENTS_ERROR_CLIP,
13959
+ TARGET_COMPRESSED_SEGMENT_BYTES,
13432
13960
  Talaria,
13433
13961
  TalariaClient,
13434
- index_default as default
13962
+ computeErrorClipDeadlineMs,
13963
+ index_default as default,
13964
+ fitCompressedPrefix,
13965
+ isErrorClipBudgetExhausted,
13966
+ planOversizedRetry
13435
13967
  };
13436
13968
  /*! Bundled license information:
13437
13969