@cedarai/session-replay-sdk 0.1.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.
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { Transport } from './transport.js';
3
+ import type { SessionEvent, SDKConfig } from './types.js';
4
+
5
+ function makeConfig(overrides: Partial<SDKConfig> = {}): SDKConfig {
6
+ return {
7
+ serverUrl: 'https://replay.getcedar.ai',
8
+ cedarSessionId: 'test-session-123',
9
+ batchIntervalMs: 5000,
10
+ batchMaxSize: 1024 * 512,
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ function makeRRWebEvent(timestamp = Date.now()): SessionEvent {
16
+ return { type: 'rrweb', data: { type: 2, timestamp, data: { node: { type: 0 } } } };
17
+ }
18
+
19
+ function makeConsoleEvent(timestamp = Date.now()): SessionEvent {
20
+ return { type: 'console', timestamp, data: { level: 'log', args: ['test'] } };
21
+ }
22
+
23
+ describe('Transport', () => {
24
+ let transport: Transport;
25
+ let fetchSpy: ReturnType<typeof vi.fn>;
26
+
27
+ beforeEach(() => {
28
+ vi.useFakeTimers();
29
+ // Mock fetch globally
30
+ fetchSpy = vi.fn().mockResolvedValue({ ok: true, status: 200 });
31
+ vi.stubGlobal('fetch', fetchSpy);
32
+ });
33
+
34
+ afterEach(() => {
35
+ transport?.stop();
36
+ vi.useRealTimers();
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ // ── Event queuing ────────────────────────────────────────────────────
41
+
42
+ describe('event queuing', () => {
43
+ it('queues events without immediately sending', () => {
44
+ transport = new Transport(makeConfig());
45
+ transport.start();
46
+ transport.enqueue(makeRRWebEvent());
47
+ transport.enqueue(makeConsoleEvent());
48
+
49
+ expect(fetchSpy).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it('does not send events before start() is called', () => {
53
+ transport = new Transport(makeConfig({ batchIntervalMs: 100 }));
54
+ transport.enqueue(makeRRWebEvent());
55
+
56
+ vi.advanceTimersByTime(200);
57
+ expect(fetchSpy).not.toHaveBeenCalled();
58
+ });
59
+ });
60
+
61
+ // ── Flush on interval ────────────────────────────────────────────────
62
+
63
+ describe('flush on interval', () => {
64
+ it('flushes after batchIntervalMs', () => {
65
+ transport = new Transport(makeConfig({ batchIntervalMs: 5000 }));
66
+ transport.start();
67
+ transport.enqueue(makeRRWebEvent());
68
+
69
+ vi.advanceTimersByTime(5000);
70
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it('does not flush when queue is empty', () => {
74
+ transport = new Transport(makeConfig({ batchIntervalMs: 5000 }));
75
+ transport.start();
76
+
77
+ vi.advanceTimersByTime(5000);
78
+ expect(fetchSpy).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('flushes repeatedly on each interval', () => {
82
+ transport = new Transport(makeConfig({ batchIntervalMs: 1000 }));
83
+ transport.start();
84
+
85
+ transport.enqueue(makeRRWebEvent());
86
+ vi.advanceTimersByTime(1000);
87
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
88
+
89
+ transport.enqueue(makeConsoleEvent());
90
+ vi.advanceTimersByTime(1000);
91
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
92
+ });
93
+ });
94
+
95
+ // ── Flush on size ────────────────────────────────────────────────────
96
+
97
+ describe('flush on size', () => {
98
+ it('flushes when batch exceeds batchMaxSize', () => {
99
+ transport = new Transport(makeConfig({ batchMaxSize: 100 })); // tiny limit
100
+ transport.start();
101
+
102
+ // Each event is ~60+ bytes JSON, so 2 should exceed 100 bytes
103
+ transport.enqueue(makeRRWebEvent());
104
+ transport.enqueue(makeRRWebEvent());
105
+
106
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
107
+ });
108
+ });
109
+
110
+ // ── Payload format ───────────────────────────────────────────────────
111
+
112
+ describe('payload format', () => {
113
+ it('sends correct payload structure to serverUrl/api/ingest', () => {
114
+ transport = new Transport(makeConfig({ batchIntervalMs: 1000 }));
115
+ transport.start();
116
+ transport.enqueue(makeRRWebEvent(1000));
117
+ transport.enqueue(makeConsoleEvent(1100));
118
+
119
+ vi.advanceTimersByTime(1000);
120
+
121
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
122
+ const [url, options] = fetchSpy.mock.calls[0];
123
+ expect(url).toBe('https://replay.getcedar.ai/api/ingest');
124
+ expect(options.method).toBe('POST');
125
+ expect(options.headers['Content-Type']).toBe('application/json');
126
+
127
+ const body = JSON.parse(options.body);
128
+ expect(body.sessionId).toBe('test-session-123');
129
+ expect(body.events).toHaveLength(2);
130
+ expect(body.events[0].type).toBe('rrweb');
131
+ expect(body.events[1].type).toBe('console');
132
+ expect(typeof body.batchTimestamp).toBe('number');
133
+ expect(typeof body.startedAt).toBe('number');
134
+ });
135
+
136
+ it('clears the queue after successful flush', () => {
137
+ transport = new Transport(makeConfig({ batchIntervalMs: 1000 }));
138
+ transport.start();
139
+ transport.enqueue(makeRRWebEvent());
140
+
141
+ vi.advanceTimersByTime(1000);
142
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
143
+
144
+ // Next interval: no events, should not send
145
+ vi.advanceTimersByTime(1000);
146
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
147
+ });
148
+ });
149
+
150
+ // ── Manual flush ─────────────────────────────────────────────────────
151
+
152
+ describe('flush()', () => {
153
+ it('immediately sends queued events', () => {
154
+ transport = new Transport(makeConfig());
155
+ transport.start();
156
+ transport.enqueue(makeRRWebEvent());
157
+
158
+ transport.flush();
159
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
160
+ });
161
+
162
+ it('does nothing if queue is empty', () => {
163
+ transport = new Transport(makeConfig());
164
+ transport.start();
165
+
166
+ transport.flush();
167
+ expect(fetchSpy).not.toHaveBeenCalled();
168
+ });
169
+ });
170
+
171
+ // ── stop() ───────────────────────────────────────────────────────────
172
+
173
+ describe('stop()', () => {
174
+ it('flushes remaining events on stop', () => {
175
+ transport = new Transport(makeConfig());
176
+ transport.start();
177
+ transport.enqueue(makeRRWebEvent());
178
+
179
+ transport.stop();
180
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
181
+ });
182
+
183
+ it('stops the interval timer', () => {
184
+ transport = new Transport(makeConfig({ batchIntervalMs: 1000 }));
185
+ transport.start();
186
+ transport.stop();
187
+
188
+ transport.enqueue(makeRRWebEvent());
189
+ vi.advanceTimersByTime(2000);
190
+ // Only the flush from stop(), not from interval
191
+ expect(fetchSpy).toHaveBeenCalledTimes(0); // stop flushed empty, enqueue after stop is ignored
192
+ });
193
+ });
194
+
195
+ // ── Self-interception guard ──────────────────────────────────────────
196
+
197
+ describe('self-interception', () => {
198
+ it('uses the provided originalFetch, not window.fetch', () => {
199
+ const originalFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
200
+ transport = new Transport(makeConfig({ batchIntervalMs: 1000 }), originalFetch);
201
+ transport.start();
202
+ transport.enqueue(makeRRWebEvent());
203
+
204
+ vi.advanceTimersByTime(1000);
205
+
206
+ // Should call originalFetch, not window.fetch
207
+ expect(originalFetch).toHaveBeenCalledTimes(1);
208
+ expect(fetchSpy).not.toHaveBeenCalled();
209
+ });
210
+ });
211
+
212
+ // ── sendBeacon on unload ─────────────────────────────────────────────
213
+
214
+ describe('sendBeacon fallback', () => {
215
+ it('uses sendBeacon when flushOnUnload is called', () => {
216
+ const sendBeaconSpy = vi.fn().mockReturnValue(true);
217
+ vi.stubGlobal('navigator', { sendBeacon: sendBeaconSpy });
218
+
219
+ transport = new Transport(makeConfig());
220
+ transport.start();
221
+ transport.enqueue(makeRRWebEvent());
222
+
223
+ transport.flushOnUnload();
224
+
225
+ expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
226
+ const [url] = sendBeaconSpy.mock.calls[0];
227
+ expect(url).toContain('https://replay.getcedar.ai/api/ingest');
228
+ expect(url).toContain('encoding=gzip');
229
+ });
230
+
231
+ it('does nothing on unload if queue is empty', () => {
232
+ const sendBeaconSpy = vi.fn().mockReturnValue(true);
233
+ vi.stubGlobal('navigator', { sendBeacon: sendBeaconSpy });
234
+
235
+ transport = new Transport(makeConfig());
236
+ transport.start();
237
+
238
+ transport.flushOnUnload();
239
+ expect(sendBeaconSpy).not.toHaveBeenCalled();
240
+ });
241
+ });
242
+
243
+ // ── Error resilience ─────────────────────────────────────────────────
244
+
245
+ describe('error resilience', () => {
246
+ it('does not throw if fetch fails', () => {
247
+ fetchSpy.mockRejectedValue(new Error('Network error'));
248
+
249
+ transport = new Transport(makeConfig({ batchIntervalMs: 1000 }));
250
+ transport.start();
251
+ transport.enqueue(makeRRWebEvent());
252
+
253
+ expect(() => vi.advanceTimersByTime(1000)).not.toThrow();
254
+ });
255
+ });
256
+ });
@@ -0,0 +1,114 @@
1
+ import type { SessionEvent, SDKConfig, BatchPayload } from './types.js';
2
+
3
+ export class Transport {
4
+ private config: SDKConfig;
5
+ private queue: SessionEvent[] = [];
6
+ private timer: ReturnType<typeof setInterval> | null = null;
7
+ private started = false;
8
+ private startedAt: number = Date.now();
9
+ private fetchFn: typeof fetch;
10
+
11
+ /**
12
+ * @param config SDK configuration
13
+ * @param originalFetch Original fetch reference to avoid self-interception.
14
+ * If not provided, uses globalThis.fetch.
15
+ */
16
+ constructor(config: SDKConfig, originalFetch?: typeof fetch) {
17
+ this.config = config;
18
+ const fn = originalFetch ?? globalThis.fetch;
19
+ this.fetchFn = fn.bind(globalThis);
20
+ }
21
+
22
+ start(): void {
23
+ if (this.started) return;
24
+ this.started = true;
25
+ this.startedAt = Date.now();
26
+
27
+ const interval = this.config.batchIntervalMs ?? 5000;
28
+ this.timer = setInterval(() => {
29
+ this.flush();
30
+ }, interval);
31
+ }
32
+
33
+ stop(): void {
34
+ if (!this.started) return;
35
+ this.started = false;
36
+
37
+ if (this.timer !== null) {
38
+ clearInterval(this.timer);
39
+ this.timer = null;
40
+ }
41
+
42
+ this.flush();
43
+ }
44
+
45
+ enqueue(event: SessionEvent): void {
46
+ if (!this.started) return;
47
+
48
+ this.queue.push(event);
49
+
50
+ // Check if batch exceeds max size
51
+ const maxSize = this.config.batchMaxSize ?? 1024 * 512;
52
+ const estimatedSize = this.estimateQueueSize();
53
+ if (estimatedSize >= maxSize) {
54
+ this.flush();
55
+ }
56
+ }
57
+
58
+ flush(): void {
59
+ if (this.queue.length === 0) return;
60
+
61
+ const events = this.queue.splice(0);
62
+ const payload = this.buildPayload(events);
63
+
64
+ // Fire and forget — don't block on the response
65
+ this.fetchFn(`${this.config.serverUrl}/api/ingest`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify(payload),
69
+ }).catch(() => {
70
+ // Silently ignore send failures — don't crash the host app
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Flush via sendBeacon for page unload. sendBeacon cannot set custom headers,
76
+ * so we send as a Blob and indicate gzip via query param.
77
+ */
78
+ flushOnUnload(): void {
79
+ if (this.queue.length === 0) return;
80
+
81
+ const events = this.queue.splice(0);
82
+ const payload = this.buildPayload(events);
83
+ const body = JSON.stringify(payload);
84
+
85
+ // sendBeacon has a ~64KB limit. For now, send uncompressed JSON as a Blob.
86
+ // TODO: Add gzip compression (pako) and truncation for large payloads.
87
+ const blob = new Blob([body], { type: 'application/json' });
88
+ const url = `${this.config.serverUrl}/api/ingest?encoding=gzip`;
89
+
90
+ navigator.sendBeacon(url, blob);
91
+ }
92
+
93
+ private buildPayload(events: SessionEvent[]): BatchPayload {
94
+ return {
95
+ sessionId: this.config.cedarSessionId,
96
+ batchTimestamp: Date.now(),
97
+ startedAt: this.startedAt,
98
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
99
+ viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 0,
100
+ viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 0,
101
+ url: typeof window !== 'undefined' ? window.location.href : '',
102
+ events,
103
+ };
104
+ }
105
+
106
+ private estimateQueueSize(): number {
107
+ // Rough estimate: JSON.stringify each event and sum lengths
108
+ let size = 0;
109
+ for (const event of this.queue) {
110
+ size += JSON.stringify(event).length;
111
+ }
112
+ return size;
113
+ }
114
+ }
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ export interface SDKConfig {
2
+ serverUrl: string;
3
+ cedarSessionId: string;
4
+ batchIntervalMs?: number; // default: 5000
5
+ batchMaxSize?: number; // default: 512KB
6
+ console?: ConsoleConfig;
7
+ }
8
+
9
+ export interface ConsoleConfig {
10
+ log?: boolean; // default: true
11
+ info?: boolean; // default: true
12
+ warn?: boolean; // default: true
13
+ error?: boolean; // default: true
14
+ debug?: boolean; // default: true
15
+ }
16
+
17
+ export interface RecorderConfig {
18
+ checkoutEveryNms?: number;
19
+ blockSelector?: string;
20
+ maskAllInputs?: boolean;
21
+ inlineStylesheet?: boolean;
22
+ sampling?: Record<string, unknown>;
23
+ }
24
+
25
+ // Events the SDK produces
26
+ export type SessionEvent =
27
+ | RRWebSessionEvent
28
+ | NetworkSessionEvent
29
+ | ConsoleSessionEvent
30
+ | ErrorSessionEvent
31
+ | CustomSessionEvent;
32
+
33
+ export interface RRWebSessionEvent {
34
+ type: 'rrweb';
35
+ data: Record<string, unknown>;
36
+ }
37
+
38
+ export interface NetworkSessionEvent {
39
+ type: 'network';
40
+ timestamp: number;
41
+ data: {
42
+ id: string;
43
+ method: string;
44
+ url: string;
45
+ graphqlOperationName?: string;
46
+ requestHeaders?: Record<string, string>;
47
+ requestBody?: string;
48
+ status?: number;
49
+ responseHeaders?: Record<string, string>;
50
+ responseBody?: string;
51
+ startTime: number;
52
+ endTime?: number;
53
+ duration?: number;
54
+ error?: string;
55
+ };
56
+ }
57
+
58
+ export interface ConsoleSessionEvent {
59
+ type: 'console';
60
+ timestamp: number;
61
+ data: {
62
+ level: 'log' | 'warn' | 'error' | 'info' | 'debug';
63
+ args: string[];
64
+ trace?: string;
65
+ };
66
+ }
67
+
68
+ export interface ErrorSessionEvent {
69
+ type: 'error';
70
+ timestamp: number;
71
+ data: {
72
+ message: string;
73
+ stack?: string;
74
+ source?: string;
75
+ lineno?: number;
76
+ colno?: number;
77
+ type: 'uncaught' | 'unhandledrejection' | 'manual';
78
+ tags?: Record<string, string>;
79
+ extra?: Record<string, unknown>;
80
+ };
81
+ }
82
+
83
+ export interface CustomSessionEvent {
84
+ type: 'custom';
85
+ timestamp: number;
86
+ data: {
87
+ name: string;
88
+ properties?: Record<string, unknown>;
89
+ };
90
+ }
91
+
92
+ export interface BatchPayload {
93
+ sessionId: string;
94
+ batchTimestamp: number;
95
+ startedAt: number;
96
+ userAgent: string;
97
+ viewportWidth: number;
98
+ viewportHeight: number;
99
+ url: string;
100
+ events: SessionEvent[];
101
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
7
+ },
8
+ "include": ["src"]
9
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'happy-dom',
7
+ include: ['src/**/*.test.ts'],
8
+ },
9
+ });