/*! * otel-rum.js - browser error/RUM collector for go-otel. * * Captures uncaught errors, unhandled promise rejections, failed resource * loads, failing HTTP requests and Web Vitals (LCP/INP/CLS/TTFB/FCP), and * ships errors 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). Web Vitals go out separately: one beacon per * page view to /v1/vitals, measured with the vendored web-vitals library. * * 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: * * * * Events are batched client-side (flushed on pagehide via keepalive fetch/sendBeacon) and * rate-limited, so a runaway error loop cannot hammer the collector. * * Source-map resolution is performed server-side by go-otel when a matching * release map has been uploaded. Session replay and PII scrubbing remain out * of scope; don't route sensitive data through breadcrumbs/attributes. */ (function (global) { 'use strict'; var DEFAULT_BASE_URL = 'https://otel.k6n.net'; // Find the loading script so declarative data-* configuration keeps working. try { var scriptEl = (typeof document !== 'undefined') ? (document.currentScript || (function() { var scripts = document.getElementsByTagName('script'); for (var i = scripts.length - 1; i >= 0; i--) { if (scripts[i].src && scripts[i].src.indexOf('otel-rum') !== -1) return scripts[i]; } return null; })()) : null; } catch (e) {} var rawFetch = typeof global.fetch === 'function' ? global.fetch : null; var rawXHR = typeof global.XMLHttpRequest === 'function' ? global.XMLHttpRequest : null; 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 scriptServiceName = (scriptEl && (scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) || ''; var scriptBaseUrl = (scriptEl && scriptEl.getAttribute('data-base-url')) || ''; var scriptEndpoint = (scriptEl && scriptEl.getAttribute('data-endpoint')) || ''; var scriptIngestKey = (scriptEl && scriptEl.getAttribute('data-ingest-key')) || ''; // Collector base URL resolution: // 1. Explicit config.baseUrl or scriptEl data-base-url // 2. Production collector: https://otel.k6n.net // Do not derive this from the page or script origin: the script may be // bundled or self-hosted while logs still belong at the collector. var baseUrl = (config.baseUrl || scriptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); var endpoint = config.endpoint || scriptEndpoint; if (!endpoint) { endpoint = baseUrl + '/v1/logs'; } else if (!/^https?:\/\//i.test(endpoint) && !endpoint.startsWith('//')) { endpoint = baseUrl + '/' + endpoint.replace(/^\/+/, ''); } var vitalsEndpoint = config.vitalsEndpoint || (scriptEl && scriptEl.getAttribute('data-vitals-endpoint')) || baseUrl + '/v1/vitals'; // The web-vitals build lives next to this script; fall back to the collector's /static. var vitalsLibUrl = config.vitalsLibUrl || (scriptEl && scriptEl.getAttribute('data-vitals-lib')) || (scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'web-vitals.attribution.iife.js' : baseUrl + '/static/web-vitals.attribution.iife.js'); function isCollectorUrl(u) { if (!u) return false; var s = String(u); if (endpoint && s.indexOf(endpoint) === 0) return true; if (s.indexOf('/v1/logs') !== -1 || s.indexOf('/v1/traces') !== -1 || s.indexOf('/v1/metrics') !== -1 || s.indexOf('/v1/vitals') !== -1) return true; return false; } var serviceName = config.serviceName || scriptServiceName || 'frontend'; var environment = config.environment || (scriptEl && scriptEl.getAttribute('data-environment')) || ''; var release = config.release || (scriptEl && scriptEl.getAttribute('data-release')) || ''; var ingestKey = config.ingestKey || scriptIngestKey || ''; 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 autoInstrumentXhr = config.autoInstrumentXhr !== false; var trackSpaRoutes = config.trackSpaRoutes !== false; var reportHttpFailures = config.reportHttpFailures !== false; var captureWebVitals = config.captureWebVitals !== false; // The todo widget (otel-todo.js) is loaded automatically unless excluded: // init({ excludeTodo: true }) or data-exclude-todo="true". var excludeTodo = config.excludeTodo === true || (scriptEl && scriptEl.getAttribute('data-exclude-todo') === 'true'); var todoLibUrl = config.todoLibUrl || (scriptEl && scriptEl.getAttribute('data-todo-lib')) || (scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'otel-todo.js' : baseUrl + '/static/otel-todo.js'); var injectTraceHeaders = config.injectTraceHeaders !== false; var traceHeaderOrigins = config.traceHeaderOrigins; // optional array; defaults to same-origin only // Client-side rate limiting: max events per rolling minute. var maxEventsPerMinute = config.maxEventsPerMinute || 60; // Batch size / flush interval for queued records. var batchSize = config.batchSize || 10; var flushIntervalMs = config.flushIntervalMs || 5000; 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 = ''; // Session id: survives reloads within the tab's lifetime, so server //-side issue views can group frontend errors by browsing session. var sessionId = ''; try { sessionId = global.sessionStorage.getItem('otel-rum-session'); if (!sessionId) { sessionId = randomHex(16); global.sessionStorage.setItem('otel-rum-session', sessionId); } } catch (e) { sessionId = randomHex(16); // storage blocked (privacy mode etc.) } // --- Client-side queue, batching and rate limiting ----------------- var queue = []; var flushTimer = null; var sentTimestamps = []; var eventsDropped = 0; function underRateLimit() { var now = Date.now(); // Drop timestamps older than the rolling 60s window. while (sentTimestamps.length && now - sentTimestamps[0] > 60000) { sentTimestamps.shift(); } if (sentTimestamps.length >= maxEventsPerMinute) return false; sentTimestamps.push(now); return true; } function enqueue(record) { queue.push(record); if (queue.length >= batchSize) { flush(); return; } if (!flushTimer) { flushTimer = global.setTimeout(flush, flushIntervalMs); } } function flush() { if (flushTimer) { global.clearTimeout(flushTimer); flushTimer = null; } if (!queue.length) return; var records = queue.splice(0, queue.length); ship(records); } // ship sends already-built log records in one OTLP payload. Uses // sendBeacon when available (survives page unload), else keepalive // fetch. function ship(records) { var payload = { resourceLogs: [ { resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) }, scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: records }] } ] }; var body = JSON.stringify(payload); var url = endpoint; if (ingestKey) { url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey); } // Use uninstrumented rawFetch with keepalive: true and mode: 'cors' // to avoid intercepting and looping on our own telemetry requests. var fetchFn = rawFetch || (typeof global.fetch === 'function' ? global.fetch : null); if (fetchFn) { var fetchHeaders = { 'Content-Type': 'application/json' }; if (ingestKey) { fetchHeaders['X-Otel-Ingest-Key'] = ingestKey; } fetchFn.call(global, url, { method: 'POST', mode: 'cors', headers: fetchHeaders, body: body, keepalive: true }).catch(function () { if (global.navigator && typeof global.navigator.sendBeacon === 'function') { try { var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' }); global.navigator.sendBeacon(url, blob); } catch (e) {} } }); return; } if (global.navigator && typeof global.navigator.sendBeacon === 'function') { try { var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' }); global.navigator.sendBeacon(url, blob); } catch (e) {} } } var severityNumbers = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17, FATAL: 21 }; function buildRecord(severity, message, attrs, traceId) { var record = { timeUnixNano: String(Date.now() * 1e6), severityText: severity, severityNumber: severityNumbers[severity] || 17, body: { stringValue: String(message).slice(0, 2000) }, attributes: toOtlpAttributes( Object.assign( {}, contextAttrs, { source: 'frontend', 'browser.url': global.location ? global.location.href : '', 'browser.userAgent': global.navigator ? global.navigator.userAgent : '', 'browser.viewport': global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '', 'session.id': sessionId, 'service.release': release, 'service.environment': environment, breadcrumbs: breadcrumbs.length ? JSON.stringify(breadcrumbs) : undefined }, attrs || {} ) ) }; if (traceId) { record.traceId = traceId; } return record; } function send(severity, message, attrs, traceId) { if (sampleRate < 1 && Math.random() > sampleRate) return; if (!underRateLimit()) { eventsDropped++; return; } enqueue(buildRecord(severity, message, attrs, traceId)); } // Web Vitals: measured by the vendored `web-vitals` library (attribution // build, loaded lazily so it never blocks the page) and reported as ONE // beacon per page view to /v1/vitals when the page is hidden. The // beacon carries every metric with its attribution (LCP element, INP // target/breakdown, CLS shift source...). If the page is hidden again // later (tab switch) and a metric changed - typically INP - a fuller // snapshot with the same pageView id is sent; the newest one wins. function observeVitals() { var pageViewId = randomHex(16); var vitals = {}; var navType = ''; var dirty = false; var sampled = sampleRate >= 1 || Math.random() <= sampleRate; var picks = { LCP: ['target', 'url', 'timeToFirstByte', 'resourceLoadDelay', 'resourceLoadDuration', 'elementRenderDelay'], INP: ['interactionTarget', 'interactionType', 'inputDelay', 'processingDuration', 'presentationDelay', 'loadState', 'totalScriptDuration', 'totalStyleAndLayoutDuration', 'totalPaintDuration'], CLS: ['largestShiftTarget', 'largestShiftValue', 'largestShiftTime', 'loadState'], FCP: ['timeToFirstByte', 'firstByteToFCP', 'loadState'], TTFB: ['waitingDuration', 'cacheDuration', 'dnsDuration', 'connectionDuration', 'requestDuration'] }; // Values keep 3 decimals: CLS is a tiny fraction and must not be rounded to an integer. function round(n) { return typeof n === 'number' ? Math.round(n * 1000) / 1000 : n; } function slim(metric) { var out = {}; var src = metric.attribution || {}; (picks[metric.name] || []).forEach(function (k) { if (src[k] !== undefined && src[k] !== null) out[k] = round(src[k]); }); var script = src.longestScript && src.longestScript.entry; if (script && script.sourceURL) { out.longestScriptUrl = script.sourceURL; if (script.sourceFunctionName) out.longestScriptFunction = script.sourceFunctionName; } return out; } function onMetric(metric) { vitals[metric.name] = { value: round(metric.value), delta: round(metric.delta), id: metric.id, attribution: slim(metric) }; navType = metric.navigationType || navType; dirty = true; } function sendVitals() { if (!dirty || !sampled) return; dirty = false; var body = JSON.stringify({ service: serviceName, environment: environment, release: release, session: sessionId, pageView: pageViewId, url: global.location ? global.location.href : '', ua: global.navigator ? global.navigator.userAgent : '', viewport: global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '', nav: navType, conn: (global.navigator && global.navigator.connection && global.navigator.connection.effectiveType) || '', metrics: vitals }); var url = vitalsEndpoint; if (ingestKey) url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey); // text/plain keeps it a CORS "simple" request: no preflight, so it survives unload. var queued = false; try { queued = !!(global.navigator && typeof global.navigator.sendBeacon === 'function') && global.navigator.sendBeacon(url, new global.Blob([body], { type: 'text/plain;charset=UTF-8' })); } catch (e) {} if (!queued && rawFetch) { rawFetch.call(global, url, { method: 'POST', mode: 'cors', headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, body: body, keepalive: true }).catch(function () {}); } } var script = global.document.createElement('script'); script.async = true; script.src = vitalsLibUrl; script.onload = function () { var wv = global.webVitals; if (!wv) return; // INP durationThreshold stays at the library default; one listener per metric. [wv.onLCP, wv.onINP, wv.onCLS, wv.onFCP, wv.onTTFB].forEach(function (observe) { if (typeof observe === 'function') observe(onMetric); }); // Registered after the library's own hidden listeners, so the // final LCP/INP/CLS values are already collected when this runs. global.addEventListener('visibilitychange', function () { if (global.document.visibilityState === 'hidden') sendVitals(); }); global.addEventListener('pagehide', sendVitals); // bfcache restore is a new page view for vitals purposes. global.addEventListener('pageshow', function (e) { if (e.persisted) { pageViewId = randomHex(16); vitals = {}; } }); }; global.document.head.appendChild(script); } function loadTodoWidget() { var todo = config.todo || {}; function start() { if (!global.OtelTodo) return; global.OtelTodo.init({ baseUrl: baseUrl, serviceName: serviceName, ingestKey: ingestKey, components: todo.components || ((scriptEl && scriptEl.getAttribute('data-components')) || '').split(','), shortcut: todo.shortcut || (scriptEl && scriptEl.getAttribute('data-shortcut')) || '', hideButton: todo.hideButton === true || (scriptEl && scriptEl.getAttribute('data-hide-button') === 'true') }); } if (global.OtelTodo) return start(); var s = global.document.createElement('script'); s.async = true; s.src = todoLibUrl; s.onload = start; global.document.head.appendChild(s); } if (!excludeTodo && global.document) { if (global.document.body) loadTodoWidget(); else global.document.addEventListener('DOMContentLoaded', loadTodoWidget); } // 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 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 setContext(context) { // Attach stable context (e.g. { user: 'u-123', plan: 'pro' }) to // every subsequent event. Values become OTLP attributes prefixed // with "context.". contextAttrs = Object.assign({}, contextAttrs, context || {}); flush(); // ensure earlier queued events don't mix contexts ambiguously } var contextAttrs = {}; 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); }; } function shouldInject(url) { if (!injectTraceHeaders) return false; if (!traceHeaderOrigins) return isSameOrigin(url); return traceHeaderOrigins.some(function (o) { return url.indexOf(o) === 0; }); } // httpFailureReport reports a failed HTTP round-trip as an ERROR event, // so backend 5xx/4xx responses show up in the Issues view even when // the app code swallows them. function httpFailureReport(kind, method, url, status, traceId, extra) { // Issue refresh failures must not create new issues notifications: // otherwise an expired session can form an API-error feedback loop. try { if (new global.URL(url, global.location.href).pathname === '/api/issues') return; } catch (e) {} var attrs = Object.assign( { 'exception.type': 'HttpFailure', 'http.request.method': method, 'url.full': String(url).slice(0, 500), 'http.response.status_code': status === undefined || status === 0 ? '' : String(status) }, extra || {} ); captureMessage( method + ' ' + url + ' failed with ' + (status === undefined || status === 0 ? 'network error' : 'HTTP ' + status), 'ERROR', attrs ); } if (autoInstrumentFetch && typeof global.fetch === 'function') { var originalFetch = global.fetch; global.fetch = function (input, requestInit) { var url = typeof input === 'string' ? input : (input && input.url) || ''; if (isCollectorUrl(url)) { return originalFetch.call(global, input, requestInit); } var method = (requestInit && requestInit.method) || (input && input.method) || 'GET'; var requestTraceId = ''; if (shouldInject(url)) { requestInit = requestInit || {}; var headers = new global.Headers(requestInit.headers || (input && input.headers) || {}); var th = traceHeaders(); headers.set('traceparent', th.traceparent); requestTraceId = th.traceparent.slice(3, 35); // trace id portion requestInit = Object.assign({}, requestInit, { headers: headers }); } var startedAt = Date.now(); return originalFetch.call(global, input, requestInit).then( function (res) { addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status, { d: Date.now() - startedAt, tid: requestTraceId }); if (reportHttpFailures && res.status >= 400) { httpFailureReport('fetch', method, url, res.status, requestTraceId); } return res; }, function (err) { addBreadcrumb('fetch', method + ' ' + url + ' -> error', { d: Date.now() - startedAt, tid: requestTraceId }); if (reportHttpFailures) { httpFailureReport('fetch', method, url, 0, requestTraceId); } throw err; } ); }; } // XHR instrumentation: trace-header injection + breadcrumbs + failure // reporting, so legacy code paths (axios older versions, jQuery, etc.) // get the same visibility as fetch. if (autoInstrumentXhr && typeof global.XMLHttpRequest === 'function') { var originalOpen = global.XMLHttpRequest.prototype.open; var originalSend = global.XMLHttpRequest.prototype.send; var originalSetRequestHeader = global.XMLHttpRequest.prototype.setRequestHeader; global.XMLHttpRequest.prototype.open = function (method, url) { if (isCollectorUrl(url)) { return originalOpen.apply(this, arguments); } try { this.__rum = { method: String(method), url: String(url) }; if (shouldInject(String(url))) { var th = traceHeaders(); this.__rum.traceId = th.traceparent.slice(3, 35); originalSetRequestHeader.call(this, 'traceparent', th.traceparent); } } catch (e) { /* ignore */ } return originalOpen.apply(this, arguments); }; global.XMLHttpRequest.prototype.send = function () { var xhr = this; var info = xhr.__rum || {}; var xhrStartedAt = Date.now(); if (info.url) { var origHandler = xhr.onreadystatechange; xhr.onreadystatechange = function () { try { if (xhr.readyState === 4) { addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status, { d: Date.now() - xhrStartedAt, tid: info.traceId }); if (reportHttpFailures && (xhr.status === 0 || xhr.status >= 400)) { httpFailureReport('xhr', info.method, info.url, xhr.status, info.traceId); } } } catch (e) { /* ignore */ } if (origHandler) return origHandler.apply(this, arguments); }; } return originalSend.apply(this, arguments); }; } // SPA route-change breadcrumbs: history.pushState/replaceState and // popstate/hashes get recorded so an error report shows which route // transitions preceded it. if (trackSpaRoutes && global.history) { function recordRoute(kind) { addBreadcrumb('navigation', kind + ' ' + (global.location ? global.location.pathname + global.location.search : '')); } var originalPush = global.history.pushState; var originalReplace = global.history.replaceState; if (typeof originalPush === 'function') { global.history.pushState = function () { var result = originalPush.apply(this, arguments); try { recordRoute('pushState'); } catch (e) {} return result; }; } if (typeof originalReplace === 'function') { global.history.replaceState = function () { var result = originalReplace.apply(this, arguments); try { recordRoute('replaceState'); } catch (e) {} return result; }; } global.addEventListener('popstate', function () { try { recordRoute('popstate'); } catch (e) {} }); } // Flush pending events when the page is being hidden or unloaded so // errors that happen right before navigation are not lost, and emit // the final CLS value. global.addEventListener('pagehide', function () { flush(); }); global.addEventListener('visibilitychange', function () { if (global.document && global.document.visibilityState === 'hidden') { flush(); } }); // First paint: report vitals after observers have a chance to buffer. if (captureWebVitals) { observeVitals(); } // extra.d = request duration in ms, extra.tid = trace id we sent on the // request. t stays the completion time; the request started at t - d. function addBreadcrumb(category, message, extra) { var crumb = { t: Date.now(), category: String(category), message: String(message) }; if (extra && typeof extra.d === 'number') crumb.d = extra.d; if (extra && extra.tid) crumb.tid = String(extra.tid); breadcrumbs.push(crumb); if (breadcrumbs.length > maxBreadcrumbs) { breadcrumbs.shift(); } } var instance = { captureException: captureException, captureMessage: captureMessage, addBreadcrumb: addBreadcrumb, setContext: setContext, flush: flush, newTraceId: newTraceId, newSpanId: newSpanId, traceHeaders: traceHeaders }; // Convenience handle for manual/console verification: // OtelRUM.last.captureMessage('test from console'). global.OtelRUM.last = instance; return instance; } global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId, last: undefined }; // Auto-initialize if data attributes are declared on the