@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
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|