- Add static/otel-rum.js: a framework-free browser script that captures
uncaught errors, unhandled promise rejections and failed resource loads,
translating them into OTLP log records POSTed to the existing /v1/logs
endpoint (source: frontend attribute) so they flow through the same
history/search/issue pipeline as backend telemetry.
- Add correlation-id support: traceHeaders() (manual) and same-origin
fetch auto-instrumentation inject a W3C traceparent header, so a
frontend error and the backend spans it triggered share a trace id and
can be found together via GET /api/traces/{traceId}.
- Add CORS handling (withCORS in cmd/server/main.go) on the OTLP HTTP
endpoints, required for any browser-origin POST.
- Add an optional shared ingest key (OTEL_INGEST_KEY, via '?key=' or
X-Otel-Ingest-Key) and a per-IP rate limit (20 rps/burst 50) on OTLP
HTTP ingestion, to scope/protect a public endpoint fed by a browser
script. Verified end-to-end (CORS preflight, cross-origin POST, ingest
key enforcement, rate limiting) against a running server.
- Serve static/otel-rum.js and agents_example.md as static HTTP routes so
both the script and the agent instructions doc are fetchable directly
from the base URL.
- Ignore the separately-cloned otel-ui/ frontend repo (has its own .git;
not meant to be nested into this repo's history).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
278 lines
9.1 KiB
JavaScript
278 lines
9.1 KiB
JavaScript
/*!
|
|
* 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:
|
|
* <script src="https://otel.k6n.net/static/otel-rum.js"></script>
|
|
* <script>
|
|
* var rum = OtelRUM.init({ serviceName: 'my-web-app' });
|
|
*
|
|
* // 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>
|
|
*
|
|
* 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);
|