@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.
- package/dist/global.global.js +4076 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +420 -0
- package/package.json +31 -0
- package/src/console.test.ts +148 -0
- package/src/console.ts +54 -0
- package/src/errors.test.ts +146 -0
- package/src/errors.ts +99 -0
- package/src/global.ts +3 -0
- package/src/index.test.ts +207 -0
- package/src/index.ts +122 -0
- package/src/network.test.ts +135 -0
- package/src/network.ts +112 -0
- package/src/recorder.test.ts +187 -0
- package/src/recorder.ts +47 -0
- package/src/transport.test.ts +256 -0
- package/src/transport.ts +114 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
|
@@ -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,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
|
+
};
|