From 4827d81eb474bf223c18172a982d0979ff2c66d2 Mon Sep 17 00:00:00 2001 From: Darrin Massena Date: Mon, 14 Sep 2026 08:48:21 -0700 Subject: [PATCH] Add a local gameplay analytics dashboard `npm run analytics:dashboard` serves the existing report as a page on 127.0.0.1:8788 and opens it: summary tiles, a daily starts chart split into finished, ended early and unfinished, game modes, winners, and every settings breakdown, with range and mode buttons. Each refresh runs the same fixed queries as `npm run analytics` through Wrangler's own login, so no analytics credential is published, stored in the browser, or added to the game. Cloudflare's Dashboards section cannot chart this data. Its D1 datasets report rows read, query latency and storage size, and its Workers Analytics Engine dataset groups only by dataset name, so none of them can break gameplay down by mode, difficulty or room. Extract the Wrangler-backed database adapter the CLI already used into tools/d1.mjs so both reports share one code path. `wrangler --json` reports API failures as a JSON object on stdout, which neither caller read, so a failed report printed Node's "Command failed" message with the full SQL attached. Parse that envelope, and when the failure is an authorization error while CF_API_TOKEN or CLOUDFLARE_API_TOKEN is set, name the variable and the command to run without it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsSAuUXmXhyA173qE3Y3Fq --- README.md | 5 +- docs/analytics.md | 23 +++- package.json | 1 + tools/analytics.mjs | 23 +--- tools/d1.mjs | 57 +++++++++ tools/dashboard.html | 250 +++++++++++++++++++++++++++++++++++++++ tools/dashboard.mjs | 64 ++++++++++ tools/dashboard.test.mjs | 96 +++++++++++++++ 8 files changed, 494 insertions(+), 25 deletions(-) create mode 100644 tools/d1.mjs create mode 100644 tools/dashboard.html create mode 100644 tools/dashboard.mjs create mode 100644 tools/dashboard.test.mjs diff --git a/README.md b/README.md index c2afa58..2fed85a 100644 --- a/README.md +++ b/README.md @@ -192,8 +192,9 @@ Anonymous rack statistics live in Cloudflare D1: games started and finished, com game modes, outcomes, shots, duration, arcade score, and starting/current settings. Attract mode is excluded; a game starts on its first shot. Online rooms count once across both players. -Run `npm run analytics` for the last seven days, or `npm run analytics -- --days 30 --mode computer`. -Use `--json` for exports. The same data is available in the Cloudflare dashboard under +Run `npm run analytics:dashboard` for charts in a browser, served from localhost through your own +Wrangler login. Run `npm run analytics` for the same report as text, with `--days 30`, +`--mode computer` and `--json` filters. The raw rows are in the Cloudflare dashboard under **Storage & databases → D1 → astrapool-analytics → Console**. See [analytics definitions, queries, and setup](docs/analytics.md). diff --git a/docs/analytics.md b/docs/analytics.md index 5540a70..801d541 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -6,13 +6,10 @@ and no reporting credential in the browser. ## View reports -In the [Cloudflare dashboard](https://dash.cloudflare.com/f1a4a152b72d6ebcb0b82a8b384d4c1b/workers/d1), -open **astrapool-analytics → Console** to run the SQL below. The database's Data view also lets -you inspect individual rack records. - From a checkout with dependencies installed and `npx wrangler login` completed: ```sh +npm run analytics:dashboard # charts in a browser npm run analytics # last 7 days, all modes npm run analytics -- --days 30 # 1, 7, 30, or 90 days npm run analytics -- --days 30 --mode computer # local, computer, free, online, or all @@ -20,6 +17,22 @@ npm run --silent analytics -- --json > analytics.json npm run analytics -- --local # local development data only ``` +`npm run analytics:dashboard` serves the same report as a page and opens it. Summary tiles, +daily starts, modes, winners and every settings breakdown are on one screen, with buttons for +the range and mode. It listens on `127.0.0.1:8788` only, so the report is readable from this +machine and not from the network. `--port`, `--local` and `--no-open` change that; Ctrl+C stops it. +Each refresh runs the fixed report queries through Wrangler's own login, so no analytics +credential is published, stored in the browser, or added to the game. + +Cloudflare's own **Dashboards** section charts Cloudflare telemetry, not table contents. Its D1 +datasets report rows read, query latency and storage size for `astrapool-analytics`, and cannot +group by mode, difficulty or room. Gameplay charts have to come from the dashboard command above, +or from SQL. + +In the [Cloudflare dashboard](https://dash.cloudflare.com/f1a4a152b72d6ebcb0b82a8b384d4c1b/workers/d1), +open **astrapool-analytics → Console** to run the SQL below. The database's Data view also lets +you inspect individual rack records. + Reports include daily counts, modes, winners, and breakdowns by starting difficulty, room, ball collection, arcade setting, effects setting, sound, input, and device category. Each breakdown includes starts, finishes, early endings, shots, and average completed-rack duration. @@ -159,6 +172,6 @@ local Worker. Its records stay in local D1. Plain `npm run dev` disables browser The separate private Worker has no analytics binding and does not collect records into the public database. -`npm test` covers lifecycle, validation, deduplication and SQL reporting. `npm run test:online` +`npm test` covers lifecycle, validation, deduplication, SQL reporting and the dashboard's routes and filters. `npm run test:online` uses an isolated local database and a real Worker to check HTTP ingestion and one-record online racks across two peers and reconnects. diff --git a/package.json b/package.json index c083ac7..35f621a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "deploy:private": "npm run heads:restore && npm run build && wrangler deploy --config wrangler.private.jsonc", "heads:guard": "node tools/heads.mjs guard", "analytics": "node tools/analytics.mjs", + "analytics:dashboard": "node tools/dashboard.mjs", "analytics:migrate": "wrangler d1 migrations apply GAME_ANALYTICS --remote" }, "dependencies": { diff --git a/tools/analytics.mjs b/tools/analytics.mjs index ebac374..aaa0e1f 100644 --- a/tools/analytics.mjs +++ b/tools/analytics.mjs @@ -1,6 +1,5 @@ -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; import { report } from '../server/analytics.js'; +import { wranglerDatabase } from './d1.mjs'; const args = process.argv.slice(2); if (args.includes('--help')) { @@ -16,22 +15,10 @@ for (let i = 0; i < args.length; i++) { else throw new Error(`Unknown option: ${args[i]}`); } if (![1, 7, 30, 90].includes(days) || !['all', 'local', 'computer', 'free', 'online'].includes(mode)) throw new Error('Invalid filter. Use --help.'); -// Only fixed SELECT queries and validated filters reach Wrangler. Its existing login -// provides access; no analytics credential is shipped to the game or written to disk. -const database = { - prepare(sql) { return { bind(...values) { let i = 0; return sql.replace(/\?/g, () => { - const value = values[i++]; return typeof value === 'number' ? String(value) : `'${value.replaceAll("'", "''")}'`; - }); } }; }, - async batch(queries) { - const stdout = execFileSync(process.execPath, ['node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'astrapool-analytics', - local ? '--local' : '--remote', '--json', '--command', queries.join(';\n')], { - cwd: fileURLToPath(new URL('../', import.meta.url)), encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, - env: { ...process.env, CLOUDFLARE_SEND_METRICS: 'false' }, stdio: ['ignore', 'pipe', 'inherit'], - }); - return JSON.parse(stdout); - }, -}; -const data = await report({ GAME_ANALYTICS: database }, days, mode); +const database = wranglerDatabase({ local }); +let data; +try { data = await report({ GAME_ANALYTICS: database }, days, mode); } +catch (error) { console.error(error.message); process.exit(1); } if (json) console.log(JSON.stringify(data, null, 2)); else { const s = data.summary, started = Number(s.started || 0), finished = Number(s.finished || 0); diff --git a/tools/d1.mjs b/tools/d1.mjs new file mode 100644 index 0000000..e691af6 --- /dev/null +++ b/tools/d1.mjs @@ -0,0 +1,57 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +// Reporting runs through Wrangler's existing login, so no analytics credential is +// shipped to the game, written to disk, or exposed on a public URL. Only fixed +// SELECT queries and validated filters reach the database. +export function wranglerDatabase({ local = false, inheritStderr = true } = {}) { + return { + prepare(sql) { + return { bind(...values) { let i = 0; return sql.replace(/\?/g, () => { + const value = values[i++]; return typeof value === 'number' ? String(value) : `'${value.replaceAll("'", "''")}'`; + }); } }; + }, + async batch(queries) { + let stdout; + try { + stdout = execFileSync(process.execPath, ['node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'astrapool-analytics', + local ? '--local' : '--remote', '--json', '--command', queries.join(';\n')], { + cwd: fileURLToPath(new URL('../', import.meta.url)), encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, CLOUDFLARE_SEND_METRICS: 'false' }, + stdio: ['ignore', 'pipe', inheritStderr ? 'inherit' : 'pipe'], + }); + } catch (error) { + throw new Error(describeWranglerFailure(error), { cause: error }); + } + return JSON.parse(stdout); + }, + }; +} + +const AUTH = /not authoriz|unauthoriz|authenticat|permission|code: (7403|10000|10001)/i; +const strip = text => (typeof text === 'string' ? text : '').replace(/\x1b\[[0-9;]*m/g, ''); + +// `wrangler --json` reports API failures as a JSON object on stdout, so the useful +// text is neither in stderr nor in the "Command failed: ..." message Node builds. +export function describeWranglerFailure(error, env = process.env) { + const stdout = strip(error.stdout), stderr = strip(error.stderr); + const start = stdout.indexOf('{'); + let message = ''; + if (start !== -1) { + try { + const reported = JSON.parse(stdout.slice(start)).error; + message = [reported?.text, ...(reported?.notes ?? []).map(note => note?.text)].filter(Boolean).join(' '); + } catch { /* not the JSON error envelope; fall through to the text below */ } + } + message ||= stderr.split('\n').map(line => line.trim()).filter(line => /ERROR|Error:/.test(line)).join(' '); + message ||= strip(error.message).split('\n')[0] || 'Wrangler failed'; + const tokens = ['CF_API_TOKEN', 'CLOUDFLARE_API_TOKEN'].filter(name => env[name]); + // Wrangler prefers these over an OAuth login, so a token without D1 access fails + // even where `wrangler login` would have worked. + if (tokens.length && AUTH.test(message + stderr)) { + const script = env.npm_lifecycle_event || 'analytics'; + message += ` Wrangler is using the API token in ${tokens.join(' and ')}. Give that token Account / D1 / Read, or run without it: ` + + `env ${tokens.map(name => `-u ${name}`).join(' ')} npm run ${script}`; + } + return message.slice(0, 600); +} diff --git a/tools/dashboard.html b/tools/dashboard.html new file mode 100644 index 0000000..d253443 --- /dev/null +++ b/tools/dashboard.html @@ -0,0 +1,250 @@ + + + + + +Astra Pool analytics + + + +
+

Astra Pool gameplay analytics

+
+
+ + + +
+
+
+
+
+
+

Daily starts

+

Racks grouped by the UTC day they started. A rack finishing tomorrow still counts on the day it began.

+
+ Finished + Ended early + Unfinished or still playing +
+
+
+
+

Game modes

Starts, with completion rate.

+

Winners

Finished racks only.

+
+
+
+
+

+

Started is the first accepted shot. Finished means the rules declared a winner, or Free Play cleared the table. + Ended early is an observed switch, restart, page exit, or expired online room. + Unfinished is everything else, which includes racks whose final update never arrived — browsers cannot report every tab closure. It is not a live-player count.

+

Definitions and SQL: docs/analytics.md · + Raw rows: D1 console

+
+ + + diff --git a/tools/dashboard.mjs b/tools/dashboard.mjs new file mode 100644 index 0000000..49eebea --- /dev/null +++ b/tools/dashboard.mjs @@ -0,0 +1,64 @@ +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { report } from '../server/analytics.js'; +import { wranglerDatabase } from './d1.mjs'; + +export const DAYS = [1, 7, 30, 90]; +export const MODES = ['all', 'local', 'computer', 'free', 'online']; +const RANGE_LABELS = { 1: 'Last 24 hours', 7: 'Last 7 days', 30: 'Last 30 days', 90: 'Last 90 days' }; +const page = () => readFileSync(new URL('./dashboard.html', import.meta.url), 'utf8'); + +// The browser never reaches D1. It calls this local server, which runs the same +// fixed report queries as `npm run analytics` through Wrangler's login. +export async function respond(pathname, params, { database, source = 'remote', html = page } = {}) { + const json = (status, body) => ({ status, type: 'application/json; charset=utf-8', body: JSON.stringify(body) }); + if (pathname === '/' || pathname === '/index.html') return { status: 200, type: 'text/html; charset=utf-8', body: html() }; + if (pathname !== '/api/report') return { status: 404, type: 'text/plain; charset=utf-8', body: 'Not found' }; + const days = Number(params.get('days') ?? 7), mode = params.get('mode') ?? 'all'; + if (!DAYS.includes(days) || !MODES.includes(mode)) { + return json(400, { error: `Invalid filter. Days must be ${DAYS.join(', ')} and mode one of ${MODES.join(', ')}.` }); + } + try { + return json(200, { ...await report({ GAME_ANALYTICS: database }, days, mode), source, rangeLabel: RANGE_LABELS[days] }); + } catch (error) { + return json(502, { error: error.message }); + } +} + +function open(url) { + const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; + spawn(command, [url], { stdio: 'ignore', shell: process.platform === 'win32', detached: true }).on('error', () => {}).unref(); +} + +export function main(argv = process.argv.slice(2)) { + if (argv.includes('--help')) { + console.log('Usage: npm run analytics:dashboard -- [--port 8788] [--local] [--no-open]'); + return null; + } + let port = 8788, local = false, launch = true; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--port') port = Number(argv[++i]); + else if (argv[i] === '--local') local = true; + else if (argv[i] === '--no-open') launch = false; + else throw new Error(`Unknown option: ${argv[i]}`); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid port'); + const database = wranglerDatabase({ local, inheritStderr: false }); + const server = createServer(async (request, response) => { + const url = new URL(request.url, 'http://localhost'); + const { status, type, body } = await respond(url.pathname, url.searchParams, { database, source: local ? 'local D1' : 'astrapool-analytics' }); + response.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); + response.end(body); + }); + // Localhost only: the report is reachable from this machine, not the network. + server.listen(port, '127.0.0.1', () => { + const url = `http://localhost:${port}`; + console.log(`Astra Pool analytics: ${url} (${local ? 'local D1' : 'remote astrapool-analytics'}; Ctrl+C to stop)`); + if (launch) open(url); + }); + return server; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/tools/dashboard.test.mjs b/tools/dashboard.test.mjs new file mode 100644 index 0000000..6579ec8 --- /dev/null +++ b/tools/dashboard.test.mjs @@ -0,0 +1,96 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { respond, DAYS, MODES } from './dashboard.mjs'; +import { describeWranglerFailure } from './d1.mjs'; +import { SETTINGS } from '../src/game-analytics.js'; + +// report() issues summary, daily, modes, outcomes and one query per setting. +const QUERIES = 4 + Object.keys(SETTINGS).length; +function stubDatabase(rows = {}) { + const sent = []; + return { sent, prepare: sql => ({ bind: (...values) => ({ sql, values }) }), + async batch(queries) { + sent.push(...queries); + assert.equal(queries.length, QUERIES); + return queries.map((query, i) => ({ results: i === 0 ? [rows.summary ?? { started: 3, finished: 1 }] : rows.list ?? [] })); + } }; +} +const params = search => new URLSearchParams(search); +const body = result => JSON.parse(result.body); + +test('serves the dashboard page', async () => { + const result = await respond('/', params(''), { database: stubDatabase(), html: () => 'page' }); + assert.equal(result.status, 200); + assert.match(result.type, /text\/html/); + assert.equal(result.body, 'page'); +}); + +test('bundled page loads and requests the report', async () => { + const result = await respond('/index.html', params(''), { database: stubDatabase() }); + assert.match(result.body, /Astra Pool analytics<\/title>/); + assert.match(result.body, /\/api\/report\?days=/); +}); + +test('reports the requested range and mode', async () => { + const database = stubDatabase({ summary: { started: 9, finished: 4 } }); + const result = await respond('/api/report', params('days=30&mode=computer'), { database }); + assert.equal(result.status, 200); + const data = body(result); + assert.equal(data.days, 30); + assert.equal(data.mode, 'computer'); + assert.equal(data.rangeLabel, 'Last 30 days'); + assert.equal(data.source, 'remote'); + assert.equal(data.summary.started, 9); + assert.deepEqual(Object.keys(data.settings), Object.keys(SETTINGS)); + // The filter reaches SQL as a bound value, never as interpolated text. + assert.ok(database.sent.every(query => query.values.includes('computer'))); +}); + +test('defaults to seven days across all modes', async () => { + const data = body(await respond('/api/report', params(''), { database: stubDatabase() })); + assert.equal(data.days, 7); + assert.equal(data.mode, 'all'); +}); + +test('rejects filters outside the fixed set', async () => { + for (const search of ['days=5', 'days=0', 'days=abc', 'mode=secret', "mode=all'--", 'days=30&mode=DROP']) { + const result = await respond('/api/report', params(search), { database: stubDatabase() }); + assert.equal(result.status, 400, search); + assert.match(body(result).error, /Invalid filter/); + } + for (const days of DAYS) for (const mode of MODES) { + assert.equal((await respond('/api/report', params(`days=${days}&mode=${mode}`), { database: stubDatabase() })).status, 200); + } +}); + +test('reports a query failure without crashing the server', async () => { + const database = { prepare: sql => ({ bind: () => sql }), batch: async () => { throw new Error('Wrangler rejected the API token'); } }; + const result = await respond('/api/report', params('days=7'), { database }); + assert.equal(result.status, 502); + assert.match(body(result).error, /API token/); +}); + +test('unknown paths are not found', async () => { + const result = await respond('/games.json', params(''), { database: stubDatabase() }); + assert.equal(result.status, 404); +}); + +test('an unprivileged API token explains the fix', () => { + // `wrangler --json` reports API failures on stdout, not stderr. + const failure = { stdout: '\n{"error":{"text":"A request to the Cloudflare API failed.","notes":[{"text":"The given account is not valid or is not authorized to access this service [code: 7403]"}],"code":7403}}\n', + stderr: 'Using "CF_API_TOKEN" environment variable. This is deprecated.\n', + message: 'Command failed: node wrangler.js d1 execute astrapool-analytics --remote --json --command SELECT ...' }; + const message = describeWranglerFailure(failure, { CF_API_TOKEN: 'x', npm_lifecycle_event: 'analytics:dashboard' }); + assert.match(message, /not authorized to access this service/); + // The hint names the command that actually failed. + assert.match(message, /env -u CF_API_TOKEN npm run analytics:dashboard/); + assert.match(describeWranglerFailure(failure, { CF_API_TOKEN: 'x' }), /npm run analytics$/); + assert.doesNotMatch(message, /Command failed/); + // Without a token in the environment there is nothing to unset, so only the API text shows. + assert.doesNotMatch(describeWranglerFailure(failure, {}), /env -u/); + assert.match(describeWranglerFailure(failure, {}), /not authorized/); + // A stderr-only failure and a spawn failure still say something specific and short. + assert.match(describeWranglerFailure({ stderr: '\x1b[31m✘ [ERROR] Authentication error [code: 10000]\n' }, { CLOUDFLARE_API_TOKEN: 'x' }), /CLOUDFLARE_API_TOKEN/); + assert.match(describeWranglerFailure({ message: 'spawn ENOENT' }, {}), /^spawn ENOENT$/); + assert.ok(describeWranglerFailure({ stdout: 'x'.repeat(5000), message: 'y'.repeat(5000) }, {}).length <= 600); +});