@cedarai/session-replay-sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,29 @@
1
+ // src/compress.ts
2
+ async function gzipCompress(data) {
3
+ const blob = new Blob([data]);
4
+ const stream = blob.stream().pipeThrough(new CompressionStream("gzip"));
5
+ const reader = stream.getReader();
6
+ const chunks = [];
7
+ for (; ; ) {
8
+ const { done, value } = await reader.read();
9
+ if (done) break;
10
+ chunks.push(value);
11
+ }
12
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
13
+ const result = new Uint8Array(totalLength);
14
+ let offset = 0;
15
+ for (const chunk of chunks) {
16
+ result.set(chunk, offset);
17
+ offset += chunk.length;
18
+ }
19
+ return result;
20
+ }
21
+ function isCompressionSupported() {
22
+ return typeof CompressionStream !== "undefined";
23
+ }
24
+
1
25
  // src/transport.ts
26
+ var BEACON_MAX_SIZE = 64 * 1024;
2
27
  var Transport = class {
3
28
  config;
4
29
  queue = [];
@@ -6,6 +31,9 @@ var Transport = class {
6
31
  started = false;
7
32
  startedAt = Date.now();
8
33
  fetchFn;
34
+ // Pre-compressed beacon payload, updated in the background after each enqueue
35
+ pendingBeaconBlob = null;
36
+ pendingBeaconUrl = "";
9
37
  /**
10
38
  * @param config SDK configuration
11
39
  * @param originalFetch Original fetch reference to avoid self-interception.
@@ -42,31 +70,88 @@ var Transport = class {
42
70
  if (estimatedSize >= maxSize) {
43
71
  this.flush();
44
72
  }
73
+ this.prepareBeaconPayload();
45
74
  }
46
75
  flush() {
47
76
  if (this.queue.length === 0) return;
48
77
  const events = this.queue.splice(0);
49
78
  const payload = this.buildPayload(events);
50
- this.fetchFn(`${this.config.serverUrl}/api/ingest`, {
51
- method: "POST",
52
- headers: { "Content-Type": "application/json" },
53
- body: JSON.stringify(payload)
54
- }).catch(() => {
55
- });
79
+ const json = JSON.stringify(payload);
80
+ this.pendingBeaconBlob = null;
81
+ if (isCompressionSupported()) {
82
+ gzipCompress(json).then((compressed) => {
83
+ this.fetchFn(`${this.config.serverUrl}/api/ingest`, {
84
+ method: "POST",
85
+ headers: {
86
+ "Content-Type": "application/json",
87
+ "Content-Encoding": "gzip"
88
+ },
89
+ body: compressed
90
+ }).catch(() => {
91
+ });
92
+ }).catch(() => {
93
+ this.fetchFn(`${this.config.serverUrl}/api/ingest`, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: json
97
+ }).catch(() => {
98
+ });
99
+ });
100
+ } else {
101
+ this.fetchFn(`${this.config.serverUrl}/api/ingest`, {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: json
105
+ }).catch(() => {
106
+ });
107
+ }
56
108
  }
57
109
  /**
58
- * Flush via sendBeacon for page unload. sendBeacon cannot set custom headers,
59
- * so we send as a Blob and indicate gzip via query param.
110
+ * Flush via sendBeacon for page unload. Uses pre-compressed data if available,
111
+ * otherwise sends uncompressed JSON with truncation to stay under 64KB.
60
112
  */
61
113
  flushOnUnload() {
62
114
  if (this.queue.length === 0) return;
63
- const events = this.queue.splice(0);
64
- const payload = this.buildPayload(events);
65
- const body = JSON.stringify(payload);
115
+ const url = `${this.config.serverUrl}/api/ingest`;
116
+ if (this.pendingBeaconBlob) {
117
+ navigator.sendBeacon(this.pendingBeaconUrl, this.pendingBeaconBlob);
118
+ this.queue.length = 0;
119
+ this.pendingBeaconBlob = null;
120
+ return;
121
+ }
122
+ let events = this.queue.splice(0);
123
+ let payload = this.buildPayload(events);
124
+ let body = JSON.stringify(payload);
125
+ while (body.length > BEACON_MAX_SIZE && events.length > 1) {
126
+ const rrwebIdx = events.findLastIndex((e) => e.type === "rrweb");
127
+ if (rrwebIdx >= 0) {
128
+ events.splice(rrwebIdx, 1);
129
+ } else {
130
+ events.pop();
131
+ }
132
+ payload = this.buildPayload(events);
133
+ body = JSON.stringify(payload);
134
+ }
66
135
  const blob = new Blob([body], { type: "application/json" });
67
- const url = `${this.config.serverUrl}/api/ingest?encoding=gzip`;
68
136
  navigator.sendBeacon(url, blob);
69
137
  }
138
+ /**
139
+ * Pre-compress the current queue in the background for sendBeacon use.
140
+ * Called after each enqueue so the compressed blob is ready when beforeunload fires.
141
+ */
142
+ prepareBeaconPayload() {
143
+ if (!isCompressionSupported() || this.queue.length === 0) return;
144
+ const snapshot = [...this.queue];
145
+ const payload = this.buildPayload(snapshot);
146
+ const json = JSON.stringify(payload);
147
+ gzipCompress(json).then((compressed) => {
148
+ if (this.queue.length > 0) {
149
+ this.pendingBeaconBlob = new Blob([compressed], { type: "application/octet-stream" });
150
+ this.pendingBeaconUrl = `${this.config.serverUrl}/api/ingest?encoding=gzip`;
151
+ }
152
+ }).catch(() => {
153
+ });
154
+ }
70
155
  buildPayload(events) {
71
156
  return {
72
157
  sessionId: this.config.cedarSessionId,
@@ -337,6 +422,91 @@ var RecorderCapture = class {
337
422
  }
338
423
  };
339
424
 
425
+ // src/performance.ts
426
+ import { onLCP, onCLS, onINP } from "web-vitals";
427
+ var MEMORY_INTERVAL_MS = 3e4;
428
+ var PerformanceCapture = class {
429
+ onEvent;
430
+ started = false;
431
+ longTaskObserver = null;
432
+ memoryTimer = null;
433
+ constructor(onEvent2) {
434
+ this.onEvent = onEvent2;
435
+ }
436
+ start() {
437
+ if (this.started) return;
438
+ this.started = true;
439
+ this.startWebVitals();
440
+ this.startLongTaskObserver();
441
+ this.startMemorySnapshots();
442
+ }
443
+ stop() {
444
+ if (!this.started) return;
445
+ this.started = false;
446
+ this.longTaskObserver?.disconnect();
447
+ this.longTaskObserver = null;
448
+ if (this.memoryTimer) {
449
+ clearInterval(this.memoryTimer);
450
+ this.memoryTimer = null;
451
+ }
452
+ }
453
+ emit(data) {
454
+ if (!this.started) return;
455
+ this.onEvent({
456
+ type: "performance",
457
+ timestamp: Date.now(),
458
+ data
459
+ });
460
+ }
461
+ startWebVitals() {
462
+ const handler = (metric) => {
463
+ this.emit({
464
+ metric: "web-vital",
465
+ name: metric.name,
466
+ value: metric.value,
467
+ rating: metric.rating
468
+ });
469
+ };
470
+ onLCP(handler);
471
+ onCLS(handler, { reportAllChanges: true });
472
+ onINP(handler, { reportAllChanges: true });
473
+ }
474
+ startLongTaskObserver() {
475
+ if (typeof globalThis.PerformanceObserver === "undefined") return;
476
+ try {
477
+ this.longTaskObserver = new globalThis.PerformanceObserver((list) => {
478
+ for (const entry of list.getEntries()) {
479
+ const attribution = entry.attribution?.[0]?.containerSrc;
480
+ this.emit({
481
+ metric: "long-task",
482
+ duration: entry.duration,
483
+ attribution
484
+ });
485
+ }
486
+ });
487
+ this.longTaskObserver.observe({ entryTypes: ["longtask"] });
488
+ } catch {
489
+ }
490
+ }
491
+ startMemorySnapshots() {
492
+ const memory = performance.memory;
493
+ if (!memory) return;
494
+ this.emitMemory(memory);
495
+ this.memoryTimer = setInterval(() => {
496
+ const mem = performance.memory;
497
+ if (mem) this.emitMemory(mem);
498
+ }, MEMORY_INTERVAL_MS);
499
+ }
500
+ emitMemory(memory) {
501
+ this.emit({
502
+ metric: "memory",
503
+ usedJSHeapSize: memory.usedJSHeapSize,
504
+ totalJSHeapSize: memory.totalJSHeapSize,
505
+ jsHeapSizeLimit: memory.jsHeapSizeLimit
506
+ });
507
+ }
508
+ };
509
+
340
510
  // src/index.ts
341
511
  var config = null;
342
512
  var transport = null;
@@ -344,6 +514,8 @@ var consoleCapture = null;
344
514
  var errorCapture = null;
345
515
  var networkCapture = null;
346
516
  var recorderCapture = null;
517
+ var performanceCapture = null;
518
+ var unloadHandler = null;
347
519
  function onEvent(event) {
348
520
  transport?.enqueue(event);
349
521
  }
@@ -372,8 +544,18 @@ var CedarReplay = {
372
544
  networkCapture.start();
373
545
  recorderCapture = new RecorderCapture(onEvent, config.recorder);
374
546
  recorderCapture.start();
547
+ performanceCapture = new PerformanceCapture(onEvent);
548
+ performanceCapture.start();
549
+ unloadHandler = () => transport?.flushOnUnload();
550
+ window.addEventListener("beforeunload", unloadHandler);
375
551
  },
376
552
  stop() {
553
+ if (unloadHandler) {
554
+ window.removeEventListener("beforeunload", unloadHandler);
555
+ unloadHandler = null;
556
+ }
557
+ performanceCapture?.stop();
558
+ performanceCapture = null;
377
559
  recorderCapture?.stop();
378
560
  recorderCapture = null;
379
561
  networkCapture?.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedarai/session-replay-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,6 @@
12
12
  }
13
13
  },
14
14
  "scripts": {
15
- "prepare": "tsup src/index.ts --format esm --dts",
16
15
  "build": "tsup src/index.ts --format esm --dts",
17
16
  "build:iife": "tsup src/global.ts --format iife --outDir dist",
18
17
  "dev": "tsup src/index.ts --format esm --dts --watch",
@@ -20,7 +19,8 @@
20
19
  "test:watch": "vitest"
21
20
  },
22
21
  "dependencies": {
23
- "rrweb": "^2.0.0-alpha.4"
22
+ "rrweb": "^2.0.0-alpha.4",
23
+ "web-vitals": "^4.2.4"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@vitest/browser": "^4.0.18",