feat: privacy-respecting analytics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 00:15:20 +00:00
co-authored by Claude Opus 4.6
parent 2170e9275c
commit e10c1dc8aa
6 changed files with 116 additions and 0 deletions
+2
View File
@@ -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))
+6
View File
@@ -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),
+9
View File
@@ -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'",
+67
View File
@@ -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<string>()
/** 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 }>
}
+13
View File
@@ -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 */ }
+19
View File
@@ -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<string, Record<string, number>> = {}
for (const row of rows) {
if (!byDate[row.date]) byDate[row.date] = {}
byDate[row.date][row.metric] = row.value
}
return c.json({ stats: byDate })
})