Merge pull request #358 from PretendoNetwork/feat/healthcheck-metrics-and-more
Some checks failed
Build and Publish Docker Image / Build and Publish Docker Image (amd64) (push) Has been cancelled
Build and Publish Docker Image / Build and Publish Docker Image (arm64) (push) Has been cancelled

Healthchecks, metrics & more
This commit is contained in:
mrjvs
2026-09-01 21:52:02 +02:00
committed by GitHub
10 changed files with 182 additions and 19 deletions

View File

@@ -96,4 +96,6 @@ Configurations are loaded through environment variables. `.env` files are suppor
| `PN_ACT_CONFIG_GRPC_MIIVERSE_HOST` | Used to remove Miiverse user data during account deletion | No |
| `PN_ACT_CONFIG_GRPC_MIIVERSE_PORT` | Used to remove Miiverse user data during account deletion | No |
| `PN_ACT_CONFIG_GRPC_MIIVERSE_KEY_API` | Used to remove Miiverse user data during account deletion | No |
| `PN_ACT_PROVISIONING_SERVER_CONFIG` | Specify a path to a JSON file containing a list of servers to provision automatically to the DB | Yes |
| `PN_ACT_PROVISIONING_SERVER_CONFIG` | Specify a path to a JSON file containing a list of servers to provision automatically to the DB | Yes |
| `PN_ACT_CONFIG_METRICS_ENABLED` | Set to `true` to enable the metrics server, uses the metrics port | Yes |
| `PN_ACT_CONFIG_METRICS_PORT` | The HTTP port the metrics server listens on | Yes |

81
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"ejs": "^3.1.10",
"email-validator": "^2.0.4",
"express": "^4.17.1",
"express-prom-bundle": "^7.0.2",
"express-rate-limit": "^6.7.0",
"fs-extra": "^8.1.0",
"got": "^11.8.2",
@@ -1636,6 +1637,16 @@
"node": ">=6"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@pretendonetwork/eslint-config": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@pretendonetwork/eslint-config/-/eslint-config-0.2.0.tgz",
@@ -1965,7 +1976,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -1998,7 +2008,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2035,7 +2044,6 @@
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@@ -2048,7 +2056,6 @@
"version": "4.19.9",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
"integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -2085,7 +2092,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -2131,7 +2137,6 @@
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/morgan": {
@@ -2184,14 +2189,12 @@
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/responselike": {
@@ -2207,7 +2210,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2217,7 +2219,6 @@
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -2229,7 +2230,6 @@
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
@@ -3302,6 +3302,13 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/bintrees": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
"license": "MIT",
"peer": true
},
"node_modules/bit-buffer": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/bit-buffer/-/bit-buffer-0.2.5.tgz",
@@ -5308,6 +5315,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-prom-bundle": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-7.0.2.tgz",
"integrity": "sha512-ffFV4HGHvCKnkNJFqm42sYztRJE5mLgOj8MpGey1HOatuFhtcwXoBD2m5gca7ZbcyjkIf7lOH5ZdrhlrBf0sGw==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.21",
"express": "^4.18.2",
"on-finished": "^2.3.0",
"url-value-parser": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"prom-client": ">=15.0.0"
}
},
"node_modules/express-rate-limit": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz",
@@ -8154,6 +8179,21 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prom-client": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
"deprecated": "prom-client has been replaced by @prometheus-io/client",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api": "^1.4.0",
"tdigest": "^0.1.1"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -9328,6 +9368,16 @@
"node": ">=10"
}
},
"node_modules/tdigest": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.3.tgz",
"integrity": "sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bintrees": "1.0.2"
}
},
"node_modules/tga": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tga/-/tga-1.0.7.tgz",
@@ -9813,6 +9863,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-value-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.2.0.tgz",
"integrity": "sha512-yIQdxJpgkPamPPAPuGdS7Q548rLhny42tg8d4vyTNzFqvOnwqrgHXvgehT09U7fwrzxi3RxCiXjoNUNnNOlQ8A==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -40,6 +40,7 @@
"ejs": "^3.1.10",
"email-validator": "^2.0.4",
"express": "^4.17.1",
"express-prom-bundle": "^7.0.2",
"express-rate-limit": "^6.7.0",
"fs-extra": "^8.1.0",
"got": "^11.8.2",

View File

@@ -38,6 +38,10 @@ export const config: Config = {
http: {
port: Number(process.env.PN_ACT_CONFIG_HTTP_PORT || '')
},
metrics: {
enabled: process.env.PN_ACT_CONFIG_METRICS_ENABLED === 'true',
port: Number(process.env.PN_ACT_CONFIG_METRICS_PORT || '')
},
mongoose: {
connection_string: process.env.PN_ACT_CONFIG_MONGO_CONNECTION_STRING || '',
options: mongooseConnectOptions

75
src/metrics.ts Normal file
View File

@@ -0,0 +1,75 @@
import { format } from 'node:util';
import { Gauge } from 'prom-client';
import expressMetrics from 'express-prom-bundle';
import express from 'express';
import { LOG_ERROR, LOG_INFO, LOG_SUCCESS } from '@/logger';
import { config } from '@/config-manager';
import { PNID } from '@/models/pnid';
import { NEXToken } from '@/models/nex-token';
import type { Express, NextFunction, Request, Response } from 'express';
export const pnidTotalGauge = new Gauge({
name: 'pn_account_pnid_total',
help: 'Total number of registered PNIDs',
async collect(): Promise<void> {
// * Aggregations are faster on large collections
const [result] = await PNID.aggregate<{ n: number } | undefined>([
{ $match: { deleted: false } },
{ $count: 'n' }
]);
this.set(result?.n ?? 0);
}
});
export const nexTokenTotalGauge = new Gauge({
name: 'pn_account_nex_token_total',
help: 'Total number of NEX tokens',
async collect(): Promise<void> {
// * Aggregations are faster on large collections
const [result] = await NEXToken.aggregate<{ n: number } | undefined>([
{ $count: 'n' }
]);
this.set(result?.n ?? 0);
}
});
export function registerMetrics(app: Express): Express {
const metrics = express();
if (config.metrics.enabled) {
LOG_INFO('Setting up metrics');
app.use(expressMetrics({
// * Include full express and nodejs metrics
includeMethod: true,
includePath: true,
urlValueParser: {
minBase64Length: 15
},
promClient: {
collectDefaultMetrics: {}
},
// * Keep metrics on a different app (so they aren't exposed)
autoregister: false,
metricsApp: metrics
}));
}
metrics.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
LOG_ERROR(`Request failed (metrics): ${format(error)}`);
res.sendStatus(500);
});
return metrics;
}
export function listenMetrics(metricsApp: Express): void {
if (!config.metrics.enabled) {
return;
}
const port = config.metrics.port;
metricsApp.listen(port, () => {
LOG_SUCCESS(`Metrics HTTP server started on port ${port}`);
});
}

View File

@@ -144,6 +144,9 @@ PNIDSchema.index({ 'pid': 1, 'username': 1, 'connections.discord.id': 1 });
PNIDSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
// * Used by metrics
PNIDSchema.index({ deleted: 1 });
/*
According to http://pf2m.com/tools/rank.php Nintendo PID's start at 1,800,000,000 and count down with each account
This means the max PID is 1799999999 and hard-limits the number of potential accounts to 1,800,000,000

View File

@@ -1,9 +1,10 @@
import { format } from 'node:util';
import express from 'express';
import morgan from 'morgan';
import xmlbuilder from 'xmlbuilder';
import xmlparser from '@/middleware/xml-parser';
import { connect as connectCache } from '@/cache';
import { checkMarkedDeletions, connect as connectDatabase } from '@/database';
import { connect as connectDatabase } from '@/database';
import { startGRPCServer } from '@/services/grpc/server';
import { fullUrl, getValueFromHeaders, setupScheduledTasks } from '@/util';
import { LOG_INFO, LOG_SUCCESS, LOG_WARN } from '@/logger';
@@ -15,8 +16,10 @@ import datastore from '@/services/datastore';
import api from '@/services/api';
import localcdn from '@/services/local-cdn';
import assets from '@/services/assets';
import healthz from '@/services/healthz';
import { config, disabledFeatures } from '@/config-manager';
import { startProvisioner } from '@/provisioning';
import { listenMetrics, registerMetrics } from '@/metrics';
process.title = 'Pretendo - Account';
process.on('uncaughtException', (err, origin) => {
@@ -29,6 +32,9 @@ process.on('SIGTERM', () => {
const app = express();
// * Metrics has to happen first so we can measure the other middleware
const metricsApp = registerMetrics(app);
// * START APPLICATION
app.set('view engine', 'ejs');
app.set('views', __dirname + '/views');
@@ -51,6 +57,7 @@ app.use(nasc);
app.use(api);
app.use(localcdn);
app.use(assets);
app.use(healthz);
if (!disabledFeatures.datastore) {
app.use(datastore);
@@ -94,7 +101,7 @@ app.use((error: any, request: express.Request, response: express.Response, _next
deviceID = 'Unknown';
}
LOG_WARN(`HTTP ${status} at ${url} from ${deviceID}: ${error.message}`);
LOG_WARN(`HTTP ${status} at ${url} from ${deviceID}: ${format(error)}`);
response.status(status).json({
app: 'api',
@@ -116,13 +123,12 @@ async function main(): Promise<void> {
startProvisioner();
await checkMarkedDeletions();
setupScheduledTasks();
app.listen(config.http.port, () => {
LOG_SUCCESS(`HTTP server started on port ${config.http.port}`);
});
listenMetrics(metricsApp);
}
main().catch(console.error);

9
src/services/healthz.ts Normal file
View File

@@ -0,0 +1,9 @@
import express from 'express';
const router = express.Router();
router.get('/healthz', (req, res) => {
res.status(200).send('OK');
});
export default router;

View File

@@ -9,6 +9,10 @@ export interface Config {
http: {
port: number;
};
metrics: {
enabled: boolean;
port: number;
};
mongoose: {
connection_string: string;
options: mongoose.ConnectOptions;

View File

@@ -9,7 +9,7 @@ import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config, disabledFeatures } from '@/config-manager';
import { PasswordResetToken } from '@/models/password-reset-token';
import { LOG_ERROR } from '@/logger';
import { LOG_ERROR, LOG_SUCCESS } from '@/logger';
import type { IncomingHttpHeaders } from 'node:http';
import type { ParsedQs } from 'qs';
import type mongoose from 'mongoose';
@@ -78,7 +78,7 @@ export function createServiceToken(server: HydratedServerDocument, options: Serv
export function fullUrl(request: express.Request): string {
const protocol = request.protocol;
const host = request.host;
const host = request.hostname;
const opath = request.originalUrl;
return `${protocol}://${host}${opath}`;
@@ -379,5 +379,5 @@ function scheduledTask(schedule: string, name: string, fn: () => void | Promise<
start: true
});
LOG_ERROR(`Added schedule ${name} for ${schedule}`);
LOG_SUCCESS(`Added schedule ${name} for ${schedule}`);
}