openvibers/openvibe.live Commits

Real-user measurements: on a quarter of page loads, once idle, the browser reports its own TTFB/FCP/LCP/CLS/INP/blocking time and up to three script errors in one beacon; kept only as aggregates per page kind and device (staff: /api/admin/rum, /metrics: live_rum_p75); the privacy page says so (#217)

OP OpenVibers committed 27 days ago · 6b701fb3e1 · 6 files, +253 −4

Changes

ddiff --git a/public/js/app.js b/public/js/app.js
iindex 4b4eeca5..bb490b21 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -2382,9 +2382,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Start proactive token refresh timer
startTokenRefreshTimer();
-- // Theme is owned centrally by openvibe.network and applied by the shared OpenVibeThemeLoader
-- // (which syncs from openvibe.network after paint). We no longer read OpenVibe.Live's local
-- // theme store, so the theme chosen at openvibe.network/themes is authoritative here.
++ // The theme is openvibe.network's (the shared OpenVibeThemeLoader); Live keeps no theme of its own.
// Route from current URL instead of always going home — but not in the popout
// chat window, which drives its own chat UI and has no SPA pages to route to.
@@@ -2392,6 +2390,8 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!location.pathname.startsWith('/popout')) {
routeFromURL();
}
++ // Real-user timings and errors on one page load in four, once idle (js/ov-rum.js, server/web/rum.js).
++ if (Math.random() < 0.25 && window.ov) (window.requestIdleCallback || setTimeout)(() => ov.loadScript('/js/ov-rum.js'));
});
// Handle browser back/forward — wait for auth to be resolved first
ddiff --git a/public/js/ov-rum.js b/public/js/ov-rum.js
nnew file mode 100644
iindex 00000000..3fc04e7b
---- /dev/null
++++ b/public/js/ov-rum.js
@@@ -0,0 +1,41 @@
++/* Real-user measurements (server/web/rum.js): this page load's own timings and up to three script errors, sent once
++ * when the page is hidden. Loaded by app.js on one page load in four, after the page is idle; nothing identifies
++ * the visitor (no account, no id, no cookie). The observers read buffered entries, so the paints before this
++ * script loaded still count.
++ */
++(function () {
++ 'use strict';
++ if (!('PerformanceObserver' in window) || !navigator.sendBeacon) return;
++ const m = { fcp: 0, lcp: 0, cls: 0, inp: 0, tbt: 0, errs: [] };
++ const watch = (type, fn, extra) => {
++ try { new PerformanceObserver((list) => list.getEntries().forEach(fn)).observe(Object.assign({ type, buffered: true }, extra)); } catch (e) { /* not in this browser */ }
++ };
++ watch('paint', (e) => { if (e.name === 'first-contentful-paint') m.fcp = e.startTime; });
++ watch('largest-contentful-paint', (e) => { m.lcp = e.startTime; });
++ // CLS as web-vitals defines it: the worst window of shifts under 1 s apart, at most 5 s long.
++ let win = 0, first = 0, last = 0;
++ watch('layout-shift', (e) => {
++ if (e.hadRecentInput) return;
++ if (e.startTime - last > 1000 || e.startTime - first > 5000) { win = 0; first = e.startTime; }
++ win += e.value; last = e.startTime;
++ if (win > m.cls) m.cls = win;
++ });
++ watch('longtask', (e) => { if (m.fcp && e.startTime > m.fcp) m.tbt += Math.max(0, e.duration - 50); });
++ watch('event', (e) => { if (e.interactionId && e.duration > m.inp) m.inp = e.duration; }, { durationThreshold: 40 });
++ const err = (msg) => { if (m.errs.length < 3) m.errs.push(String(msg).slice(0, 140)); };
++ addEventListener('error', (e) => err(e.message || 'error'));
++ addEventListener('unhandledrejection', (e) => err('unhandled rejection: ' + ((e.reason && e.reason.message) || e.reason)));
++ let sent = false;
++ const send = () => {
++ if (sent) return;
++ sent = true;
++ const nav = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
++ const r = (v) => Math.round(v);
++ navigator.sendBeacon('/api/rum', JSON.stringify({
++ path: location.pathname, w: innerWidth, ttfb: nav ? r(nav.responseStart) : 0,
++ fcp: r(m.fcp), lcp: r(m.lcp), cls: Math.round(m.cls * 1000) / 1000, inp: r(m.inp), tbt: r(m.tbt), errs: m.errs,
++ }));
++ };
++ addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') send(); });
++ addEventListener('pagehide', send);
++})();
ddiff --git a/public/privacy.html b/public/privacy.html
iindex 6987daf8..7514398c 100644
---- a/public/privacy.html
++++ b/public/privacy.html
@@@ -206,7 +206,7 @@
<main class="wrap">
<div class="page-header">
<h1><i class="fa-solid fa-user-shield"></i> Privacy Policy</h1>
-- <p class="updated">Applies to the OpenVibe network — openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.games &nbsp;·&nbsp; Last updated: April 2026</p>
++ <p class="updated">Applies to the OpenVibe network — openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.games &nbsp;·&nbsp; Last updated: October 2026</p>
<div class="philosophy">
<strong>The short version:</strong> We collect the least we can get away with, we don't sell it, and we don't run creepy ad-tech on you. We're a community project with no investors to please. Your data exists to run the service — stream your stuff, keep you logged in, and power the optional AI features — not to be packaged up and auctioned off.
</div>
@@@ -220,6 +220,7 @@
<li class="yes"><span class="rule-icon"><i class="fa-solid fa-user"></i></span><span><strong>Account info.</strong> Your username, email, and profile details — handled through the OpenVibe single sign-on (openvibe.network). We use it to identify you and keep you logged in.</span></li>
<li class="yes"><span class="rule-icon"><i class="fa-solid fa-video"></i></span><span><strong>Content you create.</strong> Streams, VODs, clips, pastes, chat messages, themes, and anything else you post. You choose whether each is public, unlisted, or private.</span></li>
<li class="yes"><span class="rule-icon"><i class="fa-solid fa-server"></i></span><span><strong>Technical basics.</strong> IP address, browser/user-agent, and timestamps — used for security, abuse prevention, rate limiting, and rough view counts. We don't build advertising profiles.</span></li>
++ <li class="yes"><span class="rule-icon"><i class="fa-solid fa-gauge-high"></i></span><span><strong>Page speed.</strong> On some page loads on openvibe.live, your browser reports how quickly the page loaded and the text of any script error, with the page's address and your screen width. Nothing that identifies you goes with it, and we keep only totals per kind of page.</span></li>
<li class="yes"><span class="rule-icon"><i class="fa-solid fa-coins"></i></span><span><strong>Payments.</strong> If you buy Vibes or tip, payment processors handle the card details — we never see or store your full card number.</span></li>
</ul>
</div>
ddiff --git a/server/index.js b/server/index.js
iindex 4bc60084..e4eb181f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -424,6 +424,10 @@ app.use('/whip', whipHandler.whipCors);
// Views counted from openvibe.video's watch pages (a beacon from the viewer's browser): its own narrow
// origin rule and no cookie, so it sits before the credentialed allowlist too.
app.use('/api/views', require('./media-proxy/view-beacon').createViewBeacon({ rateLimit }));
++// Real-user timings and script errors (server/web/rum.js): a same-origin beacon from public/js/ov-rum.js, kept only as
++// aggregates (no address, account or user agent). Staff read GET /api/admin/rum; /metrics has live_rum_p75.
++const rum = require('./web/rum').createRum();
++app.use('/api/rum', rum.router);
const allowlistedCors = cors({
origin(origin, callback) {
@@@ -875,6 +879,8 @@ observability.registerDomainGauges(metricsRegistry, {
wsServers: { broadcast: broadcastServer },
outboxStatus: async () => await require('./events/stream-events').counts(),
});
++metricsRegistry.gauge({ name: 'live_rum_p75', help: 'Real-user p75 per page kind and device class (ms; cls unitless), from the newest beacons', labelNames: ['metric', 'page', 'device'], collect: () => rum.p75Gauge() });
++app.get('/api/admin/rum', requireAuth, permissions.requireAdmin, (req, res) => { res.set('Cache-Control', 'no-store'); res.json(rum.snapshot()); });
// ── Updates / Changelog ──────────────────────────────────────
const { execFile } = require('child_process');
ddiff --git a/server/web/rum.js b/server/web/rum.js
nnew file mode 100644
iindex 00000000..a3b97e11
---- /dev/null
++++ b/server/web/rum.js
@@@ -0,0 +1,119 @@
++'use strict';
++/**
++ * Real-user measurements: how fast pages are on visitors' own devices, and the script errors they hit. Synthetic
++ * checks (scripts/perf, the browser smoke) run on one emulated phone; this is what people actually get.
++ *
++ * public/js/ov-rum.js runs on one page load in four, after the page is idle, and sends one beacon when the page is
++ * hidden: the path, the viewport width, and its own timings (TTFB, FCP, LCP, CLS, INP, blocking time) plus at most
++ * three error messages. Nothing about the visitor is kept: no IP, no account, no user agent. Each beacon becomes
++ * numbers in a reservoir per page kind and device class, and errors are counted by message.
++ *
++ * POST /api/rum the beacon (text/plain JSON from navigator.sendBeacon); always 204
++ * rum.snapshot() p50/p75/p95 per page kind × device, and the most frequent errors (GET /api/admin/rum)
++ * rum.p75Gauge() the p75s for /metrics (live_rum_p75{metric,page,device})
++ */
++const express = require('express');
++
++const METRICS = { ttfb: 60000, fcp: 60000, lcp: 60000, inp: 60000, tbt: 120000, cls: 10 };
++const KEEP = 400; // samples per reservoir (the newest)
++const MAX_ERRORS = 100; // distinct error messages kept
++const PER_MINUTE = 20; // beacons per address per minute (held in memory for that minute only)
++
++function pageKind(path) {
++ const p = String(path || '/');
++ if (p === '/') return 'home';
++ if (/^\/@[^/]+\/?$/.test(p)) return 'channel';
++ if (/^\/vod\//.test(p)) return 'vod';
++ if (/^\/clip\//.test(p)) return 'clip';
++ if (/^\/(chat|broadcast|dashboard|moments|content|arena|search)(\/|$)/.test(p)) return p.split('/')[1];
++ return 'other';
++}
++const deviceClass = (w) => (w > 0 && w < 600 ? 'phone' : w > 0 && w < 1024 ? 'tablet' : 'desktop');
++const pct = (sorted, q) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] : null);
++const round = (v, metric) => (metric === 'cls' ? Math.round(v * 1000) / 1000 : Math.round(v));
++
++function createRum({ now = () => Date.now() } = {}) {
++ const buckets = new Map(); // "page|device" -> { n, samples: { metric: [] } }
++ const errors = new Map(); // message -> count
++ const hits = new Map(); // address -> { minute, n }
++
++ function record(body) {
++ if (!body || typeof body !== 'object') return false;
++ const key = `${pageKind(body.path)}|${deviceClass(Number(body.w))}`;
++ let b = buckets.get(key);
++ if (!b) { b = { n: 0, samples: Object.fromEntries(Object.keys(METRICS).map((m) => [m, []])) }; buckets.set(key, b); }
++ b.n++;
++ for (const [m, max] of Object.entries(METRICS)) {
++ const v = Number(body[m]);
++ if (!Number.isFinite(v) || v < 0 || v > max) continue;
++ if (m !== 'cls' && m !== 'tbt' && m !== 'inp' && v === 0) continue; // not measured (0 is real only for these)
++ const arr = b.samples[m];
++ arr.push(v);
++ if (arr.length > KEEP) arr.shift();
++ }
++ for (const raw of Array.isArray(body.errs) ? body.errs.slice(0, 3) : []) {
++ const msg = String(raw || '').replace(/\s+/g, ' ').trim().slice(0, 140);
++ if (!msg) continue;
++ errors.set(msg, (errors.get(msg) || 0) + 1);
++ if (errors.size > MAX_ERRORS) {
++ let least = null;
++ for (const [k, c] of errors) if (k !== msg && (!least || c < errors.get(least))) least = k;
++ errors.delete(least);
++ }
++ }
++ return true;
++ }
++
++ function allowed(addr) {
++ const minute = Math.floor(now() / 60000);
++ const h = hits.get(addr);
++ if (!h || h.minute !== minute) {
++ if (hits.size > 5000) hits.clear();
++ hits.set(addr, { minute, n: 1 });
++ return true;
++ }
++ return ++h.n <= PER_MINUTE;
++ }
++
++ const router = express.Router();
++ router.post('/', express.text({ type: () => true, limit: '4kb' }), (req, res) => {
++ res.status(204).end();
++ const addr = req.headers['cf-connecting-ip'] || req.ip || '';
++ if (!allowed(addr)) return;
++ let body = null;
++ try { body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; } catch { body = null; }
++ record(body);
++ });
++
++ function snapshot() {
++ const pages = {};
++ for (const [key, b] of buckets) {
++ const out = { beacons: b.n };
++ for (const [m, arr] of Object.entries(b.samples)) {
++ if (!arr.length) continue;
++ const s = [...arr].sort((x, y) => x - y);
++ out[m] = { n: s.length, p50: round(pct(s, 0.5), m), p75: round(pct(s, 0.75), m), p95: round(pct(s, 0.95), m) };
++ }
++ pages[key] = out;
++ }
++ const errs = [...errors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25).map(([message, count]) => ({ message, count }));
++ return { pages, errors: errs };
++ }
++
++ function p75Gauge() {
++ const out = [];
++ for (const [key, b] of buckets) {
++ const [page, device] = key.split('|');
++ for (const [m, arr] of Object.entries(b.samples)) {
++ if (arr.length < 5) continue; // too few to mean anything
++ const s = [...arr].sort((x, y) => x - y);
++ out.push({ labels: { metric: m, page, device }, value: round(pct(s, 0.75), m) });
++ }
++ }
++ return out;
++ }
++
++ return { router, record, snapshot, p75Gauge };
++}
++
++module.exports = { createRum, pageKind, deviceClass };
ddiff --git a/test/rum.test.js b/test/rum.test.js
nnew file mode 100644
iindex 00000000..1c541079
---- /dev/null
++++ b/test/rum.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++/**
++ * Real-user measurements (server/web/rum.js, public/js/ov-rum.js): a beacon becomes numbers in a reservoir per page
++ * kind and device class, with nothing about the visitor kept. Checked here:
++ * - POST /api/rum takes the text/plain JSON navigator.sendBeacon sends and always answers 204;
++ * - page kinds and device classes; out-of-range or unmeasured values are left out; CLS keeps 3 decimals;
++ * - percentiles in the staff snapshot and the p75 gauge (only once a reservoir has 5 samples);
++ * - error messages are counted (at most 3 per beacon, at most 100 distinct); junk and floods are dropped;
++ * - app.js loads the script on a quarter of page loads, after the page is idle.
++ *
++ * node test/rum.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const { createRum, pageKind, deviceClass } = require('../server/web/rum');
++
++(async () => {
++ assert.deepStrictEqual(['/', '/@goosely', '/vod/5', '/clip/9', '/chat', '/broadcast', '/dashboard', '/docs/whip', '/@a/b'].map(pageKind),
++ ['home', 'channel', 'vod', 'clip', 'chat', 'broadcast', 'dashboard', 'other', 'other']);
++ assert.deepStrictEqual([390, 800, 1440, 0].map(deviceClass), ['phone', 'tablet', 'desktop', 'desktop']);
++
++ let t = 0;
++ const rum = createRum({ now: () => t });
++ const app = express();
++ app.set('trust proxy', true);
++ app.use('/api/rum', rum.router);
++ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const beacon = (body, ip = '10.0.0.1') => fetch(`${base}/api/rum`, { method: 'POST', headers: { 'content-type': 'text/plain;charset=UTF-8', 'cf-connecting-ip': ip }, body: typeof body === 'string' ? body : JSON.stringify(body) });
++
++ // Ten phone loads of the home page.
++ for (let i = 1; i <= 10; i++) {
++ const r = await beacon({ path: '/', w: 390, ttfb: 100 * i, fcp: 800 + i, lcp: 1000 * i, cls: 0.0123, inp: 0, tbt: 50 * i, errs: i === 1 ? ['TypeError: x is undefined', 'b', 'c', 'd'] : [] }, `10.0.0.${i}`);
++ assert.strictEqual(r.status, 204);
++ }
++ // Out of range, unmeasured and junk.
++ await beacon({ path: '/', w: 390, lcp: 999999, fcp: 0, cls: 50 }, '10.0.1.1');
++ assert.strictEqual((await beacon('not json', '10.0.1.2')).status, 204);
++ await beacon({ path: '/@goosely', w: 1440, lcp: 2500 }, '10.0.1.3');
++
++ const snap = rum.snapshot();
++ const home = snap.pages['home|phone'];
++ assert.strictEqual(home.beacons, 11, 'every well-formed beacon counts');
++ assert.strictEqual(home.lcp.n, 10, 'an LCP over 60 s is left out');
++ assert.deepStrictEqual([home.lcp.p50, home.lcp.p75, home.lcp.p95], [6000, 8000, 10000]);
++ assert.strictEqual(home.fcp.n, 10, 'an unmeasured (0) FCP is left out');
++ assert.strictEqual(home.cls.n, 10, 'a CLS over 10 is left out');
++ assert.strictEqual(home.cls.p75, 0.012);
++ assert.strictEqual(home.inp.n, 10, 'INP 0 is a real value (no slow interaction)');
++ assert.strictEqual(snap.pages['channel|desktop'].lcp.p50, 2500);
++ assert.deepStrictEqual(snap.errors.map((e) => e.message), ['TypeError: x is undefined', 'b', 'c'], 'at most three errors per beacon');
++ // The gauge: p75 per metric, only for reservoirs with at least 5 samples.
++ const g = rum.p75Gauge();
++ assert.ok(g.some((x) => x.labels.metric === 'lcp' && x.labels.page === 'home' && x.labels.device === 'phone' && x.value === 8000));
++ assert.ok(!g.some((x) => x.labels.page === 'channel'), 'one channel sample is not a p75');
++
++ // A flood from one address: 20 a minute, then dropped until the next minute.
++ const before = rum.snapshot().pages['clip|phone'];
++ for (let i = 0; i < 25; i++) await beacon({ path: '/clip/1', w: 390, lcp: 1000 }, '10.9.9.9');
++ assert.strictEqual(before, undefined);
++ assert.strictEqual(rum.snapshot().pages['clip|phone'].beacons, 20);
++ t += 60_000;
++ await beacon({ path: '/clip/1', w: 390, lcp: 1000 }, '10.9.9.9');
++ assert.strictEqual(rum.snapshot().pages['clip|phone'].beacons, 21);
++
++ // At most 100 distinct error messages: the rarest (the oldest among equals) make room; a frequent one stays.
++ rum.record({ path: '/', w: 390, errs: ['TypeError: x is undefined'] });
++ for (let i = 0; i < 120; i++) rum.record({ path: '/', w: 390, errs: [`err ${i}`] });
++ assert.ok(rum.snapshot().errors.length <= 25 && rum.snapshot().errors[0].message === 'TypeError: x is undefined');
++ server.close();
++
++ // app.js: a quarter of page loads, once idle, through the runtime's versioned loader.
++ const app_js = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');
++ assert.ok(app_js.includes("if (Math.random() < 0.25 && window.ov) (window.requestIdleCallback || setTimeout)(() => ov.loadScript('/js/ov-rum.js'));"));
++ const client = fs.readFileSync(path.join(__dirname, '../public/js/ov-rum.js'), 'utf8');
++ assert.ok(client.includes("navigator.sendBeacon('/api/rum'") && !/cookie|localStorage|userAgent/i.test(client.replace(/\/\*[\s\S]*?\*\//g, '')), 'the beacon carries no identifier');
++
++ console.log('rum: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });