From e10c1dc8aaf6dae9370b3fc45fcf42e9236c2244 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 00:15:20 +0000 Subject: [PATCH] feat: privacy-respecting analytics Co-Authored-By: Claude Opus 4.6 --- server/src/app.ts | 2 + server/src/db/schema.ts | 6 +++ server/src/db/startup.ts | 9 +++++ server/src/engine/analytics.ts | 67 +++++++++++++++++++++++++++++++ server/src/engine/orchestrator.ts | 13 ++++++ server/src/routes/stats.ts | 19 +++++++++ 6 files changed, 116 insertions(+) create mode 100644 server/src/engine/analytics.ts create mode 100644 server/src/routes/stats.ts diff --git a/server/src/app.ts b/server/src/app.ts index 8864388..4716a66 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -12,6 +12,7 @@ import { betsRouter } from './routes/bets.js' import { paymentsRouter } from './routes/payments.js' import { tournamentsRouter } from './routes/tournaments.js' import { adminRouter } from './routes/admin.js' +import { statsRouter } from './routes/stats.js' import { rateLimit } from './middleware/rate-limit.js' import { existsSync, readFileSync } from 'fs' @@ -62,6 +63,7 @@ app.route('/api/bets', betsRouter) app.route('/api/payments', paymentsRouter) app.route('/api/tournaments', tournamentsRouter) app.route('/api/admin', adminRouter) +app.route('/api/stats', statsRouter) // In production, serve the frontend SPA const __dirname = dirname(fileURLToPath(import.meta.url)) diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 2f61691..0e676df 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -134,6 +134,12 @@ export const tournamentEntries = sqliteTable('tournament_entries', { createdAt: text('created_at').notNull(), }) +export const analytics = sqliteTable('analytics', { + date: text('date').notNull(), + metric: text('metric').notNull(), + value: integer('value').notNull().default(0), +}) + export const tournamentMatches = sqliteTable('tournament_matches', { id: text('id').primaryKey(), tournamentId: text('tournament_id').notNull().references(() => tournaments.id), diff --git a/server/src/db/startup.ts b/server/src/db/startup.ts index a5cbed9..7ad7bd6 100644 --- a/server/src/db/startup.ts +++ b/server/src/db/startup.ts @@ -126,6 +126,15 @@ export function runMigrations() { ); `) + sqlite.exec(` + CREATE TABLE IF NOT EXISTS analytics ( + date TEXT NOT NULL, + metric TEXT NOT NULL, + value INTEGER NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_analytics_date_metric ON analytics(date, metric); + `) + // Column migrations for existing databases const migrations = [ "ALTER TABLE bots ADD COLUMN archetype TEXT NOT NULL DEFAULT 'standard'", diff --git a/server/src/engine/analytics.ts b/server/src/engine/analytics.ts new file mode 100644 index 0000000..ea42c78 --- /dev/null +++ b/server/src/engine/analytics.ts @@ -0,0 +1,67 @@ +import { sqlite } from '../db/index.js' + +function today(): string { + return new Date().toISOString().slice(0, 10) +} + +const incrementStmt = sqlite.prepare( + `INSERT INTO analytics (date, metric, value) VALUES (?, ?, 1) + ON CONFLICT(date, metric) DO UPDATE SET value = value + 1` +) + +const incrementByStmt = sqlite.prepare( + `INSERT INTO analytics (date, metric, value) VALUES (?, ?, ?) + ON CONFLICT(date, metric) DO UPDATE SET value = value + ?` +) + +/** Increment a metric counter for today */ +export function trackMetric(metric: string): void { + try { incrementStmt.run(today(), metric) } catch { /* best-effort */ } +} + +/** Increment a metric by a specific amount */ +export function trackMetricBy(metric: string, amount: number): void { + try { incrementByStmt.run(today(), metric, amount, amount) } catch { /* best-effort */ } +} + +/** Record a fight completion with aggregate counters */ +export function trackFightCompleted(opts: { + satsWagered: number + durationRounds: number + mode: 'free' | 'ranked' +}): void { + trackMetric('fights') + trackMetric(`fights_${opts.mode}`) + if (opts.satsWagered > 0) { + trackMetricBy('sats_wagered', opts.satsWagered) + } + trackMetricBy('total_rounds', opts.durationRounds) +} + +/** Track a unique bot that fought today */ +export function trackBotActive(botId: string): void { + const key = `${today()}:${botId}` + if (seenBots.has(key)) return + seenBots.add(key) + trackMetric('unique_bots') + + // Prune old entries (keep only today's) + const todayPrefix = today() + ':' + for (const k of seenBots) { + if (!k.startsWith(todayPrefix)) seenBots.delete(k) + } +} + +const seenBots = new Set() + +/** Get aggregate stats for the last N days */ +export function getPublicStats(days: number = 30): Array<{ date: string; metric: string; value: number }> { + const cutoff = new Date() + cutoff.setDate(cutoff.getDate() - days) + const cutoffStr = cutoff.toISOString().slice(0, 10) + + const stmt = sqlite.prepare( + 'SELECT date, metric, value FROM analytics WHERE date >= ? ORDER BY date DESC, metric' + ) + return stmt.all(cutoffStr) as Array<{ date: string; metric: string; value: number }> +} diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index 11b7ec6..523bf14 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -15,6 +15,7 @@ import { settleBets, lockBets } from './betting.js' import { publishFightResult } from './nostr-publish.js' import { getCurrentSeason } from './seasons.js' import { onFightFinished as onTournamentFightFinished } from './tournaments.js' +import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js' interface BotRecord { id: string @@ -514,6 +515,18 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec finalize() + // Track aggregate analytics (no PII) + trackBotActive(botA.id) + trackBotActive(botB.id) + trackFightCompleted({ + satsWagered: mode === 'ranked' ? ENTRY_FEE_SATS * 2 : 0, + durationRounds: lastRound, + mode, + }) + for (const ct of usedTypes) { + trackMetric(`challenge_${ct}`) + } + // Advance tournament bracket if this was a tournament match try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ } diff --git a/server/src/routes/stats.ts b/server/src/routes/stats.ts new file mode 100644 index 0000000..776be4f --- /dev/null +++ b/server/src/routes/stats.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono' +import { getPublicStats } from '../engine/analytics.js' + +export const statsRouter = new Hono() + +// GET /public — aggregate stats for last 30 days, no PII +statsRouter.get('/public', (c) => { + const days = Math.min(parseInt(c.req.query('days') || '30'), 90) + const rows = getPublicStats(days) + + // Group by date for easier consumption + const byDate: Record> = {} + for (const row of rows) { + if (!byDate[row.date]) byDate[row.date] = {} + byDate[row.date][row.metric] = row.value + } + + return c.json({ stats: byDate }) +})