/** * AnalyticsSystem — GDD Phase 3 Infrastructure * * Tracks DAU/MAU, session lengths, economy balance metrics, and funnel events. * Stores daily snapshots in MongoDB. Exposes /metrics endpoint data. * Designed to also forward events to an external pipeline (Mixpanel, custom) via ENV config. * * Collections used: * gso_analytics_sessions — per-session login/logout records * gso_analytics_daily — daily aggregated counters (DAU, revenue, economy) * gso_analytics_events — funnel + economy event stream (capped collection, 7-day TTL) */ const mongoose = require('mongoose'); // ── Schemas ──────────────────────────────────────────────────────────────────── const sessionSchema = new mongoose.Schema({ userId: { type: String, required: true, index: true }, username: { type: String }, loginAt: { type: Date, default: Date.now }, logoutAt: { type: Date, default: null }, durationS: { type: Number, default: 0 }, // filled on logout day: { type: String, index: true }, // YYYY-MM-DD month: { type: String, index: true }, // YYYY-MM }, { timestamps: false }); sessionSchema.index({ day: 1, userId: 1 }); const dailySchema = new mongoose.Schema({ day: { type: String, required: true, unique: true }, // YYYY-MM-DD month: { type: String, required: true, index: true }, // User counts dau: { type: Number, default: 0 }, // distinct users who logged in this day newUsers:{ type: Number, default: 0 }, // Economy creditsEarned: { type: Number, default: 0 }, creditsSpent: { type: Number, default: 0 }, gemsEarned: { type: Number, default: 0 }, gemsSpent: { type: Number, default: 0 }, gemsPurchased: { type: Number, default: 0 }, // via Stripe stripeRevenue: { type: Number, default: 0 }, // USD cents // Gameplay dungeonsRun: { type: Number, default: 0 }, craftsCompleted:{ type: Number, default: 0 }, pvpBattles: { type: Number, default: 0 }, raidBattles: { type: Number, default: 0 }, marketSales: { type: Number, default: 0 }, // Sessions totalSessionS: { type: Number, default: 0 }, // for avg session length calc sessionCount: { type: Number, default: 0 }, }, { timestamps: false }); const eventSchema = new mongoose.Schema({ ts: { type: Date, default: Date.now, expires: 604800 }, // 7-day TTL userId: { type: String, index: true }, event: { type: String, index: true }, data: { type: mongoose.Schema.Types.Mixed, default: {} }, day: { type: String }, }, { timestamps: false }); let SessionModel, DailyModel, EventModel; try { SessionModel = mongoose.model('GSOSession'); } catch(e) { SessionModel = mongoose.model('GSOSession', sessionSchema); } try { DailyModel = mongoose.model('GSODaily'); } catch(e) { DailyModel = mongoose.model('GSODaily', dailySchema); } try { EventModel = mongoose.model('GSOEvent'); } catch(e) { EventModel = mongoose.model('GSOEvent', eventSchema); } // ── AnalyticsSystem ──────────────────────────────────────────────────────────── class AnalyticsSystem { constructor() { this._activeSessions = new Map(); // userId → { sessionId, loginAt } this._dayCache = new Map(); // day → DailyModel doc (in-memory write buffer) this._flushInterval = null; this._externalUrl = process.env.ANALYTICS_WEBHOOK_URL || null; } // ── Lifecycle ──────────────────────────────────────────────────────────────── start() { // Flush buffered daily counters to DB every 60 seconds this._flushInterval = setInterval(() => this._flush(), 60_000); console.log('[ANALYTICS] System started'); } stop() { if (this._flushInterval) clearInterval(this._flushInterval); return this._flush(); // final flush on shutdown } // ── Session tracking ───────────────────────────────────────────────────────── async onLogin(userId, username) { const now = new Date(); const day = this._day(now); const month = this._month(now); try { const doc = await SessionModel.create({ userId, username, loginAt: now, day, month }); this._activeSessions.set(userId, { sessionId: doc._id, loginAt: now }); this._inc(day, month, 'dau_users', userId); // unique user set handled below this._incCounter(day, month, 'sessionCount', 1); } catch (err) { // Non-fatal } } async onLogout(userId) { const entry = this._activeSessions.get(userId); if (!entry) return; this._activeSessions.delete(userId); const logoutAt = new Date(); const durationS = Math.round((logoutAt - entry.loginAt) / 1000); const day = this._day(entry.loginAt); const month = this._month(entry.loginAt); try { await SessionModel.findByIdAndUpdate(entry.sessionId, { logoutAt, durationS }); this._incCounter(day, month, 'totalSessionS', durationS); } catch (err) {} } // ── Funnel / economy event tracking ───────────────────────────────────────── track(event, userId, data = {}) { const now = new Date(); const day = this._day(now); // Write to event stream (non-blocking) EventModel.create({ event, userId, data, day }).catch(() => {}); // Aggregate counters into daily snapshot buffer const COUNTER_MAP = { 'dungeon.complete': 'dungeonsRun', 'craft.complete': 'craftsCompleted', 'pvp.battle': 'pvpBattles', 'pvp.ranked.battle': 'pvpBattles', 'raid.complete': 'raidBattles', 'market.sale': 'marketSales', 'credits.earn': ['creditsEarned', data.amount || 0], 'credits.spend': ['creditsSpent', data.amount || 0], 'gems.earn': ['gemsEarned', data.amount || 0], 'gems.spend': ['gemsSpent', data.amount || 0], 'gems.purchase': ['gemsPurchased', data.gems || 0], 'stripe.payment': ['stripeRevenue', data.amountCents || 0], 'user.register': 'newUsers', }; const mapped = COUNTER_MAP[event]; const month = this._month(now); if (mapped) { if (Array.isArray(mapped)) { this._incCounter(day, month, mapped[0], mapped[1]); } else { this._incCounter(day, month, mapped, 1); } } // Optional external webhook forward if (this._externalUrl) this._forwardExternal(event, userId, data).catch(() => {}); } // ── Metrics snapshot (for /metrics endpoint) ───────────────────────────────── async getMetrics() { const today = this._day(new Date()); const thisMonth = this._month(new Date()); // Flush buffer first so stats are up-to-date await this._flush(); const [daily, mauResult, avgSession] = await Promise.all([ DailyModel.findOne({ day: today }).lean(), SessionModel.distinct('userId', { month: thisMonth }), SessionModel.aggregate([ { $match: { month: thisMonth, durationS: { $gt: 0 } } }, { $group: { _id: null, avg: { $avg: '$durationS' }, count: { $sum: 1 } } }, ]), ]); const avgS = avgSession[0]?.avg || 0; const avgMin = Math.round(avgS / 60); return { today: daily || {}, dau: daily?.dau || 0, mau: mauResult.length, avgSessionMin: avgMin, activeSessions: this._activeSessions.size, }; } // ── Last 30 days summary ────────────────────────────────────────────────────── async getLast30Days() { const docs = await DailyModel.find().sort({ day: -1 }).limit(30).lean(); return docs; } // ── Internal helpers ────────────────────────────────────────────────────────── _day(d) { return d.toISOString().slice(0, 10); } _month(d) { return d.toISOString().slice(0, 7); } _getBuffer(day, month) { if (!this._dayCache.has(day)) { this._dayCache.set(day, { day, month, _dirty: true, dau_set: new Set() }); } return this._dayCache.get(day); } _inc(day, month, _field, userId) { // Track unique DAU via a Set const buf = this._getBuffer(day, month); buf.dau_set.add(userId); buf.dau = buf.dau_set.size; buf._dirty = true; } _incCounter(day, month, field, amount) { const buf = this._getBuffer(day, month); buf[field] = (buf[field] || 0) + amount; buf._dirty = true; } async _flush() { const promises = []; for (const [day, buf] of this._dayCache.entries()) { if (!buf._dirty) continue; buf._dirty = false; const { _dirty, dau_set, ...data } = buf; promises.push( DailyModel.findOneAndUpdate( { day }, { $set: data }, { upsert: true, new: true } ).catch(() => {}) ); } await Promise.all(promises); // Evict old days (keep only today + yesterday in buffer) const today = this._day(new Date()); const yday = this._day(new Date(Date.now() - 86400000)); for (const day of this._dayCache.keys()) { if (day !== today && day !== yday) this._dayCache.delete(day); } } async _forwardExternal(event, userId, data) { const { default: fetch } = await import('node-fetch').catch(() => ({ default: null })); if (!fetch || !this._externalUrl) return; await fetch(this._externalUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event, userId, data, ts: new Date().toISOString() }), }); } } module.exports = AnalyticsSystem;