diff --git a/ansible/roles/tldraw-server/templates/configmap.yml.j2 b/ansible/roles/tldraw-server/templates/configmap.yml.j2 index f29a63a1..0d04f203 100644 --- a/ansible/roles/tldraw-server/templates/configmap.yml.j2 +++ b/ansible/roles/tldraw-server/templates/configmap.yml.j2 @@ -9,4 +9,5 @@ data: API_HOST: "http://api-svc:3030" WS_PATH_PREFIX: "/tldraw-server" LOG: "^(yjs|@y|server)" + FEATURE_PROMETHEUS_METRICS_ENABLED: true REDIS_SENTINEL_SERVICE_NAME: valkey-headless.{{ NAMESPACE }}.svc.cluster.local diff --git a/ansible/roles/tldraw-server/templates/deployment.yml.j2 b/ansible/roles/tldraw-server/templates/deployment.yml.j2 index 811cd9b4..82ab2955 100644 --- a/ansible/roles/tldraw-server/templates/deployment.yml.j2 +++ b/ansible/roles/tldraw-server/templates/deployment.yml.j2 @@ -49,6 +49,9 @@ spec: - containerPort: 3345 name: tldraw-ws protocol: TCP + - containerPort: 9090 + name: tldraw-server-metrics + protocol: TCP envFrom: - configMapRef: name: tldraw-server-configmap diff --git a/ansible/roles/tldraw-server/templates/pod-monitor.yml.j2 b/ansible/roles/tldraw-server/templates/pod-monitor.yml.j2 new file mode 100644 index 00000000..2d4837c2 --- /dev/null +++ b/ansible/roles/tldraw-server/templates/pod-monitor.yml.j2 @@ -0,0 +1,14 @@ +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: tldraw-server-pod-monitor + namespace: {{ NAMESPACE }} + labels: + app: tldraw-server +spec: + selector: + matchLabels: + app: tldraw-server + endpoints: + - path: /metrics + port: tldraw-server-metrics diff --git a/package-lock.json b/package-lock.json index 27e03c53..e6aa2e15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,10 @@ "name": "tldraw-server", "license": "AGPL-3.0", "dependencies": { - "@y/redis": "github:hpi-schul-cloud/y-redis#e7a2965d85a668f4f97040f18c129af6eb21287a", + "@y/redis": "github:hpi-schul-cloud/y-redis#ee6d920f77c3d30521f49506797e3d71dd026903", "ioredis": "^5.4.1", "lib0": "^0.2.93", + "prom-client": "^15.1.3", "uws": "github:uNetworking/uWebSockets.js#v20.40.0", "y-protocols": "^1.0.6" }, @@ -30,6 +31,14 @@ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -182,8 +191,9 @@ }, "node_modules/@y/redis": { "version": "0.1.6", - "resolved": "git+ssh://git@github.com/hpi-schul-cloud/y-redis.git#e7a2965d85a668f4f97040f18c129af6eb21287a", - "integrity": "sha512-k+3kCrhUGPt/g3jTtv4nP+Pkhl9Vi/T5Xic47Jnxpt2kDTEtyJpElCb7OvyNfx7BmNOmTTC6c99Ino7UB93rLA==", + "resolved": "git+ssh://git@github.com/hpi-schul-cloud/y-redis.git#ee6d920f77c3d30521f49506797e3d71dd026903", + "integrity": "sha512-DvBQqqmzppHC1QvAECyr3i04FEId0lCOCOzYNjL1SYi3r+VmbVHINIwl9GIrGHXmMPTpB7bbtTwiD29M/DyhEw==", + "license": "AGPL-3.0 OR PROPRIETARY", "dependencies": { "ioredis": "^5.4.1", "lib0": "^0.2.93", @@ -266,6 +276,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" + }, "node_modules/block-stream2": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", @@ -982,6 +997,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "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==", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -1036,9 +1063,6 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", - "workspaces": [ - "./packages/*" - ], "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.0", @@ -1184,6 +1208,14 @@ "node": ">=4" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", diff --git a/package.json b/package.json index 2eb9b107..d6514aa5 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "@y/redis": "github:hpi-schul-cloud/y-redis#e7a2965d85a668f4f97040f18c129af6eb21287a", + "@y/redis": "github:hpi-schul-cloud/y-redis#ee6d920f77c3d30521f49506797e3d71dd026903", "ioredis": "^5.4.1", "lib0": "^0.2.93", + "prom-client": "^15.1.3", "uws": "github:uNetworking/uWebSockets.js#v20.40.0", "y-protocols": "^1.0.6" }, diff --git a/src/metrics.js b/src/metrics.js new file mode 100644 index 00000000..ad355f97 --- /dev/null +++ b/src/metrics.js @@ -0,0 +1,33 @@ +import { number } from 'lib0'; +import * as env from 'lib0/environment'; +import { Gauge, register } from 'prom-client'; +import * as uws from 'uws'; + +const openConnectionsGauge = new Gauge({ + name: 'tldraw_open_connections', + help: 'Number of open WebSocket connections on tldraw-server.', +}); + +export const incOpenConnectionsGauge = () => { + openConnectionsGauge.inc(); +}; + +export const decOpenConnectionsGauge = () => { + openConnectionsGauge.dec(); +}; + +export const exposeMetricsToPrometheus = () => { + const route = env.getConf('prometheus-metrics-route') ?? '/metrics'; + const port = number.parseInt(env.getConf('prometheus-metrics-port') ?? '9090'); + + const app = uws.App({}); + + app.get(route, async (res) => { + const metrics = await register.metrics(); + res.end(metrics); + }); + + app.listen(port, () => { + console.log('Prometheus metrics exposed on port 9090'); + }); +}; diff --git a/src/server.js b/src/server.js index b062f37f..6d664182 100644 --- a/src/server.js +++ b/src/server.js @@ -5,6 +5,7 @@ import * as logging from 'lib0/logging'; import * as number from 'lib0/number'; import * as promise from 'lib0/promise'; import * as uws from 'uws'; +import { decOpenConnectionsGauge, exposeMetricsToPrometheus, incOpenConnectionsGauge } from './metrics.js'; import { redis } from './redis.js'; import { initStorage } from './storage.js'; @@ -34,7 +35,18 @@ export const createYWebsocketServer = async ({ redisPrefix = 'y', port, store }) const app = uws.App({}); - await registerYWebsocketServer(app, `${wsPathPrefix}/:room`, store, checkAuthz, { redisPrefix }, redis); + await registerYWebsocketServer( + app, + `${wsPathPrefix}/:room`, + store, + checkAuthz, + { + redisPrefix, + openWsCallback, + closeWsCallback, + }, + redis, + ); await promise.create((resolve, reject) => { app.listen(port, (token) => { @@ -94,8 +106,20 @@ const createAuthzRequestOptions = (room, token) => { return requestOptions; }; +const openWsCallback = () => { + incOpenConnectionsGauge(); +}; + +const closeWsCallback = () => { + decOpenConnectionsGauge(); +}; + const port = number.parseInt(env.getConf('port') || '3345'); const redisPrefix = env.getConf('redis-prefix') || 'y'; const store = await initStorage(); +if (env.getConf('feature-prometheus-metrics-enabled') === 'true') { + exposeMetricsToPrometheus(); +} + createYWebsocketServer({ port, store, redisPrefix });