/*! * otel-rum.js - minimal browser error/RUM collector for go-otel. * * Captures uncaught errors, unhandled promise rejections and failed * resource loads, and ships them as OTLP log records to this server's * existing /v1/logs endpoint (no separate ingestion path needed - the * backend's issue view picks these up like any other error log). * * Also exposes a `traceHeaders()` helper so outgoing `fetch`/XHR calls can * carry a W3C `traceparent` header. If the backend service is itself * OTel-instrumented and honors incoming trace context (most server * instrumentation does by default), spans it creates for that request will * share the same trace id - so a frontend error and the backend spans it * triggered can be found together via GET /api/traces/{traceId}. * * Usage: * * * * Explicitly out of scope for v1: source map resolution (stack traces stay * minified), session replay, PII scrubbing. Don't route sensitive data * through breadcrumbs/attributes. */ (function (global) { 'use strict'; function randomHex(len) { var chars = '0123456789abcdef'; var out = ''; for (var i = 0; i < len; i++) { out += chars[(Math.random() * 16) | 0]; } return out; } // W3C Trace Context ids: 16-byte trace id, 8-byte span id, both hex. function newTraceId() { return randomHex(32); } function newSpanId() { return randomHex(16); } function isSameOrigin(url) { try { var resolved = new global.URL(url, global.location.href); return resolved.origin === global.location.origin; } catch (e) { return false; } } function toOtlpAttributes(obj) { var out = []; for (var key in obj) { if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; var value = obj[key]; if (value === undefined || value === null || value === '') continue; out.push({ key: key, value: { stringValue: String(value) } }); } return out; } function init(config) { config = config || {}; var endpoint = config.endpoint || '/v1/logs'; var serviceName = config.serviceName || 'frontend'; var environment = config.environment || ''; var release = config.release || ''; var ingestKey = config.ingestKey || ''; var sampleRate = typeof config.sampleRate === 'number' ? config.sampleRate : 1; var maxBreadcrumbs = config.maxBreadcrumbs || 20; var captureConsoleErrors = config.captureConsoleErrors !== false; var autoInstrumentFetch = config.autoInstrumentFetch !== false; var injectTraceHeaders = config.injectTraceHeaders !== false; var traceHeaderOrigins = config.traceHeaderOrigins; // optional array; defaults to same-origin only var breadcrumbs = []; // Best-effort correlation: the most recently generated trace id is // attached to the next error report, so an error surfacing shortly // after a failed request can usually be found alongside it. This is // not a real per-async-context propagation mechanism. var lastTraceId = ''; function addBreadcrumb(category, message) { breadcrumbs.push({ t: Date.now(), category: String(category), message: String(message) }); if (breadcrumbs.length > maxBreadcrumbs) { breadcrumbs.shift(); } } // traceHeaders returns a headers object carrying a fresh (or // continued) W3C traceparent, ready to spread into any HTTP client's // request headers - fetch, XHR, axios, etc. function traceHeaders(existingTraceId) { var traceId = existingTraceId || newTraceId(); var spanId = newSpanId(); lastTraceId = traceId; return { traceparent: '00-' + traceId + '-' + spanId + '-01' }; } function send(severity, message, attrs, traceId) { if (sampleRate < 1 && Math.random() > sampleRate) return; var record = { timeUnixNano: String(Date.now() * 1e6), severityText: severity, severityNumber: severity === 'FATAL' ? 21 : 17, body: { stringValue: String(message).slice(0, 2000) }, attributes: toOtlpAttributes( Object.assign( { source: 'frontend', 'browser.url': global.location ? global.location.href : '', 'browser.userAgent': global.navigator ? global.navigator.userAgent : '', 'service.release': release, 'service.environment': environment, breadcrumbs: breadcrumbs.length ? JSON.stringify(breadcrumbs) : undefined }, attrs || {} ) ) }; if (traceId) { record.traceId = traceId; } var payload = { resourceLogs: [ { resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) }, scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: [record] }] } ] }; var body = JSON.stringify(payload); var url = endpoint; if (ingestKey) { url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey); } try { if (global.navigator && typeof global.navigator.sendBeacon === 'function') { var blob = new global.Blob([body], { type: 'application/json' }); if (global.navigator.sendBeacon(url, blob)) return; } } catch (e) { // fall through to fetch } if (typeof global.fetch === 'function') { global.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true }).catch(function () {}); } } function captureException(err, extra) { var message = (err && err.message) || String(err); send( 'ERROR', message, Object.assign( { 'exception.type': (err && err.name) || 'Error', 'exception.message': message, 'exception.stacktrace': (err && err.stack) || '' }, extra || {} ), lastTraceId ); } function captureMessage(message, severity, extra) { send(severity || 'ERROR', message, extra, lastTraceId); } function onError(event) { // Resource load errors (img/script/link) surface as an ErrorEvent-less // Event targeting the failed element; script errors carry a message. var target = event.target; if (target && target !== global && target.tagName) { var tag = String(target.tagName).toLowerCase(); if (tag === 'img' || tag === 'script' || tag === 'link') { var url = target.src || target.href || ''; captureMessage('Failed to load ' + tag + ': ' + url, 'ERROR', { 'exception.type': 'ResourceLoadError', 'resource.tag': tag, 'resource.url': url }); } return; } var message = event.message || 'Script error'; captureException(event.error || { name: 'Error', message: message }, { 'source.file': event.filename || '', 'source.line': event.lineno, 'source.column': event.colno }); } function onUnhandledRejection(event) { var reason = event.reason; captureException(reason instanceof Error ? reason : { name: 'UnhandledRejection', message: String(reason) }, { 'exception.type': (reason && reason.name) || 'UnhandledRejection' }); } global.addEventListener('error', onError); // Resource load errors don't bubble - must listen on the capturing phase. global.addEventListener('error', onError, true); global.addEventListener('unhandledrejection', onUnhandledRejection); if (captureConsoleErrors && global.console && typeof global.console.error === 'function') { var originalConsoleError = global.console.error; global.console.error = function () { try { addBreadcrumb('console.error', Array.prototype.slice.call(arguments).join(' ')); } catch (e) { /* ignore */ } return originalConsoleError.apply(global.console, arguments); }; } if (autoInstrumentFetch && typeof global.fetch === 'function') { var originalFetch = global.fetch; global.fetch = function (input, requestInit) { var url = typeof input === 'string' ? input : (input && input.url) || ''; var method = (requestInit && requestInit.method) || (input && input.method) || 'GET'; if (injectTraceHeaders) { var allowed = traceHeaderOrigins ? traceHeaderOrigins.some(function (o) { return url.indexOf(o) === 0; }) : isSameOrigin(url); if (allowed) { requestInit = requestInit || {}; var headers = new global.Headers(requestInit.headers || (input && input.headers) || {}); var th = traceHeaders(); headers.set('traceparent', th.traceparent); requestInit = Object.assign({}, requestInit, { headers: headers }); } } return originalFetch.call(global, input, requestInit).then( function (res) { addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status); return res; }, function (err) { addBreadcrumb('fetch', method + ' ' + url + ' -> error'); throw err; } ); }; } return { captureException: captureException, captureMessage: captureMessage, addBreadcrumb: addBreadcrumb, newTraceId: newTraceId, newSpanId: newSpanId, traceHeaders: traceHeaders }; } global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId }; })(typeof window !== 'undefined' ? window : this);