diff --git a/.gitignore b/.gitignore index a85026c..e7d3aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ .vscode *.log *.tsbuildinfo -.env \ No newline at end of file +.env +keywords.yml \ No newline at end of file diff --git a/README.md b/README.md index 93bf881..68614fe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,49 @@ # mtproto_exporter -mtcute powered Telegram bot +mtcute powered Prometheus metrics exporter + +*this exporter is mostly useful only with userbot* + +## Available Metrics +`messenger_dialog_info{peerId, peerType, displayName}` + +Dialog information exposed as labels + +`messenger_dialog_messages_count{peerId}` + +Messages count since exporter startup + +`messenger_dialog_unread_messages_count{peerId}` + +Number of unread messages in dialogs + +`messenger_dialog_keywords_count{peerId}` + +Number of keywords found in messages since exporter startup + +`messenger_dialog_words_count{peerId}` + +Number of words in messages since exporter startup + +This metric is disabled by default because it will produce a lot of unique time series (more info [here](https://prometheus.io/docs/practices/naming/#labels)) + +This will expose each **word** in each **message** in each **chat** as unique metric. + +This metric can be enabled with command line flag `--words-counter` + +## CLI Options +`--bind-host`, `-b` - ip address where http server will be listening on + +`--port`, `-p` - port where http server will be listening on + +`--words-counter` - enable each word counting metric + +`--keywords-file`, `-k` - path to yaml file with keywords and patterns (see [keywords.yml.example](./keywords.yml.example)) + +## Environment Variables + +`API_ID` - Telegram api id used for mtproto connection (see [mtcute.dev](https://mtcute.dev/guide/intro/sign-in.html)) +`API_HASH` - Telegram api hash used for mtproto connection (see [mtcute.dev](https://mtcute.dev/guide/intro/sign-in.html)) ## Development diff --git a/eslint.config.js b/eslint.config.js index 1377824..57e0dc0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,19 +1,20 @@ -import antfu from '@antfu/eslint-config' +import antfu from "@antfu/eslint-config"; export default antfu({ stylistic: { indent: 4, + semi: true, + quotes: "double", }, typescript: true, yaml: false, rules: { - 'curly': ['error', 'multi-line'], - 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], - 'style/quotes': ['error', 'single', { avoidEscape: true }], - 'import/order': ['error', { 'newlines-between': 'always' }], - 'antfu/if-newline': 'off', - 'style/max-statements-per-line': ['error', { max: 2 }], - 'no-console': 'off', - 'antfu/no-top-level-await': 'off', + "curly": ["error", "multi-line"], + "style/brace-style": ["error", "1tbs", { allowSingleLine: true }], + // "import/order": ["error", { "newlines-between": "always" }], this shit breaks eslint + "antfu/if-newline": "off", + "style/max-statements-per-line": ["error", { max: 2 }], + "no-console": "off", + "antfu/no-top-level-await": "off", }, -}) +}); diff --git a/keywords.yml.example b/keywords.yml.example new file mode 100644 index 0000000..deddb34 --- /dev/null +++ b/keywords.yml.example @@ -0,0 +1,4 @@ +keywords: + - meow + - name: woof + pattern: 'w[oa]+f' diff --git a/package.json b/package.json index 9cf6cd5..253b775 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,21 @@ "scripts": { "lint": "eslint .", "lint:fix": "eslint --fix .", - "start": "dotenv tsx ./src/main.ts", + "start": "dotenv tsx ./src/main.ts --", "build": "tsc" }, "dependencies": { "@mtcute/dispatcher": "^0.22.2", "@mtcute/node": "^0.22.3", - "dotenv-cli": "^8.0.0" + "command-line-args": "^6.0.1", + "dotenv-cli": "^8.0.0", + "js-yaml": "^4.1.0", + "prom-client": "^15.1.3" }, "devDependencies": { "@antfu/eslint-config": "^4.11.0", + "@types/command-line-args": "^5.2.3", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.14.0", "tsx": "^4.19.3", "typescript": "^5.8.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ba853a..8a24b92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,24 @@ importers: '@mtcute/node': specifier: ^0.22.3 version: 0.22.3 + '@types/command-line-args': + specifier: ^5.2.3 + version: 5.2.3 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + command-line-args: + specifier: ^6.0.1 + version: 6.0.1 dotenv-cli: specifier: ^8.0.0 version: 8.0.0 + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 devDependencies: '@antfu/eslint-config': specifier: ^4.11.0 @@ -421,6 +436,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@pkgr/core@0.1.2': resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -438,6 +457,9 @@ packages: '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@types/command-line-args@5.2.3': + resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -453,6 +475,9 @@ packages: '@types/events@3.0.0': resolution: {integrity: sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -649,6 +674,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-back@6.2.2: + resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} + engines: {node: '>=12.17'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -661,6 +690,9 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -728,6 +760,15 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + command-line-args@6.0.1: + resolution: {integrity: sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==} + engines: {node: '>=12.20'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} @@ -1097,6 +1138,15 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-replace@5.0.2: + resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} + engines: {node: '>=14'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -1271,6 +1321,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -1566,6 +1619,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -1726,6 +1783,9 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -1771,6 +1831,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -2217,6 +2281,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@opentelemetry/api@1.9.0': {} + '@pkgr/core@0.1.2': {} '@pkgr/core@0.2.0': {} @@ -2238,6 +2304,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/command-line-args@5.2.3': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -2253,6 +2321,8 @@ snapshots: '@types/events@3.0.0': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -2455,6 +2525,8 @@ snapshots: argparse@2.0.1: {} + array-back@6.2.2: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -2468,6 +2540,8 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 + bintrees@1.0.2: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -2532,6 +2606,13 @@ snapshots: color-name@1.1.4: {} + command-line-args@6.0.1: + dependencies: + array-back: 6.2.2 + find-replace: 5.0.2 + lodash.camelcase: 4.3.0 + typical: 7.3.0 + comment-parser@1.4.1: {} concat-map@0.0.1: {} @@ -2989,6 +3070,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-replace@5.0.2: {} + find-up-simple@1.0.1: {} find-up@5.0.0: @@ -3132,6 +3215,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -3603,6 +3688,11 @@ snapshots: prelude-ls@1.2.1: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.0 + tdigest: 0.1.2 + pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -3768,6 +3858,10 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + tinyexec@0.3.2: {} tinyglobby@0.2.12: @@ -3808,6 +3902,8 @@ snapshots: typescript@5.8.3: {} + typical@7.3.0: {} + ufo@1.5.4: {} undici-types@6.21.0: {} diff --git a/src/env.ts b/src/env.ts index c19857f..33fed5c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,10 +1,10 @@ -import process from 'node:process' +import process from "node:process"; -const API_ID = Number.parseInt(process.env.API_ID!) -const API_HASH = process.env.API_HASH! +const API_ID = Number.parseInt(process.env.API_ID!); +const API_HASH = process.env.API_HASH!; if (Number.isNaN(API_ID) || !API_HASH) { - throw new Error('API_ID or API_HASH not set!') + throw new Error("API_ID or API_HASH not set!"); } -export { API_HASH, API_ID } +export { API_HASH, API_ID }; diff --git a/src/keywords.ts b/src/keywords.ts new file mode 100644 index 0000000..3dd3d6c --- /dev/null +++ b/src/keywords.ts @@ -0,0 +1,61 @@ +import type { Dispatcher } from "@mtcute/dispatcher"; +import { PropagationAction } from "@mtcute/dispatcher"; +import { Counter } from "prom-client"; + +interface KeywordPattern { + name: string; + pattern: RegExp; +} + +export type KeywordLike = string | KeywordPattern; + +export function newWordsCounter(dp: Dispatcher) { + const counter = new Counter({ + name: "messenger_dialog_words_count", + help: "Number of words in messages since exporter startup", + labelNames: ["peerId", "word"], + }); + dp.onNewMessage(async (msg) => { + const words = msg.text.toLowerCase().split(" "); + for (const w of words) { + counter.inc({ + peerId: msg.chat.id, + word: w, + }); + } + return PropagationAction.Continue; + }); + return counter; +} + +export function newKeywordsCounter(dp: Dispatcher, keywords: KeywordLike[]) { + const counter = new Counter({ + name: "messenger_dialog_keywords_count", + help: "Number of keywords found in messages since exporter startup", + labelNames: ["peerId", "keyword"], + }); + dp.onNewMessage(async (msg) => { + for (const kw of keywords) { + let count; + let kwname; + if (typeof kw === "string") { + const words = msg.text.toLowerCase().split(" "); + count = words.filter(w => w === kw).length; + kwname = kw; + } else { + count = (msg.text.match(kw.pattern) || []).length; + kwname = kw.name; + } + if (count === 0) { + continue; + } + counter.inc({ + peerId: msg.chat.id, + keyword: kwname, + }, count); + } + return PropagationAction.Continue; + }); + + return counter; +} diff --git a/src/main.ts b/src/main.ts index 3fc97c9..47723a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,74 @@ -import { Dispatcher, filters } from '@mtcute/dispatcher' -import { TelegramClient } from '@mtcute/node' +import type { OptionDefinition } from "command-line-args"; +import type { KeywordLike } from "./keywords.js"; +import fs from "node:fs"; +import { Dispatcher } from "@mtcute/dispatcher"; +import { TelegramClient } from "@mtcute/node"; +import cmdline from "command-line-args"; +import yaml from "js-yaml"; +import { collectDefaultMetrics, Registry } from "prom-client"; -import * as env from './env.js' +import * as env from "./env.js"; +import * as metrics from "./metrics.js"; +import MetricsServer from "./server.js"; + +const optionDefinitions: OptionDefinition[] = [ + { name: "bind-host", alias: "b", type: String, defaultValue: "0.0.0.0" }, + { name: "port", alias: "p", type: Number, defaultValue: 9669 }, + { name: "words-counter", type: Boolean, defaultValue: false }, + { name: "keywords-file", alias: "k", type: String }, +]; + +const cli = cmdline(optionDefinitions); + +const keywords: KeywordLike[] = []; +if (cli["keywords-file"]) { + if (!fs.existsSync(cli["keywords-file"])) { + throw new Error("--keywords-file set, but file does not exist."); + } + const doc = yaml.load(fs.readFileSync(cli["keywords-file"], "utf8")) as { keywords?: any[] }; + + if (doc.keywords && doc.keywords.constructor.name === "Array") { + for (const item of doc.keywords) { + if (typeof item === "string") { + keywords.push(item); + } else if (typeof item === "object" && item.name && item.pattern) { + keywords.push({ + name: item.name, + pattern: new RegExp(item.pattern, "gi"), + }); + } + } + } else { + throw new Error("Keywords file format error: no 'keywords' property, or not an array."); + } +} + +const registry = new Registry(); + +collectDefaultMetrics({ register: registry }); + +const server = new MetricsServer(registry); +server.listen(cli["bind-host"], cli.port); const tg = new TelegramClient({ apiId: env.API_ID, apiHash: env.API_HASH, - storage: 'bot-data/session', -}) + storage: "bot-data/session", +}); -const dp = Dispatcher.for(tg) +const dp = Dispatcher.for(tg); -dp.onNewMessage(filters.start, async (msg) => { - await msg.answerText('Hello, world!') -}) +registry.registerMetric(metrics.newStaticPeerInfoGauge(tg)); +registry.registerMetric(metrics.newUnreadCountGauge(tg)); +registry.registerMetric(metrics.newMessagesCounter(dp)); -const user = await tg.start() -console.log('Logged in as', user.username) +if (keywords.length > 0) { + registry.registerMetric(metrics.newKeywordsCounter(dp, keywords)); +} + +if (cli["words-counter"]) { + registry.registerMetric(metrics.newWordsCounter(dp)); +} + +const user = await tg.start(); +console.log("Logged in as", user.username); diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..d8aef5d --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,63 @@ +import type { Dispatcher } from "@mtcute/dispatcher"; +import type { TelegramClient } from "@mtcute/node"; +import { PropagationAction } from "@mtcute/dispatcher"; +import { Counter, Gauge } from "prom-client"; + +import { newKeywordsCounter, newWordsCounter } from "./keywords.js"; + +function newMessagesCounter(dp: Dispatcher) { + const counter = new Counter({ + name: "messenger_dialog_messages_count", + help: "Messages count since exporter startup", + labelNames: ["peerId"], + }); + dp.onNewMessage(async (msg) => { + counter.inc({ + peerId: msg.chat.id, + }); + return PropagationAction.Continue; + }); + return counter; +} + +function newStaticPeerInfoGauge(tg: TelegramClient) { + const gauge = new Gauge({ + name: "messenger_dialog_info", + help: "Dialog information exposed as labels", + labelNames: ["peerId", "peerType", "displayName"], + collect: async () => { + for await (const d of tg.iterDialogs()) { + gauge.set({ + peerId: d.peer.id, + peerType: d.peer.type, + displayName: d.peer.displayName, + }, 1); + } + }, + }); + return gauge; +} + +function newUnreadCountGauge(tg: TelegramClient) { + const gauge = new Gauge({ + name: "messenger_dialog_unread_messages_count", + help: "Number of unread messages in dialogs", + labelNames: ["peerId"], + collect: async () => { + for await (const d of tg.iterDialogs()) { + gauge.set({ + peerId: d.peer.id, + }, d.unreadCount); + } + }, + }); + return gauge; +} + +export { + newKeywordsCounter, + newMessagesCounter, + newStaticPeerInfoGauge, + newUnreadCountGauge, + newWordsCounter, +}; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..be652db --- /dev/null +++ b/src/server.ts @@ -0,0 +1,51 @@ +import type { Registry } from "prom-client"; +import http from "node:http"; + +export default class MetricsServer { + private _registry: Registry; + private _httpServer?: http.Server; + + public constructor(registry: Registry) { + this._registry = registry; + } + + public get registry() { + return this._registry; + } + + public listen(address: string, port: number) { + if (this._httpServer) { + throw new Error("This server is already listening"); + } + this._httpServer = http.createServer(this._requestHandler.bind(this)); + this._httpServer.listen(port, address); + console.log(`HTTP Metrics Server is listening on ${address}:${port}`); + } + + private async _requestHandler(req: http.IncomingMessage, res: http.ServerResponse) { + const url = new URL(`http://${req.headers.host ?? "localhost"}${req.url}`); + console.log(`[HTTP] ${req.method} - ${url.href} from ${req.socket.remoteAddress}:${req.socket.remotePort}`); + + if (req.method === "GET" && url.pathname === "/metrics") { + try { + const metrics = await this._registry.metrics(); + res.statusCode = 200; + res.setHeader("Content-Type", this._registry.contentType); + res.write(metrics); + } catch (e) { + console.error("Metrics collection failed:", e); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json; charset=utf-8;"); + res.write("{ \"error\":{\"message\":\"failed to collect metrics.\"}}"); + } + return res.end(); + } + + if (!res.writableEnded) { + res.statusCode = 404; + res.setHeader("Content-Type", "application/json; charset=utf-8;"); + res.write("{\"error\":{\"message\":\"not found.\"}}"); + return res.end(); + } + } +}