update rum
This commit is contained in:
1 parent
7b68f60202
commit
193e6c6e39
15 files changed
+2760
-1080
No files matched your search
+1
-745
@@ -1,745 +1 @@
|
||||
/*!
|
||||
* 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);
|
||||
"use strict";(()=>{(function(e){"use strict";var ge="https://otel.k6n.net",a=null;try{a=typeof document<"u"?document.currentScript||(function(){for(var i=document.getElementsByTagName("script"),d=i.length-1;d>=0;d--)if(i[d].src&&i[d].src.indexOf("otel-rum")!==-1)return i[d];return null})():null}catch{}var V=typeof e.fetch=="function"?e.fetch:null,Ze=typeof e.XMLHttpRequest=="function"?e.XMLHttpRequest:null;function w(i){for(var d="0123456789abcdef",l="",y=0;y<i;y++)l+=d[Math.random()*16|0];return l}function W(){return w(32)}function X(){return w(16)}function Se(i){try{var d=new e.URL(i,e.location.href);return d.origin===e.location.origin}catch{return!1}}function q(i){var d=[];for(var l in i)if(Object.prototype.hasOwnProperty.call(i,l)){var y=i[l];y==null||y===""||d.push({key:l,value:{stringValue:String(y)}})}return d}function Ee(i={}){i=i||{};var d=a&&(a.getAttribute("data-service-name")||a.getAttribute("data-service"))||"",l=a&&a.getAttribute("data-base-url")||"",y=a&&a.getAttribute("data-endpoint")||"",we=a&&a.getAttribute("data-ingest-key")||"",x=(i.baseUrl||l||ge).replace(/\/+$/,""),R=i.endpoint||y;R?!/^https?:\/\//i.test(R)&&!R.startsWith("//")&&(R=x+"/"+R.replace(/^\/+/,"")):R=x+"/v1/logs";var Le=i.vitalsEndpoint||a&&a.getAttribute("data-vitals-endpoint")||x+"/v1/vitals",Te=i.vitalsLibUrl||a&&a.getAttribute("data-vitals-lib")||(a&&a.src?a.src.replace(/[^\/]*$/,"")+"web-vitals.attribution.iife.js":x+"/static/web-vitals.attribution.iife.js");function re(r){if(!r)return!1;var t=String(r);return!!(R&&t.indexOf(R)===0||t.indexOf("/v1/logs")!==-1||t.indexOf("/v1/traces")!==-1||t.indexOf("/v1/metrics")!==-1||t.indexOf("/v1/vitals")!==-1)}var _=i.serviceName||d||"frontend",G=i.environment||a&&a.getAttribute("data-environment")||"",K=i.release||a&&a.getAttribute("data-release")||"",L=i.ingestKey||we||"",H=(i.consent||a&&a.getAttribute("data-consent"))==="granted"?"granted":"denied",be=typeof i.devMode=="boolean"?i.devMode:!!(a&&a.getAttribute("data-dev-mode")==="true"),xe=i.devModeHosts||(a&&a.getAttribute("data-dev-mode-hosts")||"").split(",").map(function(r){return r.trim()}).filter(Boolean),U=!1;try{U=be&&xe.indexOf(new e.URL(e.location.href).hostname)!==-1}catch{}var D=i.minimalDiagnostics,ne=[],P=typeof i.sampleRate=="number"?i.sampleRate:1,Oe=i.maxBreadcrumbs||20,Ae=i.captureConsoleErrors!==!1,Me=i.autoInstrumentFetch!==!1,Ce=i.autoInstrumentXhr!==!1,ke=i.trackSpaRoutes!==!1,z=i.reportHttpFailures!==!1,He=i.captureWebVitals!==!1,Ue=typeof i.captureWebVitalsWithoutConsent=="boolean"?i.captureWebVitalsWithoutConsent:!!(a&&a.getAttribute("data-vitals-without-consent")==="true"),$=!1,De=typeof i.enableTodo=="boolean"?i.enableTodo:!!(a&&a.getAttribute("data-enable-todo")==="true"),Ie=i.excludeTodo===!0||a&&a.getAttribute("data-exclude-todo")==="true",Pe=i.todoLibUrl||a&&a.getAttribute("data-todo-lib")||(a&&a.src?a.src.replace(/[^\/]*$/,"")+"otel-todo.js":x+"/static/otel-todo.js"),Fe=i.injectTraceHeaders!==!1,ie=i.traceHeaderOrigins,Ne=i.maxEventsPerMinute||60,je=i.batchSize||10,Be=i.flushIntervalMs||5e3,O=[],F="",N=T()?w(16):"",A=[],E=null,I=[],Ve=0;function We(r){var t=r==="granted"?"granted":"denied",n=t==="denied"&&U;H===t&&!n||(n&&(U=!1),H=t,A=[],E&&e.clearTimeout(E),E=null,O=[],B={},F="",N=T()?w(16):"",ne.forEach(function(s){s()}),oe())}function ae(){return H}function T(){return H==="granted"||U}function se(){return U?"development":H==="granted"?"consented":D?"minimal":"counts"}function j(){return He&&(T()||Ue)}function oe(){$||!j()||($=!0,Ke())}function Xe(){for(var r=Date.now();I.length&&r-I[0]>6e4;)I.shift();return I.length>=Ne?!1:(I.push(r),!0)}function qe(r){if(A.push(r),A.length>=je){M();return}E||(E=e.setTimeout(M,Be))}function M(){if(E&&(e.clearTimeout(E),E=null),!!A.length){var r=A.splice(0,A.length);_e(r)}}function _e(r){var t={resourceLogs:[{resource:{attributes:q({"service.name":_})},scopeLogs:[{scope:{name:"otel-rum"},logRecords:r}]}]},n=JSON.stringify(t),s=R;L&&(s+=(s.indexOf("?")===-1?"?":"&")+"key="+encodeURIComponent(L));var o=V||(typeof e.fetch=="function"?e.fetch:null);if(o){var c={"Content-Type":"application/json"};L&&(c["X-Otel-Ingest-Key"]=L),o.call(e,s,{method:"POST",mode:"cors",credentials:"omit",referrerPolicy:"no-referrer",headers:c,body:n,keepalive:!0}).catch(function(){});return}}var J={DEBUG:5,INFO:9,WARN:13,ERROR:17,FATAL:21};function Ge(r,t,n,s){if(!T()){var o={source:"frontend","service.release":K,"service.environment":G,"privacy.mode":"counts"},c=n&&n["exception.type"];typeof c=="string"&&["Error","TypeError","ReferenceError","SyntaxError","RangeError","URIError","EvalError","UnhandledRejection","HttpFailure","ResourceLoadError"].indexOf(c)!==-1&&(o["exception.type"]=c);var p=n&&String(n["http.response.status_code"]||"");p&&/^[1-5][0-9]{2}$/.test(p)&&(o["http.response.status_code"]=p);var g="Frontend event";if(D)try{var f=D.sanitize({message:String(t).slice(0,2e3),type:typeof c=="string"?c.slice(0,100):void 0,stacktrace:n&&typeof n["exception.stacktrace"]=="string"?n["exLine truncated
|
||||
+1
-327
@@ -1,327 +1 @@
|
||||
/**
|
||||
* otel-todo.js - file todos / issues straight from the page.
|
||||
*
|
||||
* Shows a small floating symbol and listens for a keyboard shortcut. Opens a
|
||||
* native <dialog> in the bottom-right corner with a text box, an optional
|
||||
* component picker, and an "Reference an element" toggle that lets you click
|
||||
* any node on the page (with a hover outline) to attach its CSS selector and
|
||||
* a short label to the note. POSTs the note (with the current path, hash and
|
||||
* any picked element) to the collector's /v1/todos endpoint.
|
||||
*
|
||||
* <script src="https://otel.example.com/static/otel-todo.js"
|
||||
* data-service-name="checkout-web"
|
||||
* data-ingest-key="..."
|
||||
* data-components="Header,Cart,Payment"></script>
|
||||
*
|
||||
* Attributes (or OtelTodo.init({...}) keys): data-base-url, data-service-name
|
||||
* (the project), data-ingest-key, data-components (comma list, also
|
||||
* discovered from [data-component] elements on the page), data-shortcut
|
||||
* (default "alt+shift+t"), data-todo-endpoint, data-hide-button="true".
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
var DEFAULT_BASE_URL = 'https://otel.k6n.net';
|
||||
var scriptEl = document.currentScript;
|
||||
|
||||
function attr(name) {
|
||||
return (scriptEl && scriptEl.getAttribute(name)) || '';
|
||||
}
|
||||
|
||||
function parseShortcut(s) {
|
||||
var parts = String(s).toLowerCase().split('+');
|
||||
var sc = { key: parts.pop(), alt: false, shift: false, ctrl: false, meta: false };
|
||||
parts.forEach(function (p) {
|
||||
if (p === 'alt' || p === 'option') sc.alt = true;
|
||||
else if (p === 'shift') sc.shift = true;
|
||||
else if (p === 'ctrl' || p === 'control') sc.ctrl = true;
|
||||
else if (p === 'meta' || p === 'cmd') sc.meta = true;
|
||||
});
|
||||
return sc;
|
||||
}
|
||||
|
||||
function init(config) {
|
||||
config = config || {};
|
||||
if (global.OtelTodo && global.OtelTodo.instance) return global.OtelTodo.instance;
|
||||
|
||||
var baseUrl = (config.baseUrl || attr('data-base-url') || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
var endpoint = config.endpoint || attr('data-todo-endpoint') || baseUrl + '/v1/todos';
|
||||
var project = config.serviceName || attr('data-service-name') || attr('data-service') || 'frontend';
|
||||
var ingestKey = config.ingestKey || attr('data-ingest-key');
|
||||
var shortcut = parseShortcut(config.shortcut || attr('data-shortcut') || 'alt+shift+t');
|
||||
var hideButton = config.hideButton || attr('data-hide-button') === 'true';
|
||||
var staticComponents = config.components || attr('data-components').split(',');
|
||||
staticComponents = staticComponents.map(function (c) { return c.trim(); }).filter(Boolean);
|
||||
|
||||
var sessionId = '';
|
||||
try { sessionId = global.sessionStorage.getItem('otel-rum-session') || ''; } catch (e) {}
|
||||
|
||||
var css = document.createElement('style');
|
||||
css.textContent =
|
||||
'.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;' +
|
||||
'border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}' +
|
||||
'.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}' +
|
||||
'dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;' +
|
||||
'border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}' +
|
||||
'dialog.otel-todo *{box-sizing:border-box;font:inherit}' +
|
||||
'dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}' +
|
||||
'dialog.otel-todo textarea{min-height:90px;resize:vertical}' +
|
||||
'dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}' +
|
||||
'dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
||||
'dialog.otel-todo .status{margin-right:auto;font-size:12px}' +
|
||||
'.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;' +
|
||||
'background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}' +
|
||||
'.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}' +
|
||||
'.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}' +
|
||||
'.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;' +
|
||||
'border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}' +
|
||||
'.otel-todo-ref.show{display:flex}' +
|
||||
'.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
||||
'.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}' +
|
||||
'.otel-todo-ref button:hover{opacity:1}' +
|
||||
'.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;' +
|
||||
'background:#3b82f626;border-radius:3px;transition:all .05s linear}' +
|
||||
'.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;' +
|
||||
'background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;' +
|
||||
'box-shadow:0 4px 14px #0004}';
|
||||
document.head.appendChild(css);
|
||||
|
||||
var dlg = document.createElement('dialog');
|
||||
dlg.className = 'otel-todo';
|
||||
dlg.setAttribute('aria-label', 'Add todo');
|
||||
dlg.innerHTML =
|
||||
'<form method="dialog">' +
|
||||
'<div class="meta"></div>' +
|
||||
'<button type="button" class="otel-todo-pick" aria-pressed="false">⌖ Reference an element</button>' +
|
||||
'<div class="otel-todo-ref"><span></span><button type="button" data-clear-ref aria-label="Remove reference">✕</button></div>' +
|
||||
'<textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea>' +
|
||||
'<select name="component" aria-label="Component (optional)"></select>' +
|
||||
'<div class="row"><span class="status" role="status"></span>' +
|
||||
'<button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div>' +
|
||||
'</form>';
|
||||
var form = dlg.firstChild;
|
||||
var textEl = form.elements.text;
|
||||
var selectEl = form.elements.component;
|
||||
var statusEl = form.querySelector('.status');
|
||||
var metaEl = form.querySelector('.meta');
|
||||
var pickBtn = form.querySelector('.otel-todo-pick');
|
||||
var refEl = form.querySelector('.otel-todo-ref');
|
||||
var refLabelEl = refEl.querySelector('span');
|
||||
document.body.appendChild(dlg);
|
||||
|
||||
// --- element picker: click a node on the page to attach its selector ---
|
||||
var picked = null; // { selector, label }
|
||||
var hoverBox = null;
|
||||
var picking = false;
|
||||
var hint = null;
|
||||
|
||||
function cssPath(el) {
|
||||
if (!(el instanceof Element)) return '';
|
||||
var parts = [];
|
||||
var node = el;
|
||||
while (node && node.nodeType === 1 && parts.length < 6) {
|
||||
if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
|
||||
var part = node.tagName.toLowerCase();
|
||||
var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
||||
if (cls.length) part += '.' + cls.map(function (c) { return CSS.escape(c); }).join('.');
|
||||
var parent = node.parentElement;
|
||||
if (parent) {
|
||||
var siblings = [].filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
|
||||
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
||||
}
|
||||
parts.unshift(part);
|
||||
node = parent;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
function describeElement(el) {
|
||||
var tag = el.tagName.toLowerCase();
|
||||
var text = (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (text.length > 60) text = text.slice(0, 60) + '…';
|
||||
return { selector: cssPath(el), label: text ? tag + ' – "' + text + '"' : tag };
|
||||
}
|
||||
|
||||
function setRef(ref) {
|
||||
picked = ref;
|
||||
if (ref) {
|
||||
refLabelEl.textContent = ref.label;
|
||||
refLabelEl.title = ref.selector;
|
||||
refEl.classList.add('show');
|
||||
} else {
|
||||
refEl.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureHoverBox() {
|
||||
if (!hoverBox) {
|
||||
hoverBox = document.createElement('div');
|
||||
hoverBox.className = 'otel-todo-hover-outline';
|
||||
document.body.appendChild(hoverBox);
|
||||
}
|
||||
return hoverBox;
|
||||
}
|
||||
|
||||
function onPickMove(e) {
|
||||
var el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!el || dlg.contains(el)) { ensureHoverBox().style.display = 'none'; return; }
|
||||
var r = el.getBoundingClientRect();
|
||||
var box = ensureHoverBox();
|
||||
box.style.display = 'block';
|
||||
box.style.left = r.left + 'px';
|
||||
box.style.top = r.top + 'px';
|
||||
box.style.width = r.width + 'px';
|
||||
box.style.height = r.height + 'px';
|
||||
}
|
||||
|
||||
function onPickClick(e) {
|
||||
var el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!el || dlg.contains(el)) return; // let dialog controls work normally
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRef(describeElement(el));
|
||||
stopPicking();
|
||||
}
|
||||
|
||||
function onPickKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stopPicking(); }
|
||||
}
|
||||
|
||||
function startPicking() {
|
||||
if (picking) return;
|
||||
picking = true;
|
||||
pickBtn.setAttribute('aria-pressed', 'true');
|
||||
pickBtn.textContent = '⌖ Click an element… (Esc to cancel)';
|
||||
hint = document.createElement('div');
|
||||
hint.className = 'otel-todo-pick-hint';
|
||||
hint.textContent = 'Click the element you mean — Esc to cancel';
|
||||
document.body.appendChild(hint);
|
||||
document.addEventListener('mousemove', onPickMove, true);
|
||||
document.addEventListener('click', onPickClick, true);
|
||||
document.addEventListener('keydown', onPickKey, true);
|
||||
}
|
||||
|
||||
function stopPicking() {
|
||||
if (!picking) return;
|
||||
picking = false;
|
||||
pickBtn.setAttribute('aria-pressed', 'false');
|
||||
pickBtn.textContent = '⌖ Reference an element';
|
||||
document.removeEventListener('mousemove', onPickMove, true);
|
||||
document.removeEventListener('click', onPickClick, true);
|
||||
document.removeEventListener('keydown', onPickKey, true);
|
||||
if (hoverBox) { hoverBox.style.display = 'none'; }
|
||||
if (hint) { hint.remove(); hint = null; }
|
||||
}
|
||||
|
||||
pickBtn.addEventListener('click', function () { picking ? stopPicking() : startPicking(); });
|
||||
refEl.querySelector('[data-clear-ref]').addEventListener('click', function () { setRef(null); });
|
||||
|
||||
function components() {
|
||||
var seen = {};
|
||||
var list = [];
|
||||
staticComponents.concat([].map.call(document.querySelectorAll('[data-component]'), function (el) {
|
||||
return el.getAttribute('data-component');
|
||||
})).forEach(function (c) {
|
||||
if (c && !seen[c]) { seen[c] = true; list.push(c); }
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (dlg.open) { textEl.focus(); return; }
|
||||
selectEl.innerHTML = '';
|
||||
var none = document.createElement('option');
|
||||
none.value = '';
|
||||
none.textContent = 'No component';
|
||||
selectEl.appendChild(none);
|
||||
var list = components();
|
||||
list.forEach(function (c) {
|
||||
var o = document.createElement('option');
|
||||
o.value = o.textContent = c;
|
||||
selectEl.appendChild(o);
|
||||
});
|
||||
selectEl.hidden = list.length === 0;
|
||||
metaEl.textContent = project + ' · ' + location.pathname + location.hash;
|
||||
statusEl.textContent = '';
|
||||
setRef(null);
|
||||
// Non-modal so the page stays usable while the note is being written.
|
||||
dlg.show();
|
||||
textEl.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopPicking();
|
||||
if (dlg.open) dlg.close();
|
||||
}
|
||||
|
||||
form.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
dlg.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
|
||||
});
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var text = textEl.value.trim();
|
||||
if (!text) return;
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
var url = endpoint;
|
||||
if (ingestKey) headers['X-Otel-Ingest-Key'] = ingestKey;
|
||||
statusEl.textContent = 'Sending…';
|
||||
global.fetch(url, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
project: project,
|
||||
text: text,
|
||||
component: selectEl.value,
|
||||
path: location.pathname,
|
||||
hash: location.hash,
|
||||
url: location.href,
|
||||
ua: navigator.userAgent,
|
||||
session: sessionId,
|
||||
elementSelector: picked ? picked.selector : '',
|
||||
elementLabel: picked ? picked.label : ''
|
||||
})
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
textEl.value = '';
|
||||
setRef(null);
|
||||
close();
|
||||
}).catch(function (err) {
|
||||
statusEl.textContent = 'Failed: ' + err.message;
|
||||
});
|
||||
});
|
||||
|
||||
global.addEventListener('keydown', function (e) {
|
||||
var k = e.key.toLowerCase();
|
||||
var code = (e.code || '').replace(/^(Key|Digit)/, '').toLowerCase();
|
||||
if ((k === shortcut.key || code === shortcut.key) && e.altKey === shortcut.alt && e.shiftKey === shortcut.shift &&
|
||||
e.ctrlKey === shortcut.ctrl && e.metaKey === shortcut.meta) {
|
||||
e.preventDefault();
|
||||
dlg.open ? close() : open();
|
||||
}
|
||||
});
|
||||
|
||||
if (!hideButton) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'otel-todo-btn';
|
||||
btn.textContent = '✎';
|
||||
btn.title = 'Add todo (' + (config.shortcut || attr('data-shortcut') || 'alt+shift+t') + ')';
|
||||
btn.setAttribute('aria-label', 'Add todo');
|
||||
btn.addEventListener('click', function () { dlg.open ? close() : open(); });
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
var instance = { open: open, close: close };
|
||||
global.OtelTodo.instance = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
global.OtelTodo = { init: init, instance: undefined };
|
||||
|
||||
if (scriptEl && (attr('data-auto-init') === 'true' || attr('data-service-name') || attr('data-service'))) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { init(); });
|
||||
else init();
|
||||
}
|
||||
})(window);
|
||||
"use strict";(()=>{(function(u){"use strict";var X="https://otel.k6n.net",S=document.currentScript;function i(o){return S&&S.getAttribute(o)||""}function G(o){var k=String(o).toLowerCase().split("+"),p={key:k.pop(),alt:!1,shift:!1,ctrl:!1,meta:!1};return k.forEach(function(l){l==="alt"||l==="option"?p.alt=!0:l==="shift"?p.shift=!0:l==="ctrl"||l==="control"?p.ctrl=!0:(l==="meta"||l==="cmd")&&(p.meta=!0)}),p}function A(o={}){if(o=o||{},u.OtelTodo&&u.OtelTodo.instance)return u.OtelTodo.instance;var k=(o.baseUrl||i("data-base-url")||X).replace(/\/+$/,""),p=o.endpoint||i("data-todo-endpoint")||k+"/v1/todos",l=o.serviceName||i("data-service-name")||i("data-service")||"frontend",K=o.ingestKey||i("data-ingest-key"),h=G(o.shortcut||i("data-shortcut")||"alt+shift+t"),Y=o.hideButton||i("data-hide-button")==="true",M=o.components||i("data-components").split(",");M=M.map(function(e){return e.trim()}).filter(Boolean);var D=(o.consent||i("data-consent"))==="granted"?"granted":"denied";function N(){return o.getConsent?o.getConsent():D}function _(e){D=e==="granted"?"granted":"denied"}var q=document.createElement("style");q.textContent='.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}dialog.otel-todo *{box-sizing:border-box;font:inherit}dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}dialog.otel-todo textarea{min-height:90px;resize:vertical}dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}dialog.otel-todo .status{margin-right:auto;font-size:12px}.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}.otel-todo-ref.show{display:flex}.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}.otel-todo-ref button:hover{opacity:1}.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;background:#3b82f626;border-radius:3px;transition:all .05s linear}.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;box-shadow:0 4px 14px #0004}',document.head.appendChild(q);var a=document.createElement("dialog");a.className="otel-todo",a.setAttribute("aria-label","Add todo"),a.innerHTML='<form method="dialog"><div class="meta"></div><button type="button" class="otel-todo-pick" aria-pressed="false">\u2316 Reference an element</button><div class="otel-todo-ref"><span></span><button type="button" data-clear-ref aria-label="Remove reference">\u2715</button></div><textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea><select name="component" aria-label="Component (optional)"></select><div class="row"><span class="status" role="status"></span><button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div></form>';var s=a.firstElementChild,C=s.elements.namedItem("text"),g=s.elements.namedItem("component"),H=s.querySelector(".status"),J=s.querySelector(".meta"),b=s.querySelector(".otel-todo-pick"),L=s.querySelector(".otel-todo-ref"),O=L.querySelector("span");document.body.appendChild(a);var y=null,f=null,E=!1,m=null;function W(e){if(!(e instanceof Element))return"";for(var n=[],t=e;t&&t.nodeType===1&&n.length<6;){if(t.id){n.unshift("#"+CSS.escape(t.id));break}var r=t.tagName.toLowerCase(),d=(t.getAttribute("class")||"").trim().split(/\s+/).filter(Boolean).slice(0,2);d.length&&(r+="."+d.map(function(B){return CSS.escape(B)}).join("."));var c=t.parentElement;if(c){var Z=t.tagName,U=Array.from(c.children).filter(function(B){return B.tagName===Z});U.length>1&&(r+=":nth-of-type("+(U.indexOf(t)+1)+")")}n.unshift(r),t=c}return n.join(" > ")}function $(e){var n=e.tagName.toLowerCase(),t=(e.getAttribute("aria-label")||e.textCoLine truncated
|
||||
Reference in new issue
Block a user