Files
go-otel/scripts/otel-rum.test.mjs
T
mats 193e6c6e39
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndPushAmd64 (push) Failing after 1m27s
update rum
2026-11-04 13:04:34 +01:00

516 lines
21 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
const source = readFileSync(new URL('../static/otel-rum.js', import.meta.url), 'utf8');
function setup({ readyState = 'loading', attributes = {}, idle = true, existingTodo = false, fetch } = {}) {
const scripts = [];
const timers = [];
const idleCallbacks = [];
const listeners = new Map();
const initializations = [];
const window = {
document: {
currentScript: {
src: 'https://assets.example.com/otel-rum.js',
getAttribute: name => attributes[name] ?? null,
},
readyState,
body: {},
createElement: tagName => ({ tagName }),
head: { appendChild: script => scripts.push(script) },
addEventListener: (...args) => window.addEventListener(...args),
},
fetch,
Headers,
URL,
addEventListener(name, callback, options) {
const callbacks = listeners.get(name) || [];
callbacks.push({ callback, options });
listeners.set(name, callbacks);
},
setTimeout(callback, delay) {
timers.push({ callback, delay });
return timers.length;
},
clearTimeout() {},
};
if (idle) window.requestIdleCallback = callback => idleCallbacks.push(callback);
if (existingTodo) window.OtelTodo = { init: config => initializations.push(config) };
vm.runInNewContext(source, { window, document: window.document });
return {
window, scripts, timers, idleCallbacks, listeners, initializations,
init(config = {}) {
return window.OtelRUM.init({ captureWebVitals: false, ...config });
},
fire(name, event) {
for (const listener of listeners.get(name) || []) {
listener.callback(event);
}
},
};
}
test('Todo is disabled by default, including legacy opt-out false', () => {
for (const config of [{}, { excludeTodo: false }, { todo: { hideButton: true } }]) {
const rum = setup({ readyState: 'complete' });
rum.init(config);
assert.equal(rum.scripts.length, 0);
assert.equal(rum.idleCallbacks.length, 0);
assert.equal(rum.timers.length, 0);
}
});
test('enabled Todo waits for window load and then idle; RUM starts immediately', () => {
const rum = setup();
const instance = rum.init({ enableTodo: true });
assert.equal(typeof instance.captureException, 'function');
assert.ok(rum.listeners.has('error'));
assert.equal(rum.scripts.length, 0);
assert.equal(rum.idleCallbacks.length, 0);
assert.equal(rum.listeners.get('load')[0].options.once, true);
rum.window.document.readyState = 'complete';
rum.fire('load');
assert.equal(rum.scripts.length, 0);
assert.equal(rum.idleCallbacks.length, 1);
rum.idleCallbacks[0]();
assert.equal(rum.scripts.length, 1);
assert.equal(rum.scripts[0].src, 'https://assets.example.com/otel-todo.js');
assert.equal(rum.scripts[0].async, true);
});
test('initializing after load still waits for idle and forwards Todo options', () => {
const rum = setup({ readyState: 'complete' });
rum.init({
enableTodo: true,
serviceName: 'checkout',
baseUrl: 'https://collector.example.com',
ingestKey: 'test-key',
todoLibUrl: '/custom-todo.js',
todo: { components: ['Cart'], shortcut: 'Alt+T', hideButton: true },
});
assert.equal(rum.scripts.length, 0);
rum.idleCallbacks[0]();
assert.equal(rum.scripts[0].src, '/custom-todo.js');
let received;
rum.window.OtelTodo = { init: config => { received = config; } };
rum.scripts[0].onload();
assert.equal(received.baseUrl, 'https://collector.example.com');
assert.equal(received.serviceName, 'checkout');
assert.equal(received.ingestKey, 'test-key');
assert.deepEqual(received.components, ['Cart']);
assert.equal(received.shortcut, 'Alt+T');
assert.equal(received.hideButton, true);
});
test('without idle callbacks Todo waits three seconds after load', () => {
const rum = setup({ idle: false });
rum.init({ enableTodo: true });
assert.equal(rum.timers.length, 0);
rum.fire('load');
assert.equal(rum.scripts.length, 0);
assert.equal(rum.timers.length, 1);
assert.equal(rum.timers[0].delay, 3000);
rum.timers[0].callback();
assert.equal(rum.scripts.length, 1);
});
test('declarative opt-in works and explicit false overrides it', () => {
for (const enabled of [true, false]) {
const rum = setup({ readyState: 'complete', attributes: { 'data-enable-todo': 'true' } });
rum.init(enabled ? {} : { enableTodo: false });
assert.equal(rum.idleCallbacks.length, enabled ? 1 : 0);
}
});
test('legacy exclusion vetoes explicit or declarative opt-in', () => {
for (const viaAttribute of [true, false]) {
const rum = setup({
readyState: 'complete',
attributes: viaAttribute ? { 'data-enable-todo': 'true', 'data-exclude-todo': 'true' } : {},
});
rum.init(viaAttribute ? {} : { enableTodo: true, excludeTodo: true });
assert.equal(rum.idleCallbacks.length, 0);
assert.equal(rum.scripts.length, 0);
}
});
test('preloaded Todo also waits until idle without injecting another script', () => {
const rum = setup({ readyState: 'complete', existingTodo: true });
rum.init({ enableTodo: true });
assert.equal(rum.initializations.length, 0);
rum.idleCallbacks[0]();
assert.equal(rum.initializations.length, 1);
assert.equal(rum.scripts.length, 0);
});
test('minified RUM preserves declarative initialization and currentScript configuration', () => {
const rum = setup({
attributes: {
'data-service-name': 'checkout',
'data-enable-todo': 'true',
},
});
assert.equal(rum.window.OtelRUM.last, undefined);
rum.window.document.readyState = 'interactive';
rum.fire('DOMContentLoaded');
assert.equal(typeof rum.window.OtelRUM.last.captureException, 'function');
assert.equal(rum.scripts.length, 0);
rum.window.OtelRUM.last.setConsent('granted');
assert.equal(rum.scripts[0].src, 'https://assets.example.com/web-vitals.attribution.iife.js');
assert.equal(rum.idleCallbacks.length, 0);
rum.fire('load');
rum.idleCallbacks[0]();
let received;
rum.window.OtelTodo = { init: config => { received = config; } };
rum.scripts[1].onload();
assert.equal(received.serviceName, 'checkout');
});
test('minified RUM sends OTLP records with configured identity and trace context', () => {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push({ url, options });
return Promise.resolve({ status: 200 });
} });
const instance = rum.init({ consent: 'granted', serviceName: 'checkout', release: 'test-release', ingestKey: 'test-key' });
instance.setContext({ plan: 'test' });
const traceparent = instance.traceHeaders().traceparent;
assert.match(traceparent, /^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/);
instance.captureException(new Error('bundle-test'));
instance.flush();
assert.equal(requests.length, 1);
assert.equal(requests[0].url, 'https://otel.k6n.net/v1/logs?key=test-key');
const payload = JSON.parse(requests[0].options.body);
assert.equal(payload.resourceLogs[0].resource.attributes[0].value.stringValue, 'checkout');
const record = payload.resourceLogs[0].scopeLogs[0].logRecords[0];
assert.equal(record.body.stringValue, 'bundle-test');
assert.equal(record.traceId, traceparent.slice(3, 35));
assert.ok(record.attributes.some(attribute => attribute.key === 'service.release' && attribute.value.stringValue === 'test-release'));
});
test('minified Todo exposes the standalone API without auto-initializing', () => {
const todoSource = readFileSync(new URL('../static/otel-todo.js', import.meta.url), 'utf8');
const window = {};
vm.runInNewContext(todoSource, { window, document: { currentScript: null } });
assert.equal(typeof window.OtelTodo.init, 'function');
assert.equal(window.OtelTodo.instance, undefined);
});
test('anonymous RUM stays active without storage, identifying context, or credentials', () => {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push({ url, options });
return Promise.resolve({ status: 200 });
} });
for (const storage of ['sessionStorage', 'localStorage']) {
Object.defineProperty(rum.window, storage, { get() { throw new Error('Storage must not be accessed'); } });
}
rum.window.location = { href: 'https://example.com/users/private?token=secret#email' };
rum.window.navigator = { userAgent: 'identifying-agent' };
const instance = rum.init();
assert.equal(instance.getConsent(), 'denied');
instance.setContext({ user: 'private-user' });
instance.addBreadcrumb('private-category', 'private-breadcrumb');
instance.captureException(new TypeError('private-error'), { email: 'private-email' });
instance.flush();
assert.equal(requests.length, 1);
assert.equal(requests[0].options.credentials, 'omit');
assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
assert.doesNotMatch(requests[0].options.body, /private|secret|identifying-agent|session.id|breadcrumbs|stacktrace/);
const record = JSON.parse(requests[0].options.body).resourceLogs[0].scopeLogs[0].logRecords[0];
assert.equal(record.body.stringValue, 'Frontend event');
assert.ok(record.attributes.some(attribute => attribute.key === 'exception.type' && attribute.value.stringValue === 'TypeError'));
});
test('grant and withdrawal discard queued data and require fresh identifying context', () => {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push({ url, options });
return Promise.resolve({ status: 200 });
} });
const instance = rum.init();
instance.captureMessage('before-grant');
instance.setConsent('granted');
instance.setContext({ user: 'private-user' });
instance.captureMessage('accepted');
instance.flush();
assert.match(requests[0].options.body, /accepted|private-user/);
assert.doesNotMatch(requests[0].options.body, /before-grant/);
instance.captureMessage('queued-private-data');
instance.setConsent('denied');
instance.captureMessage('after-withdrawal');
instance.flush();
assert.equal(requests.length, 2);
assert.doesNotMatch(requests[1].options.body, /queued-private-data|private-user|session.id|after-withdrawal/);
assert.equal(instance.getConsent(), 'denied');
});
test('minimal policy sends only explicitly sanitized diagnostics without visitor context', () => {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push({ url, options });
return Promise.resolve({ status: 200 });
} });
rum.window.location = { href: 'https://example.com/users/private?token=secret' };
const instance = rum.init({
minimalDiagnostics: {
route: '/users/:id',
sanitize(event) {
assert.equal(event.message, 'Cannot render private-user');
return { message: 'Cannot render profile', code: 'PROFILE_RENDER', stacktrace: 'at renderProfile (app.js:10:2)' };
},
},
});
instance.setContext({ user: 'private-user' });
instance.captureException(new TypeError('Cannot render private-user'), { token: 'secret' });
instance.flush();
assert.equal(instance.getPrivacyMode(), 'minimal');
assert.match(requests[0].options.body, /Cannot render profile|PROFILE_RENDER|renderProfile/);
assert.doesNotMatch(requests[0].options.body, /private-user|secret|session.id|browser.url|breadcrumbs/);
const record = JSON.parse(requests[0].options.body).resourceLogs[0].scopeLogs[0].logRecords[0];
assert.equal(record.body.stringValue, 'Cannot render profile');
assert.ok(record.attributes.some(attribute => attribute.key === 'http.route' && attribute.value.stringValue === '/users/:id'));
});
test('missing, rejecting, failing, or malformed sanitizers never expose raw errors', () => {
for (const sanitize of [undefined, () => null, () => { throw new Error('private'); }, () => ({ message: 123 }), () => ({ message: '' })]) {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push(options);
return Promise.resolve({ status: 200 });
} });
const instance = rum.init({ minimalDiagnostics: { sanitize } });
instance.captureMessage('private-error');
instance.flush();
assert.doesNotMatch(requests[0].body, /private-error/);
assert.equal(JSON.parse(requests[0].body).resourceLogs[0].scopeLogs[0].logRecords[0].body.stringValue, 'Frontend event');
}
});
test('development mode requires an explicit exact hostname allowlist and is revocable', () => {
for (const allowedHosts of [[], ['example.com'], ['dev.example.com.evil'], ['dev.example.com']]) {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push(options);
return Promise.resolve({ status: 200 });
} });
rum.window.location = { href: 'https://dev.example.com/private?token=secret' };
const instance = rum.init({ devMode: true, devModeHosts: allowedHosts });
const allowed = allowedHosts.includes('dev.example.com');
assert.equal(instance.getPrivacyMode(), allowed ? 'development' : 'counts');
assert.equal(instance.getConsent(), 'denied');
instance.setContext({ user: 'private-user' });
instance.captureMessage('actual-dev-error');
instance.flush();
assert.equal(requests[0].body.includes('actual-dev-error'), allowed);
assert.equal(requests[0].body.includes('private-user'), allowed);
assert.equal(requests[0].credentials, 'omit');
instance.captureMessage('queued-private-error');
instance.setConsent('denied');
instance.captureMessage('after-revocation');
instance.flush();
assert.equal(instance.getPrivacyMode(), 'counts');
assert.doesNotMatch(requests[1].body, /queued-private-error|after-revocation|private-user|secret/);
}
});
test('anonymous vitals retain numeric performance data without identifying metadata', () => {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push({ url, options });
return Promise.resolve({ status: 200 });
} });
rum.window.location = { href: 'https://example.com/private?email=private' };
rum.window.navigator = { userAgent: 'private-agent', sendBeacon() { assert.fail('Beacon can send cookies'); } };
let onMetric;
rum.window.webVitals = { onLCP: callback => { onMetric = callback; } };
const instance = rum.init({ captureWebVitals: true, captureWebVitalsWithoutConsent: true });
rum.scripts[0].onload();
const metric = {
name: 'LCP', value: 1234, delta: 1234, id: 'private-metric-id',
attribution: { target: '#private-user', url: '/private', timeToFirstByte: 123 },
};
onMetric(metric);
rum.fire('pagehide');
const anonymous = JSON.parse(requests[0].options.body);
assert.equal(anonymous.metrics.LCP.value, 1234);
assert.equal(anonymous.metrics.LCP.attribution.timeToFirstByte, 123);
assert.equal(anonymous.session, '');
assert.match(anonymous.pageView, /^[a-f0-9]{16}$/);
assert.doesNotMatch(requests[0].options.body, /private/);
assert.equal(requests[0].options.credentials, 'omit');
assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
instance.setConsent('granted');
rum.fire('pagehide');
assert.equal(requests.length, 1);
onMetric(metric);
rum.fire('pagehide');
assert.match(requests[1].options.body, /private/);
assert.notEqual(JSON.parse(requests[1].options.body).pageView, anonymous.pageView);
onMetric(metric);
instance.setConsent('denied');
rum.fire('pagehide');
assert.equal(requests.length, 2);
onMetric(metric);
rum.fire('pagehide');
assert.doesNotMatch(requests[2].options.body, /private/);
});
test('declarative consent is opt-in and explicit denial takes precedence', () => {
for (const granted of [true, false]) {
const rum = setup({ attributes: { 'data-consent': 'granted' } });
const instance = rum.init(granted ? {} : { consent: 'denied' });
assert.equal(instance.getConsent(), granted ? 'granted' : 'denied');
}
});
test('Web Vitals loads only after permission and stops reporting on withdrawal', () => {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push(options);
return Promise.resolve({ status: 200 });
} });
let onMetric;
rum.window.webVitals = { onLCP: callback => { onMetric = callback; } };
const instance = rum.init({ captureWebVitals: true });
assert.equal(rum.scripts.length, 0);
instance.setConsent('granted');
assert.equal(rum.scripts.length, 1);
rum.scripts[0].onload();
onMetric({ name: 'LCP', value: 100, delta: 100, id: 'test' });
rum.fire('pagehide');
assert.equal(requests.length, 1);
instance.setConsent('denied');
onMetric({ name: 'LCP', value: 200, delta: 100, id: 'test' });
rum.fire('pagehide');
assert.equal(requests.length, 1);
instance.setConsent('granted');
assert.equal(rum.scripts.length, 1);
onMetric({ name: 'LCP', value: 300, delta: 100, id: 'test' });
rum.fire('pagehide');
assert.equal(requests.length, 2);
});
test('permission revoked during the vitals download does not start metric subscriptions', () => {
const rum = setup();
let subscribed = false;
rum.window.webVitals = { onLCP() { subscribed = true; } };
const instance = rum.init({ consent: 'granted', captureWebVitals: true });
instance.setConsent('denied');
rum.scripts[0].onload();
assert.equal(subscribed, false);
instance.setConsent('granted');
assert.equal(rum.scripts.length, 2);
rum.scripts[1].onload();
assert.equal(subscribed, true);
});
test('explicit false overrides development and independently permitted vitals attributes', () => {
const rum = setup({ attributes: {
'data-dev-mode': 'true',
'data-dev-mode-hosts': 'dev.example.com',
'data-vitals-without-consent': 'true',
} });
rum.window.location = { href: 'https://dev.example.com/' };
const instance = rum.init({ devMode: false, captureWebVitals: true, captureWebVitalsWithoutConsent: false });
assert.equal(instance.getPrivacyMode(), 'counts');
assert.equal(rum.scripts.length, 0);
});
test('development mode works declaratively without fabricating a consent grant', () => {
const rum = setup({ attributes: { 'data-dev-mode': 'true', 'data-dev-mode-hosts': 'localhost, dev.example.com' } });
rum.window.location = { href: 'https://dev.example.com/' };
const instance = rum.init({ captureWebVitals: true });
assert.equal(instance.getPrivacyMode(), 'development');
assert.equal(instance.getConsent(), 'denied');
assert.equal(rum.scripts.length, 1);
instance.setConsent('denied');
assert.equal(instance.getPrivacyMode(), 'counts');
});
test('withdrawal restores the independently configured minimal diagnostics policy', () => {
const requests = [];
const rum = setup({ fetch: (url, options) => {
requests.push(options);
return Promise.resolve({ status: 200 });
} });
const instance = rum.init({
consent: 'granted',
minimalDiagnostics: { sanitize: () => ({ message: 'Safe diagnostic' }) },
});
instance.captureMessage('queued-private');
instance.setConsent('denied');
instance.captureMessage('private-content');
instance.flush();
assert.equal(instance.getPrivacyMode(), 'minimal');
assert.match(requests[0].body, /Safe diagnostic/);
assert.doesNotMatch(requests[0].body, /private/);
});
test('credential-free telemetry never falls back to a cookie-bearing beacon', async () => {
for (const consent of ['denied', 'granted']) {
let beacons = 0;
const rum = setup({ fetch: () => Promise.reject(new Error('offline')) });
rum.window.navigator = { sendBeacon() { beacons++; } };
const instance = rum.init({ consent });
instance.captureMessage('test');
instance.flush();
await Promise.resolve();
assert.equal(beacons, 0);
}
});
test('Todo stays usable without storage and reads current consent for every submission', () => {
const requests = [];
const handlers = new Map();
const element = () => ({
addEventListener() {},
setAttribute() {},
classList: { remove() {} },
});
const text = { value: 'A deliberately submitted note' };
const ref = { ...element(), querySelector: () => element() };
const form = {
elements: { namedItem: name => name === 'text' ? text : { value: 'Cart' } },
querySelector: selector => selector === '.otel-todo-ref' ? ref : element(),
addEventListener: (name, handler) => handlers.set(name, handler),
};
const dialog = { ...element(), firstElementChild: form };
const document = {
currentScript: null,
createElement: tag => tag === 'dialog' ? dialog : element(),
head: { appendChild() {} },
body: { appendChild() {} },
};
const window = {
addEventListener() {},
fetch(url, options) {
requests.push({ url, options });
return Promise.resolve({ ok: true });
},
};
for (const storage of ['sessionStorage', 'localStorage']) {
Object.defineProperty(window, storage, { get() { assert.fail('Todo must not access storage'); } });
}
vm.runInNewContext(readFileSync(new URL('../static/otel-todo.js', import.meta.url), 'utf8'), {
window, document,
location: { pathname: '/private-user', hash: '#private', href: 'https://example.com/private' },
navigator: { userAgent: 'private-agent' },
});
const instance = window.OtelTodo.init({ hideButton: true });
const submit = () => handlers.get('submit')({ preventDefault() {} });
submit();
assert.equal(JSON.parse(requests[0].options.body).text, text.value);
assert.doesNotMatch(requests[0].options.body, /private/);
assert.equal(requests[0].options.credentials, 'omit');
assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
instance.setConsent('granted');
submit();
assert.match(requests[1].options.body, /private/);
instance.setConsent('denied');
submit();
assert.doesNotMatch(requests[2].options.body, /private/);
});