746 lines
27 KiB
JavaScript
746 lines
27 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 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:
|
|
* <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(/^\/+/, '');
|
|
}
|
|
|
|
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 <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);
|