add statsd monitoring

wbo is now more observable
This commit is contained in:
lovasoa 2021-06-10 16:41:44 +02:00
parent 55acf500d0
commit fcc97f58b5
No known key found for this signature in database
GPG key ID: AC8DB8E033B44AB8
8 changed files with 3370 additions and 3276 deletions

View file

@ -90,3 +90,16 @@ Some important environment variables are :
## Troubleshooting ## Troubleshooting
If you experience an issue or want to propose a new feature in WBO, please [open a github issue](https://github.com/lovasoa/whitebophir/issues/new). If you experience an issue or want to propose a new feature in WBO, please [open a github issue](https://github.com/lovasoa/whitebophir/issues/new).
## Monitoring
If you are self-hosting a WBO instance, you may want to monitor its load,
the number of connected users, and different metrics.
You can start WBO with the `STATSD_URL` to send it to a statsd-compatible
metrics collection agent.
Example: `docker run -e STATSD_URL=udp://127.0.0.1:8125 lovasoa/wbo`.
- If you use **prometheus**, you can collect the metrics with [statsd-exporter](https://hub.docker.com/r/prom/statsd-exporter).
- If you use **datadog**, you can collect the metrics with [dogstatsd](https://docs.datadoghq.com/developers/dogstatsd).

6523
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,9 +11,10 @@
"accept-language-parser": "^1.5.0", "accept-language-parser": "^1.5.0",
"async-mutex": "^0.3.1", "async-mutex": "^0.3.1",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"polyfill-library": "^3.104.0", "polyfill-library": "^3.105.0",
"serve-static": "^1.14.1", "serve-static": "^1.14.1",
"socket.io": "^3.1.2" "socket.io": "^3.1.2",
"statsd-client": "^0.4.7"
}, },
"scripts": { "scripts": {
"start": "node ./server/server.js", "start": "node ./server/server.js",
@ -26,6 +27,6 @@
}, },
"devDependencies": { "devDependencies": {
"geckodriver": "^1.22.3", "geckodriver": "^1.22.3",
"nightwatch": "^1.6.3" "nightwatch": "^1.6.4"
} }
} }

View file

@ -215,7 +215,7 @@ class BoardData {
// empty board // empty board
try { try {
await fs.promises.unlink(file); await fs.promises.unlink(file);
log("removed empty board", { name: this.name }); log("removed empty board", { board: this.name });
} catch (err) { } catch (err) {
if (err.code !== "ENOENT") { if (err.code !== "ENOENT") {
// If the file already wasn't saved, this is not an error // If the file already wasn't saved, this is not an error
@ -227,12 +227,13 @@ class BoardData {
await fs.promises.writeFile(tmp_file, board_txt, { flag: "wx" }); await fs.promises.writeFile(tmp_file, board_txt, { flag: "wx" });
await fs.promises.rename(tmp_file, file); await fs.promises.rename(tmp_file, file);
log("saved board", { log("saved board", {
name: this.name, board: this.name,
size: board_txt.length, size: board_txt.length,
delay_ms: Date.now() - this.lastSaveDate, delay_ms: Date.now() - this.lastSaveDate,
}); });
} catch (err) { } catch (err) {
log("board saving error", { log("board saving error", {
board: this.name,
err: err.toString(), err: err.toString(),
tmp_file: tmp_file, tmp_file: tmp_file,
}); });

View file

@ -47,5 +47,11 @@ module.exports = {
/** Automatically switch to White-out on finger touch after drawing /** Automatically switch to White-out on finger touch after drawing
with Pencil using a stylus. Only supported on iPad with Apple Pencil. */ with Pencil using a stylus. Only supported on iPad with Apple Pencil. */
AUTO_FINGER_WHITEOUT: process.env['AUTO_FINGER_WHITEOUT'] !== "disabled", AUTO_FINGER_WHITEOUT: process.env["AUTO_FINGER_WHITEOUT"] !== "disabled",
/** If this variable is set, it should point to a statsd listener that will
* receive WBO's monitoring information.
* example: udp://127.0.0.1
*/
STATSD_URL: process.env["STATSD_URL"],
}; };

View file

@ -1,3 +1,40 @@
const config = require("./configuration.js"),
SDC = require("statsd-client");
/**
* Parse a statsd connection string
* @param {string} url
* @returns {SDC.TcpOptions|SDC.UdpOptions}
*/
function parse_statsd_url(url) {
const regex = /^(tcp|udp|statsd):\/\/(.*):(\d+)$/;
const match = url.match(regex);
if (!match)
throw new Error("Invalid statsd connection string, doesn't match " + regex);
const [_, protocol, host, port_str] = match;
const tcp = protocol !== "udp";
const port = parseInt(port_str);
return { tcp, host, port, prefix: "wbo" };
}
/**
* Statsd client to which metrics will be reported
* @type {SDC | null}
* */
let statsd = null;
if (config.STATSD_URL) {
const options = parse_statsd_url(config.STATSD_URL);
console.log("Exposing metrics on statsd server: " + JSON.stringify(options));
statsd = new SDC(options);
}
if (statsd) {
setInterval(function reportHealth(){
statsd.gauge('memory', process.memoryUsage().heapUsed);
}, 1000);
}
/** /**
* Add a message to the logs * Add a message to the logs
* @param {string} type * @param {string} type
@ -6,7 +43,33 @@
function log(type, infos) { function log(type, infos) {
var msg = type; var msg = type;
if (infos) msg += "\t" + JSON.stringify(infos); if (infos) msg += "\t" + JSON.stringify(infos);
if (statsd) {
const tags = {};
if (infos.board) tags.board = infos.board;
if (infos.original_ip) tags.original_ip = infos.original_ip;
statsd.increment(type, 1, tags);
}
console.log(msg); console.log(msg);
} }
module.exports.log = log; /**
* @template {(...args) => any} F
* @param {F} f
* @returns {F}
*/
function monitorFunction(f) {
if (statsd) return statsd.helpers.wrapCallback(f.name, f);
else return f;
}
/**
* Report a number
* @param {string} name
* @param {number} value
* @param {{[name:string]: string}=} tags
*/
function gauge(name, value, tags){
if (statsd) statsd.gauge(name, value, tags);
}
module.exports = { log, gauge, monitorFunction };

View file

@ -1,8 +1,7 @@
var app = require("http").createServer(handler), var app = require("http").createServer(handler),
sockets = require("./sockets.js"), sockets = require("./sockets.js"),
log = require("./log.js").log, {log, monitorFunction} = require("./log.js"),
path = require("path"), path = require("path"),
url = require("url"),
fs = require("fs"), fs = require("fs"),
crypto = require("crypto"), crypto = require("crypto"),
serveStatic = require("serve-static"), serveStatic = require("serve-static"),
@ -57,7 +56,7 @@ function serveError(request, response) {
*/ */
function logRequest(request) { function logRequest(request) {
log("connection", { log("connection", {
ip: request.connection.remoteAddress, ip: request.socket.remoteAddress,
original_ip: original_ip:
request.headers["x-forwarded-for"] || request.headers["forwarded"], request.headers["x-forwarded-for"] || request.headers["forwarded"],
user_agent: request.headers["user-agent"], user_agent: request.headers["user-agent"],
@ -72,7 +71,7 @@ function logRequest(request) {
*/ */
function handler(request, response) { function handler(request, response) {
try { try {
handleRequest(request, response); handleRequestAndLog(request, response);
} catch (err) { } catch (err) {
console.trace(err); console.trace(err);
response.writeHead(500, { "Content-Type": "text/plain" }); response.writeHead(500, { "Content-Type": "text/plain" });
@ -101,7 +100,7 @@ function validateBoardName(boardName) {
* @type {import('http').RequestListener} * @type {import('http').RequestListener}
*/ */
function handleRequest(request, response) { function handleRequest(request, response) {
var parsedUrl = url.parse(request.url, true); var parsedUrl = new URL(request.url, 'http://wbo/');
var parts = parsedUrl.pathname.split("/"); var parts = parsedUrl.pathname.split("/");
if (parts[0] === "") parts.shift(); if (parts[0] === "") parts.shift();
@ -110,7 +109,7 @@ function handleRequest(request, response) {
// "boards" refers to the root directory // "boards" refers to the root directory
if (parts.length === 1) { if (parts.length === 1) {
// '/boards?board=...' This allows html forms to point to boards // '/boards?board=...' This allows html forms to point to boards
var boardName = parsedUrl.query.board || "anonymous"; var boardName = parsedUrl.searchParams.get("board") || "anonymous";
var headers = { Location: "boards/" + encodeURIComponent(boardName) }; var headers = { Location: "boards/" + encodeURIComponent(boardName) };
response.writeHead(301, headers); response.writeHead(301, headers);
response.end(); response.end();
@ -218,4 +217,5 @@ function handleRequest(request, response) {
} }
} }
const handleRequestAndLog = monitorFunction(handleRequest);
module.exports = app; module.exports = app;

View file

@ -1,5 +1,5 @@
var iolib = require("socket.io"), var iolib = require("socket.io"),
log = require("./log.js").log, { log, gauge, monitorFunction } = require("./log.js"),
BoardData = require("./boardData.js").BoardData, BoardData = require("./boardData.js").BoardData,
config = require("./configuration"); config = require("./configuration");
@ -17,9 +17,10 @@ var boards = {};
* @returns {A} * @returns {A}
*/ */
function noFail(fn) { function noFail(fn) {
const monitored = monitorFunction(fn);
return function noFailWrapped(arg) { return function noFailWrapped(arg) {
try { try {
return fn(arg); return monitored(arg);
} catch (e) { } catch (e) {
console.trace(e); console.trace(e);
} }
@ -28,7 +29,7 @@ function noFail(fn) {
function startIO(app) { function startIO(app) {
io = iolib(app); io = iolib(app);
io.on("connection", noFail(socketConnection)); io.on("connection", noFail(handleSocketConnection));
return io; return io;
} }
@ -41,6 +42,7 @@ function getBoard(name) {
} else { } else {
var board = BoardData.load(name); var board = BoardData.load(name);
boards[name] = board; boards[name] = board;
gauge("boards in memory", Object.keys(boards).length);
return board; return board;
} }
} }
@ -49,7 +51,7 @@ function getBoard(name) {
* Executes on every new connection * Executes on every new connection
* @param {iolib.Socket} socket * @param {iolib.Socket} socket
*/ */
function socketConnection(socket) { function handleSocketConnection(socket) {
/** /**
* Function to call when an user joins a board * Function to call when an user joins a board
* @param {string} name * @param {string} name
@ -64,6 +66,7 @@ function socketConnection(socket) {
var board = await getBoard(name); var board = await getBoard(name);
board.users.add(socket.id); board.users.add(socket.id);
log("board joined", { board: board.name, users: board.users.size }); log("board joined", { board: board.name, users: board.users.size });
gauge("connected", board.users.size, {board: name});
return board; return board;
} }
@ -141,9 +144,11 @@ function socketConnection(socket) {
board.users.delete(socket.id); board.users.delete(socket.id);
var userCount = board.users.size; var userCount = board.users.size;
log("disconnection", { board: board.name, users: board.users.size }); log("disconnection", { board: board.name, users: board.users.size });
gauge("connected", userCount, { board: board.name });
if (userCount === 0) { if (userCount === 0) {
board.save(); board.save();
delete boards[room]; delete boards[room];
gauge("boards in memory", Object.keys(boards).length);
} }
} }
}); });