@overcastsre/browser 1.0.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.
Files changed (3) hide show
  1. package/README.md +36 -0
  2. package/index.js +845 -0
  3. package/package.json +13 -0
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @overcast/browser
2
+
3
+ Overcast SRE monitoring SDK for browsers. One line — captures everything client-side.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @overcast/browser
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```js
14
+ import Overcast from '@overcast/browser';
15
+ Overcast.init({ apiKey: 'oc_...', serviceName: 'my-app' });
16
+ ```
17
+
18
+ Or via CDN:
19
+
20
+ ```html
21
+ <script src="https://cdn.overcastsre.com/sdk/browser/1.0.0/overcast.min.js"></script>
22
+ <script>Overcast.init({ apiKey: 'oc_...', serviceName: 'my-app' });</script>
23
+ ```
24
+
25
+ Automatically captures:
26
+
27
+ - All console output (log, info, debug, warn, error)
28
+ - JavaScript errors and unhandled promise rejections
29
+ - Resource loading failures (images, scripts, CSS)
30
+ - All fetch() and XMLHttpRequest calls
31
+ - Web Vitals (LCP, FID, CLS, INP)
32
+ - Slow page loads and high memory
33
+ - Rage clicks and dead clicks
34
+ - SPA navigation (pushState, popstate, hashchange)
35
+ - CSP violations
36
+ - Online/offline connectivity changes
package/index.js ADDED
@@ -0,0 +1,845 @@
1
+ /**
2
+ * @overcast/browser — Overcast SRE Monitoring SDK for Browsers
3
+ *
4
+ * Captures EVERYTHING from the client side:
5
+ * - All console output (log, info, debug, warn, error)
6
+ * - JavaScript errors (uncaught exceptions, promise rejections)
7
+ * - Resource loading failures (images, scripts, stylesheets)
8
+ * - All fetch() and XMLHttpRequest calls (status, latency, bodies)
9
+ * - Web Vitals (LCP, FID, CLS, TTFB, INP)
10
+ * - Performance (slow page loads, high memory)
11
+ * - UX issues (rage clicks, dead clicks)
12
+ * - Navigation / SPA route changes
13
+ * - Content Security Policy violations
14
+ *
15
+ * Usage — two lines:
16
+ * import Overcast from '@overcast/browser';
17
+ * Overcast.init({ apiKey: 'oc_...', serviceName: 'my-app' });
18
+ *
19
+ * @module @overcast/browser
20
+ */
21
+
22
+ (function (root, factory) {
23
+ if (typeof module !== 'undefined' && module.exports) {
24
+ module.exports = factory();
25
+ } else if (typeof define === 'function' && define.amd) {
26
+ define(factory);
27
+ } else {
28
+ root.Overcast = factory();
29
+ }
30
+ })(typeof window !== 'undefined' ? window : typeof globalThis !== 'undefined' ? globalThis : this, function () {
31
+ 'use strict';
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // STATE
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ var _config = {
38
+ apiKey: '',
39
+ serviceName: 'web-app',
40
+ environment: 'production',
41
+ baseUrl: 'https://platform.overcastsre.com',
42
+ version: '',
43
+
44
+ captureConsole: true,
45
+ captureErrors: true,
46
+ captureRejections: true,
47
+ captureResources: true,
48
+ captureNetwork: true,
49
+ capturePerformance: true,
50
+ captureWebVitals: true,
51
+ captureUX: true,
52
+ captureCSP: true,
53
+ captureNavigation: true,
54
+
55
+ slowPageLoadThreshold: 5000,
56
+ slowApiThreshold: 5000,
57
+ clickSamplingRate: 0.1,
58
+ logSamplingRate: 1.0,
59
+
60
+ batchSize: 50,
61
+ flushInterval: 5000,
62
+ maxBufferSize: 500,
63
+
64
+ encryptPayload: false,
65
+ encryptionKey: '',
66
+ sanitize: true,
67
+
68
+ debug: false,
69
+ };
70
+
71
+ var _initialized = false;
72
+ var _sending = false;
73
+ var _buffer = [];
74
+ var _flushTimer = null;
75
+ var _originals = {};
76
+ var INGEST_ENDPOINT = '/api/v1/ingest/logs';
77
+
78
+ // ═══════════════════════════════════════════════════════════════════════════
79
+ // PUBLIC API
80
+ // ═══════════════════════════════════════════════════════════════════════════
81
+
82
+ function init(options) {
83
+ if (_initialized) return Overcast;
84
+ if (typeof window === 'undefined') return Overcast;
85
+
86
+ if (!options || !options.apiKey) {
87
+ console.error('[Overcast] apiKey is required. Get yours at https://overcastsre.com/settings');
88
+ return Overcast;
89
+ }
90
+
91
+ _assign(_config, options);
92
+ _initialized = true;
93
+
94
+ if (window.__OVERCAST_INSTALLED__) return Overcast;
95
+ window.__OVERCAST_INSTALLED__ = true;
96
+
97
+ if (_config.captureConsole) _installConsoleCapture();
98
+ if (_config.captureErrors) _installErrorCapture();
99
+ if (_config.captureRejections) _installRejectionCapture();
100
+ if (_config.captureResources) _installResourceCapture();
101
+ if (_config.captureNetwork) _installNetworkCapture();
102
+ if (_config.capturePerformance) _installPerformanceCapture();
103
+ if (_config.captureWebVitals) _installWebVitals();
104
+ if (_config.captureUX) _installUXCapture();
105
+ if (_config.captureCSP) _installCSPCapture();
106
+ if (_config.captureNavigation) _installNavigationCapture();
107
+
108
+ // Flush before page unload
109
+ window.addEventListener('beforeunload', function () { _flush(); });
110
+ document.addEventListener('visibilitychange', function () {
111
+ if (document.visibilityState === 'hidden') _flush();
112
+ });
113
+
114
+ // Start flush timer
115
+ _flushTimer = setInterval(_flush, _config.flushInterval);
116
+
117
+ _enqueue('INFO', 'Overcast SDK initialized', {
118
+ type: 'sdk_init',
119
+ sdkVersion: '1.0.0',
120
+ userAgent: navigator.userAgent,
121
+ url: location.href,
122
+ screenWidth: screen.width,
123
+ screenHeight: screen.height,
124
+ language: navigator.language,
125
+ online: navigator.onLine,
126
+ });
127
+
128
+ return Overcast;
129
+ }
130
+
131
+ // ═══════════════════════════════════════════════════════════════════════════
132
+ // CONSOLE CAPTURE
133
+ // ═══════════════════════════════════════════════════════════════════════════
134
+
135
+ function _installConsoleCapture() {
136
+ var methods = ['log', 'info', 'debug', 'warn', 'error', 'trace'];
137
+ var levelMap = { log: 'INFO', info: 'INFO', debug: 'DEBUG', warn: 'WARNING', error: 'ERROR', trace: 'DEBUG' };
138
+
139
+ methods.forEach(function (method) {
140
+ _originals['console_' + method] = console[method];
141
+ console[method] = function () {
142
+ var args = Array.prototype.slice.call(arguments);
143
+ _originals['console_' + method].apply(console, args);
144
+
145
+ if (_sending) return;
146
+ if (Math.random() > _config.logSamplingRate) return;
147
+
148
+ var parsed = _formatArgs(args);
149
+ var meta = { type: 'console_' + method };
150
+ if (parsed.errorObj) {
151
+ meta.error = { name: parsed.errorObj.name, message: parsed.errorObj.message, stack: parsed.errorObj.stack };
152
+ }
153
+ _enqueue(levelMap[method] || 'INFO', parsed.message, meta);
154
+ };
155
+ });
156
+ }
157
+
158
+ // ═══════════════════════════════════════════════════════════════════════════
159
+ // ERROR CAPTURE — uncaught JS errors
160
+ // ═══════════════════════════════════════════════════════════════════════════
161
+
162
+ function _installErrorCapture() {
163
+ window.addEventListener('error', function (event) {
164
+ if (event.target !== window) return; // Resource errors handled separately
165
+ _enqueue('ERROR', event.message || 'Unknown JS error', {
166
+ type: 'javascript_error',
167
+ error: event.error ? { name: event.error.name, message: event.error.message, stack: event.error.stack } : null,
168
+ filename: event.filename,
169
+ lineno: event.lineno,
170
+ colno: event.colno,
171
+ url: location.href,
172
+ });
173
+ }, true);
174
+ }
175
+
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+ // PROMISE REJECTION CAPTURE
178
+ // ═══════════════════════════════════════════════════════════════════════════
179
+
180
+ function _installRejectionCapture() {
181
+ window.addEventListener('unhandledrejection', function (event) {
182
+ var err = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
183
+ _enqueue('ERROR', 'Unhandled Promise Rejection: ' + err.message, {
184
+ type: 'unhandled_rejection',
185
+ error: { name: err.name, message: err.message, stack: err.stack },
186
+ url: location.href,
187
+ });
188
+ });
189
+ }
190
+
191
+ // ═══════════════════════════════════════════════════════════════════════════
192
+ // RESOURCE FAILURE CAPTURE — images, scripts, CSS that fail to load
193
+ // ═══════════════════════════════════════════════════════════════════════════
194
+
195
+ function _installResourceCapture() {
196
+ window.addEventListener('error', function (event) {
197
+ if (event.target === window) return; // JS errors handled above
198
+ var tag = event.target.tagName || '';
199
+ var src = event.target.src || event.target.href || '';
200
+ _enqueue('WARNING', 'Failed to load ' + tag + ': ' + src, {
201
+ type: 'resource_failure',
202
+ resourceType: tag,
203
+ resourceUrl: src,
204
+ url: location.href,
205
+ });
206
+ }, true);
207
+ }
208
+
209
+ // ═══════════════════════════════════════════════════════════════════════════
210
+ // NETWORK CAPTURE — wraps fetch() and XMLHttpRequest
211
+ // ═══════════════════════════════════════════════════════════════════════════
212
+
213
+ function _installNetworkCapture() {
214
+ // ── Wrap fetch ──
215
+ if (typeof window.fetch === 'function') {
216
+ var origFetch = window.fetch;
217
+ window.fetch = function () {
218
+ var args = arguments;
219
+ var requestUrl = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url ? args[0].url : '');
220
+
221
+ // Skip Overcast's own calls
222
+ if (requestUrl.indexOf(_config.baseUrl) !== -1) return origFetch.apply(this, args);
223
+
224
+ var method = 'GET';
225
+ var requestBody = null;
226
+ try {
227
+ var opts = args[1] || {};
228
+ method = opts.method || 'GET';
229
+ if (opts.body) {
230
+ if (typeof opts.body === 'string') {
231
+ try { requestBody = JSON.parse(opts.body); } catch (e) { requestBody = opts.body.substring(0, 2000); }
232
+ } else if (opts.body instanceof FormData) {
233
+ requestBody = '[FormData]';
234
+ } else {
235
+ requestBody = '[Body]';
236
+ }
237
+ }
238
+ } catch (e) { /* ignore */ }
239
+
240
+ var startTime = Date.now();
241
+
242
+ return origFetch.apply(this, args).then(function (response) {
243
+ var duration = Date.now() - startTime;
244
+ var clone = response.clone();
245
+
246
+ // Read response body (async)
247
+ clone.text().then(function (text) {
248
+ var responseBody = text.substring(0, 2000);
249
+ try { responseBody = JSON.parse(text); } catch (e) { /* keep as string */ }
250
+
251
+ var meta = {
252
+ type: 'http_outgoing',
253
+ method: method,
254
+ url: requestUrl,
255
+ statusCode: response.status,
256
+ statusText: response.statusText,
257
+ duration: duration,
258
+ requestBody: requestBody,
259
+ responseBody: responseBody,
260
+ };
261
+
262
+ if (duration > _config.slowApiThreshold) {
263
+ _enqueue('WARNING', 'Slow request: ' + method + ' ' + requestUrl + ' (' + duration + 'ms)', _assign({}, meta, { type: 'slow_http' }));
264
+ }
265
+
266
+ if (response.status >= 500) {
267
+ _enqueue('ERROR', 'HTTP ' + response.status + ': ' + method + ' ' + requestUrl, meta);
268
+ } else if (response.status >= 400) {
269
+ _enqueue('WARNING', 'HTTP ' + response.status + ': ' + method + ' ' + requestUrl, meta);
270
+ } else {
271
+ _enqueue('DEBUG', 'HTTP ' + response.status + ': ' + method + ' ' + requestUrl + ' (' + duration + 'ms)', meta);
272
+ }
273
+ }).catch(function () { /* ignore body parse failures */ });
274
+
275
+ return response;
276
+ }).catch(function (err) {
277
+ var duration = Date.now() - startTime;
278
+ _enqueue('ERROR', 'Network error: ' + method + ' ' + requestUrl + ' — ' + err.message, {
279
+ type: 'http_network_error',
280
+ method: method,
281
+ url: requestUrl,
282
+ duration: duration,
283
+ error: { name: err.name, message: err.message, stack: err.stack },
284
+ });
285
+ throw err;
286
+ });
287
+ };
288
+ }
289
+
290
+ // ── Wrap XMLHttpRequest ──
291
+ if (typeof XMLHttpRequest !== 'undefined') {
292
+ var origOpen = XMLHttpRequest.prototype.open;
293
+ var origSend = XMLHttpRequest.prototype.send;
294
+
295
+ XMLHttpRequest.prototype.open = function (method, url) {
296
+ this._overcast = { method: method, url: url, startTime: 0 };
297
+ return origOpen.apply(this, arguments);
298
+ };
299
+
300
+ XMLHttpRequest.prototype.send = function (body) {
301
+ var xhr = this;
302
+ var meta = xhr._overcast || {};
303
+
304
+ // Skip Overcast's own calls
305
+ if (meta.url && meta.url.indexOf(_config.baseUrl) !== -1) return origSend.apply(this, arguments);
306
+
307
+ meta.startTime = Date.now();
308
+ meta.requestBody = body ? (typeof body === 'string' ? body.substring(0, 2000) : '[Body]') : null;
309
+
310
+ xhr.addEventListener('loadend', function () {
311
+ var duration = Date.now() - meta.startTime;
312
+ var responseBody = null;
313
+ try { responseBody = xhr.responseText ? xhr.responseText.substring(0, 2000) : null; } catch (e) { /* ignore */ }
314
+
315
+ var info = {
316
+ type: 'xhr_outgoing',
317
+ method: meta.method,
318
+ url: meta.url,
319
+ statusCode: xhr.status,
320
+ duration: duration,
321
+ requestBody: meta.requestBody,
322
+ responseBody: responseBody,
323
+ };
324
+
325
+ if (duration > _config.slowApiThreshold) {
326
+ _enqueue('WARNING', 'Slow XHR: ' + meta.method + ' ' + meta.url + ' (' + duration + 'ms)', _assign({}, info, { type: 'slow_xhr' }));
327
+ }
328
+
329
+ if (xhr.status >= 500) {
330
+ _enqueue('ERROR', 'XHR ' + xhr.status + ': ' + meta.method + ' ' + meta.url, info);
331
+ } else if (xhr.status >= 400) {
332
+ _enqueue('WARNING', 'XHR ' + xhr.status + ': ' + meta.method + ' ' + meta.url, info);
333
+ }
334
+ });
335
+
336
+ xhr.addEventListener('error', function () {
337
+ _enqueue('ERROR', 'XHR network error: ' + meta.method + ' ' + meta.url, {
338
+ type: 'xhr_network_error',
339
+ method: meta.method,
340
+ url: meta.url,
341
+ duration: Date.now() - meta.startTime,
342
+ });
343
+ });
344
+
345
+ xhr.addEventListener('timeout', function () {
346
+ _enqueue('ERROR', 'XHR timeout: ' + meta.method + ' ' + meta.url, {
347
+ type: 'xhr_timeout',
348
+ method: meta.method,
349
+ url: meta.url,
350
+ duration: Date.now() - meta.startTime,
351
+ });
352
+ });
353
+
354
+ return origSend.apply(this, arguments);
355
+ };
356
+ }
357
+ }
358
+
359
+ // ═══════════════════════════════════════════════════════════════════════════
360
+ // PERFORMANCE — page load, memory
361
+ // ═══════════════════════════════════════════════════════════════════════════
362
+
363
+ function _installPerformanceCapture() {
364
+ window.addEventListener('load', function () {
365
+ setTimeout(function () {
366
+ // Navigation timing
367
+ if (performance.getEntriesByType) {
368
+ var nav = performance.getEntriesByType('navigation')[0];
369
+ if (nav) {
370
+ var loadTime = Math.round(nav.loadEventEnd - nav.startTime);
371
+ if (loadTime > _config.slowPageLoadThreshold) {
372
+ _enqueue('WARNING', 'Slow page load: ' + loadTime + 'ms', {
373
+ type: 'slow_page_load',
374
+ loadTime: loadTime,
375
+ domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
376
+ ttfb: Math.round(nav.responseStart - nav.requestStart),
377
+ domInteractive: Math.round(nav.domInteractive - nav.startTime),
378
+ url: location.href,
379
+ });
380
+ } else {
381
+ _enqueue('INFO', 'Page loaded in ' + loadTime + 'ms', {
382
+ type: 'page_load',
383
+ loadTime: loadTime,
384
+ domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
385
+ ttfb: Math.round(nav.responseStart - nav.requestStart),
386
+ url: location.href,
387
+ });
388
+ }
389
+ }
390
+ }
391
+
392
+ // Memory (Chrome only)
393
+ if (performance.memory) {
394
+ var usedMB = (performance.memory.usedJSHeapSize / 1048576).toFixed(1);
395
+ var totalMB = (performance.memory.jsHeapSizeLimit / 1048576).toFixed(1);
396
+ var pct = ((performance.memory.usedJSHeapSize / performance.memory.jsHeapSizeLimit) * 100).toFixed(1);
397
+
398
+ if (+pct > 85) {
399
+ _enqueue('WARNING', 'High memory: ' + pct + '% (' + usedMB + 'MB)', {
400
+ type: 'performance_memory',
401
+ usedMB: +usedMB,
402
+ totalMB: +totalMB,
403
+ percent: +pct,
404
+ });
405
+ }
406
+ }
407
+ }, 100);
408
+ });
409
+ }
410
+
411
+ // ═══════════════════════════════════════════════════════════════════════════
412
+ // WEB VITALS — LCP, FID, CLS, INP
413
+ // ═══════════════════════════════════════════════════════════════════════════
414
+
415
+ function _installWebVitals() {
416
+ // Largest Contentful Paint (LCP)
417
+ if (typeof PerformanceObserver !== 'undefined') {
418
+ try {
419
+ new PerformanceObserver(function (list) {
420
+ var entries = list.getEntries();
421
+ var last = entries[entries.length - 1];
422
+ if (last) {
423
+ var lcp = Math.round(last.startTime);
424
+ _enqueue(lcp > 2500 ? 'WARNING' : 'INFO', 'LCP: ' + lcp + 'ms', {
425
+ type: 'web_vital_lcp',
426
+ value: lcp,
427
+ rating: lcp <= 2500 ? 'good' : lcp <= 4000 ? 'needs-improvement' : 'poor',
428
+ element: last.element ? last.element.tagName : null,
429
+ url: location.href,
430
+ });
431
+ }
432
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
433
+ } catch (e) { /* browser doesn't support */ }
434
+
435
+ // First Input Delay (FID) / Interaction to Next Paint (INP)
436
+ try {
437
+ new PerformanceObserver(function (list) {
438
+ var entries = list.getEntries();
439
+ entries.forEach(function (entry) {
440
+ var delay = Math.round(entry.processingStart - entry.startTime);
441
+ if (delay > 100) {
442
+ _enqueue('WARNING', 'Slow interaction: ' + delay + 'ms delay', {
443
+ type: 'web_vital_fid',
444
+ value: delay,
445
+ rating: delay <= 100 ? 'good' : delay <= 300 ? 'needs-improvement' : 'poor',
446
+ eventType: entry.name,
447
+ url: location.href,
448
+ });
449
+ }
450
+ });
451
+ }).observe({ type: 'first-input', buffered: true });
452
+ } catch (e) { /* ignore */ }
453
+
454
+ // Cumulative Layout Shift (CLS)
455
+ try {
456
+ var clsValue = 0;
457
+ new PerformanceObserver(function (list) {
458
+ list.getEntries().forEach(function (entry) {
459
+ if (!entry.hadRecentInput) {
460
+ clsValue += entry.value;
461
+ }
462
+ });
463
+
464
+ if (clsValue > 0.1) {
465
+ _enqueue('WARNING', 'High CLS: ' + clsValue.toFixed(3), {
466
+ type: 'web_vital_cls',
467
+ value: +clsValue.toFixed(3),
468
+ rating: clsValue <= 0.1 ? 'good' : clsValue <= 0.25 ? 'needs-improvement' : 'poor',
469
+ url: location.href,
470
+ });
471
+ }
472
+ }).observe({ type: 'layout-shift', buffered: true });
473
+ } catch (e) { /* ignore */ }
474
+ }
475
+ }
476
+
477
+ // ═══════════════════════════════════════════════════════════════════════════
478
+ // UX CAPTURE — rage clicks, dead clicks
479
+ // ═══════════════════════════════════════════════════════════════════════════
480
+
481
+ function _installUXCapture() {
482
+ var clickCounts = {};
483
+ var clickTimestamps = {};
484
+
485
+ document.addEventListener('click', function (event) {
486
+ if (Math.random() > _config.clickSamplingRate) return;
487
+
488
+ var target = event.target;
489
+ var xpath = _getXPath(target);
490
+ var now = Date.now();
491
+ var lastClick = clickTimestamps[xpath] || 0;
492
+
493
+ // Rage click: 3+ clicks in 1s on same element
494
+ if (now - lastClick < 1000) {
495
+ clickCounts[xpath] = (clickCounts[xpath] || 0) + 1;
496
+ if (clickCounts[xpath] >= 3) {
497
+ _enqueue('WARNING', 'Rage click on ' + (target.tagName || 'unknown'), {
498
+ type: 'ux_rage_click',
499
+ element: target.tagName,
500
+ id: target.id,
501
+ className: target.className,
502
+ xpath: xpath,
503
+ clickCount: clickCounts[xpath],
504
+ url: location.href,
505
+ });
506
+ clickCounts[xpath] = 0;
507
+ }
508
+ } else {
509
+ clickCounts[xpath] = 1;
510
+ }
511
+ clickTimestamps[xpath] = now;
512
+
513
+ // Dead click detection
514
+ var isInteractive = target.tagName === 'A' || target.tagName === 'BUTTON' ||
515
+ target.onclick !== null || target.getAttribute('role') === 'button' ||
516
+ target.closest('a, button, [role="button"]');
517
+
518
+ if (isInteractive) {
519
+ var url = location.href;
520
+ setTimeout(function () {
521
+ if (location.href === url && (!document.activeElement || document.activeElement === document.body)) {
522
+ _enqueue('INFO', 'Potential dead click on ' + target.tagName, {
523
+ type: 'ux_dead_click',
524
+ element: target.tagName,
525
+ id: target.id,
526
+ text: (target.textContent || '').substring(0, 50),
527
+ xpath: xpath,
528
+ url: location.href,
529
+ });
530
+ }
531
+ }, 500);
532
+ }
533
+ }, true);
534
+
535
+ // Track long tasks (> 50ms)
536
+ if (typeof PerformanceObserver !== 'undefined') {
537
+ try {
538
+ new PerformanceObserver(function (list) {
539
+ list.getEntries().forEach(function (entry) {
540
+ if (entry.duration > 100) {
541
+ _enqueue('WARNING', 'Long task: ' + Math.round(entry.duration) + 'ms', {
542
+ type: 'ux_long_task',
543
+ duration: Math.round(entry.duration),
544
+ url: location.href,
545
+ });
546
+ }
547
+ });
548
+ }).observe({ type: 'longtask', buffered: true });
549
+ } catch (e) { /* ignore */ }
550
+ }
551
+ }
552
+
553
+ // ═══════════════════════════════════════════════════════════════════════════
554
+ // CSP VIOLATION CAPTURE
555
+ // ═══════════════════════════════════════════════════════════════════════════
556
+
557
+ function _installCSPCapture() {
558
+ document.addEventListener('securitypolicyviolation', function (event) {
559
+ _enqueue('WARNING', 'CSP violation: ' + event.violatedDirective, {
560
+ type: 'csp_violation',
561
+ violatedDirective: event.violatedDirective,
562
+ blockedURI: event.blockedURI,
563
+ originalPolicy: event.originalPolicy,
564
+ sourceFile: event.sourceFile,
565
+ lineNumber: event.lineNumber,
566
+ url: location.href,
567
+ });
568
+ });
569
+ }
570
+
571
+ // ═══════════════════════════════════════════════════════════════════════════
572
+ // NAVIGATION CAPTURE — SPA route changes
573
+ // ═══════════════════════════════════════════════════════════════════════════
574
+
575
+ function _installNavigationCapture() {
576
+ // History API
577
+ var origPushState = history.pushState;
578
+ var origReplaceState = history.replaceState;
579
+
580
+ history.pushState = function () {
581
+ origPushState.apply(this, arguments);
582
+ _onNavigation('pushState');
583
+ };
584
+
585
+ history.replaceState = function () {
586
+ origReplaceState.apply(this, arguments);
587
+ _onNavigation('replaceState');
588
+ };
589
+
590
+ window.addEventListener('popstate', function () {
591
+ _onNavigation('popstate');
592
+ });
593
+
594
+ window.addEventListener('hashchange', function () {
595
+ _onNavigation('hashchange');
596
+ });
597
+
598
+ // Online/offline
599
+ window.addEventListener('online', function () {
600
+ _enqueue('INFO', 'Network came online', { type: 'connectivity_online' });
601
+ });
602
+ window.addEventListener('offline', function () {
603
+ _enqueue('WARNING', 'Network went offline', { type: 'connectivity_offline' });
604
+ });
605
+ }
606
+
607
+ function _onNavigation(trigger) {
608
+ _enqueue('INFO', 'Navigation: ' + location.href, {
609
+ type: 'navigation',
610
+ trigger: trigger,
611
+ url: location.href,
612
+ title: document.title,
613
+ });
614
+ }
615
+
616
+ // ═══════════════════════════════════════════════════════════════════════════
617
+ // DATA SANITIZATION
618
+ // ═══════════════════════════════════════════════════════════════════════════
619
+
620
+ function _sanitize(data) {
621
+ if (!_config.sanitize) return data;
622
+ if (data === null || data === undefined) return data;
623
+ if (typeof data === 'string') return _sanitizeString(data);
624
+ if (typeof data === 'number' || typeof data === 'boolean') return data;
625
+ if (data instanceof Error) return { name: data.name, message: _sanitizeString(data.message), stack: data.stack ? _sanitizeString(data.stack) : undefined };
626
+ if (Array.isArray(data)) return data.map(_sanitize);
627
+ if (typeof data === 'object') {
628
+ var out = {};
629
+ for (var key in data) {
630
+ if (!data.hasOwnProperty(key)) continue;
631
+ if (_isSensitiveKey(key.toLowerCase())) { out[key] = '[REDACTED]'; }
632
+ else { try { out[key] = _sanitize(data[key]); } catch (e) { out[key] = '[Unserializable]'; } }
633
+ }
634
+ return out;
635
+ }
636
+ return data;
637
+ }
638
+
639
+ function _isSensitiveKey(k) {
640
+ return /password|passwd|pwd|secret|token|auth|apikey|api_key|access_key|private_key|credential|credit|card|ssn|social|cookie|session_id/.test(k);
641
+ }
642
+
643
+ function _sanitizeString(str) {
644
+ if (typeof str !== 'string') return str;
645
+ return str
646
+ .replace(/\b(password|passwd|pwd|secret|pass)\s*[:=]\s*["']?([^\s"',;}\]]+)["']?/gi, '$1=[REDACTED]')
647
+ .replace(/\b(Bearer)\s+[A-Za-z0-9\-._~+\/]+=*/gi, '$1 [REDACTED]')
648
+ .replace(/\beyJ[A-Za-z0-9\-_=]+\.eyJ[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_.+\/=]*/g, '[REDACTED_JWT]')
649
+ .replace(/\b(sk_|pk_|api_|key_|token_)[A-Za-z0-9]{16,}/gi, '[REDACTED_KEY]')
650
+ .replace(/\b(AKIA|ASIA)[A-Z0-9]{16}/g, '[REDACTED_AWS]')
651
+ .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[REDACTED_EMAIL]')
652
+ .replace(/(\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g, '[REDACTED_PHONE]')
653
+ .replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, '[REDACTED_CC]')
654
+ .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED_SSN]')
655
+ .replace(/([?&])(session|sess|sid|token|auth|key|apikey)=([^&\s]+)/gi, '$1$2=[REDACTED]')
656
+ .replace(/(Authorization|X-Auth-Token|X-API-Key)\s*:\s*([^\s,;]+)/gi, '$1: [REDACTED]');
657
+ }
658
+
659
+ // ═══════════════════════════════════════════════════════════════════════════
660
+ // ENCRYPTION — AES-256-GCM (uses Web Crypto API)
661
+ // ═══════════════════════════════════════════════════════════════════════════
662
+
663
+ function _encrypt(plaintext) {
664
+ if (!_config.encryptPayload || !_config.encryptionKey || !window.crypto?.subtle) {
665
+ return Promise.resolve(plaintext);
666
+ }
667
+
668
+ var keyBytes = _hexToBytes(_config.encryptionKey);
669
+ var iv = window.crypto.getRandomValues(new Uint8Array(12));
670
+ var data = new TextEncoder().encode(plaintext);
671
+
672
+ return window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt'])
673
+ .then(function (key) {
674
+ return window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, data);
675
+ })
676
+ .then(function (encrypted) {
677
+ return JSON.stringify({
678
+ encrypted: true,
679
+ algorithm: 'aes-256-gcm',
680
+ iv: _bytesToBase64(iv),
681
+ data: _bytesToBase64(new Uint8Array(encrypted)),
682
+ });
683
+ })
684
+ .catch(function () { return plaintext; });
685
+ }
686
+
687
+ // ═══════════════════════════════════════════════════════════════════════════
688
+ // TRANSPORT
689
+ // ═══════════════════════════════════════════════════════════════════════════
690
+
691
+ function _enqueue(level, message, metadata) {
692
+ if (!_initialized || _sending) return;
693
+
694
+ var sanitizedMessage = _sanitize(message);
695
+ var sanitizedMeta = _sanitize(metadata || {});
696
+
697
+ var entry = {
698
+ timestamp: new Date().toISOString(),
699
+ level: level,
700
+ message: typeof sanitizedMessage === 'string' ? sanitizedMessage : JSON.stringify(sanitizedMessage),
701
+ service: _config.serviceName,
702
+ environment: _config.environment,
703
+ raw_log: typeof sanitizedMessage === 'string' ? sanitizedMessage : JSON.stringify(sanitizedMessage),
704
+ metadata: _assign({}, sanitizedMeta, {
705
+ sdkLanguage: 'browser',
706
+ sdkVersion: '1.0.0',
707
+ userAgent: navigator.userAgent,
708
+ url: location.href,
709
+ }),
710
+ };
711
+
712
+ if (sanitizedMeta.error && sanitizedMeta.error.stack) {
713
+ entry.message += '\n\nStack trace:\n' + sanitizedMeta.error.stack;
714
+ entry.raw_log = entry.message;
715
+ }
716
+
717
+ _buffer.push(entry);
718
+
719
+ if (_buffer.length > _config.maxBufferSize) {
720
+ _buffer = _buffer.slice(_buffer.length - _config.maxBufferSize);
721
+ }
722
+
723
+ if (level === 'ERROR' || _buffer.length >= _config.batchSize) {
724
+ _flush();
725
+ }
726
+ }
727
+
728
+ function _flush() {
729
+ if (_buffer.length === 0) return;
730
+
731
+ var logs = _buffer.splice(0, _config.batchSize);
732
+ var body = JSON.stringify({
733
+ api_key: _config.apiKey,
734
+ source_type: 'application',
735
+ source_description: _config.serviceName + ' (browser)',
736
+ logs: logs,
737
+ });
738
+
739
+ if (_config.encryptPayload) {
740
+ _encrypt(body).then(function (payload) { _send(payload); });
741
+ } else {
742
+ _send(body);
743
+ }
744
+ }
745
+
746
+ function _send(body) {
747
+ _sending = true;
748
+ var url = _config.baseUrl + INGEST_ENDPOINT;
749
+
750
+ // Use sendBeacon for unload, fetch otherwise
751
+ if (document.visibilityState === 'hidden' && navigator.sendBeacon) {
752
+ try { navigator.sendBeacon(url, body); } catch (e) { /* ignore */ }
753
+ _sending = false;
754
+ return;
755
+ }
756
+
757
+ // Use original fetch to avoid re-interception
758
+ var fetchFn = _originals._fetch || window.fetch;
759
+ fetchFn(url, {
760
+ method: 'POST',
761
+ headers: {
762
+ 'Content-Type': 'application/json',
763
+ 'X-Overcast-Key': _config.apiKey,
764
+ 'X-Overcast-Service': _config.serviceName,
765
+ 'X-Overcast-SDK': 'browser/1.0.0',
766
+ },
767
+ body: body,
768
+ keepalive: true,
769
+ }).catch(function () { /* silent fail */ }).finally(function () { _sending = false; });
770
+ }
771
+
772
+ // ═══════════════════════════════════════════════════════════════════════════
773
+ // HELPERS
774
+ // ═══════════════════════════════════════════════════════════════════════════
775
+
776
+ function _formatArgs(args) {
777
+ var errorObj = null;
778
+ var parts = [];
779
+ for (var i = 0; i < args.length; i++) {
780
+ if (args[i] instanceof Error) { if (!errorObj) errorObj = args[i]; parts.push(args[i].message); }
781
+ else if (typeof args[i] === 'object') { try { parts.push(JSON.stringify(args[i])); } catch (e) { parts.push('[Object]'); } }
782
+ else { parts.push(String(args[i])); }
783
+ }
784
+ return { message: parts.join(' '), errorObj: errorObj };
785
+ }
786
+
787
+ function _getXPath(el) {
788
+ if (!el) return '';
789
+ if (el.id) return '//*[@id="' + el.id + '"]';
790
+ if (el === document.body) return '/html/body';
791
+ var path = '';
792
+ while (el && el.nodeType === 1) {
793
+ var idx = 0, sib = el.previousSibling;
794
+ while (sib) { if (sib.nodeType === 1 && sib.nodeName === el.nodeName) idx++; sib = sib.previousSibling; }
795
+ path = '/' + el.nodeName.toLowerCase() + (idx ? '[' + (idx + 1) + ']' : '') + path;
796
+ el = el.parentNode;
797
+ }
798
+ return path;
799
+ }
800
+
801
+ function _assign(target) {
802
+ for (var i = 1; i < arguments.length; i++) {
803
+ var src = arguments[i];
804
+ if (src) { for (var k in src) { if (src.hasOwnProperty(k)) target[k] = src[k]; } }
805
+ }
806
+ return target;
807
+ }
808
+
809
+ function _hexToBytes(hex) {
810
+ var bytes = new Uint8Array(hex.length / 2);
811
+ for (var i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
812
+ return bytes;
813
+ }
814
+
815
+ function _bytesToBase64(bytes) {
816
+ var bin = '';
817
+ for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
818
+ return btoa(bin);
819
+ }
820
+
821
+ // ═══════════════════════════════════════════════════════════════════════════
822
+ // PUBLIC INTERFACE
823
+ // ═══════════════════════════════════════════════════════════════════════════
824
+
825
+ var Overcast = {
826
+ init: init,
827
+ error: function (msg, meta) { _enqueue('ERROR', msg, _assign({ type: 'manual' }, meta)); },
828
+ warn: function (msg, meta) { _enqueue('WARNING', msg, _assign({ type: 'manual' }, meta)); },
829
+ info: function (msg, meta) { _enqueue('INFO', msg, _assign({ type: 'manual' }, meta)); },
830
+ debug: function (msg, meta) { _enqueue('DEBUG', msg, _assign({ type: 'manual' }, meta)); },
831
+ captureException: function (err, meta) {
832
+ if (!(err instanceof Error)) err = new Error(String(err));
833
+ _enqueue('ERROR', err.message, _assign({ type: 'captured_exception', error: { name: err.name, message: err.message, stack: err.stack } }, meta));
834
+ },
835
+ flush: _flush,
836
+ getBufferSize: function () { return _buffer.length; },
837
+ };
838
+
839
+ // Save original fetch before we wrap it
840
+ if (typeof window !== 'undefined' && typeof window.fetch === 'function') {
841
+ _originals._fetch = window.fetch.bind(window);
842
+ }
843
+
844
+ return Overcast;
845
+ });
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@overcastsre/browser",
3
+ "version": "1.0.0",
4
+ "description": "Overcast SRE monitoring SDK for browsers — captures everything client-side.",
5
+ "main": "index.js",
6
+ "module": "index.mjs",
7
+ "types": "index.d.ts",
8
+ "license": "MIT",
9
+ "keywords": ["overcast", "monitoring", "error-tracking", "browser", "frontend", "observability"],
10
+ "files": ["index.js", "index.mjs", "index.d.ts", "README.md"],
11
+ "repository": { "type": "git", "url": "https://github.com/overcast/sdk-browser" },
12
+ "homepage": "https://overcastsre.com/docs/browser"
13
+ }