Add frontend error collector, CORS/ingest guards, and static doc routes (Phase 3)
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s

- 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>
This commit is contained in:
developerandClaude Sonnet 5 committed 2026-09-17 10:20:42 +00:00
1 parent 0d274ff89c
commit c20a0b77f3
10 files changed
+489 -17

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
/*!
* 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);