@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,43 @@
1
+ interface SDKConfig {
2
+ serverUrl: string;
3
+ cedarSessionId: string;
4
+ batchIntervalMs?: number;
5
+ batchMaxSize?: number;
6
+ console?: ConsoleConfig;
7
+ }
8
+ interface ConsoleConfig {
9
+ log?: boolean;
10
+ info?: boolean;
11
+ warn?: boolean;
12
+ error?: boolean;
13
+ debug?: boolean;
14
+ }
15
+ interface RecorderConfig {
16
+ checkoutEveryNms?: number;
17
+ blockSelector?: string;
18
+ maskAllInputs?: boolean;
19
+ inlineStylesheet?: boolean;
20
+ sampling?: Record<string, unknown>;
21
+ }
22
+
23
+ interface InitConfig {
24
+ serverUrl: string;
25
+ cedarSessionId: string;
26
+ batchIntervalMs?: number;
27
+ batchMaxSize?: number;
28
+ console?: SDKConfig['console'];
29
+ recorder?: RecorderConfig;
30
+ }
31
+ declare const CedarReplay: {
32
+ init(initConfig: InitConfig): void;
33
+ stop(): void;
34
+ captureException(error: Error, options?: {
35
+ tags?: Record<string, string>;
36
+ extra?: Record<string, unknown>;
37
+ }): void;
38
+ track(name: string, properties?: Record<string, unknown>): void;
39
+ identify(userId: string, traits?: Record<string, unknown>): void;
40
+ getSessionURL(): string | null;
41
+ };
42
+
43
+ export { CedarReplay };
package/dist/index.js ADDED
@@ -0,0 +1,420 @@
1
+ // src/transport.ts
2
+ var Transport = class {
3
+ config;
4
+ queue = [];
5
+ timer = null;
6
+ started = false;
7
+ startedAt = Date.now();
8
+ fetchFn;
9
+ /**
10
+ * @param config SDK configuration
11
+ * @param originalFetch Original fetch reference to avoid self-interception.
12
+ * If not provided, uses globalThis.fetch.
13
+ */
14
+ constructor(config2, originalFetch) {
15
+ this.config = config2;
16
+ const fn = originalFetch ?? globalThis.fetch;
17
+ this.fetchFn = fn.bind(globalThis);
18
+ }
19
+ start() {
20
+ if (this.started) return;
21
+ this.started = true;
22
+ this.startedAt = Date.now();
23
+ const interval = this.config.batchIntervalMs ?? 5e3;
24
+ this.timer = setInterval(() => {
25
+ this.flush();
26
+ }, interval);
27
+ }
28
+ stop() {
29
+ if (!this.started) return;
30
+ this.started = false;
31
+ if (this.timer !== null) {
32
+ clearInterval(this.timer);
33
+ this.timer = null;
34
+ }
35
+ this.flush();
36
+ }
37
+ enqueue(event) {
38
+ if (!this.started) return;
39
+ this.queue.push(event);
40
+ const maxSize = this.config.batchMaxSize ?? 1024 * 512;
41
+ const estimatedSize = this.estimateQueueSize();
42
+ if (estimatedSize >= maxSize) {
43
+ this.flush();
44
+ }
45
+ }
46
+ flush() {
47
+ if (this.queue.length === 0) return;
48
+ const events = this.queue.splice(0);
49
+ 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
+ });
56
+ }
57
+ /**
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.
60
+ */
61
+ flushOnUnload() {
62
+ 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);
66
+ const blob = new Blob([body], { type: "application/json" });
67
+ const url = `${this.config.serverUrl}/api/ingest?encoding=gzip`;
68
+ navigator.sendBeacon(url, blob);
69
+ }
70
+ buildPayload(events) {
71
+ return {
72
+ sessionId: this.config.cedarSessionId,
73
+ batchTimestamp: Date.now(),
74
+ startedAt: this.startedAt,
75
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "",
76
+ viewportWidth: typeof window !== "undefined" ? window.innerWidth : 0,
77
+ viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
78
+ url: typeof window !== "undefined" ? window.location.href : "",
79
+ events
80
+ };
81
+ }
82
+ estimateQueueSize() {
83
+ let size = 0;
84
+ for (const event of this.queue) {
85
+ size += JSON.stringify(event).length;
86
+ }
87
+ return size;
88
+ }
89
+ };
90
+
91
+ // src/console.ts
92
+ var LEVELS = ["log", "warn", "error", "info", "debug"];
93
+ var ConsoleCapture = class {
94
+ onEvent;
95
+ config;
96
+ originals = {};
97
+ started = false;
98
+ constructor(onEvent2, config2) {
99
+ this.onEvent = onEvent2;
100
+ this.config = config2 ?? {};
101
+ }
102
+ start() {
103
+ if (this.started) return;
104
+ this.started = true;
105
+ for (const level of LEVELS) {
106
+ if (this.config[level] === false) continue;
107
+ const original = console[level];
108
+ this.originals[level] = original;
109
+ console[level] = (...args) => {
110
+ original.apply(console, args);
111
+ this.onEvent({
112
+ type: "console",
113
+ timestamp: Date.now(),
114
+ data: {
115
+ level,
116
+ args: args.map((a) => typeof a === "string" ? a : JSON.stringify(a))
117
+ }
118
+ });
119
+ };
120
+ }
121
+ }
122
+ stop() {
123
+ if (!this.started) return;
124
+ this.started = false;
125
+ for (const level of LEVELS) {
126
+ const orig = this.originals[level];
127
+ if (orig) {
128
+ console[level] = orig;
129
+ }
130
+ }
131
+ this.originals = {};
132
+ }
133
+ };
134
+
135
+ // src/errors.ts
136
+ var ErrorCapture = class {
137
+ onEvent;
138
+ started = false;
139
+ prevOnError = null;
140
+ rejectionHandler = null;
141
+ constructor(onEvent2) {
142
+ this.onEvent = onEvent2;
143
+ }
144
+ start() {
145
+ if (this.started) return;
146
+ this.started = true;
147
+ this.prevOnError = globalThis.onerror;
148
+ globalThis.onerror = (message, source, lineno, colno, error) => {
149
+ this.onEvent({
150
+ type: "error",
151
+ timestamp: Date.now(),
152
+ data: {
153
+ message: error?.message ?? String(message),
154
+ stack: error?.stack,
155
+ source: source || void 0,
156
+ lineno,
157
+ colno,
158
+ type: "uncaught"
159
+ }
160
+ });
161
+ };
162
+ this.rejectionHandler = (event) => {
163
+ const reason = event.reason;
164
+ const message = reason instanceof Error ? reason.message : String(reason);
165
+ const stack = reason instanceof Error ? reason.stack : void 0;
166
+ this.onEvent({
167
+ type: "error",
168
+ timestamp: Date.now(),
169
+ data: {
170
+ message,
171
+ stack,
172
+ type: "unhandledrejection"
173
+ }
174
+ });
175
+ };
176
+ globalThis.addEventListener(
177
+ "unhandledrejection",
178
+ this.rejectionHandler
179
+ );
180
+ }
181
+ stop() {
182
+ if (!this.started) return;
183
+ this.started = false;
184
+ globalThis.onerror = this.prevOnError;
185
+ this.prevOnError = null;
186
+ if (this.rejectionHandler) {
187
+ globalThis.removeEventListener(
188
+ "unhandledrejection",
189
+ this.rejectionHandler
190
+ );
191
+ this.rejectionHandler = null;
192
+ }
193
+ }
194
+ captureException(error, options) {
195
+ if (!this.started) return;
196
+ this.onEvent({
197
+ type: "error",
198
+ timestamp: Date.now(),
199
+ data: {
200
+ message: error.message,
201
+ stack: error.stack,
202
+ type: "manual",
203
+ tags: options?.tags,
204
+ extra: options?.extra
205
+ }
206
+ });
207
+ }
208
+ };
209
+
210
+ // src/network.ts
211
+ var nextId = 0;
212
+ var NetworkCapture = class {
213
+ onEvent;
214
+ serverUrl;
215
+ originalFetch = null;
216
+ started = false;
217
+ constructor(onEvent2, serverUrl) {
218
+ this.onEvent = onEvent2;
219
+ this.serverUrl = serverUrl;
220
+ }
221
+ start() {
222
+ if (this.started) return;
223
+ this.started = true;
224
+ this.originalFetch = globalThis.fetch;
225
+ globalThis.fetch = async (input, init) => {
226
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
227
+ if (url.startsWith(this.serverUrl)) {
228
+ return this.originalFetch(input, init);
229
+ }
230
+ const method = init?.method?.toUpperCase() ?? "GET";
231
+ const requestBody = init?.body ? String(init.body) : void 0;
232
+ const startTime = performance.now();
233
+ const id = String(++nextId);
234
+ let graphqlOperationName;
235
+ if (requestBody) {
236
+ try {
237
+ const parsed = JSON.parse(requestBody);
238
+ if (parsed.operationName) {
239
+ graphqlOperationName = parsed.operationName;
240
+ }
241
+ } catch {
242
+ }
243
+ }
244
+ try {
245
+ const response = await this.originalFetch(input, init);
246
+ const endTime = performance.now();
247
+ let responseBody;
248
+ try {
249
+ const cloned = response.clone();
250
+ responseBody = await cloned.text();
251
+ } catch {
252
+ }
253
+ this.onEvent({
254
+ type: "network",
255
+ timestamp: Date.now(),
256
+ data: {
257
+ id,
258
+ method,
259
+ url,
260
+ graphqlOperationName,
261
+ requestBody,
262
+ status: response.status,
263
+ responseBody,
264
+ startTime,
265
+ endTime,
266
+ duration: endTime - startTime
267
+ }
268
+ });
269
+ return response;
270
+ } catch (err) {
271
+ const endTime = performance.now();
272
+ this.onEvent({
273
+ type: "network",
274
+ timestamp: Date.now(),
275
+ data: {
276
+ id,
277
+ method,
278
+ url,
279
+ graphqlOperationName,
280
+ requestBody,
281
+ startTime,
282
+ endTime,
283
+ duration: endTime - startTime,
284
+ error: err instanceof Error ? err.message : String(err)
285
+ }
286
+ });
287
+ throw err;
288
+ }
289
+ };
290
+ }
291
+ stop() {
292
+ if (!this.started) return;
293
+ this.started = false;
294
+ if (this.originalFetch) {
295
+ globalThis.fetch = this.originalFetch;
296
+ this.originalFetch = null;
297
+ }
298
+ }
299
+ };
300
+
301
+ // src/recorder.ts
302
+ import { record } from "rrweb";
303
+ var RecorderCapture = class {
304
+ onEvent;
305
+ config;
306
+ stopFn = null;
307
+ started = false;
308
+ constructor(onEvent2, config2) {
309
+ this.onEvent = onEvent2;
310
+ this.config = config2 ?? {};
311
+ }
312
+ start() {
313
+ if (this.started) return;
314
+ this.started = true;
315
+ const { checkoutEveryNms, blockSelector, maskAllInputs, inlineStylesheet, sampling } = this.config;
316
+ const result = record({
317
+ emit: (event) => {
318
+ if (!this.started) return;
319
+ this.onEvent({
320
+ type: "rrweb",
321
+ data: event
322
+ });
323
+ },
324
+ ...checkoutEveryNms !== void 0 && { checkoutEveryNms },
325
+ ...blockSelector !== void 0 && { blockSelector },
326
+ ...maskAllInputs !== void 0 && { maskAllInputs },
327
+ ...inlineStylesheet !== void 0 && { inlineStylesheet },
328
+ ...sampling !== void 0 && { sampling }
329
+ });
330
+ this.stopFn = result ?? null;
331
+ }
332
+ stop() {
333
+ if (!this.started) return;
334
+ this.started = false;
335
+ this.stopFn?.();
336
+ this.stopFn = null;
337
+ }
338
+ };
339
+
340
+ // src/index.ts
341
+ var config = null;
342
+ var transport = null;
343
+ var consoleCapture = null;
344
+ var errorCapture = null;
345
+ var networkCapture = null;
346
+ var recorderCapture = null;
347
+ function onEvent(event) {
348
+ transport?.enqueue(event);
349
+ }
350
+ var CedarReplay = {
351
+ init(initConfig) {
352
+ if (config) {
353
+ CedarReplay.stop();
354
+ }
355
+ config = initConfig;
356
+ const originalFetch = globalThis.fetch;
357
+ transport = new Transport(
358
+ {
359
+ serverUrl: config.serverUrl,
360
+ cedarSessionId: config.cedarSessionId,
361
+ batchIntervalMs: config.batchIntervalMs,
362
+ batchMaxSize: config.batchMaxSize
363
+ },
364
+ originalFetch
365
+ );
366
+ transport.start();
367
+ consoleCapture = new ConsoleCapture(onEvent, config.console);
368
+ consoleCapture.start();
369
+ errorCapture = new ErrorCapture(onEvent);
370
+ errorCapture.start();
371
+ networkCapture = new NetworkCapture(onEvent, config.serverUrl);
372
+ networkCapture.start();
373
+ recorderCapture = new RecorderCapture(onEvent, config.recorder);
374
+ recorderCapture.start();
375
+ },
376
+ stop() {
377
+ recorderCapture?.stop();
378
+ recorderCapture = null;
379
+ networkCapture?.stop();
380
+ networkCapture = null;
381
+ errorCapture?.stop();
382
+ errorCapture = null;
383
+ consoleCapture?.stop();
384
+ consoleCapture = null;
385
+ transport?.stop();
386
+ transport = null;
387
+ config = null;
388
+ },
389
+ captureException(error, options) {
390
+ errorCapture?.captureException(error, options);
391
+ },
392
+ track(name, properties) {
393
+ if (!transport) return;
394
+ const event = {
395
+ type: "custom",
396
+ timestamp: Date.now(),
397
+ data: { name, properties }
398
+ };
399
+ transport.enqueue(event);
400
+ },
401
+ identify(userId, traits) {
402
+ if (!transport) return;
403
+ const event = {
404
+ type: "custom",
405
+ timestamp: Date.now(),
406
+ data: {
407
+ name: "__cedar_identify",
408
+ properties: { userId, ...traits }
409
+ }
410
+ };
411
+ transport.enqueue(event);
412
+ },
413
+ getSessionURL() {
414
+ if (!config) return null;
415
+ return `${config.serverUrl}/sessions/${config.cedarSessionId}`;
416
+ }
417
+ };
418
+ export {
419
+ CedarReplay
420
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@cedarai/session-replay-sdk",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "prepare": "tsup src/index.ts --format esm --dts",
16
+ "build": "tsup src/index.ts --format esm --dts",
17
+ "build:iife": "tsup src/global.ts --format iife --outDir dist",
18
+ "dev": "tsup src/index.ts --format esm --dts --watch",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
22
+ "dependencies": {
23
+ "rrweb": "^2.0.0-alpha.4"
24
+ },
25
+ "devDependencies": {
26
+ "@vitest/browser": "^4.0.18",
27
+ "happy-dom": "^20.7.0",
28
+ "jsdom": "^27.0.1",
29
+ "tsup": "^8.5.1"
30
+ }
31
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { ConsoleCapture } from './console.js';
3
+ import type { ConsoleSessionEvent, ConsoleConfig } from './types.js';
4
+
5
+ describe('ConsoleCapture', () => {
6
+ let capture: ConsoleCapture;
7
+ let events: ConsoleSessionEvent[];
8
+ let originalLog: typeof console.log;
9
+ let originalWarn: typeof console.warn;
10
+ let originalError: typeof console.error;
11
+
12
+ beforeEach(() => {
13
+ events = [];
14
+ originalLog = console.log;
15
+ originalWarn = console.warn;
16
+ originalError = console.error;
17
+ });
18
+
19
+ afterEach(() => {
20
+ capture?.stop();
21
+ // Restore originals in case stop() didn't
22
+ console.log = originalLog;
23
+ console.warn = originalWarn;
24
+ console.error = originalError;
25
+ });
26
+
27
+ it('captures console.log calls', () => {
28
+ capture = new ConsoleCapture((e) => events.push(e));
29
+ capture.start();
30
+
31
+ console.log('hello', 'world');
32
+
33
+ expect(events).toHaveLength(1);
34
+ expect(events[0].type).toBe('console');
35
+ expect(events[0].data.level).toBe('log');
36
+ expect(events[0].data.args).toEqual(['hello', 'world']);
37
+ });
38
+
39
+ it('captures console.warn calls', () => {
40
+ capture = new ConsoleCapture((e) => events.push(e));
41
+ capture.start();
42
+
43
+ console.warn('warning!');
44
+
45
+ expect(events).toHaveLength(1);
46
+ expect(events[0].data.level).toBe('warn');
47
+ });
48
+
49
+ it('captures console.error calls', () => {
50
+ capture = new ConsoleCapture((e) => events.push(e));
51
+ capture.start();
52
+
53
+ console.error('error!');
54
+
55
+ expect(events).toHaveLength(1);
56
+ expect(events[0].data.level).toBe('error');
57
+ });
58
+
59
+ it('captures console.info calls', () => {
60
+ capture = new ConsoleCapture((e) => events.push(e));
61
+ capture.start();
62
+
63
+ console.info('info');
64
+
65
+ expect(events).toHaveLength(1);
66
+ expect(events[0].data.level).toBe('info');
67
+ });
68
+
69
+ it('captures console.debug calls', () => {
70
+ capture = new ConsoleCapture((e) => events.push(e));
71
+ capture.start();
72
+
73
+ console.debug('debug');
74
+
75
+ expect(events).toHaveLength(1);
76
+ expect(events[0].data.level).toBe('debug');
77
+ });
78
+
79
+ it('still calls the original console method', () => {
80
+ const origLog = vi.fn();
81
+ console.log = origLog;
82
+
83
+ capture = new ConsoleCapture((e) => events.push(e));
84
+ capture.start();
85
+
86
+ console.log('test');
87
+
88
+ expect(origLog).toHaveBeenCalledWith('test');
89
+ expect(events).toHaveLength(1);
90
+ });
91
+
92
+ it('serializes non-string arguments', () => {
93
+ capture = new ConsoleCapture((e) => events.push(e));
94
+ capture.start();
95
+
96
+ console.log('count:', 42, { key: 'val' }, [1, 2]);
97
+
98
+ expect(events[0].data.args).toEqual(['count:', '42', '{"key":"val"}', '[1,2]']);
99
+ });
100
+
101
+ it('respects per-level config (disable log)', () => {
102
+ capture = new ConsoleCapture((e) => events.push(e), { log: false });
103
+ capture.start();
104
+
105
+ console.log('ignored');
106
+ console.warn('captured');
107
+
108
+ expect(events).toHaveLength(1);
109
+ expect(events[0].data.level).toBe('warn');
110
+ });
111
+
112
+ it('respects per-level config (disable debug)', () => {
113
+ capture = new ConsoleCapture((e) => events.push(e), { debug: false });
114
+ capture.start();
115
+
116
+ console.debug('ignored');
117
+ console.info('captured');
118
+
119
+ expect(events).toHaveLength(1);
120
+ expect(events[0].data.level).toBe('info');
121
+ });
122
+
123
+ it('restores original console methods on stop()', () => {
124
+ const origLog = console.log;
125
+ capture = new ConsoleCapture((e) => events.push(e));
126
+ capture.start();
127
+
128
+ // console.log is now patched
129
+ expect(console.log).not.toBe(origLog);
130
+
131
+ capture.stop();
132
+
133
+ // Should be restored
134
+ expect(console.log).toBe(origLog);
135
+ });
136
+
137
+ it('adds timestamp to events', () => {
138
+ capture = new ConsoleCapture((e) => events.push(e));
139
+ capture.start();
140
+
141
+ const before = Date.now();
142
+ console.log('test');
143
+ const after = Date.now();
144
+
145
+ expect(events[0].timestamp).toBeGreaterThanOrEqual(before);
146
+ expect(events[0].timestamp).toBeLessThanOrEqual(after);
147
+ });
148
+ });
package/src/console.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { ConsoleSessionEvent, ConsoleConfig } from './types.js';
2
+
3
+ type ConsoleLevel = 'log' | 'warn' | 'error' | 'info' | 'debug';
4
+
5
+ const LEVELS: ConsoleLevel[] = ['log', 'warn', 'error', 'info', 'debug'];
6
+
7
+ export class ConsoleCapture {
8
+ private onEvent: (event: ConsoleSessionEvent) => void;
9
+ private config: ConsoleConfig;
10
+ private originals: Partial<Record<ConsoleLevel, (...args: unknown[]) => void>> = {};
11
+ private started = false;
12
+
13
+ constructor(onEvent: (event: ConsoleSessionEvent) => void, config?: ConsoleConfig) {
14
+ this.onEvent = onEvent;
15
+ this.config = config ?? {};
16
+ }
17
+
18
+ start(): void {
19
+ if (this.started) return;
20
+ this.started = true;
21
+
22
+ for (const level of LEVELS) {
23
+ if (this.config[level] === false) continue;
24
+
25
+ const original = console[level];
26
+ this.originals[level] = original;
27
+
28
+ console[level] = (...args: unknown[]) => {
29
+ original.apply(console, args);
30
+ this.onEvent({
31
+ type: 'console',
32
+ timestamp: Date.now(),
33
+ data: {
34
+ level,
35
+ args: args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))),
36
+ },
37
+ });
38
+ };
39
+ }
40
+ }
41
+
42
+ stop(): void {
43
+ if (!this.started) return;
44
+ this.started = false;
45
+
46
+ for (const level of LEVELS) {
47
+ const orig = this.originals[level];
48
+ if (orig) {
49
+ console[level] = orig;
50
+ }
51
+ }
52
+ this.originals = {};
53
+ }
54
+ }