diff --git a/docker-compose.yml b/docker-compose.yml index 4a2d273a1..446b0c6ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,7 @@ services: environment: GF_SECURITY_ADMIN_USER: admin GF_SECURITY_ADMIN_PASSWORD: pass + GF_SERVER_ROOT_URL: http://localhost:5000 user: "$UID:$GID" depends_on: - victoria-metrics diff --git a/src/app.ts b/src/app.ts index 14a2379d7..58a645a5c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -38,6 +38,8 @@ import { SESSIONS_COLLECTION_NAME } from './constants'; import { promAgent } from './clients/victoriaMetrics/promAgent'; import './metrics/systemMetrics'; +import './metrics/gameMetrics'; +import './metrics/miscellaneousMetrics'; const assetsPath = path.join(__dirname, '../assets'); diff --git a/src/clients/victoriaMetrics/promMetricCounter.ts b/src/clients/victoriaMetrics/promMetricCounter.ts new file mode 100644 index 000000000..7a7ff0b08 --- /dev/null +++ b/src/clients/victoriaMetrics/promMetricCounter.ts @@ -0,0 +1,39 @@ +import promClient, { Counter } from 'prom-client'; +import { promAgent } from './promAgent'; + +interface CounterConfig { + name: string; + help: string; + labelNames?: string[]; +} + +export class PromMetricCounter { + private counter: Counter; + private labelNames: string[]; + + constructor(counterConfig: CounterConfig) { + promAgent.registerMetric(counterConfig.name); + + this.counter = new promClient.Counter(counterConfig); + this.labelNames = counterConfig.labelNames; + } + + public inc(num: number, labels?: Record) { + if (labels) { + this.validateLabels(labels); + this.counter.inc(labels, num); + } else { + this.counter.inc(num); + } + } + + private validateLabels(labels: Record) { + const invalidLabels = Object.keys(labels).filter( + (label) => !this.labelNames.includes(label), + ); + + if (invalidLabels.length > 0) { + throw new Error(`Invalid labels provided: ${invalidLabels.join(', ')}.`); + } + } +} diff --git a/src/gameplay/game.ts b/src/gameplay/game.ts index 14bc4a30a..65c42b9ee 100644 --- a/src/gameplay/game.ts +++ b/src/gameplay/game.ts @@ -32,6 +32,7 @@ import { millisToStr } from '../util/time'; import shuffleArray from '../util/shuffleArray'; import { Anonymizer } from './anonymizer'; import { sendReplyToCommand } from '../sockets/sockets'; +import { gamesPlayedMetric } from '../metrics/gameMetrics'; export const WAITING = 'Waiting'; export const MIN_PLAYERS = 5; @@ -1254,6 +1255,8 @@ class Game extends Room { } // From this point on, no more game moves can be made. Game is finished. + gamesPlayedMetric.inc(1, { status: 'finished' }); + // Clean up from here. for (let i = 0; i < this.allSockets.length; i++) { this.allSockets[i].emit('gameEnded'); @@ -2074,6 +2077,8 @@ class Game extends Room { } if (this.voidGameTracker.playerVoted(socket.request.user.username)) { + gamesPlayedMetric.inc(1, { status: 'voided' }); + this.changePhase(Phase.Voided); this.sendText(`Game has been voided.`, 'server-text'); } diff --git a/src/gameplay/types.ts b/src/gameplay/types.ts index 78857050b..c531cda94 100644 --- a/src/gameplay/types.ts +++ b/src/gameplay/types.ts @@ -50,6 +50,7 @@ export interface IUser { pronoun?: string | null; dateJoined?: Date; lastLoggedIn?: Date[]; + lastLoggedInDateMetric?: Date; totalTimePlayed?: Date | number; totalGamesPlayed?: number; totalRankedGamesPlayed?: number; diff --git a/src/metrics/gameMetrics.ts b/src/metrics/gameMetrics.ts new file mode 100644 index 000000000..5640aefa3 --- /dev/null +++ b/src/metrics/gameMetrics.ts @@ -0,0 +1,7 @@ +import { PromMetricCounter } from '../clients/victoriaMetrics/promMetricCounter'; + +export const gamesPlayedMetric = new PromMetricCounter({ + name: 'games_played_total', + help: 'test', + labelNames: ['status'], +}); diff --git a/src/metrics/miscellaneousMetrics.ts b/src/metrics/miscellaneousMetrics.ts new file mode 100644 index 000000000..9efcfd2c8 --- /dev/null +++ b/src/metrics/miscellaneousMetrics.ts @@ -0,0 +1,32 @@ +import { allSockets } from '../sockets/sockets'; +import { PromMetricGauge } from '../clients/victoriaMetrics/promMetricGauge'; +import { PromMetricCounter } from '../clients/victoriaMetrics/promMetricCounter'; + +const onlinePlayersMetric = new PromMetricGauge({ + name: `online_players_total`, + help: `Number of online players.`, + collect() { + this.set(allSockets.length); + }, +}); + +export const uniqueLoginsMetric = new PromMetricCounter({ + name: 'unique_logins_total', + help: 'Total number of unique logins over a 24h time period.', +}); + +export const avatarSubmissionsMetric = new PromMetricCounter({ + name: `custom_avatar_submissions_total`, + help: `Total number of custom avatars submitted/rejected/approved.`, + labelNames: ['status'], +}); + +export const passwordResetRequestsMetric = new PromMetricCounter({ + name: `password_reset_requests_total`, + help: `Total number of password reset emails sent out.`, +}); + +export const passwordResetCompletedMetric = new PromMetricCounter({ + name: `password_resets_completed_total`, + help: `Total number of password resets completed.`, +}); diff --git a/src/models/user.ts b/src/models/user.ts index 5b27f9b6c..d97459307 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -48,6 +48,7 @@ const UserSchema = new mongoose.Schema({ // Oldest entries at the front. Latest at the end. lastLoggedIn: [Date], + lastLoggedInDateMetric: Date, totalTimePlayed: { type: Date, diff --git a/src/myFunctions/sendResetPassword.ts b/src/myFunctions/sendResetPassword.ts index 639741083..1eefa62a9 100644 --- a/src/myFunctions/sendResetPassword.ts +++ b/src/myFunctions/sendResetPassword.ts @@ -2,6 +2,7 @@ import uuid from 'uuid'; import ejs from 'ejs'; import emailTemplateResetPassword from './emailTemplateResetPassword'; import { sendEmail } from './sendEmail'; +import { passwordResetRequestsMetric } from '../metrics/miscellaneousMetrics'; const TOKEN_TIMEOUT = 60 * 60 * 1000; // 1 hour @@ -27,4 +28,6 @@ export const sendResetPassword = async (user: any, email: string) => { const subject = 'ProAvalon Reset Password Request.'; sendEmail(email, subject, message); + + passwordResetRequestsMetric.inc(1); }; diff --git a/src/routes/index.js b/src/routes/index.js index efd121793..ac79905ff 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -18,8 +18,7 @@ import { resRoles, rolesToAlliances, spyRoles } from '../gameplay/roles/roles'; import { sendResetPassword } from '../myFunctions/sendResetPassword'; import uuid from 'uuid'; import { captchaMiddleware } from '../util/captcha'; -import { PatreonAgent } from '../clients/patreon/patreonAgent'; -import { PatreonController } from '../clients/patreon/patreonController'; +import { passwordResetCompletedMetric } from '../metrics/miscellaneousMetrics'; const router = new Router(); @@ -296,6 +295,8 @@ router.get('/resetPassword/verifyResetPassword', async (req, res) => { await user.save(); + passwordResetCompletedMetric.inc(1); + req.flash('success', 'Your password has been reset!'); res.render('resetPasswordSuccess', { newPassword }); return; diff --git a/src/routes/profile/index.tsx b/src/routes/profile/index.tsx index d4fa5def9..8507ea34e 100644 --- a/src/routes/profile/index.tsx +++ b/src/routes/profile/index.tsx @@ -26,6 +26,7 @@ import { createNotification } from '../../myFunctions/createNotification'; import { getAndUpdatePatreonRewardTierForUser } from '../../rewards/getRewards'; import userAdapter from '../../databaseAdapters/user'; import { getAvatarLibrarySizeForUser } from '../../rewards/getRewards'; +import { avatarSubmissionsMetric } from '../../metrics/miscellaneousMetrics'; const MAX_ACTIVE_AVATAR_REQUESTS = 1; const MIN_GAMES_REQUIRED = 100; @@ -368,10 +369,12 @@ router.post( }); if (decision) { + avatarSubmissionsMetric.inc(1, { status: 'approved' }); console.log( `Custom avatar request approved: forUser="${avatarReq.forUsername}" byMod="${modWhoProcessed.username}" modComment="${modComment}" resLink="${avatarReq.resLink}" spyLink="${avatarReq.spyLink}"`, ); } else { + avatarSubmissionsMetric.inc(1, { status: 'rejected' }); console.log( `Custom avatar request rejected: forUser="${avatarReq.forUsername}" byMod="${modWhoProcessed.username}" modComment="${modComment}"`, ); @@ -488,6 +491,8 @@ router.post( await avatarRequest.create(avatarRequestData); + avatarSubmissionsMetric.inc(1, { status: 'submitted' }); + res .status(200) .send( diff --git a/src/sockets/sockets.ts b/src/sockets/sockets.ts index f1f76efda..85686ee9b 100644 --- a/src/sockets/sockets.ts +++ b/src/sockets/sockets.ts @@ -42,7 +42,10 @@ import { Role } from '../gameplay/roles/types'; import { Phase } from '../gameplay/phases/types'; import { Card } from '../gameplay/cards/types'; import { TOCommandsImported } from './commands/tournamentOrganisers'; -import { PromMetricGauge } from '../clients/victoriaMetrics/promMetricGauge'; +import userAdapter from '../databaseAdapters/user'; +import { uniqueLoginsMetric } from '../metrics/miscellaneousMetrics'; + +const ONE_DAY_MILLIS = 24 * 60 * 60 * 1000; // 1 day const chatSpamFilter = new ChatSpamFilter(); const createRoomFilter = new CreateRoomFilter(); @@ -57,14 +60,6 @@ if (process.env.NODE_ENV !== 'test') { }, 1000); } -const onlinePlayersMetric = new PromMetricGauge({ - name: `online_players_total`, - help: `Number of online players.`, - collect() { - this.set(allSockets.length); - }, -}); - const quote = new Quote(); const dateResetRequired = 1543480412695; @@ -739,6 +734,15 @@ export const server = function (io: SocketServer): void { socket.rewards = await getAllRewardsForUser(socket.request.user); socket = applyApplicableRewards(socket); + + if ( + !socket.request.user.lastLoggedInDateMetric || + new Date() - socket.request.user.lastLoggedInDateMetric > ONE_DAY_MILLIS + ) { + socket.request.user.lastLoggedInDateMetric = new Date(); + await socket.request.user.save(); + uniqueLoginsMetric.inc(1); + } }); };