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/); });