import { redis } from "./redis"; import { prisma } from "./prisma"; /** * XIP live stats. * * Two kinds of metrics: * - PERSISTENT totals, stored in Redis (survive restarts): messages, replies, * characters sent, letters typed (even if never sent), unique IPs, longest message. * - LIVE metrics, kept in process memory (sliding windows): letters/sec, messages/min. * * The number of connected tabs and the "currently typing" count are owned by the * realtime module and injected when building a snapshot. */ const K = { messages: "xip:stat:messages", replies: "xip:stat:replies", charsSent: "xip:stat:chars_sent", lettersTyped: "xip:stat:letters_typed", longest: "xip:stat:longest", ips: "xip:hll:ips", initialized: "xip:stat:initialized", creditsSpent: "xip:money:credits_spent", // centi-credits spent (set by wallet/catalog) impressionsTotal: "xip:money:impressions_total", // ad impressions (set by lib/ads) } as const; // Satirical CPM: "€" earned per 1000 ad impressions. const FAKE_CPM = 12.5; // ── Sliding-window live metrics (per process) ────────────────────────────── const LETTERS_WINDOW_MS = 4000; // smoothing window for letters/sec const MSGS_WINDOW_MS = 60000; // messages per minute let letterEvents: { ts: number; n: number }[] = []; let messageEvents: number[] = []; function prune(now: number): void { letterEvents = letterEvents.filter((e) => now - e.ts <= LETTERS_WINDOW_MS); messageEvents = messageEvents.filter((ts) => now - ts <= MSGS_WINDOW_MS); } export function getLettersPerSec(): number { const now = Date.now(); prune(now); const total = letterEvents.reduce((sum, e) => sum + e.n, 0); return total / (LETTERS_WINDOW_MS / 1000); } export function getMsgsPerMin(): number { const now = Date.now(); prune(now); return messageEvents.length; } // ── First-boot backfill ───────────────────────────────────────────────────── /** * Seed the persistent counters from the database the first time the server runs * (guarded by a Redis sentinel, so it's a no-op on hot reloads / restarts). * Without this, totals would show 0 while seeded messages are already visible. * letters_typed is intentionally NOT backfilled — it has no DB source. */ export async function initStats(): Promise { const first = await redis.set(K.initialized, "1", "NX").catch(() => null); if (first !== "OK") return; // already initialized try { const rows = await prisma.$queryRaw< { messages: bigint; replies: bigint; chars: bigint; longest: bigint }[] >` SELECT COUNT(*) AS messages, COUNT(*) FILTER (WHERE "parentId" IS NOT NULL) AS replies, COALESCE(SUM(LENGTH(content)), 0) AS chars, COALESCE(MAX(LENGTH(content)), 0) AS longest FROM messages `; const r = rows[0]; if (r) { const pipe = redis.pipeline(); pipe.set(K.messages, String(Number(r.messages))); pipe.set(K.replies, String(Number(r.replies))); pipe.set(K.charsSent, String(Number(r.chars))); pipe.set(K.longest, String(Number(r.longest))); await pipe.exec(); } const ips = await prisma.message.findMany({ distinct: ["authorIp"], select: { authorIp: true }, }); if (ips.length > 0) { await redis.pfadd(K.ips, ...ips.map((m) => m.authorIp)); } console.log("📊 Stats backfilled from database."); } catch (err) { // Non-fatal: release the sentinel so a later boot can retry. await redis.del(K.initialized).catch(() => {}); console.warn("⚠️ Stats backfill failed:", (err as Error).message); } } // ── Mutations ────────────────────────────────────────────────────────────── /** Record a freshly created message (top-level or reply). */ export async function recordMessage( contentLength: number, isReply: boolean ): Promise { messageEvents.push(Date.now()); const pipe = redis.pipeline(); pipe.incr(K.messages); pipe.incrby(K.charsSent, contentLength); if (isReply) pipe.incr(K.replies); // Track longest message (read-modify-write is fine; contention is negligible). pipe.get(K.longest); const res = await pipe.exec().catch(() => null); if (res) { const current = Number(res[res.length - 1]?.[1] ?? 0); if (contentLength > current) { await redis.set(K.longest, String(contentLength)).catch(() => {}); } } } /** Record letters typed (sent or not). Feeds both the persistent total and letters/sec. */ export async function recordLettersTyped(delta: number): Promise { if (!Number.isFinite(delta) || delta <= 0) return; const n = Math.min(delta, 1000); // guard against bogus client payloads letterEvents.push({ ts: Date.now(), n }); await redis.incrby(K.lettersTyped, n).catch(() => {}); } /** Register an IP in the HyperLogLog of unique visitors. */ export async function recordIp(ip: string): Promise { if (!ip) return; await redis.pfadd(K.ips, ip).catch(() => {}); } // ── Snapshot ───────────────────────────────────────────────────────────── export interface StatsSnapshot { // live connectedTabs: number; typingNow: number; lettersPerSec: number; msgsPerMin: number; // totals messages: number; replies: number; charsSent: number; lettersTyped: number; uniqueIps: number; longestMsg: number; // derived abandonRate: number; // % of typed letters that were never sent avgLength: number; // average sent-message length moneyExtorted: number; // fake "€": impressions×CPM + credits spent } export async function buildSnapshot(live: { connectedTabs: number; typingNow: number; }): Promise { const [messages, replies, charsSent, lettersTyped, longest, uniqueIps, creditsSpent, impressions] = await Promise.all([ redis.get(K.messages).catch(() => "0"), redis.get(K.replies).catch(() => "0"), redis.get(K.charsSent).catch(() => "0"), redis.get(K.lettersTyped).catch(() => "0"), redis.get(K.longest).catch(() => "0"), redis.pfcount(K.ips).catch(() => 0), redis.get(K.creditsSpent).catch(() => "0"), redis.get(K.impressionsTotal).catch(() => "0"), ]); const nMessages = Number(messages ?? 0); const nCharsSent = Number(charsSent ?? 0); const nLettersTyped = Number(lettersTyped ?? 0); const abandonRate = nLettersTyped > 0 ? Math.max(0, Math.min(100, ((nLettersTyped - nCharsSent) / nLettersTyped) * 100)) : 0; const avgLength = nMessages > 0 ? nCharsSent / nMessages : 0; // Fake revenue: ad impressions × CPM + credits spent (centi-credits → "€"). const moneyExtorted = (Number(impressions ?? 0) / 1000) * FAKE_CPM + Number(creditsSpent ?? 0) / 100; return { connectedTabs: live.connectedTabs, typingNow: live.typingNow, lettersPerSec: getLettersPerSec(), msgsPerMin: getMsgsPerMin(), messages: nMessages, replies: Number(replies ?? 0), charsSent: nCharsSent, lettersTyped: nLettersTyped, uniqueIps: Number(uniqueIps ?? 0), longestMsg: Number(longest ?? 0), abandonRate, avgLength, moneyExtorted, }; }