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 { paymentsRouter } from './routes/payments.js'
|
||||||
import { tournamentsRouter } from './routes/tournaments.js'
|
import { tournamentsRouter } from './routes/tournaments.js'
|
||||||
import { adminRouter } from './routes/admin.js'
|
import { adminRouter } from './routes/admin.js'
|
||||||
|
import { statsRouter } from './routes/stats.js'
|
||||||
import { rateLimit } from './middleware/rate-limit.js'
|
import { rateLimit } from './middleware/rate-limit.js'
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync } from 'fs'
|
||||||
@@ -62,6 +63,7 @@ app.route('/api/bets', betsRouter)
|
|||||||
app.route('/api/payments', paymentsRouter)
|
app.route('/api/payments', paymentsRouter)
|
||||||
app.route('/api/tournaments', tournamentsRouter)
|
app.route('/api/tournaments', tournamentsRouter)
|
||||||
app.route('/api/admin', adminRouter)
|
app.route('/api/admin', adminRouter)
|
||||||
|
app.route('/api/stats', statsRouter)
|
||||||
|
|
||||||
// In production, serve the frontend SPA
|
// In production, serve the frontend SPA
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ export const tournamentEntries = sqliteTable('tournament_entries', {
|
|||||||
createdAt: text('created_at').notNull(),
|
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', {
|
export const tournamentMatches = sqliteTable('tournament_matches', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
tournamentId: text('tournament_id').notNull().references(() => tournaments.id),
|
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
|
// Column migrations for existing databases
|
||||||
const migrations = [
|
const migrations = [
|
||||||
"ALTER TABLE bots ADD COLUMN archetype TEXT NOT NULL DEFAULT 'standard'",
|
"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 { publishFightResult } from './nostr-publish.js'
|
||||||
import { getCurrentSeason } from './seasons.js'
|
import { getCurrentSeason } from './seasons.js'
|
||||||
import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
|
import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
|
||||||
|
import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js'
|
||||||
|
|
||||||
interface BotRecord {
|
interface BotRecord {
|
||||||
id: string
|
id: string
|
||||||
@@ -514,6 +515,18 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
|||||||
|
|
||||||
finalize()
|
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
|
// Advance tournament bracket if this was a tournament match
|
||||||
try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ }
|
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