openvibers/openvibe.shared Commits
v1.18.0: update telemetry (roadmap WS-P task 14): release-watch counts the update prompt (prompted: optional, required, window, contract) and beats every 5 minutes with a random in-memory tab id and its release, saying ended on pagehide; the collector keeps tabs heard from in 12 minutes as release_client_sessions{generation=current|older} against the release the process serves. release-watch stays under its 5 KB brotli budget (4.96 KB) by shortening comments
Changes
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 1.18.0 — 2026-09-26++++**Update telemetry** (roadmap WS-P task 14).++- **Prompts are counted.** `release-watch.js` records `prompted` when it shows the update prompt, with reason `optional`, or `required`, `window` or `contract` when a reload must follow. `release.js` accepts the new outcome into `release_client_updates_total`.++- **Sessions by generation.** Each tab sends a beat to the metrics URL once its manifest is read and then every 5 minutes, carrying a random 16-hex id kept in memory only and the release it runs. On `pagehide` it sends `ended`. The collector keeps the tabs heard from in the last 12 minutes, at most 20,000 per registry, and exposes the gauge `release_client_sessions{generation="current"|"older"}`. `current` is the release the process serves: `mount` and `collect` pass it, or pass `collect(registry, { currentRelease })` yourself. Sec-GPC/DNT requests are still not counted.++- **Size.** release-watch stays within its 5 KB brotli budget (4.96 KB); comments were shortened to make room.++## 1.17.1 — 2026-09-26Fix: `release-watch.js` acts on a `host.release.published` event only when its `origin` (when present) is the page's own. A service id alone is not unique across the network: Sites' placeholder pages reuse product ids (`ai`, `coupons`, …), so a placeholder's release made the real product's tabs check for nothing.@@@ -214,10 +214,13 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele- **CSP.** The site's CSP `connect-src` must allow `https://events.openvibe.network`. Otherwise thebrowser refuses the stream once, and release-watch stops (state `blocked`) and keeps polling.- **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.--- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `applied`,-- `reloaded`, `deferred` or `failed`. The tab finds the endpoint in the manifest's `metrics_url`-- (set by `mount`), the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch:-- { metricsUrl } })`.++- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `prompted`++ (reason `optional`, `required`, `window` or `contract`), `applied`, `reloaded`, `deferred` or++ `failed`. `release_client_sessions{generation}` counts open tabs heard from in the last 12++ minutes, `current` when they run the release this process serves and `older` otherwise: each tab++ beats every 5 minutes with a random id it keeps in memory only (never tied to an account) and++ says when it ends. The tab finds the endpoint in the manifest's `metrics_url` (set by `mount`),++ the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch: { metricsUrl } })`.Mixed-version test: capture the previous release's `/release.json` (its `full()` form) and thecalls its pages make as fixtures, then:@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "1.17.1",++ "version": "1.18.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.17.1",++ "version": "1.18.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.17.1",++ "version": "1.18.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -5,7 +5,8 @@* server changes are applied in place; anything else prompts. It reloads by itself only when it must* (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle* 2 minutes, no focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing-- * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46).++ * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46), with a++ * 5-minute beat (random tab id, release) for sessions by generation.* Release notifications (1.17.0, WS-P task 9): one anonymous EventSource on the Events realtime stream* (topic host.release.published, public). An event naming this page's service with a release it neither* runs nor knows runs check(true) after a 0-20 s jitter, at most once per 30 s. Polling stays the fallback.@@@ -32,6 +33,8 @@let busyWith = null; // the plan/apply in flightlet pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;let counts = {}; const totals = {}; const noted = {};++ let session = null; let lastBeat = 0;++ try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ }['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));@@@ -41,12 +44,17 @@if (once) { if (noted[k]) return; noted[k] = 1; }for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }}-- function flush() {++ // beat: send even with nothing counted; ended: the tab is going++ function flush(beat, ended) {const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);-- if (!to || !Object.keys(counts).length) return;++ const any = Object.keys(counts).length > 0;++ if (!to || (!any && !(beat === true && session && current))) return;try {if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;-- const body = JSON.stringify({ service: latest && latest.service, release: current, to: latest && latest.release, counts });++ const report = { service: latest && latest.service, release: current, to: latest && latest.release };++ if (any) report.counts = counts;++ if (session) { report.session = session; lastBeat = Date.now(); if (ended === true) report.ended = true; }++ const body = JSON.stringify(report);counts = {};const n = root.navigator;if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});@@@ -78,6 +86,7 @@function prompt() {if (prompted) return;prompted = true;++ record('prompted', mustReload ? reloadWhy : 'optional');if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });}@@@ -167,11 +176,9 @@return m;}-- // ── Release notifications: OpenVibe.Host publishes host.release.published (public) to OpenVibe.Events ──-- // One EventSource per tab, without credentials: the events are public, so an account switch changes-- // nothing (and a second load of this script returns early). Closed after 5 min hidden (the poll and the-- // visibility check cover a hidden tab), reopened on return with last_event_id; errors back off 30 s to-- // 15 min, and after 6 failures in a row only polling is left.++ // ── Release notifications (host.release.published, public, via OpenVibe.Events) ──++ // One credential-less EventSource per tab (account switches change nothing). Closed after 5 min hidden,++ // reopened with last_event_id; errors back off 30 s to 15 min; after 6 failures only polling is left.const TOPIC = 'host.release.published';const HEX = /^[0-9a-f]{7,40}$/;const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastSeq: null };@@@ -224,8 +231,7 @@if (!p || ev.event_type !== TOPIC) return;if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }if (p.service !== rt.service || typeof p.release !== 'string') return;-- // A service id alone is not unique across the network (Sites' placeholder pages reuse product ids),-- // so when the event names the origin it went live on, it must be this page's.++ // Service ids repeat across origins (Sites' placeholders): a named origin must be this page's.if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }rt.events++;if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }@@@ -248,7 +254,7 @@let stopped = false;root.addEventListener('focus', () => { if (!stopped) check(false); });root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed') { rt.state = 'off'; rt.failures = 0; live(); } check(true); });-- root.addEventListener('pagehide', flush);++ root.addEventListener('pagehide', () => flush(true, true));document.addEventListener('visibilitychange', () => {if (stopped) return;if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }@@@ -260,6 +266,7 @@if (stopped) return;if (pending && commitRegions(pending)) adopt(pending);check(false); maybeReload();++ if (Date.now() - lastBeat >= 3e5) flush(true);}, 30 * 1000);const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);function stop() {@@@ -273,8 +280,7 @@flush,stop,};-- // The page's own manifest, if /release.json still serves the release the page was rendered from. A site-- // that serves no /release.json and rendered no meta tag is left alone.++ // The page's own manifest (if still served). No /release.json and no meta tag: left alone.lastCheck = Date.now();root.fetch(url, { cache: 'no-store', credentials: 'omit' }).then((r) => (r.ok ? r.json() : null))@@@ -284,6 +290,7 @@if (m.release === current) base = m;consider(m);live();++ flush(true);}).catch(() => { if (!current) stop(); });})(typeof window !== 'undefined' ? window : globalThis);@@@ -210,6 +210,7 @@ function deepFreeze(o) {// ── /release-metrics: what open tabs report (D46) ──────────────const OUTCOMES = {++ prompted: /^(optional|required|window|contract)$/,applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,reloaded: /^(user|required|window|contract)$/,deferred: /^(typing|dirty|protected|media|capture|active)$/,@@@ -218,6 +219,37 @@ const OUTCOMES = {const MAX_BODY = 4096;const MAX_PER_REASON = 50;const counters = new WeakMap();++// Open tabs by generation (WS-P task 14): each tab beats every 5 minutes with a random per-tab id (never++// stored in the browser, never tied to an account); a tab not heard from in 12 minutes, or that said it++// ended, is gone. At most MAX_SESSIONS are tracked per registry.++const SESSION_TTL_MS = 12 * 60 * 1000;++const MAX_SESSIONS = 20000;++const SESSION_RE = /^[0-9a-f]{16,32}$/;++const sessionStores = new WeakMap();++++function sessionStore(registry, currentRelease) {++ let st = sessionStores.get(registry);++ if (!st) {++ st = { map: new Map(), current: null };++ registry.gauge({++ name: 'release_client_sessions', help: 'Open tabs heard from in the last 12 minutes, by whether they run the release this process serves (current) or an older one',++ labelNames: ['generation'],++ collect: () => {++ const cutoff = Date.now() - SESSION_TTL_MS;++ const cur = typeof st.current === 'function' ? st.current() : null;++ let current = 0; let older = 0;++ for (const [id, v] of st.map) {++ if (v.at < cutoff) { st.map.delete(id); continue; }++ if (cur && v.release === cur) current++; else older++;++ }++ return [{ labels: { generation: 'current' }, value: current }, { labels: { generation: 'older' }, value: older }];++ },++ });++ sessionStores.set(registry, st);++ }++ if (currentRelease) st.current = currentRelease;++ return st;++}function updateCounter(registry) {let c = counters.get(registry);@@@ -244,15 +276,18 @@ function readBody(req) {}/**-- * POST handler for release-watch beacons: { counts: { applied|reloaded|deferred|failed: { reason: n } } }.-- * Counts go to release_client_updates_total{outcome,reason} in `registry` (openvibe-shared/metrics).++ * POST handler for release-watch beacons: { counts: { prompted|applied|reloaded|deferred|failed: { reason: n } },++ * session, release, ended }. Counts go to release_client_updates_total{outcome,reason} in `registry`++ * (openvibe-shared/metrics); a session beat (with or without counts) keeps that tab in++ * release_client_sessions{generation}, current when its release is `currentRelease()`, and `ended` drops it.* Unknown reasons count as "other"; each reason counts at most 50 per report; a report is at most 4 KB;* one client (req.ip, which honours the app's trust proxy; or `keyOf(req)`) sends at most `perMinute`* reports a minute. Sec-GPC/DNT requests are not counted.*/--function collector(registry, { perMinute = 30, keyOf = null } = {}) {++function collector(registry, { perMinute = 30, keyOf = null, currentRelease = null } = {}) {if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');const counter = updateCounter(registry);++ const sessions = sessionStore(registry, currentRelease);const seen = new Map(); let windowStart = Date.now();return async function releaseMetrics(req, res) {const done = (code) => { res.statusCode = code; res.setHeader('Cache-Control', 'no-store'); res.end(); };@@@ -268,10 +303,16 @@ function collector(registry, { perMinute = 30, keyOf = null } = {}) {let body = await readBody(req);if (body === null) return done(413);if (typeof body === 'string') { try { body = JSON.parse(body); } catch { return done(400); } }-- const counts = body && typeof body === 'object' ? body.counts : null;-- if (!counts || typeof counts !== 'object') return done(400);++ if (!body || typeof body !== 'object') return done(400);++ const counts = body.counts && typeof body.counts === 'object' ? body.counts : null;++ const sid = typeof body.session === 'string' && SESSION_RE.test(body.session) ? body.session : null;++ if (!counts && !sid) return done(400);++ if (sid) {++ if (body.ended === true) sessions.map.delete(sid);++ else if (sessions.map.size < MAX_SESSIONS || sessions.map.has(sid)) sessions.map.set(sid, { release: typeof body.release === 'string' ? body.release.slice(0, 80) : null, at: now });++ }for (const [outcome, re] of Object.entries(OUTCOMES)) {-- const reasons = counts[outcome];++ const reasons = counts && counts[outcome];if (!reasons || typeof reasons !== 'object') continue;for (const [reason, value] of Object.entries(reasons).slice(0, 16)) {const k = Math.min(MAX_PER_REASON, Math.floor(Number(value)));@@@ -373,7 +414,7 @@ function createRelease({function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {app.get(manifestPath, handler);if (registry) {-- app.post(mp, collector(registry, { perMinute }));++ app.post(mp, collector(registry, { perMinute, currentRelease: () => state.full.release }));if (metricsUrl !== mp) { metricsUrl = mp; build(); }}return api;@@@ -396,7 +437,7 @@ function createRelease({handler,metaTag,mount,-- collect: (registry, opts) => collector(registry, opts),++ collect: (registry, opts) => collector(registry, { currentRelease: () => state.full.release, ...opts }),refresh,validate,warnings,@@@ -67,6 +67,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec// can change something in place. Measured 2026-09-23: 3.2 KB and 3.0 KB brotli (release-watch was 1.7 KB).// 2026-09-26 (1.17.0): release-watch 4.7 KB with the release notifications over Events realtime (+1.1 KB of// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.++// 1.18.0: session beats and the prompted count (+0.25 KB) fit by shortening comments: 4.96 KB.{const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));@@@ -212,11 +213,11 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec{const p = await open({ hidden: false });await update(p, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') });-- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'incompatible contracts: a reload, but not under the user');++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { contract: 1 }, deferred: { active: 1 } }], 'incompatible contracts: a reload (prompted), but not under the user');assert.strictEqual(hrefs(p)[0], '/css/app.css?v=111111111111', 'and nothing in place');p.setHidden(true);assert.strictEqual(p.reloads, 1);-- assert.deepStrictEqual(counts(p), { deferred: { active: 1 }, reloaded: { contract: 1 } });++ assert.deepStrictEqual(counts(p), { prompted: { contract: 1 }, deferred: { active: 1 }, reloaded: { contract: 1 } });}{// A page one release behind whose server kept accepting it: in place (the N-1 → N window).@@@ -160,6 +160,34 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(vassert.strictEqual(await post({ counts: {} }), 204);assert.strictEqual(await post({ counts: {} }), 429, 'an address sends at most perMinute reports a minute');++ // Sessions by generation (WS-P task 14): a beat with a per-tab id keeps the tab in++ // release_client_sessions{generation}; current is the release this process serves; ended drops it.++ {++ const reg = createRegistry();++ let serving = 'r2';++ const c2 = r.collect(reg, { perMinute: 100, currentRelease: () => serving });++ const send = async (payload) => {++ const req = Readable.from([Buffer.from(JSON.stringify(payload))]);++ req.headers = {}; req.socket = { remoteAddress: '10.1.0.1' };++ const res = { statusCode: 0, setHeader() {}, end() {} };++ await c2(req, res);++ return res.statusCode;++ };++ const gauge = (g) => { const line = reg.metrics().split('\n').find((l) => l.startsWith(`release_client_sessions{generation="${g}"}`)); return line ? Number(line.split(' ').pop()) : null; };++ assert.strictEqual(await send({ session: 'a'.repeat(16), release: 'r2' }), 204, 'a beat needs no counts');++ assert.strictEqual(await send({ session: 'b'.repeat(16), release: 'r1', counts: { prompted: { required: 1 } } }), 204);++ assert.strictEqual(await send({ session: 'c'.repeat(16), release: 'r1' }), 204);++ assert.strictEqual(await send({ session: 'not-hex', release: 'r1' }), 400, 'no counts and no valid session');++ assert.deepStrictEqual([gauge('current'), gauge('older')], [1, 2]);++ assert.match(reg.metrics(), /release_client_updates_total\{outcome="prompted",reason="required"\} 1/, 'prompts are counted');++ assert.strictEqual(await send({ session: 'c'.repeat(16), release: 'r1', ended: true }), 204);++ assert.deepStrictEqual([gauge('current'), gauge('older')], [1, 1], 'an ended tab is gone at once');++ serving = 'r3';++ assert.deepStrictEqual([gauge('current'), gauge('older')], [0, 2], 'after a deploy every open tab is older until it moves');++ const realNow = Date.now;++ try { Date.now = () => realNow() + 13 * 60 * 1000; assert.deepStrictEqual([gauge('current'), gauge('older')], [0, 0], 'a tab not heard from in 12 minutes is gone'); } finally { Date.now = realNow; }++ }++// mount(): GET /release.json and POST /release-metrics on a real app, behind express.json(), fed by// the text/plain body navigator.sendBeacon sends; the manifest then says where to report.{@@@ -207,33 +235,33 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(vassert.ok(!p.document.querySelector('script[src*="release-update.js"]'), 'a manifest without components needs no release-update.js');p.toasts[0].o.action.onClick();assert.strictEqual(p.reloads, 1, 'Reload reloads');-- assert.deepStrictEqual(p.beacons.at(-1).body.counts, { reloaded: { user: 1 } }, 'and is counted before the page goes');++ assert.deepStrictEqual(p.beacons.map((x) => x.body.counts), [{ prompted: { optional: 1 } }, { reloaded: { user: 1 } }], 'the prompt is counted when shown, the reload before the page goes');p = await tab({ server: 'bbbbbbb', releasedAt: stale });assert.strictEqual(p.reloads, 1, 'outside the window and hidden: reloads');-- assert.deepStrictEqual(p.beacons[0].body.counts, { reloaded: { window: 1 } });++ assert.deepStrictEqual(p.beacons[0].body.counts, { prompted: { window: 1 }, reloaded: { window: 1 } });assert.strictEqual(p.beacons[0].to, '/release-metrics');p = await tab({ server: 'bbbbbbb', min: 'bbbbbbb' });assert.strictEqual(p.reloads, 1, 'server requires the new release: reloads when safe');-- assert.deepStrictEqual(p.metrics(), { reloaded: { required: 1 } });++ assert.deepStrictEqual(p.metrics(), { prompted: { required: 1 }, reloaded: { required: 1 } });p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<textarea></textarea>'), focus: 'textarea' });assert.strictEqual(p.reloads, 0, 'never while a text field has focus');p.tick(); p.tick();-- assert.deepStrictEqual(p.metrics(), { deferred: { typing: 1 } }, 'a deferral is counted once per release and reason');++ assert.deepStrictEqual(p.metrics(), { prompted: { window: 1 }, deferred: { typing: 1 } }, 'a deferral is counted once per release and reason');p.blur(); p.tick();assert.strictEqual(p.reloads, 1, 'and reloads once the field lets go');p = await tab({ server: 'bbbbbbb', releasedAt: stale, protectedFn: () => true });assert.strictEqual(p.reloads, 0, 'never during a protected session (upload, call, broadcast)');-- assert.deepStrictEqual(p.metrics(), { deferred: { protected: 1 } });++ assert.deepStrictEqual(p.metrics(), { prompted: { window: 1 }, deferred: { protected: 1 } });p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<form data-dirty="true"></form>') });-- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { dirty: 1 } }], 'never with unsent form input');++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { dirty: 1 } }], 'never with unsent form input');p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<video></video>') });-- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { media: 1 } }], 'never while a stream is playing (a linkedom <video> is not paused)');++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { media: 1 } }], 'never while a stream is playing (a linkedom <video> is not paused)');{// A paused <video> with a live camera track is a capture: served stale, it waits.const html = HTML('', '<video></video>');@@@ -242,14 +270,14 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(vconst vid = page.document.querySelector('video');vid.paused = true; vid.srcObject = { getTracks: () => [{ readyState: 'live' }] };await page.settle();-- assert.deepStrictEqual([page.reloads, page.metrics()], [0, { deferred: { capture: 1 } }], 'never while the camera/mic is live');++ assert.deepStrictEqual([page.reloads, page.metrics()], [0, { prompted: { window: 1 }, deferred: { capture: 1 } }], 'never while the camera/mic is live');}p = await tab({ server: 'bbbbbbb', releasedAt: stale, hidden: false });-- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'a visible tab someone just used is not reloaded');++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { active: 1 } }], 'a visible tab someone just used is not reloaded');p.setHidden(true);assert.strictEqual(p.reloads, 1, 'hiding it is the safe moment');-- assert.deepStrictEqual(p.beacons.map((b) => b.body.counts), [{ deferred: { active: 1 } }, { reloaded: { window: 1 } }], 'hiding sends the deferral, the reload its own count');++ assert.deepStrictEqual(p.beacons.map((b) => b.body.counts), [{ prompted: { window: 1 }, deferred: { active: 1 } }, { reloaded: { window: 1 } }], 'hiding sends the prompt and the deferral, the reload its own count');// Without a meta tag the first /release.json read is the baseline; a later release prompts.{@@@ -277,5 +305,26 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(vassert.strictEqual(page.beacons.length, 0);}++ // The tab beats: once when its manifest is read, then every 5 minutes, and says when it ends.++ {++ let now = Date.now();++ class FakeDate extends Date { static now() { return now; } }++ const manifest = { service: 'test', release: 'aaaaaaa', released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 };++ const page = await openPage({ url: 'https://site.test/', html: HTML(), hidden: true, config: { metricsUrl: '/release-metrics' },++ globals: { crypto: require('crypto').webcrypto, Date: FakeDate }, serve: () => ({ json: manifest }) });++ await page.settle();++ assert.strictEqual(page.beacons.length, 1, 'one beat on load');++ const b = page.beacons[0].body;++ assert.match(b.session, /^[0-9a-f]{16}$/);++ assert.deepStrictEqual([b.release, b.counts, b.ended], ['aaaaaaa', undefined, undefined], 'the release it runs, nothing counted');++ page.tick(); await page.settle();++ assert.strictEqual(page.beacons.length, 1, 'no second beat within 5 minutes');++ now += 5 * 60 * 1000; page.tick(); await page.settle();++ assert.strictEqual(page.beacons.length, 2, 'a beat every 5 minutes');++ assert.strictEqual(page.beacons[1].body.session, b.session, 'the same tab id');++ page.window.dispatchEvent(new page.window.Event('pagehide'));++ assert.deepStrictEqual([page.beacons.length, page.beacons[2].body.ended, page.beacons[2].body.session], [3, true, b.session], 'pagehide says it ended');++ }++console.log('release: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 1.18.0 — 2026-09-26
+
+**Update telemetry** (roadmap WS-P task 14).
+- **Prompts are counted.** `release-watch.js` records `prompted` when it shows the update prompt, with reason `optional`, or `required`, `window` or `contract` when a reload must follow. `release.js` accepts the new outcome into `release_client_updates_total`.
+- **Sessions by generation.** Each tab sends a beat to the metrics URL once its manifest is read and then every 5 minutes, carrying a random 16-hex id kept in memory only and the release it runs. On `pagehide` it sends `ended`. The collector keeps the tabs heard from in the last 12 minutes, at most 20,000 per registry, and exposes the gauge `release_client_sessions{generation="current"|"older"}`. `current` is the release the process serves: `mount` and `collect` pass it, or pass `collect(registry, { currentRelease })` yourself. Sec-GPC/DNT requests are still not counted.
+- **Size.** release-watch stays within its 5 KB brotli budget (4.96 KB); comments were shortened to make room.
+
## 1.17.1 — 2026-09-26
## 1.17.1 — 2026-09-26
Fix: `release-watch.js` acts on a `host.release.published` event only when its `origin` (when present) is the page's own. A service id alone is not unique across the network: Sites' placeholder pages reuse product ids (`ai`, `coupons`, …), so a placeholder's release made the real product's tabs check for nothing.
Fix: `release-watch.js` acts on a `host.release.published` event only when its `origin` (when present) is the page's own. A service id alone is not unique across the network: Sites' placeholder pages reuse product ids (`ai`, `coupons`, …), so a placeholder's release made the real product's tabs check for nothing.
@@ -214,10 +214,13 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
@@ -214,10 +214,13 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
- **CSP.** The site's CSP `connect-src` must allow `https://events.openvibe.network`. Otherwise the
- **CSP.** The site's CSP `connect-src` must allow `https://events.openvibe.network`. Otherwise the
browser refuses the stream once, and release-watch stops (state `blocked`) and keeps polling.
browser refuses the stream once, and release-watch stops (state `blocked`) and keeps polling.
- **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.
- **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.
-- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `applied`,
- `reloaded`, `deferred` or `failed`. The tab finds the endpoint in the manifest's `metrics_url`
- (set by `mount`), the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch:
- { metricsUrl } })`.
+- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `prompted`
+ (reason `optional`, `required`, `window` or `contract`), `applied`, `reloaded`, `deferred` or
+ `failed`. `release_client_sessions{generation}` counts open tabs heard from in the last 12
+ minutes, `current` when they run the release this process serves and `older` otherwise: each tab
+ beats every 5 minutes with a random id it keeps in memory only (never tied to an account) and
+ says when it ends. The tab finds the endpoint in the manifest's `metrics_url` (set by `mount`),
+ the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch: { metricsUrl } })`.
Mixed-version test: capture the previous release's `/release.json` (its `full()` form) and the
Mixed-version test: capture the previous release's `/release.json` (its `full()` form) and the
calls its pages make as fixtures, then:
calls its pages make as fixtures, then:
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.17.1",
+ "version": "1.18.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.17.1",
+ "version": "1.18.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.17.1",
+ "version": "1.18.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
* server changes are applied in place; anything else prompts. It reloads by itself only when it must
* server changes are applied in place; anything else prompts. It reloads by itself only when it must
* (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle
* (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle
* 2 minutes, no focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing
* 2 minutes, no focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing
- * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46).
+ * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46), with a
+ * 5-minute beat (random tab id, release) for sessions by generation.
* Release notifications (1.17.0, WS-P task 9): one anonymous EventSource on the Events realtime stream
* Release notifications (1.17.0, WS-P task 9): one anonymous EventSource on the Events realtime stream
* (topic host.release.published, public). An event naming this page's service with a release it neither
* (topic host.release.published, public). An event naming this page's service with a release it neither
* runs nor knows runs check(true) after a 0-20 s jitter, at most once per 30 s. Polling stays the fallback.
* runs nor knows runs check(true) after a 0-20 s jitter, at most once per 30 s. Polling stays the fallback.
@@ -32,6 +33,8 @@
@@ -32,6 +33,8 @@
let busyWith = null; // the plan/apply in flight
let busyWith = null; // the plan/apply in flight
let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;
let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;
let counts = {}; const totals = {}; const noted = {};
let counts = {}; const totals = {}; const noted = {};
+ let session = null; let lastBeat = 0;
+ try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ }
['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));
['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));
@@ -41,12 +44,17 @@
@@ -41,12 +44,17 @@
if (once) { if (noted[k]) return; noted[k] = 1; }
if (once) { if (noted[k]) return; noted[k] = 1; }
for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }
for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }
}
}
- function flush() {
+ // beat: send even with nothing counted; ended: the tab is going
+ function flush(beat, ended) {
const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);
const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);
- if (!to || !Object.keys(counts).length) return;
+ const any = Object.keys(counts).length > 0;
+ if (!to || (!any && !(beat === true && session && current))) return;
try {
try {
if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;
if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;
- const body = JSON.stringify({ service: latest && latest.service, release: current, to: latest && latest.release, counts });
+ const report = { service: latest && latest.service, release: current, to: latest && latest.release };
+ if (any) report.counts = counts;
+ if (session) { report.session = session; lastBeat = Date.now(); if (ended === true) report.ended = true; }
+ const body = JSON.stringify(report);
counts = {};
counts = {};
const n = root.navigator;
const n = root.navigator;
if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});
if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});
@@ -78,6 +86,7 @@
@@ -78,6 +86,7 @@
function prompt() {
function prompt() {
if (prompted) return;
if (prompted) return;
prompted = true;
prompted = true;
+ record('prompted', mustReload ? reloadWhy : 'optional');
if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {
if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {
root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });
root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });
}
}
@@ -167,11 +176,9 @@
@@ -167,11 +176,9 @@
return m;
return m;
}
}
- // ── Release notifications: OpenVibe.Host publishes host.release.published (public) to OpenVibe.Events ──
- // One EventSource per tab, without credentials: the events are public, so an account switch changes
- // nothing (and a second load of this script returns early). Closed after 5 min hidden (the poll and the
- // visibility check cover a hidden tab), reopened on return with last_event_id; errors back off 30 s to
- // 15 min, and after 6 failures in a row only polling is left.
+ // ── Release notifications (host.release.published, public, via OpenVibe.Events) ──
+ // One credential-less EventSource per tab (account switches change nothing). Closed after 5 min hidden,
+ // reopened with last_event_id; errors back off 30 s to 15 min; after 6 failures only polling is left.
const TOPIC = 'host.release.published';
const TOPIC = 'host.release.published';
const HEX = /^[0-9a-f]{7,40}$/;
const HEX = /^[0-9a-f]{7,40}$/;
const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastSeq: null };
const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastSeq: null };
@@ -224,8 +231,7 @@
@@ -224,8 +231,7 @@
if (!p || ev.event_type !== TOPIC) return;
if (!p || ev.event_type !== TOPIC) return;
if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }
if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }
if (p.service !== rt.service || typeof p.release !== 'string') return;
if (p.service !== rt.service || typeof p.release !== 'string') return;
- // A service id alone is not unique across the network (Sites' placeholder pages reuse product ids),
- // so when the event names the origin it went live on, it must be this page's.
+ // Service ids repeat across origins (Sites' placeholders): a named origin must be this page's.
if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }
if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }
rt.events++;
rt.events++;
if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }
if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }
@@ -248,7 +254,7 @@
@@ -248,7 +254,7 @@
let stopped = false;
let stopped = false;
root.addEventListener('focus', () => { if (!stopped) check(false); });
root.addEventListener('focus', () => { if (!stopped) check(false); });
root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed') { rt.state = 'off'; rt.failures = 0; live(); } check(true); });
root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed') { rt.state = 'off'; rt.failures = 0; live(); } check(true); });
- root.addEventListener('pagehide', flush);
+ root.addEventListener('pagehide', () => flush(true, true));
document.addEventListener('visibilitychange', () => {
document.addEventListener('visibilitychange', () => {
if (stopped) return;
if (stopped) return;
if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }
if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }
@@ -260,6 +266,7 @@
@@ -260,6 +266,7 @@
if (stopped) return;
if (stopped) return;
if (pending && commitRegions(pending)) adopt(pending);
if (pending && commitRegions(pending)) adopt(pending);
check(false); maybeReload();
check(false); maybeReload();
+ if (Date.now() - lastBeat >= 3e5) flush(true);
}, 30 * 1000);
}, 30 * 1000);
const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);
const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);
function stop() {
function stop() {
@@ -273,8 +280,7 @@
@@ -273,8 +280,7 @@
flush,
flush,
stop,
stop,
};
};
- // The page's own manifest, if /release.json still serves the release the page was rendered from. A site
- // that serves no /release.json and rendered no meta tag is left alone.
+ // The page's own manifest (if still served). No /release.json and no meta tag: left alone.
lastCheck = Date.now();
lastCheck = Date.now();
root.fetch(url, { cache: 'no-store', credentials: 'omit' })
root.fetch(url, { cache: 'no-store', credentials: 'omit' })
.then((r) => (r.ok ? r.json() : null))
.then((r) => (r.ok ? r.json() : null))
@@ -284,6 +290,7 @@
@@ -284,6 +290,7 @@
if (m.release === current) base = m;
if (m.release === current) base = m;
consider(m);
consider(m);
live();
live();
+ flush(true);
})
})
.catch(() => { if (!current) stop(); });
.catch(() => { if (!current) stop(); });
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -210,6 +210,7 @@ function deepFreeze(o) {
@@ -210,6 +210,7 @@ function deepFreeze(o) {
// ── /release-metrics: what open tabs report (D46) ──────────────
// ── /release-metrics: what open tabs report (D46) ──────────────
const OUTCOMES = {
const OUTCOMES = {
+ prompted: /^(optional|required|window|contract)$/,
applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,
applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,
reloaded: /^(user|required|window|contract)$/,
reloaded: /^(user|required|window|contract)$/,
deferred: /^(typing|dirty|protected|media|capture|active)$/,
deferred: /^(typing|dirty|protected|media|capture|active)$/,
@@ -218,6 +219,37 @@ const OUTCOMES = {
@@ -218,6 +219,37 @@ const OUTCOMES = {
const MAX_BODY = 4096;
const MAX_BODY = 4096;
const MAX_PER_REASON = 50;
const MAX_PER_REASON = 50;
const counters = new WeakMap();
const counters = new WeakMap();
+// Open tabs by generation (WS-P task 14): each tab beats every 5 minutes with a random per-tab id (never
+// stored in the browser, never tied to an account); a tab not heard from in 12 minutes, or that said it
+// ended, is gone. At most MAX_SESSIONS are tracked per registry.
+const SESSION_TTL_MS = 12 * 60 * 1000;
+const MAX_SESSIONS = 20000;
+const SESSION_RE = /^[0-9a-f]{16,32}$/;
+const sessionStores = new WeakMap();
+
+function sessionStore(registry, currentRelease) {
+ let st = sessionStores.get(registry);
+ if (!st) {
+ st = { map: new Map(), current: null };
+ registry.gauge({
+ name: 'release_client_sessions', help: 'Open tabs heard from in the last 12 minutes, by whether they run the release this process serves (current) or an older one',
+ labelNames: ['generation'],
+ collect: () => {
+ const cutoff = Date.now() - SESSION_TTL_MS;
+ const cur = typeof st.current === 'function' ? st.current() : null;
+ let current = 0; let older = 0;
+ for (const [id, v] of st.map) {
+ if (v.at < cutoff) { st.map.delete(id); continue; }
+ if (cur && v.release === cur) current++; else older++;
+ }
+ return [{ labels: { generation: 'current' }, value: current }, { labels: { generation: 'older' }, value: older }];
+ },
+ });
+ sessionStores.set(registry, st);
+ }
+ if (currentRelease) st.current = currentRelease;
+ return st;
+}
function updateCounter(registry) {
function updateCounter(registry) {
let c = counters.get(registry);
let c = counters.get(registry);
@@ -244,15 +276,18 @@ function readBody(req) {
@@ -244,15 +276,18 @@ function readBody(req) {
}
}
/**
/**
- * POST handler for release-watch beacons: { counts: { applied|reloaded|deferred|failed: { reason: n } } }.
- * Counts go to release_client_updates_total{outcome,reason} in `registry` (openvibe-shared/metrics).
+ * POST handler for release-watch beacons: { counts: { prompted|applied|reloaded|deferred|failed: { reason: n } },
+ * session, release, ended }. Counts go to release_client_updates_total{outcome,reason} in `registry`
+ * (openvibe-shared/metrics); a session beat (with or without counts) keeps that tab in
+ * release_client_sessions{generation}, current when its release is `currentRelease()`, and `ended` drops it.
* Unknown reasons count as "other"; each reason counts at most 50 per report; a report is at most 4 KB;
* Unknown reasons count as "other"; each reason counts at most 50 per report; a report is at most 4 KB;
* one client (req.ip, which honours the app's trust proxy; or `keyOf(req)`) sends at most `perMinute`
* one client (req.ip, which honours the app's trust proxy; or `keyOf(req)`) sends at most `perMinute`
* reports a minute. Sec-GPC/DNT requests are not counted.
* reports a minute. Sec-GPC/DNT requests are not counted.
*/
*/
-function collector(registry, { perMinute = 30, keyOf = null } = {}) {
+function collector(registry, { perMinute = 30, keyOf = null, currentRelease = null } = {}) {
if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');
if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');
const counter = updateCounter(registry);
const counter = updateCounter(registry);
+ const sessions = sessionStore(registry, currentRelease);
const seen = new Map(); let windowStart = Date.now();
const seen = new Map(); let windowStart = Date.now();
return async function releaseMetrics(req, res) {
return async function releaseMetrics(req, res) {
const done = (code) => { res.statusCode = code; res.setHeader('Cache-Control', 'no-store'); res.end(); };
const done = (code) => { res.statusCode = code; res.setHeader('Cache-Control', 'no-store'); res.end(); };
@@ -268,10 +303,16 @@ function collector(registry, { perMinute = 30, keyOf = null } = {}) {
@@ -268,10 +303,16 @@ function collector(registry, { perMinute = 30, keyOf = null } = {}) {
let body = await readBody(req);
let body = await readBody(req);
if (body === null) return done(413);
if (body === null) return done(413);
if (typeof body === 'string') { try { body = JSON.parse(body); } catch { return done(400); } }
if (typeof body === 'string') { try { body = JSON.parse(body); } catch { return done(400); } }
- const counts = body && typeof body === 'object' ? body.counts : null;
- if (!counts || typeof counts !== 'object') return done(400);
+ if (!body || typeof body !== 'object') return done(400);
+ const counts = body.counts && typeof body.counts === 'object' ? body.counts : null;
+ const sid = typeof body.session === 'string' && SESSION_RE.test(body.session) ? body.session : null;
+ if (!counts && !sid) return done(400);
+ if (sid) {
+ if (body.ended === true) sessions.map.delete(sid);
+ else if (sessions.map.size < MAX_SESSIONS || sessions.map.has(sid)) sessions.map.set(sid, { release: typeof body.release === 'string' ? body.release.slice(0, 80) : null, at: now });
+ }
for (const [outcome, re] of Object.entries(OUTCOMES)) {
for (const [outcome, re] of Object.entries(OUTCOMES)) {
- const reasons = counts[outcome];
+ const reasons = counts && counts[outcome];
if (!reasons || typeof reasons !== 'object') continue;
if (!reasons || typeof reasons !== 'object') continue;
for (const [reason, value] of Object.entries(reasons).slice(0, 16)) {
for (const [reason, value] of Object.entries(reasons).slice(0, 16)) {
const k = Math.min(MAX_PER_REASON, Math.floor(Number(value)));
const k = Math.min(MAX_PER_REASON, Math.floor(Number(value)));
@@ -373,7 +414,7 @@ function createRelease({
@@ -373,7 +414,7 @@ function createRelease({
function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {
function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {
app.get(manifestPath, handler);
app.get(manifestPath, handler);
if (registry) {
if (registry) {
- app.post(mp, collector(registry, { perMinute }));
+ app.post(mp, collector(registry, { perMinute, currentRelease: () => state.full.release }));
if (metricsUrl !== mp) { metricsUrl = mp; build(); }
if (metricsUrl !== mp) { metricsUrl = mp; build(); }
}
}
return api;
return api;
@@ -396,7 +437,7 @@ function createRelease({
@@ -396,7 +437,7 @@ function createRelease({
handler,
handler,
metaTag,
metaTag,
mount,
mount,
- collect: (registry, opts) => collector(registry, opts),
+ collect: (registry, opts) => collector(registry, { currentRelease: () => state.full.release, ...opts }),
refresh,
refresh,
validate,
validate,
warnings,
warnings,
@@ -67,6 +67,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -67,6 +67,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
// can change something in place. Measured 2026-09-23: 3.2 KB and 3.0 KB brotli (release-watch was 1.7 KB).
// can change something in place. Measured 2026-09-23: 3.2 KB and 3.0 KB brotli (release-watch was 1.7 KB).
// 2026-09-26 (1.17.0): release-watch 4.7 KB with the release notifications over Events realtime (+1.1 KB of
// 2026-09-26 (1.17.0): release-watch 4.7 KB with the release notifications over Events realtime (+1.1 KB of
// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.
// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.
+// 1.18.0: session beats and the prompted count (+0.25 KB) fit by shortening comments: 4.96 KB.
{
{
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
@@ -212,11 +213,11 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -212,11 +213,11 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
{
{
const p = await open({ hidden: false });
const p = await open({ hidden: false });
await update(p, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') });
await update(p, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') });
- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'incompatible contracts: a reload, but not under the user');
+ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { contract: 1 }, deferred: { active: 1 } }], 'incompatible contracts: a reload (prompted), but not under the user');
assert.strictEqual(hrefs(p)[0], '/css/app.css?v=111111111111', 'and nothing in place');
assert.strictEqual(hrefs(p)[0], '/css/app.css?v=111111111111', 'and nothing in place');
p.setHidden(true);
p.setHidden(true);
assert.strictEqual(p.reloads, 1);
assert.strictEqual(p.reloads, 1);
- assert.deepStrictEqual(counts(p), { deferred: { active: 1 }, reloaded: { contract: 1 } });
+ assert.deepStrictEqual(counts(p), { prompted: { contract: 1 }, deferred: { active: 1 }, reloaded: { contract: 1 } });
}
}
{
{
// A page one release behind whose server kept accepting it: in place (the N-1 → N window).
// A page one release behind whose server kept accepting it: in place (the N-1 → N window).
@@ -160,6 +160,34 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
@@ -160,6 +160,34 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.strictEqual(await post({ counts: {} }), 204);
assert.strictEqual(await post({ counts: {} }), 204);
assert.strictEqual(await post({ counts: {} }), 429, 'an address sends at most perMinute reports a minute');
assert.strictEqual(await post({ counts: {} }), 429, 'an address sends at most perMinute reports a minute');
+ // Sessions by generation (WS-P task 14): a beat with a per-tab id keeps the tab in
+ // release_client_sessions{generation}; current is the release this process serves; ended drops it.
+ {
+ const reg = createRegistry();
+ let serving = 'r2';
+ const c2 = r.collect(reg, { perMinute: 100, currentRelease: () => serving });
+ const send = async (payload) => {
+ const req = Readable.from([Buffer.from(JSON.stringify(payload))]);
+ req.headers = {}; req.socket = { remoteAddress: '10.1.0.1' };
+ const res = { statusCode: 0, setHeader() {}, end() {} };
+ await c2(req, res);
+ return res.statusCode;
+ };
+ const gauge = (g) => { const line = reg.metrics().split('\n').find((l) => l.startsWith(`release_client_sessions{generation="${g}"}`)); return line ? Number(line.split(' ').pop()) : null; };
+ assert.strictEqual(await send({ session: 'a'.repeat(16), release: 'r2' }), 204, 'a beat needs no counts');
+ assert.strictEqual(await send({ session: 'b'.repeat(16), release: 'r1', counts: { prompted: { required: 1 } } }), 204);
+ assert.strictEqual(await send({ session: 'c'.repeat(16), release: 'r1' }), 204);
+ assert.strictEqual(await send({ session: 'not-hex', release: 'r1' }), 400, 'no counts and no valid session');
+ assert.deepStrictEqual([gauge('current'), gauge('older')], [1, 2]);
+ assert.match(reg.metrics(), /release_client_updates_total\{outcome="prompted",reason="required"\} 1/, 'prompts are counted');
+ assert.strictEqual(await send({ session: 'c'.repeat(16), release: 'r1', ended: true }), 204);
+ assert.deepStrictEqual([gauge('current'), gauge('older')], [1, 1], 'an ended tab is gone at once');
+ serving = 'r3';
+ assert.deepStrictEqual([gauge('current'), gauge('older')], [0, 2], 'after a deploy every open tab is older until it moves');
+ const realNow = Date.now;
+ try { Date.now = () => realNow() + 13 * 60 * 1000; assert.deepStrictEqual([gauge('current'), gauge('older')], [0, 0], 'a tab not heard from in 12 minutes is gone'); } finally { Date.now = realNow; }
+ }
+
// mount(): GET /release.json and POST /release-metrics on a real app, behind express.json(), fed by
// mount(): GET /release.json and POST /release-metrics on a real app, behind express.json(), fed by
// the text/plain body navigator.sendBeacon sends; the manifest then says where to report.
// the text/plain body navigator.sendBeacon sends; the manifest then says where to report.
{
{
@@ -207,33 +235,33 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
@@ -207,33 +235,33 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.ok(!p.document.querySelector('script[src*="release-update.js"]'), 'a manifest without components needs no release-update.js');
assert.ok(!p.document.querySelector('script[src*="release-update.js"]'), 'a manifest without components needs no release-update.js');
p.toasts[0].o.action.onClick();
p.toasts[0].o.action.onClick();
assert.strictEqual(p.reloads, 1, 'Reload reloads');
assert.strictEqual(p.reloads, 1, 'Reload reloads');
- assert.deepStrictEqual(p.beacons.at(-1).body.counts, { reloaded: { user: 1 } }, 'and is counted before the page goes');
+ assert.deepStrictEqual(p.beacons.map((x) => x.body.counts), [{ prompted: { optional: 1 } }, { reloaded: { user: 1 } }], 'the prompt is counted when shown, the reload before the page goes');
p = await tab({ server: 'bbbbbbb', releasedAt: stale });
p = await tab({ server: 'bbbbbbb', releasedAt: stale });
assert.strictEqual(p.reloads, 1, 'outside the window and hidden: reloads');
assert.strictEqual(p.reloads, 1, 'outside the window and hidden: reloads');
- assert.deepStrictEqual(p.beacons[0].body.counts, { reloaded: { window: 1 } });
+ assert.deepStrictEqual(p.beacons[0].body.counts, { prompted: { window: 1 }, reloaded: { window: 1 } });
assert.strictEqual(p.beacons[0].to, '/release-metrics');
assert.strictEqual(p.beacons[0].to, '/release-metrics');
p = await tab({ server: 'bbbbbbb', min: 'bbbbbbb' });
p = await tab({ server: 'bbbbbbb', min: 'bbbbbbb' });
assert.strictEqual(p.reloads, 1, 'server requires the new release: reloads when safe');
assert.strictEqual(p.reloads, 1, 'server requires the new release: reloads when safe');
- assert.deepStrictEqual(p.metrics(), { reloaded: { required: 1 } });
+ assert.deepStrictEqual(p.metrics(), { prompted: { required: 1 }, reloaded: { required: 1 } });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<textarea></textarea>'), focus: 'textarea' });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<textarea></textarea>'), focus: 'textarea' });
assert.strictEqual(p.reloads, 0, 'never while a text field has focus');
assert.strictEqual(p.reloads, 0, 'never while a text field has focus');
p.tick(); p.tick();
p.tick(); p.tick();
- assert.deepStrictEqual(p.metrics(), { deferred: { typing: 1 } }, 'a deferral is counted once per release and reason');
+ assert.deepStrictEqual(p.metrics(), { prompted: { window: 1 }, deferred: { typing: 1 } }, 'a deferral is counted once per release and reason');
p.blur(); p.tick();
p.blur(); p.tick();
assert.strictEqual(p.reloads, 1, 'and reloads once the field lets go');
assert.strictEqual(p.reloads, 1, 'and reloads once the field lets go');
p = await tab({ server: 'bbbbbbb', releasedAt: stale, protectedFn: () => true });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, protectedFn: () => true });
assert.strictEqual(p.reloads, 0, 'never during a protected session (upload, call, broadcast)');
assert.strictEqual(p.reloads, 0, 'never during a protected session (upload, call, broadcast)');
- assert.deepStrictEqual(p.metrics(), { deferred: { protected: 1 } });
+ assert.deepStrictEqual(p.metrics(), { prompted: { window: 1 }, deferred: { protected: 1 } });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<form data-dirty="true"></form>') });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<form data-dirty="true"></form>') });
- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { dirty: 1 } }], 'never with unsent form input');
+ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { dirty: 1 } }], 'never with unsent form input');
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<video></video>') });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<video></video>') });
- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { media: 1 } }], 'never while a stream is playing (a linkedom <video> is not paused)');
+ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { media: 1 } }], 'never while a stream is playing (a linkedom <video> is not paused)');
{
{
// A paused <video> with a live camera track is a capture: served stale, it waits.
// A paused <video> with a live camera track is a capture: served stale, it waits.
const html = HTML('', '<video></video>');
const html = HTML('', '<video></video>');
@@ -242,14 +270,14 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
@@ -242,14 +270,14 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
const vid = page.document.querySelector('video');
const vid = page.document.querySelector('video');
vid.paused = true; vid.srcObject = { getTracks: () => [{ readyState: 'live' }] };
vid.paused = true; vid.srcObject = { getTracks: () => [{ readyState: 'live' }] };
await page.settle();
await page.settle();
- assert.deepStrictEqual([page.reloads, page.metrics()], [0, { deferred: { capture: 1 } }], 'never while the camera/mic is live');
+ assert.deepStrictEqual([page.reloads, page.metrics()], [0, { prompted: { window: 1 }, deferred: { capture: 1 } }], 'never while the camera/mic is live');
}
}
p = await tab({ server: 'bbbbbbb', releasedAt: stale, hidden: false });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, hidden: false });
- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'a visible tab someone just used is not reloaded');
+ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { active: 1 } }], 'a visible tab someone just used is not reloaded');
p.setHidden(true);
p.setHidden(true);
assert.strictEqual(p.reloads, 1, 'hiding it is the safe moment');
assert.strictEqual(p.reloads, 1, 'hiding it is the safe moment');
- assert.deepStrictEqual(p.beacons.map((b) => b.body.counts), [{ deferred: { active: 1 } }, { reloaded: { window: 1 } }], 'hiding sends the deferral, the reload its own count');
+ assert.deepStrictEqual(p.beacons.map((b) => b.body.counts), [{ prompted: { window: 1 }, deferred: { active: 1 } }, { reloaded: { window: 1 } }], 'hiding sends the prompt and the deferral, the reload its own count');
// Without a meta tag the first /release.json read is the baseline; a later release prompts.
// Without a meta tag the first /release.json read is the baseline; a later release prompts.
{
{
@@ -277,5 +305,26 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
@@ -277,5 +305,26 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.strictEqual(page.beacons.length, 0);
assert.strictEqual(page.beacons.length, 0);
}
}
+ // The tab beats: once when its manifest is read, then every 5 minutes, and says when it ends.
+ {
+ let now = Date.now();
+ class FakeDate extends Date { static now() { return now; } }
+ const manifest = { service: 'test', release: 'aaaaaaa', released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 };
+ const page = await openPage({ url: 'https://site.test/', html: HTML(), hidden: true, config: { metricsUrl: '/release-metrics' },
+ globals: { crypto: require('crypto').webcrypto, Date: FakeDate }, serve: () => ({ json: manifest }) });
+ await page.settle();
+ assert.strictEqual(page.beacons.length, 1, 'one beat on load');
+ const b = page.beacons[0].body;
+ assert.match(b.session, /^[0-9a-f]{16}$/);
+ assert.deepStrictEqual([b.release, b.counts, b.ended], ['aaaaaaa', undefined, undefined], 'the release it runs, nothing counted');
+ page.tick(); await page.settle();
+ assert.strictEqual(page.beacons.length, 1, 'no second beat within 5 minutes');
+ now += 5 * 60 * 1000; page.tick(); await page.settle();
+ assert.strictEqual(page.beacons.length, 2, 'a beat every 5 minutes');
+ assert.strictEqual(page.beacons[1].body.session, b.session, 'the same tab id');
+ page.window.dispatchEvent(new page.window.Event('pagehide'));
+ assert.deepStrictEqual([page.beacons.length, page.beacons[2].body.ended, page.beacons[2].body.session], [3, true, b.session], 'pagehide says it ended');
+ }
+
console.log('release: all checks passed');
console.log('release: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });