Files
go-otel/static/otel-rum.js
T
mats 21f04d696b
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s
more otel
2026-09-18 20:51:37 +00:00

686 lines
23 KiB
JavaScript

/*!
* 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 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:
* <script src="https://otel.k6n.net/static/otel-rum.js"></script>
* <script>
* var rum = OtelRUM.init({
* serviceName: 'my-web-app',
* baseUrl: 'https://otel.example.com', // optional collector override
* });
*
* // Manual correlation-id injection into any HTTP client:
* fetch('/api/orders', { headers: rum.traceHeaders() });
*
* // Manual error reporting:
* try { risky(); } catch (err) { rum.captureException(err); }
* </script>
*
* 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(/^\/+/, '');
}
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) 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;
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 via PerformanceObserver. Values are reported as INFO logs
// tagged rum.vital=<name>; the server can chart/aggregate them like
// any other telemetry. Each vital fires once when it reaches its
// final value (plus a first-input proxy for INP on older browsers).
function observeVitals() {
if (typeof global.PerformanceObserver !== 'function') return;
function report(name, value, extra) {
var attrs = Object.assign({ 'rum.vital': name, 'rum.value': Math.round(value) }, extra || {});
// Vitals are telemetry, not errors: bypass the error rate limit
// but keep sampling.
if (sampleRate < 1 && Math.random() > sampleRate) return;
enqueue(buildRecord('INFO', name + ' ' + Math.round(value), attrs, ''));
}
function onPaint(list) {
list.getEntries().forEach(function (entry) {
if (entry.name === 'first-contentful-paint') report('FCP', entry.startTime);
});
}
function onLCP(list) {
var entries = list.getEntries();
var last = entries[entries.length - 1];
if (last) report('LCP', last.startTime, { 'rum.element': last.element && last.element.tagName });
}
function onCLS() {
var cls = 0;
var lastReport = 0;
var observer = new global.PerformanceObserver(function (list) {
list.getEntries().forEach(function (entry) {
if (!entry.hadRecentInput) cls += entry.value;
});
// Throttle: report at most once per 10s, plus a final value on
// pagehide, instead of once per layout shift.
var now = Date.now();
if (now - lastReport > 10000) {
lastReport = now;
report('CLS', Math.round(cls * 1000) / 1000);
}
clsFinal = function () {
report('CLS', Math.round(cls * 1000) / 1000);
clsFinal = null;
};
});
try {
observer.observe({ type: 'layout-shift', buffered: true });
} catch (e) {
/* unsupported */
clsFinal = null;
}
}
function onINP(entry) {
var duration = entry.duration;
var interactionId = entry.interactionId;
if (!interactionId) return;
if (duration > (observerState.maxINP || 0)) {
observerState.maxINP = duration;
report('INP', duration);
}
}
var observerState = {};
try {
new global.PerformanceObserver(onPaint).observe({ type: 'paint', buffered: true });
} catch (e) {}
try {
new global.PerformanceObserver(onLCP).observe({ type: 'largest-contentful-paint', buffered: true });
} catch (e) {}
onCLS();
try {
new global.PerformanceObserver(function (list) {
list.getEntries().forEach(onINP);
}).observe({ type: 'event', buffered: true, durationThreshold: 40 });
} catch (e) {}
// Navigation timing: TTFB.
try {
var nav = performance.getEntriesByType('navigation')[0];
if (nav && nav.responseStart) {
report('TTFB', nav.responseStart);
}
} catch (e) {}
}
// 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 = {};
var clsFinal = null;
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 });
}
return originalFetch.call(global, input, requestInit).then(
function (res) {
addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status);
if (reportHttpFailures && res.status >= 400) {
httpFailureReport('fetch', method, url, res.status, requestTraceId);
}
return res;
},
function (err) {
addBreadcrumb('fetch', method + ' ' + url + ' -> error');
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 || {};
if (info.url) {
var origHandler = xhr.onreadystatechange;
xhr.onreadystatechange = function () {
try {
if (xhr.readyState === 4) {
addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status);
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 () {
if (clsFinal) clsFinal();
flush();
});
global.addEventListener('visibilitychange', function () {
if (global.document && global.document.visibilityState === 'hidden') {
if (clsFinal) clsFinal();
flush();
}
});
// First paint: report vitals after observers have a chance to buffer.
if (captureWebVitals) {
observeVitals();
}
function addBreadcrumb(category, message) {
breadcrumbs.push({ t: Date.now(), category: String(category), message: String(message) });
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 <script> element
try {
if (typeof document !== 'undefined' && scriptEl && (scriptEl.getAttribute('data-auto-init') === 'true' || scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
global.OtelRUM.init();
});
} else {
global.OtelRUM.init();
}
}
} catch (e) {}
})(typeof window !== 'undefined' ? window : this);