Daily starts
+Racks grouped by the UTC day they started. A rack finishing tomorrow still counts on the day it began.
+Game modes
Starts, with completion rate.
Winners
Finished racks only.
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 @@ + + +
+ + +Racks grouped by the UTC day they started. A rack finishing tomorrow still counts on the day it began.
+Starts, with completion rate.
Finished racks only.