From c64f1bb3de9dddf02d8a0c93dd76b73b9a489a6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:12:19 +0000 Subject: [PATCH 1/2] Composer: count each Current the door admits or refuses, by host app and refusal code One Analytics Engine data point per rise_present call: rise.current.admitted with its beats and scenes, rise.current.refused with the refusal's code from the door's closed set. Only what telemetry/events.json names is written, and every answer is byte-identical with the binding absent, present or throwing. EVENTS is bound in wrangler.production.jsonc alone. PRIVACY.md says so in the ChatGPT and Claude list and in section 11. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VHrJ4hmTtR1RFNpc215EK7 --- PRIVACY.md | 8 +- public/PRIVACY.md | 8 +- public/privacy.html | 4 +- telemetry/events.json | 40 +++++ vitest.fast.config.js | 4 +- worker/mcp-server.mjs | 54 +++++-- worker/production-config.test.js | 10 ++ worker/telemetry.mjs | 51 ++++++ worker/telemetry.test.js | 260 +++++++++++++++++++++++++++++++ wrangler.production.jsonc | 3 + 10 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 telemetry/events.json create mode 100644 worker/telemetry.mjs create mode 100644 worker/telemetry.test.js diff --git a/PRIVACY.md b/PRIVACY.md index ddd12b71..63d9e736 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -380,6 +380,10 @@ text of its passages, and optionally a theme, a look, and short notes. is) are kept in your browser's storage for the player. - Requests to the tool are counted per IP address for one minute, by Cloudflare's rate limiter, to stop abuse. +- RISE counts each call to the tool by the app it came from (ChatGPT, Claude + or another) and its outcome (accepted, with how many passages and pictures + the reading has, or refused, with the kind of fault), with no text and no + identifier, and keeps the counts for three months. - Cloudflare Workers observability logging is on for RISE's server: Cloudflare keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for its short retention window, at most 7 days (3 on its free @@ -637,7 +641,9 @@ and OpenAI process Live requests under their own policies. Cloudflare and Netlif API request data under their own policies. Cloudflare Workers observability logging is on for RISE's server and keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for at most 7 days (3 -on Cloudflare's free plan); no tool text or reader content is logged. Cloudflare +on Cloudflare's free plan); no tool text or reader content is logged. RISE's +counts of calls to its tool inside ChatGPT or Claude (section 4) hold no text +and no identifier and are kept for three months. Cloudflare keeps every page-view report for 7 days, then reduces them to a sample of about 10%; we can see the counts for the previous six months. OpenRouter processes the requests you send it under its own policy; see section 4. No provider-side retention diff --git a/public/PRIVACY.md b/public/PRIVACY.md index ddd12b71..63d9e736 100644 --- a/public/PRIVACY.md +++ b/public/PRIVACY.md @@ -380,6 +380,10 @@ text of its passages, and optionally a theme, a look, and short notes. is) are kept in your browser's storage for the player. - Requests to the tool are counted per IP address for one minute, by Cloudflare's rate limiter, to stop abuse. +- RISE counts each call to the tool by the app it came from (ChatGPT, Claude + or another) and its outcome (accepted, with how many passages and pictures + the reading has, or refused, with the kind of fault), with no text and no + identifier, and keeps the counts for three months. - Cloudflare Workers observability logging is on for RISE's server: Cloudflare keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for its short retention window, at most 7 days (3 on its free @@ -637,7 +641,9 @@ and OpenAI process Live requests under their own policies. Cloudflare and Netlif API request data under their own policies. Cloudflare Workers observability logging is on for RISE's server and keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for at most 7 days (3 -on Cloudflare's free plan); no tool text or reader content is logged. Cloudflare +on Cloudflare's free plan); no tool text or reader content is logged. RISE's +counts of calls to its tool inside ChatGPT or Claude (section 4) hold no text +and no identifier and are kept for three months. Cloudflare keeps every page-view report for 7 days, then reduces them to a sample of about 10%; we can see the counts for the previous six months. OpenRouter processes the requests you send it under its own policy; see section 4. No provider-side retention diff --git a/public/privacy.html b/public/privacy.html index b77b4a65..b910c0b8 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -268,7 +268,7 @@

Live answers (experimental)

RISE inside ChatGPT or Claude

If you add RISE to ChatGPT or Claude, your assistant can present an answer through RISE. When it does, it calls RISE's tool (rise_present) at https://rise.syberlabs.io/api/mcp with the reading it wrote: a title, the text of its passages, and optionally a theme, a look, and short notes.

- +

Your assistant's own provider (OpenAI or Anthropic) governs your conversation under its own privacy policy.

Optional voice dictation

Pressing Speak on a reading request asks your browser to use the microphone. The browser may process speech on your device or send audio to its own speech service, depending on the browser. That provider's privacy policy governs its processing. RISE application code receives only the resulting text in the editable request field; it does not upload, save, or log microphone audio. You can edit or discard the text. It is sent to the decision provider only if you submit the request. Denying microphone permission leaves typed requests available.

@@ -318,7 +318,7 @@

10. Legal basis (UK/EU GDPR)

We do not ask for consent for these counts, because they set no cookie, use no local storage, and identify no one. If we ever add analytics that do any of those things, we will ask first.


11. Retention

-

RISE does not receive or persist AI requests. An OpenRouter, Gemini, or OpenAI key is held only in the page's memory and never stored. If the OpenAI Live route is switched on, RISE's server uses the reader's OpenAI key for one request to open a session and keeps neither the key nor the request; see section 4. Google and OpenAI process Live requests under their own policies. Cloudflare and Netlify handle hosting and API request data under their own policies. Cloudflare Workers observability logging is on for RISE's server and keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for at most 7 days (3 on Cloudflare's free plan); no tool text or reader content is logged. Cloudflare keeps every page-view report for 7 days, then reduces them to a sample of about 10%; we can see the counts for the previous six months. OpenRouter processes the requests you send it under its own policy; see section 4. No provider-side retention guarantee is made here.

+

RISE does not receive or persist AI requests. An OpenRouter, Gemini, or OpenAI key is held only in the page's memory and never stored. If the OpenAI Live route is switched on, RISE's server uses the reader's OpenAI key for one request to open a session and keeps neither the key nor the request; see section 4. Google and OpenAI process Live requests under their own policies. Cloudflare and Netlify handle hosting and API request data under their own policies. Cloudflare Workers observability logging is on for RISE's server and keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for at most 7 days (3 on Cloudflare's free plan); no tool text or reader content is logged. RISE's counts of calls to its tool inside ChatGPT or Claude (section 4) hold no text and no identifier and are kept for three months. Cloudflare keeps every page-view report for 7 days, then reduces them to a sample of about 10%; we can see the counts for the previous six months. OpenRouter processes the requests you send it under its own policy; see section 4. No provider-side retention guarantee is made here.

For Plus, RISE's server keeps the current subscription period's character and financial budget/usage records, its current daily count and the standing, rate-limit, recovery-token hash and event records described in section 4. Recovery tokens expire after 15 minutes; only the most recent recovery record is kept and it is replaced by a later request. A newer billing period replaces the old period's meter; a newer day replaces the daily meter, including the separate shared administrator meter; a newer month replaces the administrator monthly spend meter. RISE stores no reading text or audio in these records. ElevenLabs retains voiced text and audio, and Stripe retains payment records, under their own policies; see section 4. Separately, saved Plus audio and timings expire after 30 days or are removed by the server-audio deletion control. A storage alarm cleans expired bytes; expired renders are refused even before that cleanup runs. Bounded fingerprint safety records remain to prevent an automatic repeat vendor charge.

Data on your device persists until you erase it or clear your browser storage.


diff --git a/telemetry/events.json b/telemetry/events.json new file mode 100644 index 00000000..cb930eef --- /dev/null +++ b/telemetry/events.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "dataset": "syberlabs_events", + "events": { + "rise.current.admitted": { + "owner": "Composer", + "decision": "Which host apps use RISE's connector, and how long and how pictured the readings their models compose are, to decide where Composer work goes next.", + "blobs": [ + { "name": "host", "values": ["claude", "chatgpt", "other"], "derived": "the request: Anthropic's published connector egress, or a User-Agent that begins openai-mcp/; neither the address nor the string is written" } + ], + "doubles": [ + { "name": "beats" }, + { "name": "scenes" } + ] + }, + "rise.current.refused": { + "owner": "Composer", + "decision": "Which refusals each host app's model meets, to decide what to fix first in the tool's description, the guide and the validator.", + "blobs": [ + { "name": "host", "values": ["claude", "chatgpt", "other"], "derived": "the request: Anthropic's published connector egress, or a User-Agent that begins openai-mcp/; neither the address nor the string is written" }, + { + "name": "code", + "values": [ + "MCP_ARGUMENTS", "MCP_TOO_LARGE", + "CURRENT_ANCHOR", "CURRENT_DIVES", "CURRENT_DUPLICATE_ID", "CURRENT_ID", "CURRENT_LITERAL", "CURRENT_LOOK", + "CURRENT_OBJECT", "CURRENT_ORIGIN", "CURRENT_PROVIDER", "CURRENT_QUOTE", "CURRENT_RESERVED_TEXT", "CURRENT_SCHEMA", + "CURRENT_SEGMENTS", "CURRENT_STYLE", "CURRENT_TEXT", "CURRENT_THEME", "CURRENT_TOTAL_TEXT", "CURRENT_UNKNOWN_FIELD", + "CURRENT_VISUAL", + "BEAT_COUNT", "BEAT_CUE", "BEAT_EMPHASIS", "BEAT_HOLD", "BEAT_KIND", "BEAT_PLACE", "BEAT_SCENE", "BEAT_SIZE", + "BEAT_SOUND", "BEAT_TRANSITION", "BEAT_TYPE", + "SCENE_CODE", "SCENE_COUNT", "SCENE_ENGINE", "SCENE_PARAM", "SCENE_SVG", + "SCENE_COLLECTION", "SCENE_CODE_ADMISSION", "SCENE_SVG_ADMISSION", + "other" + ] + } + ], + "doubles": [] + } + } +} diff --git a/vitest.fast.config.js b/vitest.fast.config.js index 39fabccf..86dc1b99 100644 --- a/vitest.fast.config.js +++ b/vitest.fast.config.js @@ -11,7 +11,7 @@ import base from './vite.config.js'; // number is changed in the same pull request. // // npx vitest run --config vitest.fast.config.js -export const FAST_TEST_FILES = 169; +export const FAST_TEST_FILES = 170; const FAST_TESTS = [ 'src/core/account-saves.test.js', @@ -58,6 +58,8 @@ const FAST_TESTS = [ // The figure kit: four kinds RISE draws itself, admitted and layout-clean at every bound. 'src/core/figures/**/*.test.js', 'worker/mcp-quality.test.js', + // The event counts: nothing telemetry/events.json does not name is written, and no answer changes for them. + 'worker/telemetry.test.js', 'src/core/jev-palette.test.js', 'src/components/read/Chamber.jev-look.test.js', 'src/app/jev-reading.test.js', diff --git a/worker/mcp-server.mjs b/worker/mcp-server.mjs index 4d34b61c..0f9d84d6 100644 --- a/worker/mcp-server.mjs +++ b/worker/mcp-server.mjs @@ -12,6 +12,8 @@ import { callGate0, GATE0_TOOL, GATE0_TOOL_NAME } from './mcp-gate0.mjs'; import { admitSceneCode, describeDiagnostic } from '../src/core/scene-admission.js'; import { admitSvg } from '../src/core/svg-admission.js'; import { logQuality } from './mcp-quality.mjs'; +import { pictureCadence } from '../src/core/quality/cadence.js'; +import { countEvent, valuesOf } from './telemetry.mjs'; /** * RISE as an MCP server: one tool that presents a Current, and the app that shows it. @@ -23,8 +25,11 @@ import { logQuality } from './mcp-quality.mjs'; * it is stateless, keeps no session and no key, calls no model and spends * nothing. Its work is to say what the tool is, to refuse a Current that is not * valid so the model can be told why and try again, and to serve the app's - * document. The transport is MCP's Streamable HTTP, in its simplest legal form: - * every request is a POST answered with one JSON body, and there is no stream. + * document. Each call to present a Current is counted by the host app it came + * from and by what the door did with it, and nothing the host sent is in the + * count (worker/telemetry.mjs). The transport is MCP's Streamable HTTP, in its + * simplest legal form: every request is a POST answered with one JSON body, and + * there is no stream. * * It is off unless MCP_ENABLED is 'true'. It answers only requests from no * browser origin or from its own (an MCP host's server has none; a page on @@ -83,6 +88,17 @@ export function inAnthropicEgress(ip) { return address >>> shift === ANTHROPIC_EGRESS.network >>> shift; } +/** + * The host app a call comes from, for the connector's counts: Claude by Anthropic's egress, the sign this door already + * reads, and ChatGPT by its connector's User-Agent, `openai-mcp/`, matched at its start and nowhere else. Any + * other request is "other", so a miss undercounts a host and never names the wrong one. The address and the string are + * read here and never kept. + */ +export function hostOf(ip, agent) { + if (inAnthropicEgress(ip ?? '')) return 'claude'; + return /^openai-mcp\//u.test(agent ?? '') ? 'chatgpt' : 'other'; +} + const RETRY_AFTER_SECONDS = 60; // Conditional on the reader's request, as the directory's review asks: a server must not tell the model to call a tool the reader did not ask for. @@ -355,10 +371,15 @@ function refusal(error) { return `RISE refused this Current: ${clean(error?.message ?? 'The Current was not valid')}. Correct it and call ${TOOL_NAME} again.`; } +/** A refusal's code for the connector's counts: one of the closed set telemetry/events.json lists (the validator's codes and the door's own), or other. */ +const REFUSAL_CODES = new Set(valuesOf('rise.current.refused', 'code')); +const refusalCode = code => (REFUSAL_CODES.has(code) ? code : 'other'); + /** * Every generated scene's code, parsed and held to the scene rules (src/core/scene-admission.js), and every figure's SVG, * held to the figure rules the card holds it to again (svg-admission.js): the refusal's lines, or none. A scene naming - * an engine the card is not offered (a collection, whose images it cannot load: HOST_ENGINES) is refused by name. + * an engine the card is not offered (a collection, whose images it cannot load: HOST_ENGINES) is refused by name. What + * the model is told to repair is also the refusal's code, for the connector's counts. */ function sceneRefusals(current) { const lines = []; @@ -375,32 +396,41 @@ function sceneRefusals(current) { code ||= scene.code !== undefined; for (const diagnostic of verdict.diagnostics) lines.push(clean(`Scene "${scene.id}" was refused: ${describeDiagnostic(diagnostic)}`)); } - return { lines: lines.slice(0, MAX_SCENE_LINES), repair: named ? 'the scene' : code ? 'the scene’s code' : 'the figure' }; + return { + lines: lines.slice(0, MAX_SCENE_LINES), + repair: named ? 'the scene' : code ? 'the scene’s code' : 'the figure', + code: named ? 'SCENE_COLLECTION' : code ? 'SCENE_CODE_ADMISSION' : 'SCENE_SVG_ADMISSION' + }; } -function call(id, params) { +/** rise_present: the Current admitted, or refused with what to repair. `count` hears which, once a call (handleMcp). */ +function call(id, params, count) { if (!params || typeof params !== 'object' || params.name !== TOOL_NAME) return failure(id, -32602, 'Unknown tool'); const args = params.arguments; if (!args || typeof args !== 'object' || Array.isArray(args) || !args.current || typeof args.current !== 'object') { + count('rise.current.refused', { code: 'MCP_ARGUMENTS' }); return result(id, { content: [{ type: 'text', text: `Call ${TOOL_NAME} with {"current": }.` }], isError: true }); } const extra = Object.keys(args).find(key => key !== 'current'); if (extra !== undefined) { const why = extra === 'theme' ? '"theme" belongs inside the Current, not beside it' : `unknown argument "${clip(extra.replace(/[\u0000-\u001F\u007F]/gu, ''), 40)}"`; + count('rise.current.refused', { code: 'MCP_ARGUMENTS' }); return result(id, { content: [{ type: 'text', text: `RISE refused these arguments: ${why}. Call ${TOOL_NAME} with {"current": } only.` }], isError: true }); } try { if (serializedUtf8Bytes(args.current) > MCP_CURRENT_BYTES) { - throw new Error(`The Current exceeds the ${MCP_CURRENT_BYTES.toLocaleString('en-US')}-byte MCP limit`); + throw Object.assign(new Error(`The Current exceeds the ${MCP_CURRENT_BYTES.toLocaleString('en-US')}-byte MCP limit`), { code: 'MCP_TOO_LARGE' }); } validateRiseCurrent(args.current); } catch (error) { + count('rise.current.refused', { code: refusalCode(error?.code) }); return result(id, { content: [{ type: 'text', text: refusal(error) }], isError: true }); } const refused = sceneRefusals(args.current); if (refused.lines.length) { const text = [...refused.lines, `Repair ${refused.repair} and call ${TOOL_NAME} again with the whole Current.`].join('\n'); + count('rise.current.refused', { code: refused.code }); return result(id, { content: [{ type: 'text', text }], isError: true }); } // The app admits the Current from structuredContent; the model, which wrote it, is told only what was accepted. @@ -409,6 +439,8 @@ function call(id, params) { structuredContent: { current: args.current } }); logQuality(args.current); + const { beats, scenes } = pictureCadence(args.current); + count('rise.current.admitted', { beats, scenes }); return answer; } @@ -471,7 +503,7 @@ function read(id, params, origin, witness, card) { } /** One JSON-RPC message, answered. `null` for one that is not answered (a notification or a response). */ -export function dispatch(message, origin, { gate0 = false, witness = false, card = null } = {}) { +export function dispatch(message, origin, { gate0 = false, witness = false, card = null, count = () => {} } = {}) { if (!message || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== '2.0') { return failure(null, -32600, 'Invalid request'); } @@ -494,7 +526,7 @@ export function dispatch(message, origin, { gate0 = false, witness = false, card case 'tools/list': return result(id, { tools: gate0 ? [TOOL, GUIDE_TOOL, GATE0_TOOL] : [TOOL, GUIDE_TOOL] }); case 'tools/call': { if (params?.name === GUIDE_TOOL_NAME) return guide(id, params); - if (!gate0 || params?.name !== GATE0_TOOL_NAME) return call(id, params); + if (!gate0 || params?.name !== GATE0_TOOL_NAME) return call(id, params, count); const probe = callGate0(params.arguments, Date.now()); if (probe.log) console.log(probe.log); return result(id, probe.result); @@ -587,7 +619,11 @@ export async function handleMcp(request, env) { /* the relay card is served instead */ } } - return dispatch(message, origin, { gate0: env.MCP_GATE0 === 'true', witness: env.MCP_WITNESS === 'true', card }); + // The connector's counts (worker/telemetry.mjs): each rise_present call the door admits or refuses is one data point + // of its host app and what the door did, written where production binds EVENTS; nothing the host sent is in it. + const host = hostOf(ip, request.headers.get('User-Agent')); + const count = (event, fields) => countEvent(env, event, { host, ...fields }); + return dispatch(message, origin, { gate0: env.MCP_GATE0 === 'true', witness: env.MCP_WITNESS === 'true', card, count }); } /** diff --git a/worker/production-config.test.js b/worker/production-config.test.js index 5b861e6d..d0a1e44d 100644 --- a/worker/production-config.test.js +++ b/worker/production-config.test.js @@ -50,6 +50,16 @@ describe('the production Worker', () => { expect(Number(config.vars.PLUS_DAILY_CHAR_CAP)).toBeGreaterThan(0); }); + // The event counts (worker/telemetry.mjs) are production's alone: a preview, the demo or staging counts nothing, + // and the one dataset is the one telemetry/events.json allows. + it('binds the event counts’ dataset, and is the only configuration that does', () => { + const allowlist = JSON.parse(readFileSync('telemetry/events.json', 'utf8')); + expect(config.analytics_engine_datasets).toEqual([{ binding: 'EVENTS', dataset: allowlist.dataset }]); + const others = readdirSync('.').filter(file => /^wrangler\..*\.jsonc$/u.test(file) && file !== 'wrangler.production.jsonc'); + expect(others.length).toBeGreaterThan(0); + for (const file of others) expect(load(file).analytics_engine_datasets, file).toBeUndefined(); + }); + // The vendor id of a voice comes only from this list; "default" is the PLUS_VOICE_ID secret. it('offers a default voice and an id for every other one', () => { const voices = config.vars.PLUS_VOICES; diff --git a/worker/telemetry.mjs b/worker/telemetry.mjs new file mode 100644 index 00000000..cfcc96de --- /dev/null +++ b/worker/telemetry.mjs @@ -0,0 +1,51 @@ +/** + * SyberLabs telemetry (telemetry.v1): what happened, counted, never who it happened to or what it was about. An event + * is `..`; telemetry/events.json names every event and every field one may carry, each field + * a closed list of values or a count, and nothing it does not name is written. A counted event is one data point in + * the Analytics Engine dataset production binds as EVENTS (wrangler.production.jsonc): `indexes: [event]`, + * `blobs: [event, release, ...the event's values]` and `doubles: [1, ...its counts]`, each in the allowlist's order. + * Without the binding (every other configuration, the tests, a local run) nothing is written. + * + * A count changes nothing: it is written once the answer is built, it is never awaited, and whatever goes wrong in it + * is let go. + */ +import EVENTS from '../telemetry/events.json' with { type: 'json' }; + +/** + * The deployed commit, as far as the Worker knows it, which is not at all: CI writes the commit into dist/release.txt + * after wrangler has bundled the Worker, and naming it here would take a deploy-time variable, a workflow change. + */ +export const RELEASE = 'unknown'; + +const allowed = event => (Object.hasOwn(EVENTS.events, event) ? EVENTS.events[event] : null); + +/** The values the allowlist gives an event's field, for a caller that must fit what it has to them. */ +export const valuesOf = (event, name) => allowed(event)?.blobs.find(blob => blob.name === name)?.values ?? []; + +/** + * An event's data point, or null for anything the allowlist does not name: an event it does not list, a field it does + * not give the event, one of the event's fields missing, a value off its field's list, or a number that is not a count. + */ +export function dataPoint(event, fields) { + const spec = allowed(event); + if (spec === null || !fields || typeof fields !== 'object') return null; + const names = [...spec.blobs, ...spec.doubles].map(field => field.name); + if (Object.keys(fields).length !== names.length || !names.every(name => Object.hasOwn(fields, name))) return null; + if (!spec.blobs.every(({ name, values }) => Array.isArray(values) && values.includes(fields[name]))) return null; + if (!spec.doubles.every(({ name }) => Number.isSafeInteger(fields[name]) && fields[name] >= 0)) return null; + return { + indexes: [event], + blobs: [event, RELEASE, ...spec.blobs.map(({ name }) => fields[name])], + doubles: [1, ...spec.doubles.map(({ name }) => fields[name])] + }; +} + +/** The event, counted where EVENTS is bound; whatever goes wrong is let go, because a count is never worth an answer. */ +export function countEvent(env, event, fields) { + try { + const point = dataPoint(event, fields); + if (point !== null) env?.EVENTS?.writeDataPoint(point); + } catch { + // Nothing: the answer does not wait on a count. + } +} diff --git a/worker/telemetry.test.js b/worker/telemetry.test.js new file mode 100644 index 00000000..726ea3db --- /dev/null +++ b/worker/telemetry.test.js @@ -0,0 +1,260 @@ +// @vitest-environment node +/** + * SyberLabs telemetry in RISE (telemetry.v1), and the connector's counts. + * + * What is held: nothing telemetry/events.json does not name is written, and every field it names is a closed list of + * values or a count; each rise_present call the door admits or refuses is one data point of its host app and of what + * the door did, and nothing the host sent is in it; and a count changes nothing: every answer is byte-identical with + * the binding absent, present and throwing. + */ +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BLACK_HOLES_CURRENT } from '../src/test/sealed-current.js'; +import { CURRENT_EXAMPLE, CURRENT_EXAMPLE_V2 } from '../src/live/guide/index.js'; +import { FOREST_AFTER_FIRE, WEATHER_CHAOS } from '../src/live/fixtures/explanations.js'; +import { DRY_RUN_1_THREE_FRAMES } from '../src/core/quality/fixtures/figures.js'; +import { pictureCadence } from '../src/core/quality/cadence.js'; +import sample from '../src/core/changes/fixtures/2026-10-14.json' with { type: 'json' }; +import allowlist from '../telemetry/events.json' with { type: 'json' }; +import { RELEASE, countEvent, dataPoint } from './telemetry.mjs'; +import { APP_URI, handleMcp, hostOf } from './mcp-server.mjs'; + +const SITE = 'https://rise.example'; +const ON = { MCP_ENABLED: 'true' }; +const ADMITTED = 'rise.current.admitted'; +const REFUSED = 'rise.current.refused'; +const CLAUDE = { 'CF-Connecting-IP': '160.79.104.10' }; +const CHATGPT = { 'CF-Connecting-IP': '192.0.2.1', 'User-Agent': 'openai-mcp/1.0.0' }; +/** The codes the door refuses with itself, beside the validator's. */ +const DOOR = ['MCP_ARGUMENTS', 'MCP_TOO_LARGE', 'SCENE_COLLECTION', 'SCENE_CODE_ADMISSION', 'SCENE_SVG_ADMISSION']; + +/** An EVENTS binding that keeps what it is given. */ +function recorder() { + const points = []; + return { points, env: { EVENTS: { writeDataPoint: point => points.push(point) } } }; +} + +const rpc = (method, params) => ({ jsonrpc: '2.0', id: 7, method, params }); +const present = args => rpc('tools/call', { name: 'rise_present', arguments: args }); +const post = (body, { env = {}, headers = {} } = {}) => handleMcp(new Request(`${SITE}/api/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body) +}), { ...ON, ...env }); +const bytes = async response => ({ status: response.status, headers: [...response.headers], body: await response.text() }); + +const withScene = scene => { + const current = structuredClone(CURRENT_EXAMPLE_V2); + current.scenes.push(scene); + return current; +}; +/** A Current of beats whose second beat starts the dry run's three frames. */ +const DRY_RUN = { + ...CURRENT_EXAMPLE_V2, + scenes: [{ id: 'frames', svg: DRY_RUN_1_THREE_FRAMES }], + beats: [{ say: 'Three frames.' }, { say: 'The inner eye, the outer shell, the full frame.', scene: 'frames' }] +}; +/** Valid, and over the MCP-only budget (mcp-server.test.js). */ +const TOO_LARGE = { + ...BLACK_HOLES_CURRENT, + segments: Array.from({ length: 16 }, (_, s) => ({ + id: `s${s}`, + text: '界 '.repeat(625), + visual: 'still', + dives: Array.from({ length: 8 }, (_, d) => ({ id: `d${s}-${d}`, text: '界'.repeat(200), anchor: { fromCharacter: 0, toCharacter: 1, quoteStart: '界', quoteEnd: '界' } })) + })) +}; + +const ADMISSIONS = { CURRENT_EXAMPLE, CURRENT_EXAMPLE_V2, BLACK_HOLES_CURRENT, FOREST_AFTER_FIRE, WEATHER_CHAOS, sample: sample.current, DRY_RUN }; +/** What is refused, the call's arguments, and the code the refusal is counted under. */ +const REFUSALS = [ + ['no Current', {}, 'MCP_ARGUMENTS'], + ['a Current that is not an object', { current: 'Black holes' }, 'MCP_ARGUMENTS'], + ['an argument beside the Current', { current: BLACK_HOLES_CURRENT, theme: 'jade' }, 'MCP_ARGUMENTS'], + ['a Current over the MCP budget', { current: TOO_LARGE }, 'MCP_TOO_LARGE'], + ['a theme off the catalog', { current: { ...BLACK_HOLES_CURRENT, theme: 'plaid' } }, 'CURRENT_THEME'], + ['a field the validator does not know', { current: { ...BLACK_HOLES_CURRENT, mood: 'calm' } }, 'CURRENT_UNKNOWN_FIELD'], + ['a playback marker in its text', { current: { ...BLACK_HOLES_CURRENT, segments: [{ id: 's', text: 'a | b' }] } }, 'CURRENT_RESERVED_TEXT'], + ['a cue to a figure', { current: { ...DRY_RUN, beats: [{ say: 'Three frames.', scene: 'frames', cue: 'spin' }] } }, 'BEAT_CUE'], + ['a scene that names a collection', { current: withScene({ id: 'sky', engine: 'astronomy' }) }, 'SCENE_COLLECTION'], + ['scene code its parse refuses', { current: withScene({ id: 'vector', code: 'export default function scene(rise) {\n debugger;\n return { frame() {} };\n}\n' }) }, 'SCENE_CODE_ADMISSION'], + ['a figure with a script', { current: withScene({ id: 'triangle', svg: '\n \n' }) }, 'SCENE_SVG_ADMISSION'] +]; + +afterEach(() => { + vi.doUnmock('../src/core/rise-current.js'); + vi.resetModules(); +}); + +describe('the allowlist', () => { + it('is version 1 of one dataset, and each event is .. with an owner and the decision it informs', () => { + expect(allowlist.version).toBe(1); + expect(allowlist.dataset).toBe('syberlabs_events'); + expect(Object.keys(allowlist.events)).toEqual([ADMITTED, REFUSED]); + for (const [event, spec] of Object.entries(allowlist.events)) { + expect(event).toMatch(/^[a-z]+\.[a-z_]+\.[a-z_]+$/u); + expect(Object.keys(spec), event).toEqual(['owner', 'decision', 'blobs', 'doubles']); + expect(spec.owner, event).toMatch(/\S/u); + expect(spec.decision, event).toMatch(/\S/u); + } + }); + + it('gives every field a closed list of values or makes it a count, and fits a data point', () => { + for (const [event, spec] of Object.entries(allowlist.events)) { + for (const blob of spec.blobs) { + expect(Object.keys(blob).every(key => ['name', 'values', 'derived'].includes(key)), `${event}.${blob.name}`).toBe(true); + expect(blob.values.length, `${event}.${blob.name}`).toBeGreaterThan(0); + expect(blob.values.every(value => typeof value === 'string' && /^[A-Za-z0-9_]+$/u.test(value)), `${event}.${blob.name}`).toBe(true); + expect(new Set(blob.values).size, `${event}.${blob.name}`).toBe(blob.values.length); + } + for (const double of spec.doubles) expect(Object.keys(double), `${event}.${double.name}`).toEqual(['name']); + const names = [...spec.blobs, ...spec.doubles].map(field => field.name); + expect(new Set(names).size, event).toBe(names.length); + // The event and the release lead the blobs, and the 1 the doubles; Analytics Engine takes twenty of each. + expect(spec.blobs.length + 2).toBeLessThanOrEqual(20); + expect(spec.doubles.length + 1).toBeLessThanOrEqual(20); + } + }); + + it('names every code the door can refuse with, and none it cannot', () => { + // What the validator the door calls can throw: the codes written in the modules validateRiseCurrent reaches. + const validator = ['src/core/current-validation.js', 'src/core/rise-current.js', 'src/core/beats.js', 'src/scenes/manifests.js'] + .flatMap(file => [...readFileSync(file, 'utf8').matchAll(/'((?:CURRENT|BEAT|SCENE)_[A-Z_]+)'/gu)].map(match => match[1])) + // compileRiseCurrent's alone: the door validates a Current and never compiles one. + .filter(code => code !== 'CURRENT_PROJECTION'); + const codes = allowlist.events[REFUSED].blobs.find(blob => blob.name === 'code').values; + expect(new Set(codes)).toEqual(new Set([...validator, ...DOOR, 'other'])); + }); +}); + +describe('a data point', () => { + const fields = { host: 'claude', beats: 3, scenes: 1 }; + + it('is the event as its index, the event, the release and its values as blobs, and 1 and its counts as doubles, in the allowlist’s order', () => { + expect(RELEASE).toBe('unknown'); + expect(dataPoint(ADMITTED, fields)).toEqual({ indexes: [ADMITTED], blobs: [ADMITTED, 'unknown', 'claude'], doubles: [1, 3, 1] }); + expect(dataPoint(ADMITTED, { scenes: 1, beats: 3, host: 'claude' })).toEqual(dataPoint(ADMITTED, fields)); + expect(dataPoint(REFUSED, { code: 'CURRENT_THEME', host: 'chatgpt' })).toEqual({ indexes: [REFUSED], blobs: [REFUSED, 'unknown', 'chatgpt', 'CURRENT_THEME'], doubles: [1] }); + }); + + it('is nothing for anything the allowlist does not name', () => { + for (const [event, given] of [ + ['rise.current.viewed', fields], ['constructor', {}], ['__proto__', {}], ['toString', {}], [undefined, fields], + [ADMITTED, { host: 'claude', beats: 3 }], + [ADMITTED, { ...fields, title: 'Black holes' }], + [ADMITTED, { ...fields, host: 'gemini' }], [ADMITTED, { ...fields, host: 'Claude' }], [ADMITTED, { ...fields, host: ['claude'] }], + [ADMITTED, { ...fields, beats: 2.5 }], [ADMITTED, { ...fields, beats: -1 }], [ADMITTED, { ...fields, beats: '3' }], + [ADMITTED, { ...fields, beats: Number.NaN }], [ADMITTED, { ...fields, beats: Number.POSITIVE_INFINITY }], + [REFUSED, { host: 'other', code: 'RISE refused this Current: Unknown theme' }], [REFUSED, { host: 'other', code: 'CURRENT_PROJECTION' }], + [ADMITTED, null], [ADMITTED, undefined], [ADMITTED, 'host=claude'] + ]) { + expect(dataPoint(event, given), `${String(event)} ${JSON.stringify(given)}`).toBeNull(); + } + }); + + it('is written where EVENTS is bound, and never throws: not without a binding, nor for one that fails', () => { + const { points, env } = recorder(); + countEvent(env, ADMITTED, fields); + countEvent(env, ADMITTED, { ...fields, host: 'gemini' }); + expect(points).toEqual([dataPoint(ADMITTED, fields)]); + for (const given of [undefined, null, {}, { EVENTS: null }, { EVENTS: {} }, { EVENTS: { writeDataPoint: () => { throw new Error('the dataset is down'); } } }]) { + expect(() => countEvent(given, ADMITTED, fields)).not.toThrow(); + } + }); +}); + +describe('the connector’s counts', () => { + it('names the host: Claude by Anthropic’s egress, ChatGPT by its connector’s User-Agent at its start, and anything else other', () => { + for (const ip of ['160.79.104.0', '160.79.106.77', '160.79.111.255']) { + expect(hostOf(ip, undefined), ip).toBe('claude'); + expect(hostOf(ip, 'openai-mcp/1.0.0'), ip).toBe('claude'); + } + for (const agent of ['openai-mcp/1.0.0', 'openai-mcp/2.1']) expect(hostOf('192.0.2.1', agent), agent).toBe('chatgpt'); + expect(hostOf(undefined, 'openai-mcp/1.0.0')).toBe('chatgpt'); + for (const [ip, agent] of [ + [undefined, undefined], [null, null], ['', ''], ['160.79.112.0', 'Claude-User'], ['192.0.2.1', 'Mozilla/5.0 (compatible; openai-mcp/1.0.0)'], + ['192.0.2.1', 'OpenAI-MCP/1.0.0'], ['192.0.2.1', 'openai-mcp'], ['192.0.2.1', 'ChatGPT-User/1.0'], ['::ffff:160.79.104.1', 'curl/8.5.0'] + ]) { + expect(hostOf(ip, agent), `${ip} ${agent}`).toBe('other'); + } + }); + + it('writes one data point for each Current the door admits: its host, its beats and its scenes', async () => { + for (const [name, current] of Object.entries(ADMISSIONS)) { + const { points, env } = recorder(); + const answer = await (await post(present({ current }), { env, headers: CLAUDE })).json(); + expect(answer.result.isError, name).toBeUndefined(); + const { beats, scenes } = pictureCadence(current); + expect(points, name).toEqual([{ indexes: [ADMITTED], blobs: [ADMITTED, RELEASE, 'claude'], doubles: [1, beats, scenes] }]); + } + const { points, env } = recorder(); + await post(present({ current: sample.current }), { env, headers: CHATGPT }); + await post(present({ current: DRY_RUN }), { env }); + expect(points.map(point => [point.blobs[2], ...point.doubles.slice(1)])).toEqual([['chatgpt', 15, 8], ['other', 2, 1]]); + }); + + it.each(REFUSALS)('writes one data point for each Current it refuses, under the refusal’s code: %s', async (_, args, code) => { + const { points, env } = recorder(); + const answer = await (await post(present(args), { env, headers: CHATGPT })).json(); + expect(answer.result.isError).toBe(true); + expect(points).toEqual([{ indexes: [REFUSED], blobs: [REFUSED, RELEASE, 'chatgpt', code], doubles: [1] }]); + }); + + it('counts a refusal whose code the allowlist does not name as other', async () => { + for (const thrown of [new TypeError('surprise'), Object.assign(new Error('A new rule'), { code: 'CURRENT_NEW_RULE' })]) { + vi.resetModules(); + vi.doMock('../src/core/rise-current.js', async original => ({ ...await original(), validateRiseCurrent: () => { throw thrown; } })); + const { handleMcp: door } = await import('./mcp-server.mjs'); + const { points, env } = recorder(); + await door(new Request(`${SITE}/api/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(present({ current: BLACK_HOLES_CURRENT })) }), { ...ON, ...env }); + expect(points).toEqual([{ indexes: [REFUSED], blobs: [REFUSED, RELEASE, 'other', 'other'], doubles: [1] }]); + } + }); + + it('writes nothing for anything else the door answers, nor for what it turns away before reading', async () => { + const { points, env } = recorder(); + for (const body of [ + rpc('initialize', { protocolVersion: '2025-06-18' }), rpc('ping'), rpc('tools/list'), rpc('resources/list'), rpc('resources/read', { uri: APP_URI }), + rpc('tools/call', { name: 'rise_guide', arguments: { style: 'explainer' } }), rpc('tools/call', { name: 'other', arguments: {} }), + { jsonrpc: '2.0', method: 'notifications/initialized' } + ]) await post(body, { env, headers: CLAUDE }); + await post(present({ current: BLACK_HOLES_CURRENT }), { env: { ...env, MCP_ENABLED: 'false' } }); + await post(present({ current: BLACK_HOLES_CURRENT }), { env, headers: { Origin: 'https://evil.example' } }); + await post(present({ current: BLACK_HOLES_CURRENT }), { env: { ...env, DECISION_LIMITER: { limit: async () => ({ success: false }) } }, headers: { 'CF-Connecting-IP': '192.0.2.1' } }); + expect(points).toEqual([]); + }); + + it('writes only what the allowlist names: no address, no User-Agent, nothing the host wrote', async () => { + const { points, env } = recorder(); + const headers = { 'CF-Connecting-IP': '160.79.104.10', 'User-Agent': 'openai-mcp/1.0.0 (Mozilla/5.0; a reader)' }; + for (const current of Object.values(ADMISSIONS)) await post(present({ current }), { env, headers }); + for (const [, args] of REFUSALS) await post(present(args), { env, headers: { ...headers, 'CF-Connecting-IP': '192.0.2.1' } }); + expect(points).toHaveLength(Object.keys(ADMISSIONS).length + REFUSALS.length); + for (const point of points) { + const [event, release, ...values] = point.blobs; + const spec = allowlist.events[event]; + expect(point.indexes).toEqual([event]); + expect(release).toBe(RELEASE); + expect(values).toHaveLength(spec.blobs.length); + values.forEach((value, index) => expect(spec.blobs[index].values).toContain(value)); + expect(point.doubles).toHaveLength(spec.doubles.length + 1); + expect(point.doubles[0]).toBe(1); + expect(point.doubles.every(count => Number.isSafeInteger(count) && count >= 0)).toBe(true); + } + const written = JSON.stringify(points); + for (const sent of ['160.79', '192.0.2', 'openai-mcp', 'Mozilla', 'reader', BLACK_HOLES_CURRENT.title, BLACK_HOLES_CURRENT.id, 'harmonograph', ' { + const bindings = [{}, recorder().env, { EVENTS: { writeDataPoint: () => { throw new Error('the dataset is down'); } } }, { EVENTS: null }, { EVENTS: {} }]; + const bodies = [rpc('ping'), rpc('tools/list'), ...Object.values(ADMISSIONS).map(current => present({ current })), ...REFUSALS.map(([, args]) => present(args))]; + for (const body of bodies) { + for (const headers of [CLAUDE, CHATGPT]) { + const [first, ...rest] = await Promise.all(bindings.map(async env => bytes(await post(body, { env, headers })))); + for (const answer of rest) expect(answer).toEqual(first); + } + } + }); +}); diff --git a/wrangler.production.jsonc b/wrangler.production.jsonc index 3a457e0a..22e1b12c 100644 --- a/wrangler.production.jsonc +++ b/wrangler.production.jsonc @@ -32,6 +32,9 @@ "PLUS_VOICES": [{ "slug": "default", "label": "Default" }, { "slug": "george", "label": "George — warm British narrator", "id": "JBFqnCBsd6RMkjVDRZzb" }, { "slug": "rachel", "label": "Rachel — calm American", "id": "21m00Tcm4TlvDq8ikWAM" }, { "slug": "daniel", "label": "Daniel — deep British", "id": "onwK4e9ZLuTAKqWW03F9" }, { "slug": "charlotte", "label": "Charlotte — soft, measured", "id": "XB0fDUnXU5powFXDhCwa" }] }, // rise.syberlabs.io is attached to this Worker in the Cloudflare dashboard. "observability": { "enabled": true }, + // The SyberLabs event counts (worker/telemetry.mjs): only what telemetry/events.json names, in the Analytics Engine + // dataset, which keeps a data point for three months. Production alone binds it; without it nothing is counted. + "analytics_engine_datasets": [{ "binding": "EVENTS", "dataset": "syberlabs_events" }], "assets": { "directory": "./dist", "not_found_handling": "single-page-application", From 4d935383b3c5b5f8e0a470141398acc59a21ba11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:49:35 +0000 Subject: [PATCH 2/2] Reader: anonymous event counts, sent as beacons to POST /api/count A reading's begin and end, the first word after Home's Begin, and four failure classes, each one beacon from rise.syberlabs.io (never from a frame) to worker/count.mjs. It takes only same-origin JSON of at most 512 bytes within COUNT_LIMITER, derives the browser and the device from the User-Agent, fits the numbers, writes only what telemetry/events.json names, and always answers 204. PRIVACY.md names the counts in sections 4, 6, 10 and 11. Builds on #762's writer and allowlist. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VHrJ4hmTtR1RFNpc215EK7 --- PRIVACY.md | 30 +++- docs/specs/ARCHITECTURE.md | 8 +- public/PRIVACY.md | 30 +++- public/privacy.html | 7 +- src/audio/engine.js | 3 + src/audio/engine.lifecycle.test.js | 7 + src/components/Home.js | 5 + src/components/read/Chamber.counts.test.js | 147 +++++++++++++++++ src/components/read/Chamber.js | 66 +++++++- src/core/begin-steps.js | 23 +++ src/core/error-boundary.js | 12 ++ src/core/error-boundary.test.js | 25 +++ src/core/telemetry.js | 18 +++ src/core/telemetry.test.js | 102 ++++++++++++ src/live/host/LiveHost.js | 4 + src/live/host/LiveHost.test.js | 16 ++ telemetry/events.json | 44 +++++ vitest.fast.config.js | 7 +- worker/count.mjs | 87 ++++++++++ worker/count.test.js | 177 +++++++++++++++++++++ worker/index.mjs | 3 + worker/production-config.test.js | 10 ++ worker/telemetry.mjs | 6 +- worker/telemetry.test.js | 22 ++- wrangler.production.jsonc | 6 + 25 files changed, 851 insertions(+), 14 deletions(-) create mode 100644 src/components/read/Chamber.counts.test.js create mode 100644 src/core/error-boundary.test.js create mode 100644 src/core/telemetry.js create mode 100644 src/core/telemetry.test.js create mode 100644 worker/count.mjs create mode 100644 worker/count.test.js diff --git a/PRIVACY.md b/PRIVACY.md index 63d9e736..6c280956 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -454,6 +454,22 @@ Cloudflare describes the service at and . +RISE's own server also keeps anonymous event counts, which the page on +`rise.syberlabs.io` sends to `/api/count`: that a reading began (and whether it +came from RISE's library, your own text, a public page or elsewhere, and +whether it opened as a stream or a page); that it ended (whether it reached its +last word, and roughly how many minutes it lasted); how long its first word +took after you pressed Begin on Home; and that one of a few failures happened +(no speech voice, audio that would not start, no WebGL graphics, or an +unexpected error). Each count also names your browser's family and whether +your device is a phone or tablet or a computer, which the server reads from the +user-agent and does not keep. The counts set no cookie, use no storage in your +browser, and carry no identifier: not your address, not the user-agent, not a +session. They never include reading text or anything you type. Each address may send 60 a minute, counted for that minute +by Cloudflare's rate limiter. Nothing is sent from inside ChatGPT or Claude, +from a page that frames RISE, or from `rise.syberlabs.space`. The counts are +kept for three months. + --- ## 5. What your browser fetches from other people @@ -512,6 +528,12 @@ We state these plainly because the absence is the point. page-load speed on `rise.syberlabs.io` (section 4). It sets no cookie and keeps no identifier. No Google Analytics, no Tag Manager, no Plausible, no Sentry, no other analytics. No other third-party script runs on the page. +- **Anonymous event counts.** RISE's own server also counts a few events on + `rise.syberlabs.io` (section 4): a reading's begin and end, how long its first + word took after Begin, and a few kinds of failure, each with the browser's + family and whether the device is a phone or tablet or a computer. They set no + cookie, use no storage, carry no identifier, never include reading text or + anything you type, and are kept for three months. - **No advertising, no pixels, no fingerprinting.** - **No advertising or reading-history tracking across sites or visits.** Subscriber and administrator authorization cookies identify their respective @@ -624,6 +646,11 @@ We do not, and never have. counts are totals. They use no cookie or identifier and never include reading text or anything you type. You can object under section 8, or block the script yourself. +- **Counting reading events** with RISE's anonymous event counts, in section 4: + the same legitimate interest, in knowing how readings begin and end and what + fails in them, so we can fix and improve the site (Article 6(1)(f)). Each + count is one anonymous event with no cookie or identifier, and never includes + reading text or anything you type. You can object under section 8. We do not ask for consent for these counts, because they set no cookie, use no local storage, and identify no one. If we ever add analytics that do any of @@ -643,7 +670,8 @@ logging is on for RISE's server and keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for at most 7 days (3 on Cloudflare's free plan); no tool text or reader content is logged. RISE's counts of calls to its tool inside ChatGPT or Claude (section 4) hold no text -and no identifier and are kept for three months. Cloudflare +and no identifier and are kept for three months. RISE's anonymous event counts +from `rise.syberlabs.io` (section 4) are kept for three months. Cloudflare keeps every page-view report for 7 days, then reduces them to a sample of about 10%; we can see the counts for the previous six months. OpenRouter processes the requests you send it under its own policy; see section 4. No provider-side retention diff --git a/docs/specs/ARCHITECTURE.md b/docs/specs/ARCHITECTURE.md index 1b764bc1..ebe3546c 100644 --- a/docs/specs/ARCHITECTURE.md +++ b/docs/specs/ARCHITECTURE.md @@ -165,7 +165,7 @@ flowchart LR audio["audio
Web Audio, recitation
17 modules"] components["components
routed views
58 modules"] content["content
texts, imagery, journeys
307 modules"] - core["core
session, player, router
194 modules"] + core["core
session, player, router
195 modules"] enterprise["enterprise
talk program, speaker rail
36 modules"] live["live
realtime Current: events, runtime, providers
79 modules"] page["page
spatial projection
4 modules"] @@ -185,12 +185,12 @@ flowchart LR app -.-> |1 lazy| visuals audio --> |1| app audio --> |1| content - audio --> |8| core + audio --> |9| core components --> |3| affect components --> |10| app components --> |6| audio components --> |29| content - components --> |192| core + components --> |194| core components -.-> |1 lazy| page components --> |3| scenes components --> |4| sources @@ -209,7 +209,7 @@ flowchart LR live -.-> |3 lazy| app live --> |3| audio live --> |1| components - live --> |48| core + live --> |49| core live --> |3| scenes live -.-> |1 lazy| visuals page --> |4| core diff --git a/public/PRIVACY.md b/public/PRIVACY.md index 63d9e736..6c280956 100644 --- a/public/PRIVACY.md +++ b/public/PRIVACY.md @@ -454,6 +454,22 @@ Cloudflare describes the service at and . +RISE's own server also keeps anonymous event counts, which the page on +`rise.syberlabs.io` sends to `/api/count`: that a reading began (and whether it +came from RISE's library, your own text, a public page or elsewhere, and +whether it opened as a stream or a page); that it ended (whether it reached its +last word, and roughly how many minutes it lasted); how long its first word +took after you pressed Begin on Home; and that one of a few failures happened +(no speech voice, audio that would not start, no WebGL graphics, or an +unexpected error). Each count also names your browser's family and whether +your device is a phone or tablet or a computer, which the server reads from the +user-agent and does not keep. The counts set no cookie, use no storage in your +browser, and carry no identifier: not your address, not the user-agent, not a +session. They never include reading text or anything you type. Each address may send 60 a minute, counted for that minute +by Cloudflare's rate limiter. Nothing is sent from inside ChatGPT or Claude, +from a page that frames RISE, or from `rise.syberlabs.space`. The counts are +kept for three months. + --- ## 5. What your browser fetches from other people @@ -512,6 +528,12 @@ We state these plainly because the absence is the point. page-load speed on `rise.syberlabs.io` (section 4). It sets no cookie and keeps no identifier. No Google Analytics, no Tag Manager, no Plausible, no Sentry, no other analytics. No other third-party script runs on the page. +- **Anonymous event counts.** RISE's own server also counts a few events on + `rise.syberlabs.io` (section 4): a reading's begin and end, how long its first + word took after Begin, and a few kinds of failure, each with the browser's + family and whether the device is a phone or tablet or a computer. They set no + cookie, use no storage, carry no identifier, never include reading text or + anything you type, and are kept for three months. - **No advertising, no pixels, no fingerprinting.** - **No advertising or reading-history tracking across sites or visits.** Subscriber and administrator authorization cookies identify their respective @@ -624,6 +646,11 @@ We do not, and never have. counts are totals. They use no cookie or identifier and never include reading text or anything you type. You can object under section 8, or block the script yourself. +- **Counting reading events** with RISE's anonymous event counts, in section 4: + the same legitimate interest, in knowing how readings begin and end and what + fails in them, so we can fix and improve the site (Article 6(1)(f)). Each + count is one anonymous event with no cookie or identifier, and never includes + reading text or anything you type. You can object under section 8. We do not ask for consent for these counts, because they set no cookie, use no local storage, and identify no one. If we ever add analytics that do any of @@ -643,7 +670,8 @@ logging is on for RISE's server and keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for at most 7 days (3 on Cloudflare's free plan); no tool text or reader content is logged. RISE's counts of calls to its tool inside ChatGPT or Claude (section 4) hold no text -and no identifier and are kept for three months. Cloudflare +and no identifier and are kept for three months. RISE's anonymous event counts +from `rise.syberlabs.io` (section 4) are kept for three months. Cloudflare keeps every page-view report for 7 days, then reduces them to a sample of about 10%; we can see the counts for the previous six months. OpenRouter processes the requests you send it under its own policy; see section 4. No provider-side retention diff --git a/public/privacy.html b/public/privacy.html index b910c0b8..053bb97c 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -283,6 +283,7 @@

Page-view counts

It never receives the text of a reading, text you paste or write, what you ask Jev, Kev, or Live, or any key. None of these is ever in a page address. A private reading's address is /read/session. A public page, such as a Keystone or a Chapel chapter, has an address that names that public text, so the counts show how often that page was opened.

You can block static.cloudflareinsights.com in your browser or with a content blocker. RISE works the same without it.

Cloudflare describes the service at https://developers.cloudflare.com/web-analytics/about/, https://developers.cloudflare.com/web-analytics/data-metrics/data-origin-and-collection/, https://developers.cloudflare.com/web-analytics/faq/ and https://www.cloudflare.com/web-analytics/.

+

RISE's own server also keeps anonymous event counts, which the page on rise.syberlabs.io sends to /api/count: that a reading began (and whether it came from RISE's library, your own text, a public page or elsewhere, and whether it opened as a stream or a page); that it ended (whether it reached its last word, and roughly how many minutes it lasted); how long its first word took after you pressed Begin on Home; and that one of a few failures happened (no speech voice, audio that would not start, no WebGL graphics, or an unexpected error). Each count also names your browser's family and whether your device is a phone or tablet or a computer, which the server reads from the user-agent and does not keep. The counts set no cookie, use no storage in your browser, and carry no identifier: not your address, not the user-agent, not a session. They never include reading text or anything you type. Each address may send 60 a minute, counted for that minute by Cloudflare's rate limiter. Nothing is sent from inside ChatGPT or Claude, from a page that frames RISE, or from rise.syberlabs.space. The counts are kept for three months.


5. What your browser fetches from other people

RISE holds texts and artworks by reference. When you open a reading, your browser may request material directly from the institution that holds it. Those requests come from your browser, not from us, and we never see them — but the receiving organisation will see your IP address, as it would for any website you visit.

@@ -294,7 +295,7 @@

5. What your browser fetches from other people


6. What we do not do

We state these plainly because the absence is the point.

-
  • Subscriber cookies. The Worker sets __Secure-rise_plus for voice access and __Secure-rise_plus_billing for billing management. Both are HttpOnly, read only by /api/plus/*, and never by page script. Billing identity alone cannot authorize voice access. Cloudflare Access sets a separate authorization cookie for administrator sign-in. Neither grants authority through a browser role flag.
  • The Plus voice. With a Plus subscription or verified administrator access and its voice on, the text of readings of your own material goes through RISE's server to ElevenLabs on our account, and audio with playback timings is saved privately for up to 30 days. RISE does not persist the submitted text (section 4).
  • Page-view counts only. Cloudflare Web Analytics counts page views and page-load speed on rise.syberlabs.io (section 4). It sets no cookie and keeps no identifier. No Google Analytics, no Tag Manager, no Plausible, no Sentry, no other analytics. No other third-party script runs on the page.
  • No advertising, no pixels, no fingerprinting.
  • No advertising or reading-history tracking across sites or visits. Subscriber and administrator authorization cookies identify their respective entitlements; saved reading work and settings remain on your device.
  • No sale or sharing of personal information, as those terms are used in the California Consumer Privacy Act. Optional Scriptorium routing is for the action you choose, not advertising. An AI request goes to the decision connection you chose (your OpenRouter account or Kev on your computer).
  • No camera or location access. The browser security policy denies both. It permits the microphone only on RISE's own origin, for optional voice dictation. RISE asks for microphone access only after you press Speak.
+
  • Subscriber cookies. The Worker sets __Secure-rise_plus for voice access and __Secure-rise_plus_billing for billing management. Both are HttpOnly, read only by /api/plus/*, and never by page script. Billing identity alone cannot authorize voice access. Cloudflare Access sets a separate authorization cookie for administrator sign-in. Neither grants authority through a browser role flag.
  • The Plus voice. With a Plus subscription or verified administrator access and its voice on, the text of readings of your own material goes through RISE's server to ElevenLabs on our account, and audio with playback timings is saved privately for up to 30 days. RISE does not persist the submitted text (section 4).
  • Page-view counts only. Cloudflare Web Analytics counts page views and page-load speed on rise.syberlabs.io (section 4). It sets no cookie and keeps no identifier. No Google Analytics, no Tag Manager, no Plausible, no Sentry, no other analytics. No other third-party script runs on the page.
  • Anonymous event counts. RISE's own server also counts a few events on rise.syberlabs.io (section 4): a reading's begin and end, how long its first word took after Begin, and a few kinds of failure, each with the browser's family and whether the device is a phone or tablet or a computer. They set no cookie, use no storage, carry no identifier, never include reading text or anything you type, and are kept for three months.
  • No advertising, no pixels, no fingerprinting.
  • No advertising or reading-history tracking across sites or visits. Subscriber and administrator authorization cookies identify their respective entitlements; saved reading work and settings remain on your device.
  • No sale or sharing of personal information, as those terms are used in the California Consumer Privacy Act. Optional Scriptorium routing is for the action you choose, not advertising. An AI request goes to the decision connection you chose (your OpenRouter account or Kev on your computer).
  • No camera or location access. The browser security policy denies both. It permits the microphone only on RISE's own origin, for optional voice dictation. RISE asks for microphone access only after you press Speak.

7. Do Not Track, and tracking by others

California's Online Privacy Protection Act requires every site to say how it answers a browser's "Do Not Track" signal. Ours is a short answer.

@@ -314,11 +315,11 @@

9. California residents

Under California Civil Code § 1798.83, "Shine the Light", you may ask whether we disclosed personal information to third parties for their direct marketing. We do not, and never have.


10. Legal basis (UK/EU GDPR)

-
  • Serving the sites and the public catalog, including request data in section 4: our legitimate interest in delivering and securing the application and fulfilling the action you chose (Article 6(1)(f)).
  • Storing your work on your device, in section 3: necessary to provide the service you have asked for. It holds your reading and your writing, carries no identifier, and is not used to observe you.
  • Counting page views and load times with Cloudflare Web Analytics, in section 4: our legitimate interest in knowing which pages are used and how fast they load, so we can fix and improve the site (Article 6(1)(f)). The counts are totals. They use no cookie or identifier and never include reading text or anything you type. You can object under section 8, or block the script yourself.
+
  • Serving the sites and the public catalog, including request data in section 4: our legitimate interest in delivering and securing the application and fulfilling the action you chose (Article 6(1)(f)).
  • Storing your work on your device, in section 3: necessary to provide the service you have asked for. It holds your reading and your writing, carries no identifier, and is not used to observe you.
  • Counting page views and load times with Cloudflare Web Analytics, in section 4: our legitimate interest in knowing which pages are used and how fast they load, so we can fix and improve the site (Article 6(1)(f)). The counts are totals. They use no cookie or identifier and never include reading text or anything you type. You can object under section 8, or block the script yourself.
  • Counting reading events with RISE's anonymous event counts, in section 4: the same legitimate interest, in knowing how readings begin and end and what fails in them, so we can fix and improve the site (Article 6(1)(f)). Each count is one anonymous event with no cookie or identifier, and never includes reading text or anything you type. You can object under section 8.

We do not ask for consent for these counts, because they set no cookie, use no local storage, and identify no one. If we ever add analytics that do any of those things, we will ask first.


11. Retention

-

RISE does not receive or persist AI requests. An OpenRouter, Gemini, or OpenAI key is held only in the page's memory and never stored. If the OpenAI Live route is switched on, RISE's server uses the reader's OpenAI key for one request to open a session and keeps neither the key nor the request; see section 4. Google and OpenAI process Live requests under their own policies. Cloudflare and Netlify handle hosting and API request data under their own policies. Cloudflare Workers observability logging is on for RISE's server and keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for at most 7 days (3 on Cloudflare's free plan); no tool text or reader content is logged. RISE's counts of calls to its tool inside ChatGPT or Claude (section 4) hold no text and no identifier and are kept for three months. Cloudflare keeps every page-view report for 7 days, then reduces them to a sample of about 10%; we can see the counts for the previous six months. OpenRouter processes the requests you send it under its own policy; see section 4. No provider-side retention guarantee is made here.

+

RISE does not receive or persist AI requests. An OpenRouter, Gemini, or OpenAI key is held only in the page's memory and never stored. If the OpenAI Live route is switched on, RISE's server uses the reader's OpenAI key for one request to open a session and keeps neither the key nor the request; see section 4. Google and OpenAI process Live requests under their own policies. Cloudflare and Netlify handle hosting and API request data under their own policies. Cloudflare Workers observability logging is on for RISE's server and keeps request metadata (method, path, status, timing, and the client address Cloudflare sees) for at most 7 days (3 on Cloudflare's free plan); no tool text or reader content is logged. RISE's counts of calls to its tool inside ChatGPT or Claude (section 4) hold no text and no identifier and are kept for three months. RISE's anonymous event counts from rise.syberlabs.io (section 4) are kept for three months. Cloudflare keeps every page-view report for 7 days, then reduces them to a sample of about 10%; we can see the counts for the previous six months. OpenRouter processes the requests you send it under its own policy; see section 4. No provider-side retention guarantee is made here.

For Plus, RISE's server keeps the current subscription period's character and financial budget/usage records, its current daily count and the standing, rate-limit, recovery-token hash and event records described in section 4. Recovery tokens expire after 15 minutes; only the most recent recovery record is kept and it is replaced by a later request. A newer billing period replaces the old period's meter; a newer day replaces the daily meter, including the separate shared administrator meter; a newer month replaces the administrator monthly spend meter. RISE stores no reading text or audio in these records. ElevenLabs retains voiced text and audio, and Stripe retains payment records, under their own policies; see section 4. Separately, saved Plus audio and timings expire after 30 days or are removed by the server-audio deletion control. A storage alarm cleans expired bytes; expired renders are refused even before that cleanup runs. Bounded fingerprint safety records remain to prevent an automatic repeat vendor charge.

Data on your device persists until you erase it or clear your browser storage.


diff --git a/src/audio/engine.js b/src/audio/engine.js index 06c1959e..3e901246 100644 --- a/src/audio/engine.js +++ b/src/audio/engine.js @@ -138,6 +138,7 @@ import { soundTrimDb } from './sound-levels.js'; import { createChantBed, isChantBedId, CHANT_BED_IDS } from './chant.js'; import { audioDiag } from '../core/audio-diagnostics.js'; import { siteUrl } from '../core/embed-address.js'; +import { count } from '../core/telemetry.js'; import { AudioLifecycle, AUDIO_STATUS } from './lifecycle.js'; /** @@ -450,6 +451,8 @@ export class AudioEngine { this.sessionGain = null; this.voiceGain = null; this.isInitialized = false; + // Counted with nothing of the error in it (src/core/telemetry.js). + count('rise.app.failed', { class: 'audio_failed' }); this.onUnavailable('Audio initialization blocked. Interact to enable.', 4000); throw error; } diff --git a/src/audio/engine.lifecycle.test.js b/src/audio/engine.lifecycle.test.js index e6e3ebc6..b889f477 100644 --- a/src/audio/engine.lifecycle.test.js +++ b/src/audio/engine.lifecycle.test.js @@ -1,4 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../core/telemetry.js', () => ({ count: vi.fn() })); + +import { count } from '../core/telemetry.js'; import { AudioEngine } from './engine.js'; describe('AudioEngine lifecycle ownership', () => { @@ -66,6 +70,7 @@ describe('AudioEngine lifecycle ownership', () => { }); it('reports initialization failure through its injected boundary', async () => { + count.mockClear(); const onUnavailable = vi.fn(); vi.stubGlobal('AudioContext', class BlockedAudioContext { constructor() { throw new Error('blocked'); } @@ -78,6 +83,8 @@ describe('AudioEngine lifecycle ownership', () => { 'Audio initialization blocked. Interact to enable.', 4000 ); + // And counted as its class alone (src/core/telemetry.js), with nothing of the error in it. + expect(count.mock.calls).toEqual([['rise.app.failed', { class: 'audio_failed' }]]); }); /** diff --git a/src/components/Home.js b/src/components/Home.js index 16f75523..8f17b002 100644 --- a/src/components/Home.js +++ b/src/components/Home.js @@ -42,6 +42,7 @@ import { claimOpenRouterReturn } from '../core/openrouter-callback.js'; import { localDateKey, watchLocalDay } from '../core/local-day.js'; import { USER_DATA_KEYS } from '../core/user-data-keys.js'; import { resolveChamberStreamFace } from '../core/chamber-stream-face.js'; +import { markBegin, takeBegin } from '../core/begin-steps.js'; const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'; const SETTINGS_PATH = ''; @@ -640,6 +641,8 @@ export class Home { async proceed(action) { const reading = this.reading; if (!reading || this.busy) return; + // Begin starts R2's interval, which the reading's first word ends and counts (src/core/begin-steps.js). + if (action === 'enter') markBegin(); this.setBusy(action); this.getAudioEngine()?.playClick(); this.showError(''); @@ -654,6 +657,8 @@ export class Home { if (preview) this.firstReadChoiceUsed = true; } } catch (error) { + // No reading began, so no later one measures from this press. + takeBegin(); this.showError('That reading couldn’t be opened. Try again, or try another reading.', error?.message || ''); } finally { this.setBusy(null); diff --git a/src/components/read/Chamber.counts.test.js b/src/components/read/Chamber.counts.test.js new file mode 100644 index 00000000..27910680 --- /dev/null +++ b/src/components/read/Chamber.counts.test.js @@ -0,0 +1,147 @@ +/** + * A reading's counts from the Chamber (src/core/telemetry.js): its begin, its end, and the first word after Home's Begin. + * + * What is held: the begin is counted once, at the first play or as the page opens, with where the reading came from + * and how it opened; the end once, yes when the Player reaches the last word and no when the reader leaves or closes + * the tab; the first word once, only for a reading Home's Begin opened, measured from that press. A reading that never + * began is never ended, and no interval is left open for a later reading to measure from. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../core/telemetry.js', () => ({ count: vi.fn() })); + +import { count } from '../../core/telemetry.js'; +import { markBegin, takeBegin } from '../../core/begin-steps.js'; +import { Chamber } from './Chamber.js'; + +function mount(session = {}) { + const container = document.createElement('div'); + document.body.appendChild(container); + const player = { state: 'idle', pause: vi.fn(), play: vi.fn(), stop: vi.fn(), on: vi.fn(), setInterlocutionHandler: vi.fn() }; + const chamber = new Chamber(container, { + session: { title: 'Meditations', atoms: [{ content: 'A thought', duration: 60000 }], totalDuration: 60000, atomCount: 1, visualConfig: { visualMode: 'off' }, ...session }, + player + }); + // What the Player would say: each handler the Chamber gave it for `event`. + const emit = (event, data) => { for (const [name, handler] of player.on.mock.calls) if (name === event) handler(data); }; + return { chamber, emit }; +} + +const counted = event => count.mock.calls.filter(([name]) => name === event).map(([, fields]) => fields); +const MINUTES = { minutes: expect.any(Number) }; + +afterEach(() => { + document.body.replaceChildren(); + count.mockClear(); + takeBegin(); + vi.useRealTimers(); +}); + +describe('a reading’s begin', () => { + it('is counted once, at its first play, as the stream', () => { + const { chamber, emit } = mount({ provenance: { kind: 'library-division' } }); + emit('state', { state: 'playing' }); + emit('state', { state: 'paused' }); + emit('state', { state: 'playing' }); + expect(counted('rise.reading.begun')).toEqual([{ source: 'library', mode: 'stream' }]); + chamber.destroy(); + }); + + it('names where it came from: the reader’s own text, a public address, RISE’s library, or other', () => { + for (const [session, source] of [ + [{ provenance: { kind: 'local-text' } }, 'import'], + [{ provenance: { kind: 'local-text' }, publicPath: '/try/your-text/' }, 'import'], + [{ provenance: { kind: 'keystone' }, publicPath: '/keystones/meditations' }, 'public'], + [{ provenance: { kind: 'library-division' }, publicPath: '/' }, 'public'], + [{ provenance: { kind: 'library-work' } }, 'library'], + [{ provenance: { kind: 'school' } }, 'library'], + [{ provenance: { kind: 'chapel-book' } }, 'library'], + [{ provenance: { kind: 'journey' } }, 'other'], + [{}, 'other'] + ]) { + count.mockClear(); + const { chamber, emit } = mount(session); + emit('state', { state: 'playing' }); + expect(counted('rise.reading.begun'), JSON.stringify(session)).toEqual([{ source, mode: 'stream' }]); + chamber.destroy(); + } + }); + + it('is counted as the page when the reading opens as one', () => { + vi.useFakeTimers(); + const { chamber } = mount({ projection: 'page' }); + vi.advanceTimersByTime(120); + expect(counted('rise.reading.begun')).toEqual([{ source: 'other', mode: 'page' }]); + chamber.destroy(); + }); +}); + +describe('a reading’s end', () => { + it('is counted once, as yes, when the Player reaches the last word, and not again as the reader leaves', () => { + vi.useFakeTimers(); + const { chamber, emit } = mount(); + emit('state', { state: 'playing' }); + emit('complete'); + chamber.destroy(); + window.dispatchEvent(new Event('pagehide')); + expect(counted('rise.reading.ended')).toEqual([{ end: 'yes', ...MINUTES }]); + }); + + it('is counted once, as no, when the reader leaves before it, or closes the tab', () => { + const left = mount(); + left.emit('state', { state: 'playing' }); + left.chamber.destroy(); + const closed = mount(); + closed.emit('state', { state: 'playing' }); + window.dispatchEvent(new Event('pagehide')); + closed.chamber.destroy(); + window.dispatchEvent(new Event('pagehide')); + expect(counted('rise.reading.ended')).toEqual([{ end: 'no', ...MINUTES }, { end: 'no', ...MINUTES }]); + }); + + it('is never counted for a reading that never began', () => { + const { chamber } = mount(); + chamber.destroy(); + window.dispatchEvent(new Event('pagehide')); + expect(count).not.toHaveBeenCalled(); + }); +}); + +describe('the first word', () => { + it('is counted once after Home’s Begin, from the press, at the first atom spoken or shown', () => { + markBegin(); + const { chamber, emit } = mount({ origin: { view: 'home' } }); + emit('state', { state: 'playing' }); + emit('atom', { atom: { content: '' }, index: 0 }); + emit('atom', { atom: { content: 'A thought' }, index: 0, concealed: true }); + expect(counted('rise.reading.first_word')).toEqual([]); + emit('atom', { atom: { content: 'A thought' }, index: 0 }); + emit('atom', { atom: { content: 'A thought' }, index: 0 }); + expect(counted('rise.reading.first_word')).toEqual([{ ms: expect.any(Number) }]); + expect(counted('rise.reading.first_word')[0].ms).toBeGreaterThanOrEqual(0); + expect(takeBegin()).toBeNull(); + chamber.destroy(); + }); + + it('is not counted for a reading Home did not begin, nor for one that waited for a second press', () => { + markBegin(); + const library = mount({ origin: { view: 'library' } }); + library.emit('state', { state: 'playing' }); + library.emit('atom', { atom: { content: 'A thought' }, index: 0 }); + library.chamber.destroy(); + const waited = mount({ origin: { view: 'home' } }); + expect(waited.chamber._deferToGesture()).toBe(true); + waited.emit('state', { state: 'playing' }); + waited.emit('atom', { atom: { content: 'A thought' }, index: 0 }); + waited.chamber.destroy(); + expect(counted('rise.reading.first_word')).toEqual([]); + }); + + it('leaves no interval open when a reading Home began is left before its first word', () => { + markBegin(); + const { chamber, emit } = mount({ origin: { view: 'home' } }); + emit('state', { state: 'playing' }); + chamber.destroy(); + expect(takeBegin()).toBeNull(); + }); +}); diff --git a/src/components/read/Chamber.js b/src/components/read/Chamber.js index 862987a4..846ebb99 100644 --- a/src/components/read/Chamber.js +++ b/src/components/read/Chamber.js @@ -101,7 +101,8 @@ import { import { hasNextLibraryDivision } from '../../core/reading-continuation.js'; import { READING_PACE } from '../../core/reading-limits.js'; import { resolveChamberStreamFace } from '../../core/chamber-stream-face.js'; -import { beginStep } from '../../core/begin-steps.js'; +import { beginStep, takeBegin } from '../../core/begin-steps.js'; +import { count } from '../../core/telemetry.js'; import { clearChromeTheme, paintChromeTheme } from '../../core/chrome-theme.js'; import { estimateGlyphBox, @@ -192,6 +193,19 @@ const PROGRESSIVE_GLASS_PANE = 'linear-gradient(to right, ' /** What a reader is told when the Player could not go on (onPlayerError). */ const READING_HELD_NOTICE = 'The reading could not continue here. Press play to try again.'; +/** The provenances of RISE's own library: an Archive or Library work, a School text, a Chapel book and a Keystone. */ +const LIBRARY_KINDS = Object.freeze(['library-work', 'library-division', 'school', 'chapel-book', 'keystone']); + +/** + * Where a reading came from, for its count (src/core/telemetry.js): the reader's own text, a reading kept at a public + * address (route-url.js), a work of RISE's library, or other. + */ +function readingSource(session) { + if (session?.provenance?.kind === 'local-text') return 'import'; + if (typeof session?.publicPath === 'string') return 'public'; + return LIBRARY_KINDS.includes(session?.provenance?.kind) ? 'library' : 'other'; +} + /** * The words of what is shown, with its formulas each as one word (math-typeset.js) and a beat's * emphasis marked on the words it names, whatever their punctuation or case. @@ -258,6 +272,11 @@ export class Chamber { this._settingsFailed = false; this._jevLook = {}; this._destroyed = false; + // The reading's counts (src/core/telemetry.js): when it began, and whether its end and its first word are counted. + this._begunAt = null; + this._endCounted = false; + this._firstWordCounted = false; + this._onPageHide = null; this._firstReadChoiceSeen = false; this._fitBoxSnapshot = null; this.fitMask = new FitMaskRuntime(this); @@ -524,6 +543,7 @@ export class Chamber { this._pageOpenTimer = setTimeout(() => { this._pageOpenTimer = null; this.togglePageMode(true); + this._countBegun('page'); }, 120); } else if (this.autoStart && !this.hostPlays) { // Auto-start if requested (skip pre-session screen), in a task of its @@ -1474,6 +1494,8 @@ export class Chamber { concealed, spoken }); + // The first word spoken or shown ends R2's interval, when Home's Begin opened the reading. + if (!concealed && !isBoundary && data.atom?.content?.trim()) this._countFirstWord(); // Refill after painting: generation is the slow neighbour and // must never delay the frame the reader is waiting on. @@ -1709,6 +1731,8 @@ export class Chamber { display.style.display = 'none'; pre.style.display = ''; pre.style.opacity = '1'; + // The reader's second press is no part of R2's interval: this reading's first word is not counted. + takeBegin(); return true; } @@ -5591,6 +5615,7 @@ export class Chamber { } onSessionComplete() { + this._countEnded('yes'); if (this.chromeless) { // The host shows what comes next; the field holds its last frame under the last sentence. this._visualFieldDirector?.pause(); @@ -5689,9 +5714,43 @@ export class Chamber { this.announceMovement(READING_HELD_NOTICE); } + /** + * The reading's begin, counted once (src/core/telemetry.js): where it came from, and whether it opened as the stream + * or the page. From then on a reader who closes the tab ends it as surely as one who leaves it. + */ + _countBegun(mode) { + if (this._begunAt !== null) return; + this._begunAt = performance.now(); + count('rise.reading.begun', { source: readingSource(this.session), mode }); + this._onPageHide = () => this._countEnded('no'); + window.addEventListener('pagehide', this._onPageHide); + } + + /** + * Its end, counted once: whether it reached its last word, and its minutes since it began, which the server buckets. + */ + _countEnded(end) { + if (this._begunAt === null || this._endCounted) return; + this._endCounted = true; + count('rise.reading.ended', { end, minutes: (performance.now() - this._begunAt) / 60_000 }); + } + + /** + * Its first word, counted once, when Home's Begin opened it: R2's interval (src/core/begin-steps.js), measured on + * the reader's own device. + */ + _countFirstWord() { + if (this._firstWordCounted) return; + this._firstWordCounted = true; + if (this.session?.origin?.view !== 'home') return; + const ms = takeBegin(); + if (ms !== null) count('rise.reading.first_word', { ms }); + } + onStateChange(data) { const state = data.state; console.log('[Chamber] Player state change:', state); + if (state === 'playing') this._countBegun('stream'); if (state === 'playing' && this._readingHeld) { this._readingHeld = false; this.announceMovement(this._activeMovement?.title || null); @@ -5848,6 +5907,11 @@ export class Chamber { for (const off of this._playerOffs || []) off(); this._playerOffs = []; this._destroyed = true; + // A reading left before its end ends here, and a Home reading left before its first word leaves no interval open. + this._countEnded('no'); + if (!this._firstWordCounted && this.session?.origin?.view === 'home') takeBegin(); + if (this._onPageHide) window.removeEventListener('pagehide', this._onPageHide); + this._onPageHide = null; this._glass?.destroy(); this._stopGlassMotion(); if (this._syncFullscreenControl) { diff --git a/src/core/begin-steps.js b/src/core/begin-steps.js index 655c6133..331b2f01 100644 --- a/src/core/begin-steps.js +++ b/src/core/begin-steps.js @@ -6,3 +6,26 @@ export function beginStep(name) { globalThis.__riseBegin?.steps?.push([name, performance.now()]); } + +/** The mark Home's Begin leaves on the page's timeline, for the reading it begins to measure its first word from. */ +const BEGIN_MARK = 'rise:begin'; + +/** Home's Begin, pressed: where R2's interval to the first word starts (RDR-015), as the timing test starts it. */ +export function markBegin() { + try { + performance.mark(BEGIN_MARK); + } catch { + // No timeline: nothing is measured. + } +} + +/** The milliseconds since Home's Begin, once: the mark is cleared, so no later reading measures from it. Null without one. */ +export function takeBegin() { + try { + const mark = performance.getEntriesByName(BEGIN_MARK, 'mark').at(-1); + performance.clearMarks(BEGIN_MARK); + return mark ? performance.now() - mark.startTime : null; + } catch { + return null; + } +} diff --git a/src/core/error-boundary.js b/src/core/error-boundary.js index 1944ce69..77bdf171 100644 --- a/src/core/error-boundary.js +++ b/src/core/error-boundary.js @@ -2,6 +2,7 @@ * RISE Global Error Boundary * Catches and handles uncaught errors and promise rejections */ +import { count } from './telemetry.js'; /** * Error severity levels @@ -82,7 +83,17 @@ class ErrorBoundary { if (this.isInitialized) return; if (typeof window === 'undefined') return; + // Counted once a page (src/core/telemetry.js), with nothing of the error in it: one that recurs every frame + // must not become a request every frame. + let counted = false; + const countUnhandled = () => { + if (counted) return; + counted = true; + count('rise.app.failed', { class: 'unhandled' }); + }; + window.addEventListener('error', (event) => { + countUnhandled(); this.handleError(event.error || new Error(event.message), { category: ErrorCategory.UNKNOWN, severity: ErrorSeverity.HIGH, @@ -93,6 +104,7 @@ class ErrorBoundary { }); window.addEventListener('unhandledrejection', (event) => { + countUnhandled(); const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason)); diff --git a/src/core/error-boundary.test.js b/src/core/error-boundary.test.js new file mode 100644 index 00000000..d7f2938b --- /dev/null +++ b/src/core/error-boundary.test.js @@ -0,0 +1,25 @@ +/** + * The global error boundary's count (src/core/telemetry.js): an unhandled error or promise rejection is counted + * as its class alone, once a page, so one that recurs every frame does not become a request every frame. + */ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./telemetry.js', () => ({ count: vi.fn() })); + +import { count } from './telemetry.js'; +import { errorBoundary } from './error-boundary.js'; + +describe('the error boundary', () => { + it('counts an unhandled error or rejection once a page, as the class alone, with nothing of the error in it', () => { + for (const level of ['error', 'log', 'group', 'groupEnd']) vi.spyOn(console, level).mockImplementation(() => {}); + errorBoundary.init(); + expect(count).not.toHaveBeenCalled(); + window.dispatchEvent(new ErrorEvent('error', { error: new Error('Call me Ishmael'), message: 'Call me Ishmael' })); + window.dispatchEvent(new ErrorEvent('error', { error: new Error('again, every frame') })); + const rejection = new Event('unhandledrejection'); + rejection.reason = new Error('rejected'); + window.dispatchEvent(rejection); + expect(count.mock.calls).toEqual([['rise.app.failed', { class: 'unhandled' }]]); + expect(errorBoundary.getRecentErrors()).toHaveLength(3); + }); +}); diff --git a/src/core/telemetry.js b/src/core/telemetry.js new file mode 100644 index 00000000..014a2665 --- /dev/null +++ b/src/core/telemetry.js @@ -0,0 +1,18 @@ +/** + * The reader's anonymous event counts (telemetry.v1): one beacon to RISE's own server, POST /api/count, carrying an + * event and the fields telemetry/events.json gives it. worker/count.mjs works out the browser and the device, fits the + * numbers and drops anything else. A count goes only from rise.syberlabs.io itself, never from a frame (RISE inside + * ChatGPT or Claude, an embed), and so never from a preview, a local run or a test. Nothing is kept: no storage, no + * cookie and no queue, so a beacon the browser does not take is gone. It never throws. + */ +const SITE = 'rise.syberlabs.io'; + +export function count(event, fields) { + try { + if (globalThis.location?.hostname !== SITE || globalThis.top !== globalThis) return; + const body = JSON.stringify({ v: 1, e: event, f: fields }); + globalThis.navigator?.sendBeacon?.('/api/count', new Blob([body], { type: 'application/json' })); + } catch { + // Nothing: a count is never worth a reading. + } +} diff --git a/src/core/telemetry.test.js b/src/core/telemetry.test.js new file mode 100644 index 00000000..da854bf4 --- /dev/null +++ b/src/core/telemetry.test.js @@ -0,0 +1,102 @@ +// @vitest-environment node +/** + * The reader's anonymous event counts in the browser (src/core/telemetry.js), and the mark Home's Begin leaves for + * the first word to be measured from (src/core/begin-steps.js). + * + * What is held: a count is one beacon to /api/count, sent only from rise.syberlabs.io and never from a frame; no + * storage and no cookie is touched, nothing is kept for later, and nothing throws. The Begin mark is taken once. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { count } from './telemetry.js'; +import { markBegin, takeBegin } from './begin-steps.js'; + +/** A page at `hostname`, framed or not, whose browser takes every beacon it is handed. */ +function page({ hostname = 'rise.syberlabs.io', framed = false } = {}) { + const sendBeacon = vi.fn(() => true); + vi.stubGlobal('location', { hostname }); + vi.stubGlobal('top', framed ? {} : globalThis); + vi.stubGlobal('navigator', { sendBeacon }); + return sendBeacon; +} + +afterEach(() => { + vi.unstubAllGlobals(); + takeBegin(); +}); + +describe('a count', () => { + it('is one beacon to /api/count: the version, the event and its fields, as JSON', async () => { + const send = page(); + count('rise.reading.begun', { source: 'library', mode: 'stream' }); + expect(send).toHaveBeenCalledTimes(1); + const [address, blob] = send.mock.calls[0]; + expect(address).toBe('/api/count'); + expect(blob.type).toBe('application/json'); + expect(JSON.parse(await blob.text())).toEqual({ v: 1, e: 'rise.reading.begun', f: { source: 'library', mode: 'stream' } }); + }); + + it('is sent from rise.syberlabs.io alone: never a preview, the other site, a local run or a look-alike', () => { + for (const hostname of ['rise.syberlabs.space', 'localhost', '127.0.0.1', 'rise-production.example.workers.dev', 'preview.rise.syberlabs.io', 'rise.syberlabs.io.evil.example', '']) { + const send = page({ hostname }); + count('rise.app.failed', { class: 'unhandled' }); + expect(send, hostname).not.toHaveBeenCalled(); + } + }); + + it('is never sent from a frame: not from inside ChatGPT or Claude, nor any page that embeds RISE', () => { + const send = page({ framed: true }); + count('rise.reading.begun', { source: 'public', mode: 'stream' }); + expect(send).not.toHaveBeenCalled(); + }); + + it('touches no storage and no cookie, and keeps nothing: a beacon the browser refuses is gone', () => { + const touched = []; + const trap = name => new Proxy({}, { get: (_, key) => { touched.push(`${name}.${String(key)}`); return () => null; } }); + for (const name of ['localStorage', 'sessionStorage', 'indexedDB', 'caches']) vi.stubGlobal(name, trap(name)); + vi.stubGlobal('document', { get cookie() { touched.push('document.cookie'); return ''; }, set cookie(_) { touched.push('document.cookie='); } }); + const send = page(); + send.mockReturnValueOnce(false); + count('rise.reading.ended', { end: 'no', minutes: 1.5 }); + count('rise.reading.ended', { end: 'yes', minutes: 3 }); + expect(touched).toEqual([]); + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls.map(([, blob]) => blob.size)).toEqual([ + JSON.stringify({ v: 1, e: 'rise.reading.ended', f: { end: 'no', minutes: 1.5 } }).length, + JSON.stringify({ v: 1, e: 'rise.reading.ended', f: { end: 'yes', minutes: 3 } }).length + ]); + }); + + it('never throws: not for a browser without beacons, a beacon that throws, no page at all, or fields that are not JSON', () => { + page(); + vi.stubGlobal('navigator', {}); + expect(() => count('rise.app.failed', { class: 'unhandled' })).not.toThrow(); + vi.stubGlobal('navigator', { sendBeacon: () => { throw new TypeError('Beacon refused'); } }); + expect(() => count('rise.app.failed', { class: 'unhandled' })).not.toThrow(); + const send = page(); + expect(() => count('rise.reading.first_word', { ms: 10n })).not.toThrow(); + expect(send).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + expect(() => count('rise.app.failed', { class: 'unhandled' })).not.toThrow(); + }); +}); + +describe('the interval from Home’s Begin', () => { + it('is taken once, from the latest press, and is nothing without one', () => { + expect(takeBegin()).toBeNull(); + markBegin(); + markBegin(); + const latest = performance.getEntriesByName('rise:begin', 'mark').at(-1).startTime; + const before = performance.now(); + const ms = takeBegin(); + expect(ms).toBeGreaterThanOrEqual(before - latest); + expect(ms).toBeLessThanOrEqual(performance.now() - latest); + expect(takeBegin()).toBeNull(); + expect(performance.getEntriesByName('rise:begin', 'mark')).toEqual([]); + }); + + it('measures nothing, and never throws, where the page has no timeline', () => { + vi.stubGlobal('performance', {}); + expect(() => markBegin()).not.toThrow(); + expect(takeBegin()).toBeNull(); + }); +}); diff --git a/src/live/host/LiveHost.js b/src/live/host/LiveHost.js index 928ede59..a0c1d50f 100644 --- a/src/live/host/LiveHost.js +++ b/src/live/host/LiveHost.js @@ -34,6 +34,7 @@ import { GEMINI_DEFAULT_MODEL } from '../adapters/gemini-model.js'; import { OPENROUTER_DEFAULT_MODEL } from '../adapters/openrouter-model.js'; import { describeDegradations, detectCapabilities } from '../capabilities.js'; +import { count } from '../../core/telemetry.js'; import { admitCatalogVisual } from '../../core/visual-catalog.js'; import { jevColors } from '../../core/jev-palette.js'; import { lookTheme } from '../../core/current-look.js'; @@ -173,6 +174,9 @@ export class LiveHost { this.params = new URLSearchParams(search); this.env = env; this.caps = detectCapabilities(env); + // What this browser lacks, counted once a host from the record asked once (src/core/telemetry.js). + if (this.caps.speechOutput === 'none') count('rise.app.failed', { class: 'speech_unavailable' }); + if (!this.caps.webgl2) count('rise.app.failed', { class: 'webgl_unavailable' }); this.runtime = null; this.controls = null; // Which voice the reading uses, and how many voices the browser offered, once each is known. diff --git a/src/live/host/LiveHost.test.js b/src/live/host/LiveHost.test.js index 90e00716..19fd8a18 100644 --- a/src/live/host/LiveHost.test.js +++ b/src/live/host/LiveHost.test.js @@ -7,6 +7,10 @@ * what the reader is told, what is refused, and that nothing starts by itself. */ import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../core/telemetry.js', () => ({ count: vi.fn() })); + +import { count } from '../../core/telemetry.js'; import { LiveHost, framedBy, sceneReportLine } from './LiveHost.js'; import { createRealClock, createVirtualClock } from '../clock.js'; import { createMockAdapter } from '../adapters/mock.js'; @@ -247,6 +251,18 @@ describe('what this device cannot do is said, and only what it cannot', () => { mount('?voice=paced', env({ motion: false, recognition: true })); expect(notes()).not.toContain('reducedMotion'); }); + + // The same record, counted as classes alone (src/core/telemetry.js): once a host, and nothing a browser has. + it('is counted once a host: no speech voice, and no WebGL2', () => { + count.mockClear(); + const flat = env(); + mount('', { ...flat, document: { ...flat.document, createElement: () => ({ getContext: kind => (kind === 'webgl2' ? null : {}) }) } }); + expect(count.mock.calls).toEqual([['rise.app.failed', { class: 'speech_unavailable' }], ['rise.app.failed', { class: 'webgl_unavailable' }]]); + host.destroy(); + count.mockClear(); + mount('', env({ speech: true })); + expect(count).not.toHaveBeenCalled(); + }); }); describe('refusing, in words', () => { diff --git a/telemetry/events.json b/telemetry/events.json index cb930eef..5650d18c 100644 --- a/telemetry/events.json +++ b/telemetry/events.json @@ -35,6 +35,50 @@ } ], "doubles": [] + }, + "rise.reading.begun": { + "owner": "Reader", + "decision": "Where readings start (RISE's library, the reader's own text, a public page) and how they open, on which browsers and devices, to decide what the Reader builds and tests next.", + "blobs": [ + { "name": "browser", "values": ["chrome", "safari", "firefox", "edge", "other"], "derived": "the User-Agent, read by worker/count.mjs; the string is never written" }, + { "name": "device", "values": ["mobile", "desktop"], "derived": "the User-Agent, read by worker/count.mjs; the string is never written" }, + { "name": "source", "values": ["library", "import", "public", "other"] }, + { "name": "mode", "values": ["stream", "page"] } + ], + "doubles": [] + }, + "rise.reading.ended": { + "owner": "Reader", + "decision": "How many readings reach their end and how long readers stay, to decide where readings lose their readers.", + "blobs": [ + { "name": "browser", "values": ["chrome", "safari", "firefox", "edge", "other"], "derived": "the User-Agent, read by worker/count.mjs; the string is never written" }, + { "name": "device", "values": ["mobile", "desktop"], "derived": "the User-Agent, read by worker/count.mjs; the string is never written" }, + { "name": "end", "values": ["yes", "no"] } + ], + "doubles": [ + { "name": "minutes", "values": [1, 2, 5, 10, 20, 30, 60, 120] } + ] + }, + "rise.app.failed": { + "owner": "Reader", + "decision": "Which failures readers meet, on which browsers and devices, to decide what to fix first.", + "blobs": [ + { "name": "browser", "values": ["chrome", "safari", "firefox", "edge", "other"], "derived": "the User-Agent, read by worker/count.mjs; the string is never written" }, + { "name": "device", "values": ["mobile", "desktop"], "derived": "the User-Agent, read by worker/count.mjs; the string is never written" }, + { "name": "class", "values": ["speech_unavailable", "audio_failed", "webgl_unavailable", "kev_load_failed", "unhandled"] } + ], + "doubles": [] + }, + "rise.reading.first_word": { + "owner": "Reader", + "decision": "How long Home's Begin takes to reach the first word on readers' own browsers and devices (R2, RDR-015), to decide whether its 1.5-second budget holds outside CI.", + "blobs": [ + { "name": "browser", "values": ["chrome", "safari", "firefox", "edge", "other"], "derived": "the User-Agent, read by worker/count.mjs; the string is never written" }, + { "name": "device", "values": ["mobile", "desktop"], "derived": "the User-Agent, read by worker/count.mjs; the string is never written" } + ], + "doubles": [ + { "name": "ms", "max": 60000, "step": 50 } + ] } } } diff --git a/vitest.fast.config.js b/vitest.fast.config.js index 86dc1b99..d059cee9 100644 --- a/vitest.fast.config.js +++ b/vitest.fast.config.js @@ -11,7 +11,7 @@ import base from './vite.config.js'; // number is changed in the same pull request. // // npx vitest run --config vitest.fast.config.js -export const FAST_TEST_FILES = 170; +export const FAST_TEST_FILES = 174; const FAST_TESTS = [ 'src/core/account-saves.test.js', @@ -60,6 +60,11 @@ const FAST_TESTS = [ 'worker/mcp-quality.test.js', // The event counts: nothing telemetry/events.json does not name is written, and no answer changes for them. 'worker/telemetry.test.js', + // The reader's counts: the beacon's door, the browser's sender, and where a reading and a failure are counted. + 'worker/count.test.js', + 'src/core/telemetry.test.js', + 'src/core/error-boundary.test.js', + 'src/components/read/Chamber.counts.test.js', 'src/core/jev-palette.test.js', 'src/components/read/Chamber.jev-look.test.js', 'src/app/jev-reading.test.js', diff --git a/worker/count.mjs b/worker/count.mjs new file mode 100644 index 00000000..70296cd5 --- /dev/null +++ b/worker/count.mjs @@ -0,0 +1,87 @@ +/** + * POST /api/count: the reader's anonymous event counts (telemetry.v1, worker/telemetry.mjs), one beacon each from + * src/core/telemetry.js. What is written: a request from a page of this site (its Origin is the request's own), within + * its address's limit (COUNT_LIMITER, wrangler.production.jsonc), of at most MAX_COUNT_BYTES of JSON + * `{ "v": 1, "e": , "f": { : } }` whose event is a reader's and whose fields are exactly that + * event's own (telemetry/events.json). The browser and the device are read from the User-Agent here, and the string + * goes no further; a number is fitted to its field. Anything else is dropped. + * + * Every request is answered 204 with no body, whatever became of it, so the answer tells a sender nothing; and nothing + * a request sent is logged. + */ +import EVENTS from '../telemetry/events.json' with { type: 'json' }; +import { readText } from './live-realtime.mjs'; +import { countEvent } from './telemetry.mjs'; + +export const COUNT_PATH = '/api/count'; +export const MAX_COUNT_BYTES = 512; + +/** What this door reads for itself. An event with any other field the server derives (the connector's host) is not a browser's to send. */ +const DERIVED = ['browser', 'device']; + +/** First match wins: Edge and the other Chromium browsers name Chrome as well, and Chrome and Firefox name Safari. */ +const BROWSERS = [ + ['edge', /\bEdg(?:e|A|iOS)?\//u], + ['other', /\b(?:OPR|Opera|SamsungBrowser|YaBrowser|UCBrowser)\//u], + ['firefox', /\b(?:Firefox|FxiOS)\//u], + ['chrome', /\b(?:Chrome|CriOS)\//u], + ['safari', /^(?!.*\bAndroid\b).*\bVersion\/[\d.]+.*\bSafari\//u] +]; + +/** A browser's family by its User-Agent: one of BROWSERS', or other. */ +export const browserOf = agent => BROWSERS.find(([, pattern]) => pattern.test(agent ?? ''))?.[0] ?? 'other'; + +/** A phone or a tablet by its User-Agent, and a desktop otherwise (an iPad that asks for desktop sites says it is a Mac). */ +export const deviceOf = agent => (/Mobi|Android|iPhone|iPad|iPod/u.test(agent ?? '') ? 'mobile' : 'desktop'); + +/** A plain object of exactly these keys. */ +const exactly = (value, keys) => Boolean(value) && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); + +/** + * A reader's number, fitted to its field: to the least of its values it does not pass (the greatest beyond them), or + * rounded to its step and held between 0 and its most. Null for anything that is not a finite number, and for a field + * the allowlist gives no fitting. + */ +function fit({ values, max, step }, value) { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + if (values !== undefined) return values.find(bucket => value <= bucket) ?? values.at(-1); + if (max !== undefined && step !== undefined) return Math.min(max, Math.max(0, Math.round(value / step) * step)); + return null; +} + +/** The fields a beacon's event is counted with, or null to drop it. Its values are held to their lists when written. */ +export function admitted(body, agent) { + if (!exactly(body, ['v', 'e', 'f']) || body.v !== EVENTS.version || typeof body.e !== 'string' || !Object.hasOwn(EVENTS.events, body.e)) return null; + const { blobs, doubles } = EVENTS.events[body.e]; + const derived = blobs.filter(blob => blob.derived !== undefined).map(blob => blob.name); + if (derived.length !== DERIVED.length || !DERIVED.every(name => derived.includes(name))) return null; + const sent = blobs.filter(blob => blob.derived === undefined); + if (!exactly(body.f, [...sent, ...doubles].map(field => field.name))) return null; + const fields = { browser: browserOf(agent), device: deviceOf(agent) }; + for (const { name } of sent) fields[name] = body.f[name]; + for (const double of doubles) { + const value = fit(double, body.f[double.name]); + if (value === null) return null; + fields[double.name] = value; + } + return fields; +} + +/** Every request, answered 204 with no body; a beacon that passes every check is counted on the way. */ +export async function handleCount(request, env) { + try { + const ip = request.headers.get('CF-Connecting-IP')?.trim(); + // A page of this site only, and each address held to its limit before the body is read; with no limiter, no + // address, or a limiter that fails, nothing is written. + if (request.method === 'POST' && request.headers.get('Origin') === new URL(request.url).origin && ip + && (await env?.COUNT_LIMITER?.limit({ key: `count:${ip}` }))?.success === true) { + const body = JSON.parse(await readText(request, MAX_COUNT_BYTES)); + const fields = admitted(body, request.headers.get('User-Agent')); + if (fields !== null) countEvent(env, body.e, fields); + } + } catch { + // Dropped: too large, not JSON, not UTF-8, or the limiter's fault; the answer is the same. + } + return new Response(null, { status: 204 }); +} diff --git a/worker/count.test.js b/worker/count.test.js new file mode 100644 index 00000000..96d8db6e --- /dev/null +++ b/worker/count.test.js @@ -0,0 +1,177 @@ +// @vitest-environment node +/** + * POST /api/count, the reader's anonymous event counts (worker/count.mjs). + * + * What is held: every request is answered 204 with no body, whatever it was; a beacon is written only from a page of + * this site, within its address's limit, as at most 512 bytes of JSON whose event is a reader's and whose fields are + * exactly that event's own; the browser and the device are read from the User-Agent and nothing else of it is written; + * numbers are fitted to their fields; nothing outside telemetry/events.json is written; and a binding that throws + * changes nothing. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import allowlist from '../telemetry/events.json' with { type: 'json' }; +import worker from './index.mjs'; +import { COUNT_PATH, MAX_COUNT_BYTES, admitted, browserOf, deviceOf, handleCount } from './count.mjs'; +import { RELEASE } from './telemetry.mjs'; + +const SITE = 'https://rise.example'; +const CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'; +const BEGUN = { v: 1, e: 'rise.reading.begun', f: { source: 'library', mode: 'stream' } }; + +/** Production's two bindings: a dataset that keeps what it is given, and a limiter that admits until told not to. */ +function production({ success = true } = {}) { + const points = []; + const limit = vi.fn(async () => ({ success })); + return { points, limit, env: { EVENTS: { writeDataPoint: point => points.push(point) }, COUNT_LIMITER: { limit } } }; +} + +const beacon = (body, { headers = {}, method = 'POST', raw } = {}) => new Request(`${SITE}${COUNT_PATH}`, { + method, + headers: { Origin: SITE, 'Content-Type': 'application/json', 'CF-Connecting-IP': '192.0.2.1', 'User-Agent': CHROME, ...headers }, + ...(['GET', 'HEAD'].includes(method) ? {} : { body: raw ?? JSON.stringify(body) }) +}); +const bytes = async response => ({ status: response.status, headers: [...response.headers], body: await response.text() }); + +afterEach(() => vi.restoreAllMocks()); + +describe('a beacon from a page of this site', () => { + it('is written as the event, the release, the browser and the device read from its User-Agent, then its own fields', async () => { + const { points, env } = production(); + const response = await handleCount(beacon(BEGUN), env); + expect(await bytes(response)).toEqual({ status: 204, headers: [], body: '' }); + expect(points).toEqual([{ indexes: ['rise.reading.begun'], blobs: ['rise.reading.begun', RELEASE, 'chrome', 'desktop', 'library', 'stream'], doubles: [1] }]); + }); + + it('is written for each of the reader’s events, and for no event of the connector’s', async () => { + const { points, env } = production(); + for (const body of [ + BEGUN, + { v: 1, e: 'rise.reading.ended', f: { end: 'yes', minutes: 7.5 } }, + { v: 1, e: 'rise.app.failed', f: { class: 'audio_failed' } }, + { v: 1, e: 'rise.reading.first_word', f: { ms: 1234.5 } }, + { v: 1, e: 'rise.current.admitted', f: { beats: 3, scenes: 1 } }, + { v: 1, e: 'rise.current.refused', f: { code: 'CURRENT_THEME' } } + ]) await handleCount(beacon(body), env); + expect(points.map(point => [point.blobs.slice(2), point.doubles])).toEqual([ + [['chrome', 'desktop', 'library', 'stream'], [1]], + [['chrome', 'desktop', 'yes'], [1, 10]], + [['chrome', 'desktop', 'audio_failed'], [1]], + [['chrome', 'desktop'], [1, 1250]] + ]); + }); + + it('reads only the browser’s family and whether it is a phone from the User-Agent', () => { + for (const [agent, browser, device] of [ + [CHROME, 'chrome', 'desktop'], + ['Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36', 'chrome', 'mobile'], + ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.6723.90 Mobile/15E148 Safari/604.1', 'chrome', 'mobile'], + ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', 'safari', 'mobile'], + ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15', 'safari', 'desktop'], + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0', 'firefox', 'desktop'], + ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/131.0 Mobile/15E148 Safari/605.1.15', 'firefox', 'mobile'], + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0', 'edge', 'desktop'], + ['Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36', 'other', 'mobile'], + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0', 'other', 'desktop'], + ['Mozilla/5.0 (Linux; U; Android 4.0.3; en-us) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', 'other', 'mobile'], + ['curl/8.5.0', 'other', 'desktop'], + ['', 'other', 'desktop'], + [null, 'other', 'desktop'] + ]) { + expect([browserOf(agent), deviceOf(agent)], String(agent)).toEqual([browser, device]); + } + }); + + it('fits each number to its field: minutes to their buckets, the first word’s milliseconds to 50 within a minute', () => { + const minutes = value => admitted({ v: 1, e: 'rise.reading.ended', f: { end: 'no', minutes: value } }, CHROME)?.minutes; + const ms = value => admitted({ v: 1, e: 'rise.reading.first_word', f: { ms: value } }, CHROME)?.ms; + expect([-3, 0, 0.5, 1, 1.01, 4, 7, 15, 25, 45, 61, 500].map(minutes)).toEqual([1, 1, 1, 1, 2, 5, 10, 20, 30, 60, 120, 120]); + expect([-40, 0, 24, 25, 1234, 59_990, 75_000].map(ms)).toEqual([0, 0, 0, 50, 1250, 60_000, 60_000]); + for (const value of ['5', null, true, [], {}]) { + expect(minutes(value), JSON.stringify(value)).toBeUndefined(); + expect(ms(value), JSON.stringify(value)).toBeUndefined(); + } + }); +}); + +describe('anything else', () => { + it('is dropped when it is not exactly an event of the allowlist’s with exactly its fields', async () => { + const { points, env } = production(); + for (const body of [ + null, [], 'rise.reading.begun', { ...BEGUN, v: 2 }, { ...BEGUN, v: '1' }, { e: BEGUN.e, f: BEGUN.f }, { ...BEGUN, at: 1 }, + { ...BEGUN, e: 'rise.reading.viewed' }, { ...BEGUN, e: 'constructor' }, { ...BEGUN, e: '__proto__' }, { ...BEGUN, e: ['rise.reading.begun'] }, + { ...BEGUN, f: null }, { ...BEGUN, f: [] }, { ...BEGUN, f: { source: 'library' } }, { ...BEGUN, f: { ...BEGUN.f, title: 'Meditations' } }, + { ...BEGUN, f: { ...BEGUN.f, browser: 'chrome' } }, { ...BEGUN, f: { source: 'Library', mode: 'stream' } }, { ...BEGUN, f: { source: 'library', mode: 'scroll' } }, + { v: 1, e: 'rise.app.failed', f: { class: 'TypeError: x is undefined' } }, { v: 1, e: 'rise.reading.ended', f: { end: 'maybe', minutes: 3 } }, + { v: 1, e: 'rise.reading.ended', f: { end: 'yes', minutes: '3' } }, { v: 1, e: 'rise.reading.first_word', f: { ms: null } } + ]) { + expect(await bytes(await handleCount(beacon(body), env)), JSON.stringify(body)).toEqual({ status: 204, headers: [], body: '' }); + } + expect(points).toEqual([]); + }); + + it('is dropped from any other page: no Origin, another site’s, or a near one', async () => { + const { points, limit, env } = production(); + for (const origin of [null, 'https://evil.example', 'http://rise.example', 'https://rise.example.evil.example', 'https://rise.example:8443', 'null', '']) { + const request = beacon(BEGUN, { headers: origin === null ? {} : { Origin: origin } }); + if (origin === null) request.headers.delete('Origin'); + expect((await handleCount(request, env)).status, String(origin)).toBe(204); + } + expect(points).toEqual([]); + expect(limit).not.toHaveBeenCalled(); + }); + + it('is dropped unless it is a POST', async () => { + const { points, limit, env } = production(); + for (const method of ['GET', 'HEAD', 'PUT', 'DELETE']) expect((await handleCount(beacon(BEGUN, { method }), env)).status, method).toBe(204); + expect(points).toEqual([]); + expect(limit).not.toHaveBeenCalled(); + }); + + it('is dropped past 512 bytes, and when it is not JSON or not UTF-8', async () => { + expect(MAX_COUNT_BYTES).toBe(512); + const { points, env } = production(); + const exact = JSON.stringify(BEGUN).padEnd(512, ' '); + await handleCount(beacon(null, { raw: exact }), env); + expect(points).toHaveLength(1); + for (const raw of [`${exact} `, '{not json', new Uint8Array([0x7b, 0x22, 0xff, 0x22, 0x7d]), '']) await handleCount(beacon(null, { raw }), env); + expect(points).toHaveLength(1); + }); + + it('is dropped over its address’s limit, which is charged before the body is read; and with no limiter or no address', async () => { + const over = production({ success: false }); + for (const raw of [JSON.stringify(BEGUN), '{not json']) await handleCount(beacon(null, { raw }), over.env); + expect(over.limit).toHaveBeenCalledTimes(2); + expect(over.limit).toHaveBeenCalledWith({ key: 'count:192.0.2.1' }); + expect(over.points).toEqual([]); + const { points, limit, env } = production(); + const request = beacon(BEGUN); + request.headers.delete('CF-Connecting-IP'); + await handleCount(request, env); + await handleCount(beacon(BEGUN), { EVENTS: env.EVENTS }); + await handleCount(beacon(BEGUN), { ...env, COUNT_LIMITER: { limit: async () => { throw new Error('the limiter is down'); } } }); + expect(limit).not.toHaveBeenCalled(); + expect(points).toEqual([]); + }); +}); + +describe('the answer', () => { + it('is the same 204 with no body whether the dataset is bound, absent or throwing, and nothing is logged', async () => { + const logs = ['log', 'info', 'warn', 'error', 'debug'].map(level => vi.spyOn(console, level).mockImplementation(() => {})); + const { env } = production(); + const bindings = [env, { COUNT_LIMITER: env.COUNT_LIMITER }, { ...env, EVENTS: { writeDataPoint: () => { throw new Error('the dataset is down'); } } }, { ...env, EVENTS: {} }, {}, undefined]; + for (const raw of [JSON.stringify(BEGUN), '{not json', JSON.stringify({ ...BEGUN, e: 'rise.reading.viewed' })]) { + for (const binding of bindings) expect(await bytes(await handleCount(beacon(null, { raw }), binding))).toEqual({ status: 204, headers: [], body: '' }); + } + for (const log of logs) expect(log).not.toHaveBeenCalled(); + }); + + it('is reached at /api/count on the worker, and writes only what the allowlist names', async () => { + const { points, env } = production(); + const response = await worker.fetch(beacon({ v: 1, e: 'rise.app.failed', f: { class: 'unhandled' } }, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1' } }), env); + expect(response.status).toBe(204); + expect(points).toEqual([{ indexes: ['rise.app.failed'], blobs: ['rise.app.failed', RELEASE, 'safari', 'mobile', 'unhandled'], doubles: [1] }]); + const [event, , ...values] = points[0].blobs; + allowlist.events[event].blobs.forEach((blob, index) => expect(blob.values).toContain(values[index])); + expect(JSON.stringify(points)).not.toMatch(/Mozilla|iPhone|192\.0\.2/u); + }); +}); diff --git a/worker/index.mjs b/worker/index.mjs index 09392335..6547827d 100644 --- a/worker/index.mjs +++ b/worker/index.mjs @@ -1,4 +1,5 @@ import { CHANGES_PREFIX, serveChangesFile } from './changes.mjs'; +import { COUNT_PATH, handleCount } from './count.mjs'; import { isKevWorkerScript, serveKevWorkerScript } from './kev-worker-script.mjs'; import { isRetiredInferenceRoute, retiredInference } from './retired-inference.mjs'; import { handleLiveRealtime } from './live-realtime.mjs'; @@ -71,6 +72,8 @@ export default { if (isRetiredInferenceRoute(path)) return retiredInference(); if (path.startsWith('/content/arena/')) return serveArenaFile(request, env); if (path.startsWith(CHANGES_PREFIX)) return serveChangesFile(request); + // The reader's anonymous event counts (worker/count.mjs): a 204 always, written only where production binds EVENTS. + if (path === COUNT_PATH) return handleCount(request, env); // Optional integration routes remain available but are disabled by default. if (path === MCP_PATH) return handleMcp(request, env); diff --git a/worker/production-config.test.js b/worker/production-config.test.js index d0a1e44d..770c6d64 100644 --- a/worker/production-config.test.js +++ b/worker/production-config.test.js @@ -60,6 +60,16 @@ describe('the production Worker', () => { for (const file of others) expect(load(file).analytics_engine_datasets, file).toBeUndefined(); }); + // A reader's beacon (worker/count.mjs) is written only within its address's limit, a namespace of its own, and only + // production declares it, as only production binds the dataset. + it('holds each address to 60 counts a minute, and is the only configuration that does', () => { + expect(config.ratelimits).toContainEqual({ name: 'COUNT_LIMITER', namespace_id: expect.stringMatching(/^\d+$/u), simple: { limit: 60, period: 60 } }); + const namespaces = config.ratelimits.map(limiter => limiter.namespace_id); + expect(new Set(namespaces).size).toBe(namespaces.length); + const others = readdirSync('.').filter(file => /^wrangler\..*\.jsonc$/u.test(file) && file !== 'wrangler.production.jsonc'); + for (const file of others) expect(load(file).ratelimits ?? [], file).not.toContainEqual(expect.objectContaining({ name: 'COUNT_LIMITER' })); + }); + // The vendor id of a voice comes only from this list; "default" is the PLUS_VOICE_ID secret. it('offers a default voice and an id for every other one', () => { const voices = config.vars.PLUS_VOICES; diff --git a/worker/telemetry.mjs b/worker/telemetry.mjs index cfcc96de..c8c061f8 100644 --- a/worker/telemetry.mjs +++ b/worker/telemetry.mjs @@ -22,6 +22,10 @@ const allowed = event => (Object.hasOwn(EVENTS.events, event) ? EVENTS.events[ev /** The values the allowlist gives an event's field, for a caller that must fit what it has to them. */ export const valuesOf = (event, name) => allowed(event)?.blobs.find(blob => blob.name === name)?.values ?? []; +/** A count: a whole number from 0, and within what its field gives it, one of its values or a step up to its most. */ +const isCount = (value, { values, max = Number.MAX_SAFE_INTEGER, step = 1 }) => Number.isSafeInteger(value) && value >= 0 + && value <= max && value % step === 0 && (values === undefined || values.includes(value)); + /** * An event's data point, or null for anything the allowlist does not name: an event it does not list, a field it does * not give the event, one of the event's fields missing, a value off its field's list, or a number that is not a count. @@ -32,7 +36,7 @@ export function dataPoint(event, fields) { const names = [...spec.blobs, ...spec.doubles].map(field => field.name); if (Object.keys(fields).length !== names.length || !names.every(name => Object.hasOwn(fields, name))) return null; if (!spec.blobs.every(({ name, values }) => Array.isArray(values) && values.includes(fields[name]))) return null; - if (!spec.doubles.every(({ name }) => Number.isSafeInteger(fields[name]) && fields[name] >= 0)) return null; + if (!spec.doubles.every(double => isCount(fields[double.name], double))) return null; return { indexes: [event], blobs: [event, RELEASE, ...spec.blobs.map(({ name }) => fields[name])], diff --git a/worker/telemetry.test.js b/worker/telemetry.test.js index 726ea3db..546f4436 100644 --- a/worker/telemetry.test.js +++ b/worker/telemetry.test.js @@ -90,7 +90,7 @@ describe('the allowlist', () => { it('is version 1 of one dataset, and each event is .. with an owner and the decision it informs', () => { expect(allowlist.version).toBe(1); expect(allowlist.dataset).toBe('syberlabs_events'); - expect(Object.keys(allowlist.events)).toEqual([ADMITTED, REFUSED]); + expect(Object.keys(allowlist.events)).toEqual([ADMITTED, REFUSED, 'rise.reading.begun', 'rise.reading.ended', 'rise.app.failed', 'rise.reading.first_word']); for (const [event, spec] of Object.entries(allowlist.events)) { expect(event).toMatch(/^[a-z]+\.[a-z_]+\.[a-z_]+$/u); expect(Object.keys(spec), event).toEqual(['owner', 'decision', 'blobs', 'doubles']); @@ -107,7 +107,12 @@ describe('the allowlist', () => { expect(blob.values.every(value => typeof value === 'string' && /^[A-Za-z0-9_]+$/u.test(value)), `${event}.${blob.name}`).toBe(true); expect(new Set(blob.values).size, `${event}.${blob.name}`).toBe(blob.values.length); } - for (const double of spec.doubles) expect(Object.keys(double), `${event}.${double.name}`).toEqual(['name']); + // A count is any whole number from 0, or one of a rising list of them, or a step up to a most. + for (const double of spec.doubles) { + expect([['name'], ['name', 'values'], ['name', 'max', 'step']], `${event}.${double.name}`).toContainEqual(Object.keys(double)); + if (double.values) expect(double.values.every((value, i) => Number.isSafeInteger(value) && value >= 0 && (i === 0 || value > double.values[i - 1])), `${event}.${double.name}`).toBe(true); + if (double.max !== undefined) expect(Number.isSafeInteger(double.max) && Number.isSafeInteger(double.step) && double.step > 0 && double.max % double.step === 0, `${event}.${double.name}`).toBe(true); + } const names = [...spec.blobs, ...spec.doubles].map(field => field.name); expect(new Set(names).size, event).toBe(names.length); // The event and the release lead the blobs, and the 1 the doubles; Analytics Engine takes twenty of each. @@ -135,6 +140,19 @@ describe('a data point', () => { expect(dataPoint(ADMITTED, fields)).toEqual({ indexes: [ADMITTED], blobs: [ADMITTED, 'unknown', 'claude'], doubles: [1, 3, 1] }); expect(dataPoint(ADMITTED, { scenes: 1, beats: 3, host: 'claude' })).toEqual(dataPoint(ADMITTED, fields)); expect(dataPoint(REFUSED, { code: 'CURRENT_THEME', host: 'chatgpt' })).toEqual({ indexes: [REFUSED], blobs: [REFUSED, 'unknown', 'chatgpt', 'CURRENT_THEME'], doubles: [1] }); + expect(dataPoint('rise.reading.ended', { browser: 'safari', device: 'mobile', end: 'yes', minutes: 10 })) + .toEqual({ indexes: ['rise.reading.ended'], blobs: ['rise.reading.ended', 'unknown', 'safari', 'mobile', 'yes'], doubles: [1, 10] }); + expect(dataPoint('rise.reading.first_word', { browser: 'edge', device: 'desktop', ms: 60_000 })) + .toEqual({ indexes: ['rise.reading.first_word'], blobs: ['rise.reading.first_word', 'unknown', 'edge', 'desktop'], doubles: [1, 60_000] }); + }); + + it('is nothing for a number off its field’s list, past its most, or between its steps', () => { + const ended = minutes => dataPoint('rise.reading.ended', { browser: 'chrome', device: 'desktop', end: 'no', minutes }); + const first = ms => dataPoint('rise.reading.first_word', { browser: 'chrome', device: 'desktop', ms }); + expect([1, 2, 5, 10, 20, 30, 60, 120].every(minutes => ended(minutes) !== null)).toBe(true); + for (const minutes of [0, 3, 7.5, 121, 240]) expect(ended(minutes), String(minutes)).toBeNull(); + expect([0, 50, 1250, 60_000].every(ms => first(ms) !== null)).toBe(true); + for (const ms of [1234, 25, 60_050, -50, 1250.5]) expect(first(ms), String(ms)).toBeNull(); }); it('is nothing for anything the allowlist does not name', () => { diff --git a/wrangler.production.jsonc b/wrangler.production.jsonc index 22e1b12c..5cf78892 100644 --- a/wrangler.production.jsonc +++ b/wrangler.production.jsonc @@ -75,5 +75,11 @@ "name": "PLUS_CLAIM_LIMITER", "namespace_id": "2026100802", "simple": { "limit": 5, "period": 60 } + }, { + // POST /api/count (worker/count.mjs) answers 204 whatever happens; each address is counted at most 60 times a + // minute, and the rest are dropped unwritten. Production alone binds it, as it alone binds EVENTS. + "name": "COUNT_LIMITER", + "namespace_id": "2026103102", + "simple": { "limit": 60, "period": 60 } }] }