feat: privacy-respecting analytics
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2170e9275c
commit
e10c1dc8aa
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
@@ -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 */ }
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user