258 lines
10 KiB
JavaScript
258 lines
10 KiB
JavaScript
/**
|
|
* 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;
|