Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
23 changes: 18 additions & 5 deletions docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,33 @@ 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
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.
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
23 changes: 5 additions & 18 deletions tools/analytics.mjs
Original file line number Diff line number Diff line change
@@ -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')) {
Expand All @@ -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);
Expand Down
57 changes: 57 additions & 0 deletions tools/d1.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading