kodo-sdk 0.4.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/README.md +260 -0
- package/dist/browser.d.ts +288 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +766 -0
- package/dist/browser.js.map +1 -0
- package/dist/index.d.ts +194 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +403 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
package/dist/browser.js
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kodo Browser SDK
|
|
3
|
+
* Client-side error tracking, structured logging, distributed tracing, and performance monitoring
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Automatic error capture (window.onerror, unhandledrejection)
|
|
7
|
+
* - Breadcrumb tracking (console, clicks, navigation, XHR, fetch)
|
|
8
|
+
* - Structured logging with levels and context
|
|
9
|
+
* - Distributed tracing with spans
|
|
10
|
+
* - Web Vitals (LCP, FID, CLS)
|
|
11
|
+
* - User context
|
|
12
|
+
* - Ad-blocker bypass (tunneling)
|
|
13
|
+
*/
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Trace class for distributed tracing
|
|
16
|
+
// =============================================================================
|
|
17
|
+
class Trace {
|
|
18
|
+
constructor(name, options) {
|
|
19
|
+
this._spans = [];
|
|
20
|
+
this._currentSpanId = null;
|
|
21
|
+
this._status = 'ok';
|
|
22
|
+
this._tags = {};
|
|
23
|
+
this._traceId = generateTraceId();
|
|
24
|
+
this._name = name;
|
|
25
|
+
this._op = options?.op || 'custom';
|
|
26
|
+
this._startTime = Date.now();
|
|
27
|
+
this._tags = options?.tags || {};
|
|
28
|
+
}
|
|
29
|
+
get traceId() {
|
|
30
|
+
return this._traceId;
|
|
31
|
+
}
|
|
32
|
+
/** Start a child span */
|
|
33
|
+
startSpan(name, options) {
|
|
34
|
+
const spanId = generateSpanId();
|
|
35
|
+
const span = new Span(this, spanId, this._currentSpanId, name, options?.kind || 'internal', options?.attributes);
|
|
36
|
+
this._currentSpanId = spanId;
|
|
37
|
+
return span;
|
|
38
|
+
}
|
|
39
|
+
/** Set HTTP details for the trace */
|
|
40
|
+
setHttpDetails(method, url, statusCode) {
|
|
41
|
+
this._httpMethod = method;
|
|
42
|
+
this._httpUrl = url;
|
|
43
|
+
this._httpStatusCode = statusCode;
|
|
44
|
+
}
|
|
45
|
+
/** Add a tag to the trace */
|
|
46
|
+
setTag(key, value) {
|
|
47
|
+
this._tags[key] = value;
|
|
48
|
+
}
|
|
49
|
+
/** Mark trace as error */
|
|
50
|
+
setError() {
|
|
51
|
+
this._status = 'error';
|
|
52
|
+
}
|
|
53
|
+
/** Internal: Add completed span */
|
|
54
|
+
_addSpan(span) {
|
|
55
|
+
this._spans.push(span);
|
|
56
|
+
// Reset current span to parent
|
|
57
|
+
this._currentSpanId = span.parent_span_id;
|
|
58
|
+
}
|
|
59
|
+
/** Finish the trace and send it */
|
|
60
|
+
finish() {
|
|
61
|
+
const endTime = Date.now();
|
|
62
|
+
const event = {
|
|
63
|
+
type: 'trace',
|
|
64
|
+
timestamp: this._startTime,
|
|
65
|
+
session_id: _sessionId,
|
|
66
|
+
url: window.location.href,
|
|
67
|
+
user_agent: navigator.userAgent,
|
|
68
|
+
service: _config?.service || null,
|
|
69
|
+
release: _config?.release || null,
|
|
70
|
+
environment: _config?.environment || detectEnvironment(),
|
|
71
|
+
data: {
|
|
72
|
+
trace_id: this._traceId,
|
|
73
|
+
name: this._name,
|
|
74
|
+
op: this._op,
|
|
75
|
+
start_time: this._startTime,
|
|
76
|
+
end_time: endTime,
|
|
77
|
+
status: this._status,
|
|
78
|
+
tags: this._tags,
|
|
79
|
+
http_method: this._httpMethod,
|
|
80
|
+
http_url: this._httpUrl,
|
|
81
|
+
http_status_code: this._httpStatusCode,
|
|
82
|
+
spans: this._spans,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
queueEvent(event);
|
|
86
|
+
_log('Trace finished', { traceId: this._traceId, spans: this._spans.length });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
class Span {
|
|
90
|
+
constructor(trace, spanId, parentSpanId, name, kind, attributes) {
|
|
91
|
+
this._events = [];
|
|
92
|
+
this._status = 'ok';
|
|
93
|
+
this._finished = false;
|
|
94
|
+
this._trace = trace;
|
|
95
|
+
this._spanId = spanId;
|
|
96
|
+
this._parentSpanId = parentSpanId;
|
|
97
|
+
this._name = name;
|
|
98
|
+
this._kind = kind;
|
|
99
|
+
this._startTime = Date.now();
|
|
100
|
+
this._attributes = attributes || {};
|
|
101
|
+
}
|
|
102
|
+
get spanId() {
|
|
103
|
+
return this._spanId;
|
|
104
|
+
}
|
|
105
|
+
/** Set attributes on the span */
|
|
106
|
+
setAttributes(attrs) {
|
|
107
|
+
Object.assign(this._attributes, attrs);
|
|
108
|
+
}
|
|
109
|
+
/** Add an event to the span */
|
|
110
|
+
addEvent(name, attributes) {
|
|
111
|
+
this._events.push({
|
|
112
|
+
name,
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
attributes,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** Mark span as error */
|
|
118
|
+
setError() {
|
|
119
|
+
this._status = 'error';
|
|
120
|
+
}
|
|
121
|
+
/** Finish the span */
|
|
122
|
+
finish(options) {
|
|
123
|
+
if (this._finished)
|
|
124
|
+
return;
|
|
125
|
+
this._finished = true;
|
|
126
|
+
if (options?.status) {
|
|
127
|
+
this._status = options.status;
|
|
128
|
+
}
|
|
129
|
+
const spanData = {
|
|
130
|
+
span_id: this._spanId,
|
|
131
|
+
parent_span_id: this._parentSpanId,
|
|
132
|
+
name: this._name,
|
|
133
|
+
kind: this._kind,
|
|
134
|
+
start_time: this._startTime,
|
|
135
|
+
end_time: Date.now(),
|
|
136
|
+
status: this._status,
|
|
137
|
+
attributes: this._attributes,
|
|
138
|
+
events: this._events,
|
|
139
|
+
};
|
|
140
|
+
this._trace._addSpan(spanData);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// Global state
|
|
145
|
+
// =============================================================================
|
|
146
|
+
let _config = null;
|
|
147
|
+
let _sessionId = null;
|
|
148
|
+
let _breadcrumbs = [];
|
|
149
|
+
let _user = null;
|
|
150
|
+
let _eventQueue = [];
|
|
151
|
+
let _flushTimer = null;
|
|
152
|
+
let _initialized = false;
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// Main API
|
|
155
|
+
// =============================================================================
|
|
156
|
+
/**
|
|
157
|
+
* Initialize the Kodo SDK
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```typescript
|
|
161
|
+
* import Kodo from 'kodo-sdk/browser';
|
|
162
|
+
*
|
|
163
|
+
* Kodo.init({
|
|
164
|
+
* dsn: 'bpk_your_key_here',
|
|
165
|
+
* service: 'my-app',
|
|
166
|
+
* release: '1.0.0',
|
|
167
|
+
* });
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
function init(config) {
|
|
171
|
+
if (_initialized) {
|
|
172
|
+
console.warn('[Kodo] Already initialized');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
_config = {
|
|
176
|
+
baseUrl: 'https://kodostatus.com',
|
|
177
|
+
enableBreadcrumbs: true,
|
|
178
|
+
maxBreadcrumbs: 100,
|
|
179
|
+
debug: false,
|
|
180
|
+
...config,
|
|
181
|
+
};
|
|
182
|
+
_sessionId = generateSessionId();
|
|
183
|
+
_initialized = true;
|
|
184
|
+
setupGlobalHandlers();
|
|
185
|
+
if (_config.enableBreadcrumbs) {
|
|
186
|
+
setupBreadcrumbCapture();
|
|
187
|
+
}
|
|
188
|
+
captureWebVitals();
|
|
189
|
+
_log('Initialized', { sessionId: _sessionId });
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Set user context for all events
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```typescript
|
|
196
|
+
* Kodo.setUser({ id: 'user123', email: 'user@example.com' });
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
function setUser(user) {
|
|
200
|
+
_user = user;
|
|
201
|
+
_log('User set', user);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Add a breadcrumb manually
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```typescript
|
|
208
|
+
* Kodo.addBreadcrumb({
|
|
209
|
+
* category: 'user',
|
|
210
|
+
* message: 'Clicked checkout button',
|
|
211
|
+
* level: 'info',
|
|
212
|
+
* });
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
function addBreadcrumb(breadcrumb) {
|
|
216
|
+
if (!_config?.enableBreadcrumbs)
|
|
217
|
+
return;
|
|
218
|
+
const bc = {
|
|
219
|
+
...breadcrumb,
|
|
220
|
+
timestamp: Date.now(),
|
|
221
|
+
};
|
|
222
|
+
_breadcrumbs.push(bc);
|
|
223
|
+
if (_breadcrumbs.length > (_config?.maxBreadcrumbs || 100)) {
|
|
224
|
+
_breadcrumbs = _breadcrumbs.slice(-(_config?.maxBreadcrumbs || 100));
|
|
225
|
+
}
|
|
226
|
+
_log('Breadcrumb added', bc);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Capture an exception manually
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```typescript
|
|
233
|
+
* try {
|
|
234
|
+
* riskyOperation();
|
|
235
|
+
* } catch (error) {
|
|
236
|
+
* Kodo.captureException(error);
|
|
237
|
+
* }
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
function captureException(error, context) {
|
|
241
|
+
if (!_config || !_sessionId) {
|
|
242
|
+
console.warn('[Kodo] Not initialized');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const event = createErrorEvent(error, context);
|
|
246
|
+
queueEvent(event);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Send a structured log message
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* ```typescript
|
|
253
|
+
* Kodo.log('info', 'User signed in', {
|
|
254
|
+
* logger: 'auth',
|
|
255
|
+
* context: { userId: '123', method: 'oauth' }
|
|
256
|
+
* });
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
function log(level, message, options) {
|
|
260
|
+
if (!_config || !_sessionId)
|
|
261
|
+
return;
|
|
262
|
+
const event = {
|
|
263
|
+
type: 'log',
|
|
264
|
+
timestamp: Date.now(),
|
|
265
|
+
session_id: _sessionId,
|
|
266
|
+
url: window.location.href,
|
|
267
|
+
user_agent: navigator.userAgent,
|
|
268
|
+
service: _config.service || null,
|
|
269
|
+
release: _config.release || null,
|
|
270
|
+
environment: _config.environment || detectEnvironment(),
|
|
271
|
+
data: {
|
|
272
|
+
level,
|
|
273
|
+
message,
|
|
274
|
+
logger: options?.logger,
|
|
275
|
+
context: options?.context,
|
|
276
|
+
stack: options?.stack,
|
|
277
|
+
trace_id: options?.traceId,
|
|
278
|
+
span_id: options?.spanId,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
queueEvent(event);
|
|
282
|
+
}
|
|
283
|
+
// Convenience log methods
|
|
284
|
+
const debug = (message, options) => log('debug', message, options);
|
|
285
|
+
const info = (message, options) => log('info', message, options);
|
|
286
|
+
const warn = (message, options) => log('warn', message, options);
|
|
287
|
+
const error = (message, options) => log('error', message, options);
|
|
288
|
+
const fatal = (message, options) => log('fatal', message, options);
|
|
289
|
+
/**
|
|
290
|
+
* Start a distributed trace
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```typescript
|
|
294
|
+
* const trace = Kodo.startTrace('checkout-flow', { op: 'user.action' });
|
|
295
|
+
*
|
|
296
|
+
* const fetchSpan = trace.startSpan('fetch-cart', { kind: 'client' });
|
|
297
|
+
* await fetchCart();
|
|
298
|
+
* fetchSpan.finish();
|
|
299
|
+
*
|
|
300
|
+
* trace.finish();
|
|
301
|
+
* ```
|
|
302
|
+
*/
|
|
303
|
+
function startTrace(name, options) {
|
|
304
|
+
return new Trace(name, options);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Wrap an async function with tracing
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```typescript
|
|
311
|
+
* const result = await Kodo.trace('api-call', async (span) => {
|
|
312
|
+
* span.setAttributes({ endpoint: '/api/orders' });
|
|
313
|
+
* const response = await fetch('/api/orders');
|
|
314
|
+
* span.setAttributes({ status: response.status });
|
|
315
|
+
* return response.json();
|
|
316
|
+
* });
|
|
317
|
+
* ```
|
|
318
|
+
*/
|
|
319
|
+
async function trace(name, fn, options) {
|
|
320
|
+
const traceObj = startTrace(name, { op: options?.op || 'function' });
|
|
321
|
+
const span = traceObj.startSpan(name, { kind: 'internal' });
|
|
322
|
+
try {
|
|
323
|
+
const result = await fn(span);
|
|
324
|
+
span.finish({ status: 'ok' });
|
|
325
|
+
traceObj.finish();
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
span.setError();
|
|
330
|
+
span.finish({ status: 'error' });
|
|
331
|
+
traceObj.setError();
|
|
332
|
+
traceObj.finish();
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Flush all pending events immediately
|
|
338
|
+
*/
|
|
339
|
+
async function flush() {
|
|
340
|
+
if (_eventQueue.length === 0)
|
|
341
|
+
return;
|
|
342
|
+
const events = [..._eventQueue];
|
|
343
|
+
_eventQueue = [];
|
|
344
|
+
await sendEvents(events);
|
|
345
|
+
}
|
|
346
|
+
// =============================================================================
|
|
347
|
+
// Internal functions
|
|
348
|
+
// =============================================================================
|
|
349
|
+
function generateSessionId() {
|
|
350
|
+
return 'sess_' + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
351
|
+
const r = (Math.random() * 16) | 0;
|
|
352
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
353
|
+
return v.toString(16);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
function generateTraceId() {
|
|
357
|
+
return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/x/g, () => {
|
|
358
|
+
return Math.floor(Math.random() * 16).toString(16);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function generateSpanId() {
|
|
362
|
+
return 'xxxxxxxxxxxxxxxx'.replace(/x/g, () => {
|
|
363
|
+
return Math.floor(Math.random() * 16).toString(16);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
function detectEnvironment() {
|
|
367
|
+
const hostname = window.location.hostname;
|
|
368
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
369
|
+
return 'development';
|
|
370
|
+
}
|
|
371
|
+
if (hostname.includes('staging') || hostname.includes('preview') || hostname.includes('dev.')) {
|
|
372
|
+
return 'staging';
|
|
373
|
+
}
|
|
374
|
+
return 'production';
|
|
375
|
+
}
|
|
376
|
+
function _log(...args) {
|
|
377
|
+
if (_config?.debug) {
|
|
378
|
+
console.log('[Kodo]', ...args);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function createErrorEvent(err, extraContext) {
|
|
382
|
+
const stack = err.stack || '';
|
|
383
|
+
const stackLines = stack.split('\n').slice(1);
|
|
384
|
+
let file;
|
|
385
|
+
let line;
|
|
386
|
+
let col;
|
|
387
|
+
const frameMatch = stackLines[0]?.match(/at\s+(?:\S+\s+)?\(?(.+):(\d+):(\d+)\)?/);
|
|
388
|
+
if (frameMatch) {
|
|
389
|
+
file = frameMatch[1];
|
|
390
|
+
line = parseInt(frameMatch[2], 10);
|
|
391
|
+
col = parseInt(frameMatch[3], 10);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
type: 'error',
|
|
395
|
+
timestamp: Date.now(),
|
|
396
|
+
session_id: _sessionId,
|
|
397
|
+
url: window.location.href,
|
|
398
|
+
user_agent: navigator.userAgent,
|
|
399
|
+
service: _config?.service || null,
|
|
400
|
+
release: _config?.release || null,
|
|
401
|
+
environment: _config?.environment || detectEnvironment(),
|
|
402
|
+
context: getBrowserContext(),
|
|
403
|
+
data: {
|
|
404
|
+
type: err.name,
|
|
405
|
+
message: err.message,
|
|
406
|
+
source: file,
|
|
407
|
+
lineno: line,
|
|
408
|
+
colno: col,
|
|
409
|
+
stack: stack.substring(0, 2000),
|
|
410
|
+
...extraContext,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function queueEvent(event) {
|
|
415
|
+
if (_config?.beforeSend) {
|
|
416
|
+
const modified = _config.beforeSend(event);
|
|
417
|
+
if (modified === null) {
|
|
418
|
+
_log('Event dropped by beforeSend');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
event = modified;
|
|
422
|
+
}
|
|
423
|
+
_eventQueue.push(event);
|
|
424
|
+
scheduleFlush();
|
|
425
|
+
}
|
|
426
|
+
function scheduleFlush() {
|
|
427
|
+
if (_flushTimer)
|
|
428
|
+
return;
|
|
429
|
+
_flushTimer = setTimeout(() => {
|
|
430
|
+
_flushTimer = null;
|
|
431
|
+
flush().catch(console.error);
|
|
432
|
+
}, 2000);
|
|
433
|
+
}
|
|
434
|
+
async function sendEvents(events) {
|
|
435
|
+
if (!_config || events.length === 0)
|
|
436
|
+
return;
|
|
437
|
+
const payload = {
|
|
438
|
+
key: _config.dsn,
|
|
439
|
+
events,
|
|
440
|
+
breadcrumbs: _breadcrumbs.slice(-50),
|
|
441
|
+
user: _user,
|
|
442
|
+
};
|
|
443
|
+
const endpoint = _config.tunnel
|
|
444
|
+
? `${window.location.origin}${_config.tunnel}`
|
|
445
|
+
: `${_config.baseUrl}/api/beacon`;
|
|
446
|
+
try {
|
|
447
|
+
if (document.visibilityState === 'hidden' && navigator.sendBeacon) {
|
|
448
|
+
navigator.sendBeacon(endpoint, JSON.stringify(payload));
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
await fetch(endpoint, {
|
|
452
|
+
method: 'POST',
|
|
453
|
+
headers: { 'Content-Type': 'application/json' },
|
|
454
|
+
body: JSON.stringify(payload),
|
|
455
|
+
keepalive: true,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
_log('Events sent', { count: events.length });
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
console.error('[Kodo] Failed to send events:', err);
|
|
462
|
+
_eventQueue.unshift(...events);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function setupGlobalHandlers() {
|
|
466
|
+
window.addEventListener('error', (event) => {
|
|
467
|
+
if (event.error) {
|
|
468
|
+
captureException(event.error);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
const err = new Error(event.message);
|
|
472
|
+
captureException(err, {
|
|
473
|
+
source: event.filename,
|
|
474
|
+
lineno: event.lineno,
|
|
475
|
+
colno: event.colno,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
480
|
+
const err = event.reason instanceof Error
|
|
481
|
+
? event.reason
|
|
482
|
+
: new Error(String(event.reason));
|
|
483
|
+
captureException(err, { type: 'unhandled_promise' });
|
|
484
|
+
});
|
|
485
|
+
document.addEventListener('visibilitychange', () => {
|
|
486
|
+
if (document.visibilityState === 'hidden') {
|
|
487
|
+
flush();
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
window.addEventListener('beforeunload', () => {
|
|
491
|
+
flush();
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
function setupBreadcrumbCapture() {
|
|
495
|
+
// Console capture
|
|
496
|
+
const originalConsole = {
|
|
497
|
+
log: console.log,
|
|
498
|
+
info: console.info,
|
|
499
|
+
warn: console.warn,
|
|
500
|
+
error: console.error,
|
|
501
|
+
};
|
|
502
|
+
['log', 'info', 'warn', 'error'].forEach((level) => {
|
|
503
|
+
console[level] = (...args) => {
|
|
504
|
+
addBreadcrumb({
|
|
505
|
+
category: 'console',
|
|
506
|
+
message: args.map(String).join(' ').substring(0, 500),
|
|
507
|
+
level: level === 'log' ? 'info' : level === 'warn' ? 'warning' : level,
|
|
508
|
+
});
|
|
509
|
+
originalConsole[level](...args);
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
// Click capture
|
|
513
|
+
document.addEventListener('click', (event) => {
|
|
514
|
+
const target = event.target;
|
|
515
|
+
const selector = getElementSelector(target);
|
|
516
|
+
addBreadcrumb({
|
|
517
|
+
category: 'click',
|
|
518
|
+
message: `Click on ${selector}`,
|
|
519
|
+
data: {
|
|
520
|
+
selector,
|
|
521
|
+
text: target.textContent?.substring(0, 100),
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
}, true);
|
|
525
|
+
// Navigation capture
|
|
526
|
+
const originalPushState = history.pushState;
|
|
527
|
+
history.pushState = function (...args) {
|
|
528
|
+
addBreadcrumb({
|
|
529
|
+
category: 'navigation',
|
|
530
|
+
message: `Navigate to ${args[2]}`,
|
|
531
|
+
data: { from: window.location.href, to: String(args[2]) },
|
|
532
|
+
});
|
|
533
|
+
return originalPushState.apply(this, args);
|
|
534
|
+
};
|
|
535
|
+
window.addEventListener('popstate', () => {
|
|
536
|
+
addBreadcrumb({
|
|
537
|
+
category: 'navigation',
|
|
538
|
+
message: `Navigate to ${window.location.href}`,
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
// Fetch capture
|
|
542
|
+
const originalFetch = window.fetch;
|
|
543
|
+
window.fetch = async function (input, init) {
|
|
544
|
+
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
|
545
|
+
const method = init?.method || 'GET';
|
|
546
|
+
try {
|
|
547
|
+
const response = await originalFetch.apply(this, [input, init]);
|
|
548
|
+
addBreadcrumb({
|
|
549
|
+
category: 'fetch',
|
|
550
|
+
message: `${method} ${url}`,
|
|
551
|
+
level: response.ok ? 'info' : 'error',
|
|
552
|
+
data: { method, url, status: response.status },
|
|
553
|
+
});
|
|
554
|
+
return response;
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
addBreadcrumb({
|
|
558
|
+
category: 'fetch',
|
|
559
|
+
message: `${method} ${url}`,
|
|
560
|
+
level: 'error',
|
|
561
|
+
data: { method, url, error: String(err) },
|
|
562
|
+
});
|
|
563
|
+
throw err;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
// XHR capture
|
|
567
|
+
const originalXHROpen = XMLHttpRequest.prototype.open;
|
|
568
|
+
const originalXHRSend = XMLHttpRequest.prototype.send;
|
|
569
|
+
XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
|
|
570
|
+
this._kodo = {
|
|
571
|
+
method,
|
|
572
|
+
url: String(url),
|
|
573
|
+
};
|
|
574
|
+
return originalXHROpen.call(this, method, url, async ?? true, username, password);
|
|
575
|
+
};
|
|
576
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
577
|
+
const xhr = this;
|
|
578
|
+
const kodoData = xhr._kodo;
|
|
579
|
+
xhr.addEventListener('loadend', () => {
|
|
580
|
+
if (kodoData) {
|
|
581
|
+
addBreadcrumb({
|
|
582
|
+
category: 'xhr',
|
|
583
|
+
message: `${kodoData.method} ${kodoData.url}`,
|
|
584
|
+
level: xhr.status >= 400 ? 'error' : 'info',
|
|
585
|
+
data: { method: kodoData.method, url: kodoData.url, status: xhr.status },
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
return originalXHRSend.call(this, body);
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function getElementSelector(element) {
|
|
593
|
+
if (element.id)
|
|
594
|
+
return `#${element.id}`;
|
|
595
|
+
if (element.className && typeof element.className === 'string') {
|
|
596
|
+
const classes = element.className.split(' ').filter(Boolean).slice(0, 2).join('.');
|
|
597
|
+
if (classes)
|
|
598
|
+
return `${element.tagName.toLowerCase()}.${classes}`;
|
|
599
|
+
}
|
|
600
|
+
return element.tagName.toLowerCase();
|
|
601
|
+
}
|
|
602
|
+
function getBrowserContext() {
|
|
603
|
+
const nav = navigator;
|
|
604
|
+
const perf = performance;
|
|
605
|
+
const context = {
|
|
606
|
+
viewport: {
|
|
607
|
+
width: window.innerWidth,
|
|
608
|
+
height: window.innerHeight,
|
|
609
|
+
},
|
|
610
|
+
devicePixelRatio: window.devicePixelRatio || 1,
|
|
611
|
+
language: navigator.language,
|
|
612
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
613
|
+
};
|
|
614
|
+
// Network Information API (Chrome/Edge/Opera)
|
|
615
|
+
if (nav.connection) {
|
|
616
|
+
context.connection = {
|
|
617
|
+
effectiveType: nav.connection.effectiveType || 'unknown',
|
|
618
|
+
downlink: nav.connection.downlink,
|
|
619
|
+
rtt: nav.connection.rtt,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
// Memory API (Chrome only)
|
|
623
|
+
if (perf.memory) {
|
|
624
|
+
context.memory = {
|
|
625
|
+
jsHeapSizeLimit: perf.memory.jsHeapSizeLimit,
|
|
626
|
+
totalJSHeapSize: perf.memory.totalJSHeapSize,
|
|
627
|
+
usedJSHeapSize: perf.memory.usedJSHeapSize,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return context;
|
|
631
|
+
}
|
|
632
|
+
function captureWebVitals() {
|
|
633
|
+
if (!('PerformanceObserver' in window))
|
|
634
|
+
return;
|
|
635
|
+
// LCP with element metadata
|
|
636
|
+
try {
|
|
637
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
638
|
+
const entries = list.getEntries();
|
|
639
|
+
const lastEntry = entries[entries.length - 1];
|
|
640
|
+
if (lastEntry) {
|
|
641
|
+
const metadata = {};
|
|
642
|
+
// Capture LCP element selector
|
|
643
|
+
if (lastEntry.element) {
|
|
644
|
+
metadata.element = getElementSelector(lastEntry.element);
|
|
645
|
+
metadata.elementTag = lastEntry.element.tagName.toLowerCase();
|
|
646
|
+
}
|
|
647
|
+
// Capture resource URL (for images/videos)
|
|
648
|
+
if (lastEntry.url) {
|
|
649
|
+
metadata.url = lastEntry.url;
|
|
650
|
+
}
|
|
651
|
+
// Capture element size
|
|
652
|
+
if (lastEntry.size) {
|
|
653
|
+
metadata.size = lastEntry.size;
|
|
654
|
+
}
|
|
655
|
+
sendVital('LCP', lastEntry.startTime, metadata);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
659
|
+
}
|
|
660
|
+
catch { /* not supported */ }
|
|
661
|
+
// FID with event type metadata
|
|
662
|
+
try {
|
|
663
|
+
const fidObserver = new PerformanceObserver((list) => {
|
|
664
|
+
const entries = list.getEntries();
|
|
665
|
+
const firstEntry = entries[0];
|
|
666
|
+
if (firstEntry) {
|
|
667
|
+
const delay = firstEntry.processingStart - firstEntry.startTime;
|
|
668
|
+
const metadata = {
|
|
669
|
+
eventType: firstEntry.name, // 'click', 'keydown', etc.
|
|
670
|
+
};
|
|
671
|
+
if (firstEntry.processingEnd) {
|
|
672
|
+
metadata.processingTime = firstEntry.processingEnd - firstEntry.processingStart;
|
|
673
|
+
}
|
|
674
|
+
sendVital('FID', delay, metadata);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
fidObserver.observe({ type: 'first-input', buffered: true });
|
|
678
|
+
}
|
|
679
|
+
catch { /* not supported */ }
|
|
680
|
+
// CLS with shift source tracking
|
|
681
|
+
try {
|
|
682
|
+
let clsValue = 0;
|
|
683
|
+
let largestShift = 0;
|
|
684
|
+
let largestShiftSources = [];
|
|
685
|
+
const clsObserver = new PerformanceObserver((list) => {
|
|
686
|
+
for (const entry of list.getEntries()) {
|
|
687
|
+
if (!entry.hadRecentInput) {
|
|
688
|
+
clsValue += entry.value;
|
|
689
|
+
// Track largest shift for debugging
|
|
690
|
+
if (entry.value > largestShift) {
|
|
691
|
+
largestShift = entry.value;
|
|
692
|
+
largestShiftSources = (entry.sources || [])
|
|
693
|
+
.filter(s => s.node)
|
|
694
|
+
.slice(0, 3)
|
|
695
|
+
.map(s => ({
|
|
696
|
+
element: getElementSelector(s.node),
|
|
697
|
+
score: entry.value,
|
|
698
|
+
}));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
clsObserver.observe({ type: 'layout-shift', buffered: true });
|
|
704
|
+
document.addEventListener('visibilitychange', () => {
|
|
705
|
+
if (document.visibilityState === 'hidden' && clsValue > 0) {
|
|
706
|
+
sendVital('CLS', clsValue, {
|
|
707
|
+
largestShift,
|
|
708
|
+
sources: largestShiftSources,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
catch { /* not supported */ }
|
|
714
|
+
}
|
|
715
|
+
function sendVital(metric, value, metadata) {
|
|
716
|
+
if (!_config || !_sessionId)
|
|
717
|
+
return;
|
|
718
|
+
const rating = getVitalRating(metric, value);
|
|
719
|
+
const event = {
|
|
720
|
+
type: 'vital',
|
|
721
|
+
timestamp: Date.now(),
|
|
722
|
+
session_id: _sessionId,
|
|
723
|
+
url: window.location.href,
|
|
724
|
+
user_agent: navigator.userAgent,
|
|
725
|
+
service: _config.service || null,
|
|
726
|
+
release: _config.release || null,
|
|
727
|
+
environment: _config.environment || detectEnvironment(),
|
|
728
|
+
data: { metric, value, rating, ...metadata },
|
|
729
|
+
};
|
|
730
|
+
queueEvent(event);
|
|
731
|
+
}
|
|
732
|
+
function getVitalRating(metric, value) {
|
|
733
|
+
switch (metric) {
|
|
734
|
+
case 'LCP':
|
|
735
|
+
return value <= 2500 ? 'good' : value <= 4000 ? 'needs_improvement' : 'poor';
|
|
736
|
+
case 'FID':
|
|
737
|
+
return value <= 100 ? 'good' : value <= 300 ? 'needs_improvement' : 'poor';
|
|
738
|
+
case 'CLS':
|
|
739
|
+
return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs_improvement' : 'poor';
|
|
740
|
+
default:
|
|
741
|
+
return 'good';
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// =============================================================================
|
|
745
|
+
// Export
|
|
746
|
+
// =============================================================================
|
|
747
|
+
const Kodo = {
|
|
748
|
+
init,
|
|
749
|
+
setUser,
|
|
750
|
+
addBreadcrumb,
|
|
751
|
+
captureException,
|
|
752
|
+
flush,
|
|
753
|
+
// Logging
|
|
754
|
+
log,
|
|
755
|
+
debug,
|
|
756
|
+
info,
|
|
757
|
+
warn,
|
|
758
|
+
error,
|
|
759
|
+
fatal,
|
|
760
|
+
// Tracing
|
|
761
|
+
startTrace,
|
|
762
|
+
trace,
|
|
763
|
+
};
|
|
764
|
+
export default Kodo;
|
|
765
|
+
export { init, setUser, addBreadcrumb, captureException, flush, log, debug, info, warn, error, fatal, startTrace, trace };
|
|
766
|
+
//# sourceMappingURL=browser.js.map
|