This repository has been archived on 2026-05-04. You can view files and clone it, but cannot push or open issues or pull requests.
Galaxy-Strike-Online/GameServer/systems/AnalyticsSystem.js
2026-03-11 00:32:45 -03:00

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;