this is draft af, i aint even linted this
This commit is contained in:
commit
a0f765131e
11 changed files with 4209 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.nyc_output/
|
||||||
|
**/.DS_Store
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
config.yaml
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apk add python3 make g++ && corepack enable
|
||||||
|
|
||||||
|
COPY package*.json pnpm*.yaml tsconfig.json ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm run build && pnpm prune --prod
|
||||||
|
|
||||||
|
FROM node:22-alpine AS final
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /build/package*.json ./
|
||||||
|
COPY --from=build /build/dist ./dist
|
||||||
|
COPY --from=build /build/node_modules ./node_modules
|
||||||
|
|
||||||
|
ENTRYPOINT [ "docker-entrypoint.sh", "node", "dist/main.js" ]
|
||||||
68
README.md
Normal file
68
README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# mtproto_exporter
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
`--watch-file`, `-w` - watch for keywords file updates and reload keywords configuration in runtime
|
||||||
|
|
||||||
|
`--include-peers`, `-i` - comma-separated list of `peer.id`s to gather metrics from.
|
||||||
|
if set, only specified peers will be exposed in metrics.
|
||||||
|
can be specified multiple times. can not be used along with `--exclude-peers`
|
||||||
|
|
||||||
|
`--exclude-peers` `x` - comma-separated list of `peer.id`s to exclude from metrics.
|
||||||
|
if set, specified peers will not be exposed in metrics.
|
||||||
|
can be specified multiple times. can not be used along with `--include-peers`
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
*generated with @mtcute/create-bot*
|
||||||
0
config.example.yaml
Normal file
0
config.example.yaml
Normal file
17
docker-compose.yaml
Normal file
17
docker-compose.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./bot-data:/app/bot-data
|
||||||
|
- ./keywords.yml:/app/keywords.yml
|
||||||
|
command:
|
||||||
|
- "--keywords-file"
|
||||||
|
- "/app/keywords.yml"
|
||||||
|
- "--watch-file"
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:9669:9669
|
||||||
20
eslint.config.js
Normal file
20
eslint.config.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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 }],
|
||||||
|
// "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",
|
||||||
|
},
|
||||||
|
});
|
||||||
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "prometheus_policy_proxy",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"packageManager": "pnpm@10.6.5",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint --fix .",
|
||||||
|
"start": "dotenv tsx ./src/main.ts --",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tinyhttp/app": "^2.5.2",
|
||||||
|
"command-line-args": "^6.0.1",
|
||||||
|
"dotenv-cli": "^8.0.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
3800
pnpm-lock.yaml
generated
Normal file
3800
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
70
src/config.ts
Normal file
70
src/config.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type { OptionDefinition } from "command-line-args";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import cmdline from "command-line-args";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export interface Configuration {
|
||||||
|
bindHost: string;
|
||||||
|
port: number;
|
||||||
|
configFile: string;
|
||||||
|
watchFile: boolean;
|
||||||
|
prometheusURL: string;
|
||||||
|
permit_query: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionDefinitions: OptionDefinition[] = [
|
||||||
|
{ name: "bind-host", alias: "b", type: String, defaultValue: "0.0.0.0" },
|
||||||
|
{ name: "port", alias: "p", type: Number, defaultValue: 9091 },
|
||||||
|
{ name: "config-file", alias: "c", type: String },
|
||||||
|
{ name: "watch-file", alias: "w", type: Boolean, defaultValue: false },
|
||||||
|
{ name: "prometheus-url", alias: "u", type: String },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cli = cmdline(optionDefinitions);
|
||||||
|
|
||||||
|
if (!cli["prometheus-url"]) {
|
||||||
|
throw new Error("--prometheus-url is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Configuration = {
|
||||||
|
bindHost: cli["bind-host"],
|
||||||
|
port: cli.port,
|
||||||
|
configFile: cli["config-file"],
|
||||||
|
watchFile: cli["watch-file"],
|
||||||
|
prometheusURL: cli["prometheus-url"],
|
||||||
|
permit_query: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadConfigFile(filePath: string): Promise<void> {
|
||||||
|
const doc = yaml.load(await readFile(filePath, "utf8")) as { permit_query?: any[] };
|
||||||
|
|
||||||
|
if (typeof doc.permit_query === "object" && doc.permit_query.constructor.name === "Array") {
|
||||||
|
for (const item of doc.permit_query) {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
config.permit_query.push(item);
|
||||||
|
// } else if (typeof item === "object") {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Config file format error: no 'permit_query' property, or not an array.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadConfigFile(config.configFile);
|
||||||
|
|
||||||
|
if (config.watchFile) {
|
||||||
|
fs.watchFile(config.configFile, async (curr, prev) => {
|
||||||
|
if (curr.mtimeMs === prev.mtimeMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[watch-file] Config file was updated. Re-reading configuration...");
|
||||||
|
try {
|
||||||
|
await loadConfigFile(config.configFile);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to read config file", config.configFile, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { config };
|
||||||
153
src/main.ts
Normal file
153
src/main.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { collectDefaultMetrics, Counter, Registry } from "prom-client";
|
||||||
|
import { App, Request, Response } from '@tinyhttp/app'
|
||||||
|
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
|
const app = new App()
|
||||||
|
const registry = new Registry();
|
||||||
|
|
||||||
|
collectDefaultMetrics({ register: registry });
|
||||||
|
|
||||||
|
let rejectedCounter = new Counter({
|
||||||
|
name: "prom_policy_requests_rejected_count",
|
||||||
|
help: "Number of rejected requests to datasource",
|
||||||
|
labelNames: ["endpoint"],
|
||||||
|
});
|
||||||
|
let badRequestCounter = new Counter({
|
||||||
|
name: "prom_policy_bad_requests_count",
|
||||||
|
help: "Number of bad requests to datasource",
|
||||||
|
labelNames: ["endpoint"],
|
||||||
|
});
|
||||||
|
let requestsServedCounter = new Counter({
|
||||||
|
name: "prom_policy_requests_served_count",
|
||||||
|
help: "Number of successfully served requests to datasource",
|
||||||
|
labelNames: ["endpoint"],
|
||||||
|
});
|
||||||
|
registry.registerMetric(rejectedCounter);
|
||||||
|
registry.registerMetric(badRequestCounter);
|
||||||
|
registry.registerMetric(requestsServedCounter);
|
||||||
|
|
||||||
|
function readParams(req: Request, params: string[]) {
|
||||||
|
let data: { [key: string]: string } = {};
|
||||||
|
|
||||||
|
if (req.headers["content-type"] === "application/x-www-form-urlencoded") {
|
||||||
|
const bodyData = new URLSearchParams(String(req.read()));
|
||||||
|
for(const p of params) {
|
||||||
|
const v = bodyData.get(p);
|
||||||
|
if(v) {
|
||||||
|
data[p] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
function first<T>(strOrArr: T | T[] | undefined) {
|
||||||
|
if(Array.isArray(strOrArr)) {
|
||||||
|
return strOrArr[0];
|
||||||
|
}
|
||||||
|
return strOrArr;
|
||||||
|
}
|
||||||
|
for(const p of params) {
|
||||||
|
const v = first(req.query[p]);
|
||||||
|
if(v) {
|
||||||
|
data[p] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateQuery(query: string, req: Request, res: Response) {
|
||||||
|
if(!query) {
|
||||||
|
badRequestCounter.inc({ endpoint: req.path });
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.json({
|
||||||
|
status: "error",
|
||||||
|
errorType: "bad_data",
|
||||||
|
error: "unknown position: parse error: no expression found in input",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!config.permit_query.includes(query)) {
|
||||||
|
rejectedCounter.inc({ endpoint: req.path });
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.json({
|
||||||
|
status: "error",
|
||||||
|
errorType: "access_denied",
|
||||||
|
error: "you are not allowed to perform this query. (bonk!)",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.all("/api/v1/query", async (req, res) => {
|
||||||
|
const data = readParams(req, ["query", "time", "timeout", "limit"]);
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
if(!validateQuery(data.query, req, res)) {
|
||||||
|
console.log("query was rejected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let promRes = await fetch(config.prometheusURL + "/api/v1/query", {
|
||||||
|
method: 'POST',
|
||||||
|
headers:{
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
let promData = await promRes.text();
|
||||||
|
console.log("prometheus response:", promData);
|
||||||
|
res.send(promData);
|
||||||
|
requestsServedCounter.inc({ endpoint: req.path });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.all("/api/v1/query_range", async (req, res) => {
|
||||||
|
const data = readParams(req, ["query", "start", "end", "step", "timeout", "limit"]);
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
if(!validateQuery(data.query, req, res)) {
|
||||||
|
console.log("query was rejected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let promRes = await fetch(config.prometheusURL + "/api/v1/query_range", {
|
||||||
|
method: 'POST',
|
||||||
|
headers:{
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
let promData = await promRes.text();
|
||||||
|
console.log("prometheus response:", promData);
|
||||||
|
res.send(promData);
|
||||||
|
requestsServedCounter.inc({ endpoint: req.path });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/series", (req, res) => {});
|
||||||
|
app.post("/api/v1/series", (req, res) => {});
|
||||||
|
|
||||||
|
app.get("/api/v1/labels", (req, res) => {});
|
||||||
|
app.post("/api/v1/labels", (req, res) => {});
|
||||||
|
|
||||||
|
app.get("/api/v1/label/<label_name>/values", (req, res) => {});
|
||||||
|
|
||||||
|
|
||||||
|
app.get('/metrics', async (req, res) => {
|
||||||
|
const metrics = await registry.metrics();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", registry.contentType);
|
||||||
|
res.send(metrics);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(
|
||||||
|
config.port,
|
||||||
|
() => console.log(`HTTP server started on ${config.bindHost}:${config.port}`),
|
||||||
|
config.bindHost,
|
||||||
|
);
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"incremental": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"allowJs": true,
|
||||||
|
"strict": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"sourceMap": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./src"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"**/node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue