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.
@@ -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