@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,135 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { NetworkCapture } from './network.js';
3
+ import type { NetworkSessionEvent } from './types.js';
4
+
5
+ describe('NetworkCapture', () => {
6
+ let capture: NetworkCapture;
7
+ let events: NetworkSessionEvent[];
8
+ let originalFetch: typeof fetch;
9
+
10
+ beforeEach(() => {
11
+ events = [];
12
+ // Mock fetch as a basic function
13
+ originalFetch = vi.fn().mockResolvedValue({
14
+ ok: true,
15
+ status: 200,
16
+ headers: new Headers({ 'content-type': 'application/json' }),
17
+ clone: () => ({
18
+ text: () => Promise.resolve('{"data":"response"}'),
19
+ headers: new Headers({ 'content-type': 'application/json' }),
20
+ }),
21
+ text: () => Promise.resolve('{"data":"response"}'),
22
+ }) as any;
23
+ vi.stubGlobal('fetch', originalFetch);
24
+ });
25
+
26
+ afterEach(() => {
27
+ capture?.stop();
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ it('intercepts fetch calls and captures request/response', async () => {
32
+ capture = new NetworkCapture((e) => events.push(e), 'https://replay.getcedar.ai');
33
+ capture.start();
34
+
35
+ await fetch('https://api.getcedar.ai/graphql', {
36
+ method: 'POST',
37
+ body: JSON.stringify({ query: '{ users { id } }', operationName: 'GetUsers' }),
38
+ });
39
+
40
+ // Wait for async capture to complete
41
+ await vi.waitFor(() => expect(events).toHaveLength(1));
42
+
43
+ expect(events[0].type).toBe('network');
44
+ expect(events[0].data.method).toBe('POST');
45
+ expect(events[0].data.url).toBe('https://api.getcedar.ai/graphql');
46
+ expect(events[0].data.status).toBe(200);
47
+ });
48
+
49
+ it('extracts GraphQL operation name from request body', async () => {
50
+ capture = new NetworkCapture((e) => events.push(e), 'https://replay.getcedar.ai');
51
+ capture.start();
52
+
53
+ await fetch('https://api.getcedar.ai/graphql', {
54
+ method: 'POST',
55
+ body: JSON.stringify({ query: '{ users { id } }', operationName: 'GetUsers' }),
56
+ });
57
+
58
+ await vi.waitFor(() => expect(events).toHaveLength(1));
59
+ expect(events[0].data.graphqlOperationName).toBe('GetUsers');
60
+ });
61
+
62
+ it('does NOT capture requests to the SDK server URL', async () => {
63
+ capture = new NetworkCapture((e) => events.push(e), 'https://replay.getcedar.ai');
64
+ capture.start();
65
+
66
+ await fetch('https://replay.getcedar.ai/api/ingest', { method: 'POST', body: '{}' });
67
+
68
+ // Give it a moment to process
69
+ await new Promise((r) => setTimeout(r, 10));
70
+ expect(events).toHaveLength(0);
71
+ });
72
+
73
+ it('captures GET requests', async () => {
74
+ capture = new NetworkCapture((e) => events.push(e), 'https://replay.getcedar.ai');
75
+ capture.start();
76
+
77
+ await fetch('https://api.getcedar.ai/users');
78
+
79
+ await vi.waitFor(() => expect(events).toHaveLength(1));
80
+ expect(events[0].data.method).toBe('GET');
81
+ });
82
+
83
+ it('captures fetch errors', async () => {
84
+ (originalFetch as any).mockRejectedValueOnce(new Error('Network failed'));
85
+
86
+ capture = new NetworkCapture((e) => events.push(e), 'https://replay.getcedar.ai');
87
+ capture.start();
88
+
89
+ try {
90
+ await fetch('https://api.getcedar.ai/down');
91
+ } catch {
92
+ // Expected
93
+ }
94
+
95
+ await vi.waitFor(() => expect(events).toHaveLength(1));
96
+ expect(events[0].data.error).toBe('Network failed');
97
+ });
98
+
99
+ it('records timing information', async () => {
100
+ capture = new NetworkCapture((e) => events.push(e), 'https://replay.getcedar.ai');
101
+ capture.start();
102
+
103
+ await fetch('https://api.getcedar.ai/users');
104
+
105
+ await vi.waitFor(() => expect(events).toHaveLength(1));
106
+ expect(typeof events[0].data.startTime).toBe('number');
107
+ expect(typeof events[0].data.endTime).toBe('number');
108
+ expect(typeof events[0].data.duration).toBe('number');
109
+ expect(events[0].data.duration!).toBeGreaterThanOrEqual(0);
110
+ });
111
+
112
+ it('restores original fetch on stop()', async () => {
113
+ const origFetch = globalThis.fetch;
114
+ capture = new NetworkCapture((e) => events.push(e), 'https://replay.getcedar.ai');
115
+ capture.start();
116
+
117
+ expect(globalThis.fetch).not.toBe(origFetch);
118
+
119
+ capture.stop();
120
+ expect(globalThis.fetch).toBe(origFetch);
121
+ });
122
+
123
+ it('adds timestamp to events', async () => {
124
+ capture = new NetworkCapture((e) => events.push(e), 'https://replay.getcedar.ai');
125
+ capture.start();
126
+
127
+ const before = Date.now();
128
+ await fetch('https://api.getcedar.ai/users');
129
+ await vi.waitFor(() => expect(events).toHaveLength(1));
130
+ const after = Date.now();
131
+
132
+ expect(events[0].timestamp).toBeGreaterThanOrEqual(before);
133
+ expect(events[0].timestamp).toBeLessThanOrEqual(after);
134
+ });
135
+ });
package/src/network.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { NetworkSessionEvent } from './types.js';
2
+
3
+ let nextId = 0;
4
+
5
+ export class NetworkCapture {
6
+ private onEvent: (event: NetworkSessionEvent) => void;
7
+ private serverUrl: string;
8
+ private originalFetch: typeof fetch | null = null;
9
+ private started = false;
10
+
11
+ constructor(onEvent: (event: NetworkSessionEvent) => void, serverUrl: string) {
12
+ this.onEvent = onEvent;
13
+ this.serverUrl = serverUrl;
14
+ }
15
+
16
+ start(): void {
17
+ if (this.started) return;
18
+ this.started = true;
19
+
20
+ this.originalFetch = globalThis.fetch;
21
+
22
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
23
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
24
+
25
+ // Skip requests to our own server to avoid self-interception
26
+ if (url.startsWith(this.serverUrl)) {
27
+ return this.originalFetch!(input, init);
28
+ }
29
+
30
+ const method = init?.method?.toUpperCase() ?? 'GET';
31
+ const requestBody = init?.body ? String(init.body) : undefined;
32
+ const startTime = performance.now();
33
+ const id = String(++nextId);
34
+
35
+ // Extract GraphQL operation name if present
36
+ let graphqlOperationName: string | undefined;
37
+ if (requestBody) {
38
+ try {
39
+ const parsed = JSON.parse(requestBody);
40
+ if (parsed.operationName) {
41
+ graphqlOperationName = parsed.operationName;
42
+ }
43
+ } catch {
44
+ // Not JSON, skip
45
+ }
46
+ }
47
+
48
+ try {
49
+ const response = await this.originalFetch!(input, init);
50
+ const endTime = performance.now();
51
+
52
+ // Read response body from a clone to avoid consuming the original
53
+ let responseBody: string | undefined;
54
+ try {
55
+ const cloned = response.clone();
56
+ responseBody = await cloned.text();
57
+ } catch {
58
+ // Ignore body read failures
59
+ }
60
+
61
+ this.onEvent({
62
+ type: 'network',
63
+ timestamp: Date.now(),
64
+ data: {
65
+ id,
66
+ method,
67
+ url,
68
+ graphqlOperationName,
69
+ requestBody,
70
+ status: response.status,
71
+ responseBody,
72
+ startTime,
73
+ endTime,
74
+ duration: endTime - startTime,
75
+ },
76
+ });
77
+
78
+ return response;
79
+ } catch (err) {
80
+ const endTime = performance.now();
81
+
82
+ this.onEvent({
83
+ type: 'network',
84
+ timestamp: Date.now(),
85
+ data: {
86
+ id,
87
+ method,
88
+ url,
89
+ graphqlOperationName,
90
+ requestBody,
91
+ startTime,
92
+ endTime,
93
+ duration: endTime - startTime,
94
+ error: err instanceof Error ? err.message : String(err),
95
+ },
96
+ });
97
+
98
+ throw err;
99
+ }
100
+ };
101
+ }
102
+
103
+ stop(): void {
104
+ if (!this.started) return;
105
+ this.started = false;
106
+
107
+ if (this.originalFetch) {
108
+ globalThis.fetch = this.originalFetch;
109
+ this.originalFetch = null;
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import type { RRWebSessionEvent } from './types.js';
3
+
4
+ // vi.hoisted runs before vi.mock hoisting, so these are available in the factory
5
+ const { mockStopFn, mockRecord } = vi.hoisted(() => ({
6
+ mockStopFn: vi.fn(),
7
+ mockRecord: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('rrweb', () => ({
11
+ record: mockRecord,
12
+ }));
13
+
14
+ import { RecorderCapture } from './recorder.js';
15
+
16
+ describe('RecorderCapture', () => {
17
+ let onEvent: ReturnType<typeof vi.fn>;
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ onEvent = vi.fn();
22
+ // Default: record returns a stop function
23
+ mockRecord.mockReturnValue(mockStopFn);
24
+ });
25
+
26
+ // ── start / stop ────────────────────────────────────────────
27
+
28
+ it('calls rrweb.record() on start with an emit callback', () => {
29
+ const capture = new RecorderCapture(onEvent);
30
+ capture.start();
31
+
32
+ expect(mockRecord).toHaveBeenCalledTimes(1);
33
+ const opts = mockRecord.mock.calls[0][0];
34
+ expect(typeof opts.emit).toBe('function');
35
+
36
+ capture.stop();
37
+ });
38
+
39
+ it('wraps emitted rrweb events as RRWebSessionEvent', () => {
40
+ const capture = new RecorderCapture(onEvent);
41
+ capture.start();
42
+
43
+ // Grab the emit callback passed to rrweb.record
44
+ const emitFn = mockRecord.mock.calls[0][0].emit;
45
+
46
+ // Simulate rrweb emitting a DOM snapshot event
47
+ const rrwebEvent = { type: 2, data: { node: { id: 1 } }, timestamp: 1700000000 };
48
+ emitFn(rrwebEvent);
49
+
50
+ expect(onEvent).toHaveBeenCalledTimes(1);
51
+ const captured: RRWebSessionEvent = onEvent.mock.calls[0][0];
52
+ expect(captured.type).toBe('rrweb');
53
+ expect(captured.data).toBe(rrwebEvent);
54
+
55
+ capture.stop();
56
+ });
57
+
58
+ it('calls the stop function returned by record() on stop()', () => {
59
+ const capture = new RecorderCapture(onEvent);
60
+ capture.start();
61
+ capture.stop();
62
+
63
+ expect(mockStopFn).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('is idempotent — double start does not call record twice', () => {
67
+ const capture = new RecorderCapture(onEvent);
68
+ capture.start();
69
+ capture.start();
70
+
71
+ expect(mockRecord).toHaveBeenCalledTimes(1);
72
+
73
+ capture.stop();
74
+ });
75
+
76
+ it('is idempotent — double stop does not call stopFn twice', () => {
77
+ const capture = new RecorderCapture(onEvent);
78
+ capture.start();
79
+ capture.stop();
80
+ capture.stop();
81
+
82
+ expect(mockStopFn).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it('stop is safe when not started', () => {
86
+ const capture = new RecorderCapture(onEvent);
87
+ expect(() => capture.stop()).not.toThrow();
88
+ });
89
+
90
+ // ── config passthrough ──────────────────────────────────────
91
+
92
+ it('passes config options to rrweb.record()', () => {
93
+ const capture = new RecorderCapture(onEvent, {
94
+ checkoutEveryNms: 10_000,
95
+ blockSelector: '.private',
96
+ maskAllInputs: true,
97
+ inlineStylesheet: false,
98
+ sampling: { mousemove: false, scroll: 150 },
99
+ });
100
+ capture.start();
101
+
102
+ const opts = mockRecord.mock.calls[0][0];
103
+ expect(opts.checkoutEveryNms).toBe(10_000);
104
+ expect(opts.blockSelector).toBe('.private');
105
+ expect(opts.maskAllInputs).toBe(true);
106
+ expect(opts.inlineStylesheet).toBe(false);
107
+ expect(opts.sampling).toEqual({ mousemove: false, scroll: 150 });
108
+
109
+ capture.stop();
110
+ });
111
+
112
+ it('works with default config (no config passed)', () => {
113
+ const capture = new RecorderCapture(onEvent);
114
+ capture.start();
115
+
116
+ const opts = mockRecord.mock.calls[0][0];
117
+ expect(typeof opts.emit).toBe('function');
118
+ // Should not have undefined config keys spread in
119
+ expect(opts.blockSelector).toBeUndefined();
120
+
121
+ capture.stop();
122
+ });
123
+
124
+ // ── edge cases ──────────────────────────────────────────────
125
+
126
+ it('handles record() returning undefined (no DOM mutations possible)', () => {
127
+ mockRecord.mockReturnValue(undefined);
128
+
129
+ const capture = new RecorderCapture(onEvent);
130
+ capture.start();
131
+
132
+ // stop should not throw even though there's no stopFn
133
+ expect(() => capture.stop()).not.toThrow();
134
+ });
135
+
136
+ it('can be restarted after stop', () => {
137
+ const capture = new RecorderCapture(onEvent);
138
+ capture.start();
139
+ capture.stop();
140
+
141
+ // Reset mock to track second call
142
+ mockRecord.mockClear();
143
+ mockStopFn.mockClear();
144
+
145
+ capture.start();
146
+ expect(mockRecord).toHaveBeenCalledTimes(1);
147
+
148
+ capture.stop();
149
+ expect(mockStopFn).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it('emits multiple events correctly', () => {
153
+ const capture = new RecorderCapture(onEvent);
154
+ capture.start();
155
+
156
+ const emitFn = mockRecord.mock.calls[0][0].emit;
157
+
158
+ emitFn({ type: 4, data: { href: 'https://example.com' }, timestamp: 1 });
159
+ emitFn({ type: 3, data: { source: 1 }, timestamp: 2 });
160
+ emitFn({ type: 3, data: { source: 2 }, timestamp: 3 });
161
+
162
+ expect(onEvent).toHaveBeenCalledTimes(3);
163
+ expect(onEvent.mock.calls[0][0].type).toBe('rrweb');
164
+ expect(onEvent.mock.calls[1][0].type).toBe('rrweb');
165
+ expect(onEvent.mock.calls[2][0].type).toBe('rrweb');
166
+
167
+ capture.stop();
168
+ });
169
+
170
+ it('does not emit events after stop', () => {
171
+ const capture = new RecorderCapture(onEvent);
172
+ capture.start();
173
+
174
+ const emitFn = mockRecord.mock.calls[0][0].emit;
175
+ emitFn({ type: 2, data: {}, timestamp: 1 });
176
+ expect(onEvent).toHaveBeenCalledTimes(1);
177
+
178
+ capture.stop();
179
+
180
+ // Even if someone holds a reference to emitFn and calls it after stop,
181
+ // we should not forward events
182
+ emitFn({ type: 3, data: {}, timestamp: 2 });
183
+ expect(onEvent).toHaveBeenCalledTimes(1);
184
+
185
+ capture.stop();
186
+ });
187
+ });
@@ -0,0 +1,47 @@
1
+ import { record } from 'rrweb';
2
+ import type { RRWebSessionEvent, RecorderConfig } from './types.js';
3
+
4
+ export class RecorderCapture {
5
+ private onEvent: (event: RRWebSessionEvent) => void;
6
+ private config: RecorderConfig;
7
+ private stopFn: (() => void) | null = null;
8
+ private started = false;
9
+
10
+ constructor(onEvent: (event: RRWebSessionEvent) => void, config?: RecorderConfig) {
11
+ this.onEvent = onEvent;
12
+ this.config = config ?? {};
13
+ }
14
+
15
+ start(): void {
16
+ if (this.started) return;
17
+ this.started = true;
18
+
19
+ const { checkoutEveryNms, blockSelector, maskAllInputs, inlineStylesheet, sampling } =
20
+ this.config;
21
+
22
+ const result = record({
23
+ emit: (event) => {
24
+ if (!this.started) return;
25
+ this.onEvent({
26
+ type: 'rrweb',
27
+ data: event as unknown as Record<string, unknown>,
28
+ });
29
+ },
30
+ ...(checkoutEveryNms !== undefined && { checkoutEveryNms }),
31
+ ...(blockSelector !== undefined && { blockSelector }),
32
+ ...(maskAllInputs !== undefined && { maskAllInputs }),
33
+ ...(inlineStylesheet !== undefined && { inlineStylesheet }),
34
+ ...(sampling !== undefined && { sampling: sampling as any }),
35
+ });
36
+
37
+ this.stopFn = result ?? null;
38
+ }
39
+
40
+ stop(): void {
41
+ if (!this.started) return;
42
+ this.started = false;
43
+
44
+ this.stopFn?.();
45
+ this.stopFn = null;
46
+ }
47
+ }