@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,146 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { ErrorCapture } from './errors.js';
3
+ import type { ErrorSessionEvent } from './types.js';
4
+
5
+ describe('ErrorCapture', () => {
6
+ let capture: ErrorCapture;
7
+ let events: ErrorSessionEvent[];
8
+
9
+ beforeEach(() => {
10
+ events = [];
11
+ });
12
+
13
+ afterEach(() => {
14
+ capture?.stop();
15
+ });
16
+
17
+ // ── captureException (manual) ────────────────────────────────────────
18
+
19
+ describe('captureException', () => {
20
+ it('captures a manually reported error', () => {
21
+ capture = new ErrorCapture((e) => events.push(e));
22
+ capture.start();
23
+
24
+ const err = new Error('Something broke');
25
+ capture.captureException(err);
26
+
27
+ expect(events).toHaveLength(1);
28
+ expect(events[0].type).toBe('error');
29
+ expect(events[0].data.message).toBe('Something broke');
30
+ expect(events[0].data.type).toBe('manual');
31
+ expect(events[0].data.stack).toContain('Error: Something broke');
32
+ });
33
+
34
+ it('captures error with tags and extra', () => {
35
+ capture = new ErrorCapture((e) => events.push(e));
36
+ capture.start();
37
+
38
+ capture.captureException(new Error('fail'), {
39
+ tags: { component: 'Checkout' },
40
+ extra: { orderId: '123' },
41
+ });
42
+
43
+ expect(events[0].data.tags).toEqual({ component: 'Checkout' });
44
+ expect(events[0].data.extra).toEqual({ orderId: '123' });
45
+ });
46
+
47
+ it('adds timestamp', () => {
48
+ capture = new ErrorCapture((e) => events.push(e));
49
+ capture.start();
50
+
51
+ const before = Date.now();
52
+ capture.captureException(new Error('test'));
53
+ const after = Date.now();
54
+
55
+ expect(events[0].timestamp).toBeGreaterThanOrEqual(before);
56
+ expect(events[0].timestamp).toBeLessThanOrEqual(after);
57
+ });
58
+ });
59
+
60
+ // ── window.onerror (auto capture) ────────────────────────────────────
61
+
62
+ describe('window.onerror', () => {
63
+ it('captures uncaught errors via onerror handler', () => {
64
+ capture = new ErrorCapture((e) => events.push(e));
65
+ capture.start();
66
+
67
+ // Simulate window.onerror being called
68
+ const handler = globalThis.onerror as Function;
69
+ if (handler) {
70
+ handler('Uncaught Error: boom', 'app.js', 42, 10, new Error('boom'));
71
+ }
72
+
73
+ expect(events).toHaveLength(1);
74
+ expect(events[0].data.message).toBe('boom');
75
+ expect(events[0].data.type).toBe('uncaught');
76
+ expect(events[0].data.source).toBe('app.js');
77
+ expect(events[0].data.lineno).toBe(42);
78
+ expect(events[0].data.colno).toBe(10);
79
+ });
80
+
81
+ it('handles onerror without an Error object', () => {
82
+ capture = new ErrorCapture((e) => events.push(e));
83
+ capture.start();
84
+
85
+ const handler = globalThis.onerror as Function;
86
+ if (handler) {
87
+ handler('Script error.', '', 0, 0, undefined);
88
+ }
89
+
90
+ expect(events).toHaveLength(1);
91
+ expect(events[0].data.message).toBe('Script error.');
92
+ expect(events[0].data.stack).toBeUndefined();
93
+ });
94
+ });
95
+
96
+ // ── unhandledrejection ───────────────────────────────────────────────
97
+
98
+ describe('unhandledrejection', () => {
99
+ it('captures unhandled promise rejections', () => {
100
+ capture = new ErrorCapture((e) => events.push(e));
101
+ capture.start();
102
+
103
+ // Simulate unhandledrejection event
104
+ const event = new Event('unhandledrejection') as any;
105
+ event.reason = new Error('promise rejected');
106
+ globalThis.dispatchEvent(event);
107
+
108
+ expect(events).toHaveLength(1);
109
+ expect(events[0].data.message).toBe('promise rejected');
110
+ expect(events[0].data.type).toBe('unhandledrejection');
111
+ });
112
+
113
+ it('handles rejection with string reason', () => {
114
+ capture = new ErrorCapture((e) => events.push(e));
115
+ capture.start();
116
+
117
+ const event = new Event('unhandledrejection') as any;
118
+ event.reason = 'string rejection';
119
+ globalThis.dispatchEvent(event);
120
+
121
+ expect(events).toHaveLength(1);
122
+ expect(events[0].data.message).toBe('string rejection');
123
+ });
124
+ });
125
+
126
+ // ── stop() ───────────────────────────────────────────────────────────
127
+
128
+ describe('stop()', () => {
129
+ it('removes event listeners on stop', () => {
130
+ capture = new ErrorCapture((e) => events.push(e));
131
+ capture.start();
132
+ capture.stop();
133
+
134
+ // Trigger onerror after stop — should not capture
135
+ const handler = globalThis.onerror;
136
+ if (handler) {
137
+ (handler as Function)('error', '', 0, 0, new Error('after stop'));
138
+ }
139
+
140
+ // If the listener was properly removed, events should still be from
141
+ // the handler we saved (which is now the original, not our capture)
142
+ // The test is mainly that stop() doesn't throw
143
+ expect(events).toHaveLength(0);
144
+ });
145
+ });
146
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,99 @@
1
+ import type { ErrorSessionEvent } from './types.js';
2
+
3
+ interface CaptureExceptionOptions {
4
+ tags?: Record<string, string>;
5
+ extra?: Record<string, unknown>;
6
+ }
7
+
8
+ export class ErrorCapture {
9
+ private onEvent: (event: ErrorSessionEvent) => void;
10
+ private started = false;
11
+ private prevOnError: OnErrorEventHandler | null = null;
12
+ private rejectionHandler: ((event: PromiseRejectionEvent) => void) | null = null;
13
+
14
+ constructor(onEvent: (event: ErrorSessionEvent) => void) {
15
+ this.onEvent = onEvent;
16
+ }
17
+
18
+ start(): void {
19
+ if (this.started) return;
20
+ this.started = true;
21
+
22
+ // Capture window.onerror
23
+ this.prevOnError = globalThis.onerror;
24
+ globalThis.onerror = (
25
+ message: string | Event,
26
+ source?: string,
27
+ lineno?: number,
28
+ colno?: number,
29
+ error?: Error,
30
+ ) => {
31
+ this.onEvent({
32
+ type: 'error',
33
+ timestamp: Date.now(),
34
+ data: {
35
+ message: error?.message ?? String(message),
36
+ stack: error?.stack,
37
+ source: source || undefined,
38
+ lineno,
39
+ colno,
40
+ type: 'uncaught',
41
+ },
42
+ });
43
+ };
44
+
45
+ // Capture unhandledrejection
46
+ this.rejectionHandler = (event: PromiseRejectionEvent) => {
47
+ const reason = event.reason;
48
+ const message =
49
+ reason instanceof Error ? reason.message : String(reason);
50
+ const stack = reason instanceof Error ? reason.stack : undefined;
51
+
52
+ this.onEvent({
53
+ type: 'error',
54
+ timestamp: Date.now(),
55
+ data: {
56
+ message,
57
+ stack,
58
+ type: 'unhandledrejection',
59
+ },
60
+ });
61
+ };
62
+ globalThis.addEventListener(
63
+ 'unhandledrejection',
64
+ this.rejectionHandler as EventListener,
65
+ );
66
+ }
67
+
68
+ stop(): void {
69
+ if (!this.started) return;
70
+ this.started = false;
71
+
72
+ globalThis.onerror = this.prevOnError;
73
+ this.prevOnError = null;
74
+
75
+ if (this.rejectionHandler) {
76
+ globalThis.removeEventListener(
77
+ 'unhandledrejection',
78
+ this.rejectionHandler as EventListener,
79
+ );
80
+ this.rejectionHandler = null;
81
+ }
82
+ }
83
+
84
+ captureException(error: Error, options?: CaptureExceptionOptions): void {
85
+ if (!this.started) return;
86
+
87
+ this.onEvent({
88
+ type: 'error',
89
+ timestamp: Date.now(),
90
+ data: {
91
+ message: error.message,
92
+ stack: error.stack,
93
+ type: 'manual',
94
+ tags: options?.tags,
95
+ extra: options?.extra,
96
+ },
97
+ });
98
+ }
99
+ }
package/src/global.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { CedarReplay } from './index.js';
2
+
3
+ (globalThis as any).CedarReplay = CedarReplay;
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+
3
+ // Mock rrweb so RecorderCapture doesn't try to use real DOM recording
4
+ const { mockRecordStopFn, mockRecord } = vi.hoisted(() => ({
5
+ mockRecordStopFn: vi.fn(),
6
+ mockRecord: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('rrweb', () => ({
10
+ record: mockRecord,
11
+ }));
12
+
13
+ import { CedarReplay } from './index.js';
14
+
15
+ describe('CedarReplay', () => {
16
+ let mockFetch: ReturnType<typeof vi.fn>;
17
+
18
+ beforeEach(() => {
19
+ mockFetch = vi.fn().mockResolvedValue({
20
+ ok: true,
21
+ status: 200,
22
+ headers: new Headers({ 'content-type': 'application/json' }),
23
+ clone: () => ({
24
+ text: () => Promise.resolve('{}'),
25
+ headers: new Headers({ 'content-type': 'application/json' }),
26
+ }),
27
+ text: () => Promise.resolve('{}'),
28
+ });
29
+ vi.stubGlobal('fetch', mockFetch);
30
+ mockRecord.mockClear();
31
+ mockRecordStopFn.mockClear();
32
+ mockRecord.mockReturnValue(mockRecordStopFn);
33
+ });
34
+
35
+ afterEach(() => {
36
+ CedarReplay.stop();
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ // ── init / stop ──────────────────────────────────────────────
41
+
42
+ it('starts capture modules on init()', () => {
43
+ CedarReplay.init({
44
+ serverUrl: 'https://replay.getcedar.ai',
45
+ cedarSessionId: 'sess-123',
46
+ });
47
+
48
+ // After init, console capture should be active (patched)
49
+ // We can verify by logging and checking that fetch is called on flush
50
+ console.log('test-after-init');
51
+
52
+ // Transport should be started — verify by triggering a flush
53
+ // (We'll test more specific behavior in integration tests)
54
+ expect(CedarReplay.getSessionURL()).toBe(
55
+ 'https://replay.getcedar.ai/sessions/sess-123',
56
+ );
57
+ });
58
+
59
+ it('returns null for getSessionURL() before init', () => {
60
+ expect(CedarReplay.getSessionURL()).toBeNull();
61
+ });
62
+
63
+ it('returns session URL after init', () => {
64
+ CedarReplay.init({
65
+ serverUrl: 'https://replay.getcedar.ai',
66
+ cedarSessionId: 'sess-abc',
67
+ });
68
+
69
+ expect(CedarReplay.getSessionURL()).toBe(
70
+ 'https://replay.getcedar.ai/sessions/sess-abc',
71
+ );
72
+ });
73
+
74
+ it('stop() cleans up all captures', () => {
75
+ const origLog = console.log;
76
+ const origFetch = globalThis.fetch;
77
+
78
+ CedarReplay.init({
79
+ serverUrl: 'https://replay.getcedar.ai',
80
+ cedarSessionId: 'sess-123',
81
+ });
82
+
83
+ // After init, console.log and fetch should be patched
84
+ expect(console.log).not.toBe(origLog);
85
+ expect(globalThis.fetch).not.toBe(origFetch);
86
+
87
+ CedarReplay.stop();
88
+
89
+ // After stop, originals should be restored
90
+ expect(console.log).toBe(origLog);
91
+ // fetch is restored by NetworkCapture.stop()
92
+ expect(globalThis.fetch).toBe(origFetch);
93
+ });
94
+
95
+ it('is idempotent — double init does not crash', () => {
96
+ CedarReplay.init({
97
+ serverUrl: 'https://replay.getcedar.ai',
98
+ cedarSessionId: 'sess-1',
99
+ });
100
+ CedarReplay.init({
101
+ serverUrl: 'https://replay.getcedar.ai',
102
+ cedarSessionId: 'sess-2',
103
+ });
104
+
105
+ // Should use the latest session
106
+ expect(CedarReplay.getSessionURL()).toBe(
107
+ 'https://replay.getcedar.ai/sessions/sess-2',
108
+ );
109
+ });
110
+
111
+ // ── captureException ─────────────────────────────────────────
112
+
113
+ it('exposes captureException() that delegates to ErrorCapture', () => {
114
+ CedarReplay.init({
115
+ serverUrl: 'https://replay.getcedar.ai',
116
+ cedarSessionId: 'sess-123',
117
+ });
118
+
119
+ // Should not throw
120
+ CedarReplay.captureException(new Error('manual error'), {
121
+ tags: { page: 'checkout' },
122
+ });
123
+ });
124
+
125
+ it('captureException is no-op before init', () => {
126
+ // Should not throw even if not initialized
127
+ expect(() => {
128
+ CedarReplay.captureException(new Error('noop'));
129
+ }).not.toThrow();
130
+ });
131
+
132
+ // ── track (custom events) ───────────────────────────────────
133
+
134
+ it('exposes track() for custom events', () => {
135
+ CedarReplay.init({
136
+ serverUrl: 'https://replay.getcedar.ai',
137
+ cedarSessionId: 'sess-123',
138
+ });
139
+
140
+ // Should not throw
141
+ CedarReplay.track('button_clicked', { buttonId: 'checkout' });
142
+ });
143
+
144
+ it('track is no-op before init', () => {
145
+ expect(() => {
146
+ CedarReplay.track('event_name');
147
+ }).not.toThrow();
148
+ });
149
+
150
+ // ── identify ─────────────────────────────────────────────────
151
+
152
+ it('exposes identify() to tag the session with user info', () => {
153
+ CedarReplay.init({
154
+ serverUrl: 'https://replay.getcedar.ai',
155
+ cedarSessionId: 'sess-123',
156
+ });
157
+
158
+ // Should not throw
159
+ CedarReplay.identify('user-456', {
160
+ email: 'user@example.com',
161
+ name: 'Test User',
162
+ });
163
+ });
164
+
165
+ it('identify is no-op before init', () => {
166
+ expect(() => {
167
+ CedarReplay.identify('user-456');
168
+ }).not.toThrow();
169
+ });
170
+
171
+ // ── recorder integration ────────────────────────────────────
172
+
173
+ it('init() starts rrweb recording', () => {
174
+ CedarReplay.init({
175
+ serverUrl: 'https://replay.getcedar.ai',
176
+ cedarSessionId: 'sess-123',
177
+ });
178
+
179
+ expect(mockRecord).toHaveBeenCalledTimes(1);
180
+ expect(typeof mockRecord.mock.calls[0][0].emit).toBe('function');
181
+ });
182
+
183
+ it('stop() calls rrweb stop function', () => {
184
+ CedarReplay.init({
185
+ serverUrl: 'https://replay.getcedar.ai',
186
+ cedarSessionId: 'sess-123',
187
+ });
188
+
189
+ CedarReplay.stop();
190
+ expect(mockRecordStopFn).toHaveBeenCalledTimes(1);
191
+ });
192
+
193
+ it('passes recorder config to rrweb.record()', () => {
194
+ CedarReplay.init({
195
+ serverUrl: 'https://replay.getcedar.ai',
196
+ cedarSessionId: 'sess-123',
197
+ recorder: {
198
+ blockSelector: '.secret',
199
+ maskAllInputs: true,
200
+ },
201
+ });
202
+
203
+ const opts = mockRecord.mock.calls[0][0];
204
+ expect(opts.blockSelector).toBe('.secret');
205
+ expect(opts.maskAllInputs).toBe(true);
206
+ });
207
+ });
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { SDKConfig, SessionEvent, CustomSessionEvent, RecorderConfig } from './types.js';
2
+ import { Transport } from './transport.js';
3
+ import { ConsoleCapture } from './console.js';
4
+ import { ErrorCapture } from './errors.js';
5
+ import { NetworkCapture } from './network.js';
6
+ import { RecorderCapture } from './recorder.js';
7
+
8
+ interface InitConfig {
9
+ serverUrl: string;
10
+ cedarSessionId: string;
11
+ batchIntervalMs?: number;
12
+ batchMaxSize?: number;
13
+ console?: SDKConfig['console'];
14
+ recorder?: RecorderConfig;
15
+ }
16
+
17
+ let config: InitConfig | null = null;
18
+ let transport: Transport | null = null;
19
+ let consoleCapture: ConsoleCapture | null = null;
20
+ let errorCapture: ErrorCapture | null = null;
21
+ let networkCapture: NetworkCapture | null = null;
22
+ let recorderCapture: RecorderCapture | null = null;
23
+
24
+ function onEvent(event: SessionEvent): void {
25
+ transport?.enqueue(event);
26
+ }
27
+
28
+ export const CedarReplay = {
29
+ init(initConfig: InitConfig): void {
30
+ // If already running, stop first (idempotent re-init)
31
+ if (config) {
32
+ CedarReplay.stop();
33
+ }
34
+
35
+ config = initConfig;
36
+
37
+ // Save reference to original fetch before network capture patches it
38
+ const originalFetch = globalThis.fetch;
39
+
40
+ // Start transport (uses original fetch to avoid self-interception)
41
+ transport = new Transport(
42
+ {
43
+ serverUrl: config.serverUrl,
44
+ cedarSessionId: config.cedarSessionId,
45
+ batchIntervalMs: config.batchIntervalMs,
46
+ batchMaxSize: config.batchMaxSize,
47
+ },
48
+ originalFetch,
49
+ );
50
+ transport.start();
51
+
52
+ // Start capture modules
53
+ consoleCapture = new ConsoleCapture(onEvent, config.console);
54
+ consoleCapture.start();
55
+
56
+ errorCapture = new ErrorCapture(onEvent);
57
+ errorCapture.start();
58
+
59
+ networkCapture = new NetworkCapture(onEvent, config.serverUrl);
60
+ networkCapture.start();
61
+
62
+ recorderCapture = new RecorderCapture(onEvent, config.recorder);
63
+ recorderCapture.start();
64
+ },
65
+
66
+ stop(): void {
67
+ recorderCapture?.stop();
68
+ recorderCapture = null;
69
+
70
+ networkCapture?.stop();
71
+ networkCapture = null;
72
+
73
+ errorCapture?.stop();
74
+ errorCapture = null;
75
+
76
+ consoleCapture?.stop();
77
+ consoleCapture = null;
78
+
79
+ transport?.stop();
80
+ transport = null;
81
+
82
+ config = null;
83
+ },
84
+
85
+ captureException(
86
+ error: Error,
87
+ options?: { tags?: Record<string, string>; extra?: Record<string, unknown> },
88
+ ): void {
89
+ errorCapture?.captureException(error, options);
90
+ },
91
+
92
+ track(name: string, properties?: Record<string, unknown>): void {
93
+ if (!transport) return;
94
+
95
+ const event: CustomSessionEvent = {
96
+ type: 'custom',
97
+ timestamp: Date.now(),
98
+ data: { name, properties },
99
+ };
100
+ transport.enqueue(event);
101
+ },
102
+
103
+ identify(userId: string, traits?: Record<string, unknown>): void {
104
+ if (!transport) return;
105
+
106
+ // Send identify as a custom event that the server can use to tag the session
107
+ const event: CustomSessionEvent = {
108
+ type: 'custom',
109
+ timestamp: Date.now(),
110
+ data: {
111
+ name: '__cedar_identify',
112
+ properties: { userId, ...traits },
113
+ },
114
+ };
115
+ transport.enqueue(event);
116
+ },
117
+
118
+ getSessionURL(): string | null {
119
+ if (!config) return null;
120
+ return `${config.serverUrl}/sessions/${config.cedarSessionId}`;
121
+ },
122
+ };