diff --git a/package.json b/package.json index 45eeb0f..76d1d49 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "main": "./dist/bot.js", "type": "module", "scripts": { - "start": "NODE_ENV=production node dist/bot.js", + "start": "NODE_ENV=production node --import ./dist/instrumentation.js dist/bot.js", "build": "tsup", - "dev:raw": "NODE_ENV=development tsx watch --clear-screen=false --env-file=.env src/bot.ts", + "dev:raw": "NODE_ENV=development tsx watch --clear-screen=false --env-file=.env --import ./src/instrumentation.ts src/bot.ts", "dev": "pnpm run dev:raw | pino-pretty", "test": "vitest", "typecheck": "tsc --noEmit", @@ -37,6 +37,20 @@ "@grammyjs/menu": "^1.3.1", "@grammyjs/parse-mode": "^1.11.1", "@grammyjs/runner": "^2.0.3", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.213.0", + "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation-pino": "^0.59.0", + "@opentelemetry/instrumentation-redis-4": "^0.49.0", + "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/sdk-logs": "^0.213.0", + "@opentelemetry/sdk-metrics": "^2.6.0", + "@opentelemetry/sdk-node": "^0.213.0", + "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/sdk-trace-node": "^2.6.0", + "@opentelemetry/semantic-conventions": "^1.40.0", "@polinetwork/backend": "^0.15.3", "@t3-oss/env-core": "^0.13.4", "@trpc/client": "^11.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9216892..da6bfcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,48 @@ importers: '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.37.0) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/exporter-logs-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pino': + specifier: ^0.59.0 + version: 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis-4': + specifier: ^0.49.0 + version: 0.49.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': + specifier: ^2.6.0 + version: 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': + specifier: ^2.6.0 + version: 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': + specifier: ^2.6.0 + version: 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': + specifier: ^2.6.0 + version: 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': + specifier: ^1.40.0 + version: 1.40.0 '@polinetwork/backend': specifier: ^0.15.3 version: 0.15.3 @@ -98,7 +140,7 @@ importers: version: 10.9.2(@types/node@22.13.1)(typescript@5.7.3) tsup: specifier: ^8.4.0 - version: 8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.7.3) + version: 8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.8.2) tsx: specifier: ^4.19.3 version: 4.19.3 @@ -107,7 +149,7 @@ importers: version: 5.7.3 vitest: specifier: ^3.1.1 - version: 3.1.1(@types/node@22.13.1)(tsx@4.19.3) + version: 3.1.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2) packages: @@ -363,6 +405,15 @@ packages: '@grammyjs/types@3.21.0': resolution: {integrity: sha512-IMj0EpmglPCICuyfGRx4ENKPSuzS2xMSoPgSPzHC6FtnWKDEmJLBP/GbPv/h3TAeb27txqxm/BUld+gbJk6ccQ==} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@ioredis/commands@1.4.0': resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} @@ -391,6 +442,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} cpu: [arm64] @@ -421,6 +475,207 @@ packages: cpu: [x64] os: [win32] + '@opentelemetry/api-logs@0.202.0': + resolution: {integrity: sha512-fTBjMqKCfotFWfLzaKyhjLvyEyq5vDKTTFfBmx21btv3gvy8Lq6N5Dh2OzqeuN4DjtpSvNT1uNVfg08eD2Rfxw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/configuration@0.213.0': + resolution: {integrity: sha512-MfVgZiUuwL1d3bPPvXcEkVHGTGNUGoqGK97lfwBuRoKttcVGGqDyxTCCVa5MGbirtBQkUTysXMBUVWPaq7zbWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.6.0': + resolution: {integrity: sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0': + resolution: {integrity: sha512-QiRZzvayEOFnenSXi85Eorgy5WTqyNQ+E7gjl6P6r+W3IUIwAIH8A9/BgMWfP056LwmdrBL6+qvnwaIEmug6Yg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.213.0': + resolution: {integrity: sha512-vqDVSpLp09ZzcFIdb7QZrEFPxUlO3GzdhBKLstq3jhYB5ow3+ZtV5V0ngSdi/0BZs+J5WPiN1+UDV4X5zD/GzA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.213.0': + resolution: {integrity: sha512-gQk41nqfK3KhDk8jbSo3LR/fQBlV7f6Q5xRcfDmL1hZlbgXQPdVFV9/rIfYUrCoq1OM+2NnKnFfGjBt6QpLSsA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0': + resolution: {integrity: sha512-Z8gYKUAU48qwm+a1tjnGv9xbE7a5lukVIwgF6Z5i3VPXPVMe4Sjra0nN3zU7m277h+V+ZpsPGZJ2Xf0OTkL7/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.213.0': + resolution: {integrity: sha512-FyV3/JfKGAgx+zJUwCHdjQHbs+YeGd2fOWvBHYrW6dmfv/w89lb8WhJTSZEoWgP525jwv/gFeBttlGu1flebdA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0': + resolution: {integrity: sha512-L8y6piP4jBIIx1Nv7/9hkx25ql6/Cro/kQrs+f9e8bPF0Ar5Dm991v7PnbtubKz6Q4fT872H56QXUWVnz/Cs4Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.213.0': + resolution: {integrity: sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.6.0': + resolution: {integrity: sha512-AFP77OQMLfw/Jzh6WT2PtrywstNjdoyT9t9lYrYdk1s4igsvnMZ8DkZKCwxsItC01D+4Lydgrb+Wy0bAvpp8xg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-http@0.213.0': + resolution: {integrity: sha512-B978Xsm5XEPGhm1P07grDoaOFLHapJPkOG9h016cJsyWWxmiLnPu2M/4Nrm7UCkHSiLnkXgC+zVGUAIahy8EEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.59.0': + resolution: {integrity: sha512-IgImVFtWjfMmqxc0NIe3iSjp+J3Asf9lLX8reouUFUk3Aa/qJQO5PEvOtO3sNQtJBkC9bAd1OQdFaFWSFQc03g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis-4@0.49.0': + resolution: {integrity: sha512-i+Wsl7M2LXEDA2yXouNJ3fttSzzb5AhlehvSBVRIFuinY51XrrKSH66biO0eox+pYQMwAlPxJ778XcMQffN78A==} + engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use "@opentelemetry/instrumentation-redis", which (as of v0.50.0) includes support for instrumenting redis v4. + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.202.0': + resolution: {integrity: sha512-Uz3BxZWPgDwgHM2+vCKEQRh0R8WKrd/q6Tus1vThRClhlPO39Dyz7mDrOr6KuqGXAlBQ1e5Tnymzri4RMZNaWA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.213.0': + resolution: {integrity: sha512-XgRGuLE9usFNlnw2lgMIM4HTwpcIyjdU/xPoJ8v3LbBLBfjaDkIugjc9HoWa7ZSJ/9Bhzgvm/aD0bGdYUFgnTw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.6.0': + resolution: {integrity: sha512-SguK4jMmRvQ0c0dxAMl6K+Eu1+01X0OP7RLiIuHFjOS8hlB23ZYNnhnbAdSQEh5xVXQmH0OAS0TnmVI+6vB2Kg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.6.0': + resolution: {integrity: sha512-KGWJuvp9X8X36bhHgIhWEnHAzXDInFr+Fvo9IQhhuu6pXLT8mF7HzFyx/X+auZUITvPaZhM39Phj3vK12MbhwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.37.0': + resolution: {integrity: sha512-tJwgE6jt32bLs/9J6jhQRKU2EZnsD8qaO13aoFyXwF6s4LhpT7YFHf3Z03MqdILk6BA2BFUhoyh7k9fj9i032A==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.213.0': + resolution: {integrity: sha512-8s7SQtY8DIAjraXFrUf0+I90SBAUQbsMWMtUGKmusswRHWXtKJx42aJQMoxEtC82Csqj+IlBH6FoP8XmmUDSrQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.6.0': + resolution: {integrity: sha512-YhswtasmsbIGEFvLGvR9p/y3PVRTfFf+mgY8van4Ygpnv4sA3vooAjvh+qAn9PNWxs4/IwGGqiQS0PPsaRJ0vQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -428,6 +683,36 @@ packages: '@polinetwork/backend@0.15.3': resolution: {integrity: sha512-W63S2omBKMQnoEWKtrHBPywJaQf2I39ZubHTBIivseEfPMcC3M3HUDlTOiGcg/TpMIIuGj9lp6dmRGtH4YhFHw==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -646,6 +931,11 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -655,6 +945,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -730,6 +1025,16 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -863,6 +1168,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -916,6 +1225,9 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -928,6 +1240,10 @@ packages: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -973,10 +1289,21 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} + ioredis@5.8.0: resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==} engines: {node: '>=12.22.0'} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1006,6 +1333,9 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -1015,6 +1345,9 @@ packages: lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} @@ -1054,6 +1387,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1132,6 +1468,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -1197,6 +1536,10 @@ packages: process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -1226,6 +1569,18 @@ packages: redis@4.7.0: resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1233,6 +1588,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + rollup@4.37.0: resolution: {integrity: sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1333,6 +1693,10 @@ packages: resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} engines: {node: '>=16'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1567,9 +1931,26 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -1730,6 +2111,18 @@ snapshots: '@grammyjs/types@3.21.0': {} + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@ioredis/commands@1.4.0': {} '@isaacs/cliui@8.0.2': @@ -1763,6 +2156,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@js-sdsl/ordered-map@4.4.2': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': optional: true @@ -1781,11 +2176,314 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@opentelemetry/api-logs@0.202.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/configuration@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + yaml: 2.8.2 + + '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-prometheus@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-zipkin@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/instrumentation-http@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis-4@0.49.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.202.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.37.0 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.202.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.202.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-grpc-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 + + '@opentelemetry/propagator-b3@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/propagator-jaeger@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/redis-common@0.37.0': {} + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-node@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/configuration': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-node@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/semantic-conventions@1.40.0': {} + '@pkgjs/parseargs@0.11.0': optional: true '@polinetwork/backend@0.15.3': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@redis/bloom@1.2.0(@redis/client@1.6.0)': dependencies: '@redis/client': 1.6.0 @@ -1922,13 +2620,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.1(vite@6.4.1(@types/node@22.13.1)(tsx@4.19.3))': + '@vitest/mocker@3.1.1(vite@6.4.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.1.1 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.4.1(@types/node@22.13.1)(tsx@4.19.3) + vite: 6.4.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2) '@vitest/pretty-format@3.1.1': dependencies: @@ -1959,12 +2657,22 @@ snapshots: dependencies: event-target-shim: 5.0.1 + acorn-import-attributes@1.9.5(acorn@8.14.0): + dependencies: + acorn: 8.14.0 + + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-walk@8.3.4: dependencies: acorn: 8.14.0 acorn@8.14.0: {} + acorn@8.16.0: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -2038,6 +2746,16 @@ snapshots: dependencies: readdirp: 4.1.2 + cjs-module-lexer@1.4.3: {} + + cjs-module-lexer@2.2.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cluster-key-slot@1.1.2: {} color-convert@2.0.1: @@ -2170,6 +2888,8 @@ snapshots: '@esbuild/win32-ia32': 0.25.1 '@esbuild/win32-x64': 0.25.1 + escalade@3.2.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.6 @@ -2212,6 +2932,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + forwarded-parse@2.1.2: {} + fsevents@2.3.3: optional: true @@ -2219,6 +2941,8 @@ snapshots: generic-pool@3.9.0: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2280,6 +3004,20 @@ snapshots: dependencies: ms: 2.1.3 + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.14.0 + acorn-import-attributes: 1.9.5(acorn@8.14.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + ioredis@5.8.0: dependencies: '@ioredis/commands': 1.4.0 @@ -2294,6 +3032,10 @@ snapshots: transitivePeerDependencies: - supports-color + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + is-fullwidth-code-point@3.0.0: {} is-what@4.1.16: {} @@ -2314,12 +3056,16 @@ snapshots: load-tsconfig@0.2.5: {} + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} lodash.sortby@4.7.0: {} + long@5.3.2: {} + loupe@3.1.3: {} lru-cache@10.4.3: {} @@ -2348,6 +3094,8 @@ snapshots: minipass@7.1.2: {} + module-details-from-path@1.0.4: {} + ms@2.1.3: {} msgpackr-extract@3.0.3: @@ -2419,6 +3167,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -2472,12 +3222,13 @@ snapshots: pirates@4.0.7: {} - postcss-load-config@6.0.1(postcss@8.5.3)(tsx@4.19.3): + postcss-load-config@6.0.1(postcss@8.5.3)(tsx@4.19.3)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.3 tsx: 4.19.3 + yaml: 2.8.2 postcss@8.5.3: dependencies: @@ -2487,6 +3238,21 @@ snapshots: process-warning@4.0.1: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.13.1 + long: 5.3.2 + pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -2515,10 +3281,33 @@ snapshots: '@redis/search': 1.2.0(@redis/client@1.6.0) '@redis/time-series': 1.1.0(@redis/client@1.6.0) + require-directory@2.1.1: {} + + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.0 + module-details-from-path: 1.0.4 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.0 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rollup@4.37.0: dependencies: '@types/estree': 1.0.6 @@ -2637,6 +3426,8 @@ snapshots: dependencies: copy-anything: 3.0.5 + supports-preserve-symlinks-flag@1.0.0: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -2699,7 +3490,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.7.3): + tsup@8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.25.1) cac: 6.7.14 @@ -2709,7 +3500,7 @@ snapshots: esbuild: 0.25.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.3)(tsx@4.19.3) + postcss-load-config: 6.0.1(postcss@8.5.3)(tsx@4.19.3)(yaml@2.8.2) resolve-from: 5.0.0 rollup: 4.37.0 source-map: 0.8.0-beta.0 @@ -2743,13 +3534,13 @@ snapshots: v8-compile-cache-lib@3.0.1: {} - vite-node@3.1.1(@types/node@22.13.1)(tsx@4.19.3): + vite-node@3.1.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@22.13.1)(tsx@4.19.3) + vite: 6.4.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -2764,7 +3555,7 @@ snapshots: - tsx - yaml - vite@6.4.1(@types/node@22.13.1)(tsx@4.19.3): + vite@6.4.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2): dependencies: esbuild: 0.25.1 fdir: 6.5.0(picomatch@4.0.3) @@ -2776,11 +3567,12 @@ snapshots: '@types/node': 22.13.1 fsevents: 2.3.3 tsx: 4.19.3 + yaml: 2.8.2 - vitest@3.1.1(@types/node@22.13.1)(tsx@4.19.3): + vitest@3.1.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2): dependencies: '@vitest/expect': 3.1.1 - '@vitest/mocker': 3.1.1(vite@6.4.1(@types/node@22.13.1)(tsx@4.19.3)) + '@vitest/mocker': 3.1.1(vite@6.4.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2)) '@vitest/pretty-format': 3.1.1 '@vitest/runner': 3.1.1 '@vitest/snapshot': 3.1.1 @@ -2796,8 +3588,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.4.1(@types/node@22.13.1)(tsx@4.19.3) - vite-node: 3.1.1(@types/node@22.13.1)(tsx@4.19.3) + vite: 6.4.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2) + vite-node: 3.1.1(@types/node@22.13.1)(tsx@4.19.3)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.13.1 @@ -2859,8 +3651,24 @@ snapshots: xmlhttprequest-ssl@2.1.2: {} + y18n@5.0.8: {} + yallist@4.0.0: {} + yaml@2.8.2: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yn@3.1.1: {} zod@4.1.11: {} diff --git a/src/backend.ts b/src/backend.ts index 83bf398..414a960 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -1,13 +1,46 @@ import { type AppRouter, TRPC_PATH } from "@polinetwork/backend" import { createTRPCClient, httpBatchLink, TRPCClientError } from "@trpc/client" import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server" +import { observable } from "@trpc/server/observable" import { SuperJSON } from "superjson" import { env } from "./env" import { logger } from "./logger" +import { BotAttributes, botMetrics } from "./telemetry" const url = `http://${env.BACKEND_URL}${TRPC_PATH}` -export const api = createTRPCClient({ links: [httpBatchLink({ url, transformer: SuperJSON })] }) +export const api = createTRPCClient({ + links: [ + // Custom link that measures tRPC call duration + () => + ({ op, next }) => { + const start = performance.now() + return observable((observer) => { + const sub = next(op).subscribe({ + next(value) { + botMetrics.trpcDuration.record(performance.now() - start, { + [BotAttributes.TRPC_PROCEDURE]: op.path, + [BotAttributes.TRPC_SUCCESS]: true, + }) + observer.next(value) + }, + error(err) { + botMetrics.trpcDuration.record(performance.now() - start, { + [BotAttributes.TRPC_PROCEDURE]: op.path, + [BotAttributes.TRPC_SUCCESS]: false, + }) + observer.error(err) + }, + complete() { + observer.complete() + }, + }) + return sub.unsubscribe + }) + }, + httpBatchLink({ url, transformer: SuperJSON }), + ], +}) export type ApiOutput = inferRouterOutputs export type ApiInput = inferRouterInputs diff --git a/src/bot.ts b/src/bot.ts index 0527610..5b2b660 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -15,9 +15,11 @@ import { checkUsername } from "./middlewares/check-username" import { GroupSpecificActions } from "./middlewares/group-specific-actions" import { messageLink } from "./middlewares/message-link" import { MessageUserStorage } from "./middlewares/message-user-storage" +import { telemetryMiddleware } from "./middlewares/telemetry" import { modules, sharedDataInit } from "./modules" import { Moderation } from "./modules/moderation" import { redis } from "./redis" +import { BotAttributes, recordException } from "./telemetry" import { once } from "./utils/once" import { setTelegramId } from "./utils/telegram-id" import type { Context, ModuleShared } from "./utils/types" @@ -63,6 +65,9 @@ bot.use( }) ) +// Telemetry: root span per update — must be first after sequentialize +bot.use(telemetryMiddleware) + bot.init().then(() => { const sharedData: ModuleShared = { api: bot.api, @@ -94,6 +99,10 @@ bot.on("message", checkUsername) bot.catch(async (err) => { const { error } = err + recordException(error, { + name: "bot.error", + attributes: { [BotAttributes.IMPORTANCE]: "high" }, + }) if (error instanceof GrammyError) { await tgLogger.exception({ type: "BOT_ERROR", error }, "bot.catch() -- middleware stack") } else if (error instanceof HttpError) { @@ -123,7 +132,10 @@ const terminate = once(async (signal: NodeJS.Signals) => { const p2 = redis.quit() const p3 = runner.isRunning() && runner.stop() const p4 = modules.stop() - await Promise.all([p1, p2, p3, p4]) + // Flush pending telemetry (set by instrumentation.ts via globalThis) + const otelShutdown = (globalThis as Record).__otelShutdown as (() => Promise) | undefined + const p5 = otelShutdown?.() ?? Promise.resolve() + await Promise.all([p1, p2, p3, p4, p5]) logger.info("Bot stopped!") process.exit(0) }) @@ -132,6 +144,10 @@ process.on("SIGINT", () => terminate("SIGINT")) process.on("SIGTERM", () => terminate("SIGTERM")) process.on("unhandledRejection", (reason: Error, promise) => { + recordException(reason, { + name: "bot.unhandled_rejection", + attributes: { [BotAttributes.IMPORTANCE]: "high" }, + }) logger.fatal({ reason, promise }, "UNHANDLED PROMISE REJECTION") void tgLogger.exception({ type: "UNHANDLED_PROMISE", error: reason, promise }) }) diff --git a/src/env.ts b/src/env.ts index 1e592f3..7d12839 100644 --- a/src/env.ts +++ b/src/env.ts @@ -10,8 +10,13 @@ export const env = createEnv({ REDIS_PORT: z.coerce.number().min(1).max(65535).default(6379), REDIS_USERNAME: z.string().min(1).optional(), REDIS_PASSWORD: z.string().min(1).optional(), + LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("debug"), NODE_ENV: z.enum(["development", "production"]).default("development"), OPENAI_API_KEY: z.string().optional(), + OTEL_EXPORTER_OTLP_ENDPOINT: z.string().default("http://localhost:4318"), + OTEL_SERVICE_NAME: z.string().default("polinetwork-telegram-bot"), + OTEL_SERVICE_VERSION: z.string().default("unknown"), + OTEL_STORAGE_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(0.1), }, runtimeEnv: process.env, diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..70fd3fb --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,97 @@ +import type { Attributes } from "@opentelemetry/api" +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto" +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto" +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto" +import { HttpInstrumentation, type HttpInstrumentationConfig } from "@opentelemetry/instrumentation-http" +import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino" +import { RedisInstrumentation } from "@opentelemetry/instrumentation-redis-4" +import { resourceFromAttributes } from "@opentelemetry/resources" +import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs" +import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics" +import { NodeSDK } from "@opentelemetry/sdk-node" +import { + ParentBasedSampler, + type Sampler, + SamplingDecision, + type SamplingResult, + TraceIdRatioBasedSampler, +} from "@opentelemetry/sdk-trace-node" +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, +} from "@opentelemetry/semantic-conventions" +import { env } from "./env" + +const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT +const serviceName = env.OTEL_SERVICE_NAME +const serviceVersion = env.OTEL_SERVICE_VERSION +const storageRate = env.OTEL_STORAGE_SAMPLE_RATE +const nodeEnv = env.NODE_ENV + +function shouldIgnoreOutgoingHttpRequest( + request: Parameters>[0] +) { + if (typeof request.path === "string") return request.path.endsWith("/getUpdates") + return false +} + +/** + * Custom sampler that always traces high-importance spans (commands, automod) + * and samples storage/caching operations at a configurable rate. + */ +class BotSampler implements Sampler { + private ratioSampler = new TraceIdRatioBasedSampler(storageRate) + + shouldSample( + context: Parameters[0], + traceId: string, + _spanName: string, + _spanKind: Parameters[3], + attributes: Attributes + ): SamplingResult { + const importance = attributes["bot.importance"] as string | undefined + + if (importance === "high") { + return { decision: SamplingDecision.RECORD_AND_SAMPLED } + } + + if (importance === "low") { + return this.ratioSampler.shouldSample(context, traceId) + } + + // Default: always sample (covers auto-instrumented HTTP, Redis, etc.) + return { decision: SamplingDecision.RECORD_AND_SAMPLED } + } + + toString(): string { + return `BotSampler{storageRate=${storageRate}}` + } +} + +const sdk = new NodeSDK({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: serviceName, + [ATTR_SERVICE_VERSION]: serviceVersion, + [SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: nodeEnv, + }), + sampler: new ParentBasedSampler({ root: new BotSampler() }), + traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }), + }), + logRecordProcessor: new BatchLogRecordProcessor(new OTLPLogExporter({ url: `${endpoint}/v1/logs` })), + instrumentations: [ + new HttpInstrumentation({ + ignoreOutgoingRequestHook: shouldIgnoreOutgoingHttpRequest, + }), + new RedisInstrumentation(), + new PinoInstrumentation(), + ], +}) + +sdk.start() + +// Expose shutdown via globalThis so the app can flush telemetry on exit +// without importing this file (which would cause tsup to bundle it twice). +;(globalThis as Record).__otelShutdown = () => sdk.shutdown() diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index a2caaf2..dd43997 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -12,6 +12,7 @@ import type { ChatMember, Message } from "grammy/types" import type { Result } from "neverthrow" import { err, ok } from "neverthrow" import type { LogFn } from "pino" +import { BotAttributes, botMetrics, withSpan } from "@/telemetry" import { fmt } from "@/utils/format" import { wait } from "@/utils/wait" import type { @@ -375,6 +376,10 @@ export class ManagedCommands< if (cmd.permissions) { const allowed = await this.permissionHandler({ command: cmd, context: ctx }) if (!allowed) { + botMetrics.commandsCount.add(1, { + [BotAttributes.COMMAND_NAME]: cmd.trigger, + [BotAttributes.COMMAND_PERMITTED]: false, + }) this.logger.info( { command_permissions: cmd.permissions }, `[ManagedCommands] command '/${cmd.trigger}' invoked by ${this.printUsername(ctx)} without permissions` @@ -387,8 +392,26 @@ export class ManagedCommands< } } + botMetrics.commandsCount.add(1, { + [BotAttributes.COMMAND_NAME]: cmd.trigger, + [BotAttributes.COMMAND_PERMITTED]: true, + }) + // enter the conversation that handles the command execution - await ctx.conversation.enter(cmd.trigger) + await withSpan( + `bot.command.${cmd.trigger}`, + { + [BotAttributes.IMPORTANCE]: "high", + [BotAttributes.COMMAND_NAME]: cmd.trigger, + [BotAttributes.COMMAND_SCOPE]: cmd.scope ?? "both", + [BotAttributes.CHAT_ID]: ctx.chat.id, + [BotAttributes.USER_ID]: ctx.from?.id ?? 0, + [BotAttributes.USERNAME]: ctx.from?.username ?? "unknown", + }, + async () => { + await ctx.conversation.enter(cmd.trigger) + } + ) }) return this } diff --git a/src/logger.ts b/src/logger.ts index 18bc9df..5901799 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,8 +1,17 @@ +import { createEnv } from "@t3-oss/env-core" import pino from "pino" +import { z } from "zod/v4" + +const loggerEnv = createEnv({ + server: { + LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("debug"), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, +}) export const logger = pino({ - // the reason why we use process.env instead of @/env is that - // we want the logger to be working also in tests where we do not have - // environment variables set. If we used @/env it would throw an error - level: process.env.LOG_LEVEL || "debug", + // Keep logger bootstrap independent from the main app env module: + // tests may import the logger without having the full runtime env set. + level: loggerEnv.LOG_LEVEL, }) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index e3bde46..32adb81 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -7,6 +7,7 @@ import { logger } from "@/logger" import { modules } from "@/modules" import { Moderation } from "@/modules/moderation" import { redis } from "@/redis" +import { BotAttributes, botMetrics, recordException, withSpan } from "@/telemetry" import { defer } from "@/utils/deferred-middleware" import { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" @@ -107,6 +108,7 @@ export class AutoModerationStack implements MiddlewareObj const grant = await api.tg.grants.checkUser.query({ userId: ctx.from.id }) if (grant.isGranted) return { role: "user" } } catch (e) { + recordException(e) debouncedError(e, "Error checking whitelist status in auto-moderation") } @@ -133,41 +135,63 @@ export class AutoModerationStack implements MiddlewareObj const allowed = await checkForAllowedLinks(links) if (allowed) return - if (ctx.whitelisted) { - // no mod action - if (ctx.whitelisted.role === "user") { - // log the grant usage - await modules.get("tgLogger").grants({ - action: "USAGE", - from: ctx.from, - chat: ctx.chat, - message, + await withSpan( + "bot.automod.link_check", + { + [BotAttributes.IMPORTANCE]: "high", + [BotAttributes.AUTOMOD_CHECK]: "link", + [BotAttributes.CHAT_ID]: ctx.chat.id, + [BotAttributes.USER_ID]: ctx.from.id, + }, + async (span) => { + if (ctx.whitelisted) { + // no mod action + span.setAttribute(BotAttributes.AUTOMOD_RESULT, "skip") + span.setAttribute(BotAttributes.AUTOMOD_REASON, "whitelist_bypass") + if (ctx.whitelisted.role === "user") { + // log the grant usage + await modules.get("tgLogger").grants({ + action: "USAGE", + from: ctx.from, + chat: ctx.chat, + message, + }) + } + return + } + + span.setAttribute(BotAttributes.AUTOMOD_RESULT, "moderate") + span.setAttribute(BotAttributes.AUTOMOD_ACTION, "mute") + botMetrics.automodActions.add(1, { + [BotAttributes.AUTOMOD_CHECK]: "link", + [BotAttributes.AUTOMOD_ACTION]: "mute", }) - } - return - } - const res = await Moderation.mute( - ctx.from, - ctx.chat, - ctx.me, - duration.zod.parse("1m"), // 1 minute - [message], - "Shared link not allowed" - ) + const res = await Moderation.mute( + ctx.from, + ctx.chat, + ctx.me, + duration.zod.parse("1m"), + [message], + "Shared link not allowed" + ) + if (res.isErr()) { + recordException(new Error(`Link automod mute failed: ${res.error.fmtError}`)) + } - const msg = await ctx.reply( - res.isOk() - ? fmt(({ b }) => [ - b`${fmtUser(ctx.from)}`, - "The link you shared is not allowed.", - "Please refrain from sharing links that could be considered spam", - ]) - : res.error.fmtError + const msg = await ctx.reply( + res.isOk() + ? fmt(({ b }) => [ + b`${fmtUser(ctx.from)}`, + "The link you shared is not allowed.", + "Please refrain from sharing links that could be considered spam", + ]) + : res.error.fmtError + ) + await wait(5000) + await msg.delete() + } ) - await wait(5000) - await msg.delete() - return } /** @@ -177,22 +201,42 @@ export class AutoModerationStack implements MiddlewareObj private async harmfulContentHandler(ctx: Filter, "message">) { const message = ctx.message const flaggedCategories = await this.aiModeration.checkForHarmfulContent(ctx) + if (flaggedCategories.length === 0) return - if (flaggedCategories.length > 0) { - const reasons = flaggedCategories.map((cat) => ` - ${cat.category} (${(cat.score * 100).toFixed(1)}%)`).join("\n") + const reasons = flaggedCategories.map((cat) => ` - ${cat.category} (${(cat.score * 100).toFixed(1)}%)`).join("\n") + + await withSpan( + "bot.automod.harmful_content", + { + [BotAttributes.IMPORTANCE]: "high", + [BotAttributes.AUTOMOD_CHECK]: "harmful_content", + [BotAttributes.CHAT_ID]: ctx.chat.id, + [BotAttributes.USER_ID]: ctx.from.id, + }, + async (span) => { + if (flaggedCategories.some((cat) => cat.aboveThreshold)) { + if (ctx.whitelisted) { + // log the action but do not mute + span.setAttribute(BotAttributes.AUTOMOD_RESULT, "skip") + span.setAttribute(BotAttributes.AUTOMOD_REASON, "whitelist_bypass") + if (ctx.whitelisted.role === "user") + await modules.get("tgLogger").grants({ + action: "USAGE", + from: ctx.from, + chat: ctx.chat, + message, + }) + return + } - if (flaggedCategories.some((cat) => cat.aboveThreshold)) { - if (ctx.whitelisted) { - // log the action but do not mute - if (ctx.whitelisted.role === "user") - await modules.get("tgLogger").grants({ - action: "USAGE", - from: ctx.from, - chat: ctx.chat, - message, - }) - } else { // above threshold, mute user and delete the message + span.setAttribute(BotAttributes.AUTOMOD_RESULT, "moderate") + span.setAttribute(BotAttributes.AUTOMOD_ACTION, "mute") + botMetrics.automodActions.add(1, { + [BotAttributes.AUTOMOD_CHECK]: "harmful_content", + [BotAttributes.AUTOMOD_ACTION]: "mute", + }) + const res = await Moderation.mute( ctx.from, ctx.chat, @@ -201,6 +245,9 @@ export class AutoModerationStack implements MiddlewareObj [message], `Automatic moderation detected harmful content\n${reasons}` ) + if (res.isErr()) { + recordException(new Error(`Harmful-content automod mute failed: ${res.error.fmtError}`)) + } const msg = await ctx.reply( res.isOk() @@ -212,9 +259,12 @@ export class AutoModerationStack implements MiddlewareObj ) await wait(5000) await msg.delete() + return } - } else { + // no flagged category is above the threshold, still log it for manual review + span.setAttribute(BotAttributes.AUTOMOD_RESULT, "observe") + span.setAttribute(BotAttributes.AUTOMOD_REASON, "below_threshold") await modules.get("tgLogger").moderationAction({ action: "SILENT", from: ctx.me, @@ -223,7 +273,7 @@ export class AutoModerationStack implements MiddlewareObj reason: `Message flagged for moderation: \n${reasons}`, }) } - } + ) } /** @@ -233,30 +283,48 @@ export class AutoModerationStack implements MiddlewareObj private async nonLatinHandler(ctx: Filter, "message:text" | "message:caption">) { const text = ctx.message.caption ?? ctx.message.text const match = text.match(NON_LATIN.REGEX) - // 1. there are non latin characters // 2. there are more than LENGTH_THR non-latin characters // 3. the percentage of non-latin characters after the LENGTH_THR is more than PERCENTAGE_THR // that should catch messages respecting this inequality: 0.2y + 8 < x ≤ y // with x = number of non-latin characters, y = total length of the message // longer messages can have more non-latin characters, but less in percentage - if (match && (match.length - NON_LATIN.LENGTH_THR) / text.length > NON_LATIN.PERCENTAGE_THR) { - // just delete the message and mute the user for 10 minutes - const res = await Moderation.mute( - ctx.from, - ctx.chat, - ctx.me, - duration.zod.parse(NON_LATIN.MUTE_DURATION), - [ctx.message], - "Message contains non-latin characters" - ) - if (res.isErr()) { - logger.error( - { from: ctx.from, chat: ctx.chat, messageId: ctx.message.message_id }, - "AUTOMOD: nonLatinHandler - Cannot mute" + if (!(match && (match.length - NON_LATIN.LENGTH_THR) / text.length > NON_LATIN.PERCENTAGE_THR)) return + + await withSpan( + "bot.automod.non_latin", + { + [BotAttributes.IMPORTANCE]: "high", + [BotAttributes.AUTOMOD_CHECK]: "non_latin", + [BotAttributes.CHAT_ID]: ctx.chat.id, + [BotAttributes.USER_ID]: ctx.from.id, + }, + async (span) => { + // just delete the message and mute the user for 10 minutes + span.setAttribute(BotAttributes.AUTOMOD_RESULT, "moderate") + span.setAttribute(BotAttributes.AUTOMOD_ACTION, "mute") + botMetrics.automodActions.add(1, { + [BotAttributes.AUTOMOD_CHECK]: "non_latin", + [BotAttributes.AUTOMOD_ACTION]: "mute", + }) + + const res = await Moderation.mute( + ctx.from, + ctx.chat, + ctx.me, + duration.zod.parse(NON_LATIN.MUTE_DURATION), + [ctx.message], + "Message contains non-latin characters" ) + if (res.isErr()) { + recordException(new Error("Non-latin automod mute failed")) + logger.error( + { from: ctx.from, chat: ctx.chat, messageId: ctx.message.message_id }, + "AUTOMOD: nonLatinHandler - Cannot mute" + ) + } } - } + ) } /** @@ -267,41 +335,58 @@ export class AutoModerationStack implements MiddlewareObj const { text } = getText(ctx.message) if (text === null) return if (text.length < MULTI_CHAT_SPAM.LENGTH_THR) return // skip because too short + const key = `moderation:multichatspam:${ctx.from.id}` // the key is unique for each user const hash = ssdeep.digest(text) // hash to compute message similarity const res = await redis.rPush(key, `${hash}|${ctx.chat.id}|${ctx.message.message_id}`) // push the message data to the redis list await redis.expire(key, MULTI_CHAT_SPAM.EXPIRY) // seconds expiry, refreshed with each message + if (res < 3) return // triggered when more than 3 messages have been sent within EXPIRY seconds of each other - if (res >= 3) { - const range = await redis.lRange(key, 0, -2) // get all but the last - const similarMessages: Message[] = await Promise.all( - range - .map((r) => r.split("|")) - .map(([hash, chatId, messageId]) => ({ - hash, - chatId: Number(chatId), - messageId: Number(messageId), - })) - .filter((v) => ssdeep.similarity(v.hash, hash) > MULTI_CHAT_SPAM.SIMILARITY_THR) - .map(async (v) => { - const msg = await MessageUserStorage.getInstance().get(v.chatId, v.messageId) - const message = createFakeMessage(v.chatId, v.messageId, ctx.from, msg?.timestamp) - return message - }) - ) - - if (similarMessages.length === 0) return - similarMessages.push(ctx.message) + const range = await redis.lRange(key, 0, -2) + const similarMessages: Message[] = await Promise.all( + range + .map((r) => r.split("|")) + .map(([hash, chatId, messageId]) => ({ + hash, + chatId: Number(chatId), + messageId: Number(messageId), + })) + .filter((v) => ssdeep.similarity(v.hash, hash) > MULTI_CHAT_SPAM.SIMILARITY_THR) + .map(async (v) => { + const msg = await MessageUserStorage.getInstance().get(v.chatId, v.messageId) + const message = createFakeMessage(v.chatId, v.messageId, ctx.from, msg?.timestamp) + return message + }) + ) + if (similarMessages.length === 0) return + similarMessages.push(ctx.message) - const muteDuration = duration.zod.parse(MULTI_CHAT_SPAM.MUTE_DURATION) + await withSpan( + "bot.automod.multichat_spam", + { + [BotAttributes.IMPORTANCE]: "high", + [BotAttributes.AUTOMOD_CHECK]: "multichat_spam", + [BotAttributes.CHAT_ID]: ctx.chat.id, + [BotAttributes.USER_ID]: ctx.from.id, + }, + async (span) => { + span.setAttribute(BotAttributes.AUTOMOD_RESULT, "moderate") + span.setAttribute(BotAttributes.AUTOMOD_ACTION, "mute") + botMetrics.automodActions.add(1, { + [BotAttributes.AUTOMOD_CHECK]: "multichat_spam", + [BotAttributes.AUTOMOD_ACTION]: "mute", + }) - const res = await Moderation.multiChatSpam(ctx.from, similarMessages, muteDuration) + const muteDuration = duration.zod.parse(MULTI_CHAT_SPAM.MUTE_DURATION) + const res = await Moderation.multiChatSpam(ctx.from, similarMessages, muteDuration) - if (res.isErr()) { - logger.error({ error: res.error }, "Cannot execute moderation action for MULTI_CHAT_SPAM") + if (res.isErr()) { + recordException(new Error("Multichat-spam automod action failed")) + logger.error({ error: res.error }, "Cannot execute moderation action for MULTI_CHAT_SPAM") + } } - } + ) } middleware() { diff --git a/src/middlewares/message-user-storage.ts b/src/middlewares/message-user-storage.ts index 7aafc84..54f0d23 100644 --- a/src/middlewares/message-user-storage.ts +++ b/src/middlewares/message-user-storage.ts @@ -3,6 +3,7 @@ import { Composer, type Context, type MiddlewareObj } from "grammy" import type { User } from "grammy/types" import { type ApiInput, api } from "@/backend" import { logger } from "@/logger" +import { BotAttributes, botMetrics, recordException, withSpan } from "@/telemetry" import { padChatId } from "@/utils/chat" import { toGrammyUser } from "@/utils/types" @@ -38,6 +39,7 @@ export class MessageUserStorage implements MiddlewareObj { message: text, timestamp: new Date(ctx.message.date * 1000), }) + botMetrics.storageBufferSize.add(1) this.userStorage.set(ctx.from.id, ctx.from) return next() @@ -53,6 +55,7 @@ export class MessageUserStorage implements MiddlewareObj { if (!error) return dbMsg if (error === "DECRYPT_ERROR") { + recordException(new Error(`Failed to decrypt message ${messageId} in chat ${chatId}`)) logger.error( `messageLink: there was an error in the backend while decrypting the message ${messageId} in chat ${chatId}` ) @@ -64,21 +67,45 @@ export class MessageUserStorage implements MiddlewareObj { } async sync(): Promise { - await Promise.all([this.syncMessages(), this.syncUsers()]) + await withSpan( + "bot.storage.sync", + { + [BotAttributes.IMPORTANCE]: "low", + [BotAttributes.STORAGE_OPERATION]: "sync", + }, + async (span) => { + span.setAttribute(BotAttributes.STORAGE_COUNT, this.memoryStorage.length + this.userStorage.size) + await Promise.all([this.syncMessages(), this.syncUsers()]) + } + ) } private async syncMessages(): Promise { if (this.memoryStorage.length === 0) return - const { error } = await api.tg.messages.add.mutate({ messages: this.memoryStorage }) - if (error) { - logger.error( - "memoryStorage: There was an error while encrypting messages in the backend, cannot save messages in table" - ) - return - } - - logger.debug(`memoryStorage: ${this.memoryStorage.length} messages written to the database`) - this.memoryStorage = [] + const count = this.memoryStorage.length + + await withSpan( + "bot.storage.message_sync", + { + [BotAttributes.IMPORTANCE]: "low", + [BotAttributes.STORAGE_OPERATION]: "message_sync", + [BotAttributes.STORAGE_COUNT]: count, + }, + async () => { + const { error } = await api.tg.messages.add.mutate({ messages: this.memoryStorage }) + if (error) { + recordException(new Error(`Failed to store ${count} messages: ${error}`)) + logger.error( + "memoryStorage: There was an error while encrypting messages in the backend, cannot save messages in table" + ) + return + } + + logger.debug(`memoryStorage: ${count} messages written to the database`) + botMetrics.storageBufferSize.add(-count) + this.memoryStorage = [] + } + ) } public async getStoredUser(userId: number): Promise { @@ -89,9 +116,12 @@ export class MessageUserStorage implements MiddlewareObj { const fromBackend = await api.tg.users.get.query({ userId }) if (fromBackend.user) return toGrammyUser(fromBackend.user) - if (fromBackend.error !== "NOT_FOUND") + if (fromBackend.error !== "NOT_FOUND") { + recordException(new Error(`Failed to retrieve stored user ${userId}: ${fromBackend.error}`)) logger.error({ error: fromBackend.error }, "userStorage: error from API while retrieving user from backend") + } } catch (error) { + recordException(error) logger.error({ error }, "userStorage: error while calling API for retrieving user from backend") } return null @@ -111,18 +141,31 @@ export class MessageUserStorage implements MiddlewareObj { langCode: u.language_code, })) + const count = users.length this.userStorage.clear() - const { error } = await api.tg.users.add.mutate({ users }) - if (error === "ENCRYPT_ERROR") { - logger.error("userStorage: There was an error while encrypting users in the backend, users voided") - return - } else if (error === "INTERNAL_SERVER_ERROR") { - logger.error("userStorage: There was an UNEXPECTED error while saving users in backend, users voided") - return - } - - logger.debug(`userStorage: ${users.length} users upserted in the database`) + await withSpan( + "bot.storage.user_sync", + { + [BotAttributes.IMPORTANCE]: "low", + [BotAttributes.STORAGE_OPERATION]: "user_sync", + [BotAttributes.STORAGE_COUNT]: count, + }, + async () => { + const { error } = await api.tg.users.add.mutate({ users }) + if (error === "ENCRYPT_ERROR") { + recordException(new Error(`Failed to store ${count} users: ${error}`)) + logger.error("userStorage: There was an error while encrypting users in the backend, users voided") + return + } else if (error === "INTERNAL_SERVER_ERROR") { + recordException(new Error(`Failed to store ${count} users: ${error}`)) + logger.error("userStorage: There was an UNEXPECTED error while saving users in backend, users voided") + return + } + + logger.debug(`userStorage: ${count} users upserted in the database`) + } + ) } middleware() { diff --git a/src/middlewares/telemetry.ts b/src/middlewares/telemetry.ts new file mode 100644 index 0000000..9ecdcb1 --- /dev/null +++ b/src/middlewares/telemetry.ts @@ -0,0 +1,24 @@ +import type { MiddlewareFn } from "grammy" +import { BotAttributes, botMetrics } from "@/telemetry" +import type { Context } from "@/utils/types" + +function getUpdateType(update: Context["update"]): string { + if ("message" in update && update.message) return "message" + if ("edited_message" in update && update.edited_message) return "edited_message" + if ("callback_query" in update && update.callback_query) return "callback_query" + if ("inline_query" in update && update.inline_query) return "inline_query" + if ("my_chat_member" in update && update.my_chat_member) return "my_chat_member" + if ("chat_member" in update && update.chat_member) return "chat_member" + if ("message_reaction" in update && update.message_reaction) return "message_reaction" + if ("poll" in update && update.poll) return "poll" + return "unknown" +} + +/** Tracks incoming update volume without tracing low-value no-op updates. */ +export const telemetryMiddleware: MiddlewareFn = async (ctx, next) => { + const updateType = getUpdateType(ctx.update) + + botMetrics.updatesCount.add(1, { [BotAttributes.UPDATE_TYPE]: updateType }) + + await next() +} diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index a2819bd..7805169 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -1,8 +1,10 @@ +import type { Span } from "@opentelemetry/api" import { Composer, type Context, type MiddlewareObj } from "grammy" import type { Chat, ChatMember, Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import { type ApiInput, api } from "@/backend" import { logger } from "@/logger" +import { BotAttributes, recordException, withSpan } from "@/telemetry" import { groupMessagesByChat, RestrictPermissions } from "@/utils/chat" import { type Duration, duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" @@ -84,10 +86,55 @@ class ModerationClass implements MiddlewareObj { moderationAction.duration = duration.fromUntilDate(new_chat_member.until_date) } - await this.post(moderationAction, null) + await withSpan("bot.moderation.action", this.actionModerationAttributes(moderationAction), async (span) => { + span.addEvent("moderation.detected", { + source: "telegram_ui", + }) + await this.post(moderationAction, null, span) + span.setAttribute(BotAttributes.MODERATION_RESULT, "logged") + }) }) } + private actionModerationAttributes(p: ModerationAction) { + const attributes: Record = { + [BotAttributes.IMPORTANCE]: "high", + [BotAttributes.MODERATION_ACTION]: p.action, + [BotAttributes.CHAT_ID]: p.chat.id, + [BotAttributes.MODERATION_MODERATOR_ID]: p.from.id, + [BotAttributes.MODERATION_TARGET_ID]: p.target.id, + } + if ("reason" in p && p.reason) attributes[BotAttributes.MODERATION_REASON] = p.reason + if (p.action === "MULTI_CHAT_SPAM") attributes[BotAttributes.MESSAGE_COUNT] = p.messages.length + return attributes + } + + private deleteModerationAttributes(messages: Message[], executor: User, reason: string) { + const chatIds = new Set(messages.map((message) => message.chat.id)) + const targetIds = new Set(messages.flatMap((message) => (message.from ? [message.from.id] : []))) + const attributes: Record = { + [BotAttributes.IMPORTANCE]: "high", + [BotAttributes.MODERATION_ACTION]: "DELETE", + [BotAttributes.MODERATION_MODERATOR_ID]: executor.id, + [BotAttributes.MODERATION_REASON]: reason, + [BotAttributes.MESSAGE_COUNT]: messages.length, + [BotAttributes.MODERATION_CHAT_COUNT]: chatIds.size, + [BotAttributes.MODERATION_TARGET_COUNT]: targetIds.size, + } + + if (chatIds.size === 1) { + const [chatId] = chatIds + if (chatId !== undefined) attributes[BotAttributes.CHAT_ID] = chatId + } + + if (targetIds.size === 1) { + const [targetId] = targetIds + if (targetId !== undefined) attributes[BotAttributes.MODERATION_TARGET_ID] = targetId + } + + return attributes + } + private getModerationError(p: ModerationAction, code: ModerationErrorCode): ModerationError { // biome-ignore lint/nursery/noUnnecessaryConditions: lying switch (code) { @@ -152,25 +199,40 @@ class ModerationClass implements MiddlewareObj { .banChatMember(p.chat.id, p.target.id, { until_date: Date.now() / 1000 + duration.values.m, }) - .catch(() => false) + .catch((error) => { + recordException(error) + return false + }) case "BAN": return modules.shared.api .banChatMember(p.chat.id, p.target.id, { until_date: p.duration?.timestamp_s, }) - .catch(() => false) + .catch((error) => { + recordException(error) + return false + }) case "UNBAN": - return modules.shared.api.unbanChatMember(p.chat.id, p.target.id).catch(() => false) + return modules.shared.api.unbanChatMember(p.chat.id, p.target.id).catch((error) => { + recordException(error) + return false + }) case "MUTE": return modules.shared.api .restrictChatMember(p.chat.id, p.target.id, RestrictPermissions.mute, { until_date: p.duration?.timestamp_s, }) - .catch(() => false) + .catch((error) => { + recordException(error) + return false + }) case "UNMUTE": return modules.shared.api .restrictChatMember(p.chat.id, p.target.id, RestrictPermissions.unmute) - .catch(() => false) + .catch((error) => { + recordException(error) + return false + }) case "MULTI_CHAT_SPAM": return Promise.all( groupMessagesByChat(p.messages) @@ -180,21 +242,36 @@ class ModerationClass implements MiddlewareObj { .restrictChatMember(chatId, p.target.id, RestrictPermissions.mute, { until_date: p.duration.timestamp_s, }) - .catch(() => false) + .catch((error) => { + recordException(error) + return false + }) ) ).then((res) => res.every((r) => r)) } } - private async post(p: ModerationAction, preDeleteRes: PreDeleteResult | null) { - // TODO: handle errors? - await Promise.allSettled([ + private async post(p: ModerationAction, preDeleteRes: PreDeleteResult | null, span: Span) { + const results = await Promise.allSettled([ modules.get("tgLogger").moderationAction({ ...p, preDeleteRes: preDeleteRes, }), this.audit(p), ]) + + const rejected = results.filter((result) => result.status === "rejected") + if (rejected.length > 0) { + span.addEvent("moderation.post_failed", { + rejected_count: rejected.length, + }) + for (const result of rejected) { + recordException(result.reason) + } + return + } + + span.addEvent("moderation.post_logged") } public async deleteMessages( @@ -203,60 +280,117 @@ class ModerationClass implements MiddlewareObj { reason: string ): Promise> { if (messages.length === 0) return ok(null) + return await withSpan( + "bot.moderation.delete", + this.deleteModerationAttributes(messages, executor, reason), + async (span) => { + const tgLogger = modules.get("tgLogger") + const preRes = await tgLogger.preDelete(messages, reason, executor) + if (preRes === null || preRes.count === 0) { + span.setAttribute(BotAttributes.MODERATION_RESULT, "not_found") + span.addEvent("moderation.delete_not_found") + return err("NOT_FOUND") + } - const tgLogger = modules.get("tgLogger") - const preRes = await tgLogger.preDelete(messages, reason, executor) - if (preRes === null || preRes.count === 0) return err("NOT_FOUND") - - let delCount = 0 - for (const [chatId, mIds] of groupMessagesByChat(messages)) { - const delOk = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) - if (delOk) delCount += mIds.length - } + let delCount = 0 + for (const [chatId, mIds] of groupMessagesByChat(messages)) { + const delOk = await modules.shared.api.deleteMessages(chatId, mIds).catch((error) => { + recordException(error) + return false + }) + if (delOk) delCount += mIds.length + } - if (delCount === 0) { - logger.error( - { initialMessages: messages, executor, forwardedCount: preRes.count, deletedCount: 0 }, - "[Moderation:deleteMessages] no message(s) could be deleted" - ) - void modules.shared.api.deleteMessages(tgLogger.groupId, preRes.logMessageIds) - return err("DELETE_ERROR") - } + if (delCount === 0) { + recordException(new Error("[Moderation:deleteMessages] no message(s) could be deleted")) + span.setAttribute(BotAttributes.MODERATION_RESULT, "failed") + span.setAttribute(BotAttributes.MODERATION_ERROR_CODE, "DELETE_ERROR") + span.addEvent("moderation.delete_failed", { + forwarded_count: preRes.count, + deleted_count: delCount, + }) + logger.error( + { initialMessages: messages, executor, forwardedCount: preRes.count, deletedCount: 0 }, + "[Moderation:deleteMessages] no message(s) could be deleted" + ) + void modules.shared.api.deleteMessages(tgLogger.groupId, preRes.logMessageIds) + return err("DELETE_ERROR") + } - if (delCount / preRes.count < 0.2) { - logger.warn( - { - initialMessages: messages, - executor, - forwardedCount: preRes.count, - deletedCount: delCount, - deletedPercentage: (delCount / preRes.count).toFixed(3), - }, - "[Moderation:deleteMessages] delete count is much lower than forwarded count" - ) - } + if (delCount / preRes.count < 0.2) { + span.addEvent("moderation.delete_partial", { + forwarded_count: preRes.count, + deleted_count: delCount, + }) + logger.warn( + { + initialMessages: messages, + executor, + forwardedCount: preRes.count, + deletedCount: delCount, + deletedPercentage: (delCount / preRes.count).toFixed(3), + }, + "[Moderation:deleteMessages] delete count is much lower than forwarded count" + ) + } - return ok(preRes) + span.setAttribute(BotAttributes.MODERATION_RESULT, "applied") + span.addEvent("moderation.delete_completed", { + forwarded_count: preRes.count, + deleted_count: delCount, + }) + return ok(preRes) + } + ) } private async moderate(p: ModerationAction, messagesToDelete?: Message[]): Promise> { - const check = await this.checkTargetValid(p) - if (check.isErr()) return err(this.getModerationError(p, check.error)) - - const preDeleteRes = - messagesToDelete !== undefined - ? await this.deleteMessages( - messagesToDelete, - p.from, - `${p.action}${"reason" in p && p.reason ? ` -- ${p.reason}` : ""}` - ) - : ok(null) + return await withSpan("bot.moderation.action", this.actionModerationAttributes(p), async (span) => { + const check = await this.checkTargetValid(p) + if (check.isErr()) { + span.setAttribute(BotAttributes.MODERATION_RESULT, "rejected") + span.setAttribute(BotAttributes.MODERATION_ERROR_CODE, check.error) + span.addEvent("moderation.rejected", { + error_code: check.error, + }) + return err(this.getModerationError(p, check.error)) + } + + const preDeleteRes = + messagesToDelete !== undefined + ? await this.deleteMessages( + messagesToDelete, + p.from, + `${p.action}${"reason" in p && p.reason ? ` -- ${p.reason}` : ""}` + ) + : ok(null) + + if (preDeleteRes.isErr()) { + span.addEvent("moderation.delete_result", { + result: preDeleteRes.error, + }) + } else if (preDeleteRes.value) { + span.addEvent("moderation.delete_result", { + result: "applied", + deleted_count: preDeleteRes.value.count, + }) + } - const performOk = await this.perform(p) - if (!performOk) return err(this.getModerationError(p, "PERFORM_ERROR")) // TODO: make the perform output a Result + const performOk = await this.perform(p) + if (!performOk) { + span.setAttribute(BotAttributes.MODERATION_RESULT, "failed") + span.setAttribute(BotAttributes.MODERATION_ERROR_CODE, "PERFORM_ERROR") + span.addEvent("moderation.perform_failed", { + error_code: "PERFORM_ERROR", + }) + return err(this.getModerationError(p, "PERFORM_ERROR")) + } - await this.post(p, preDeleteRes.unwrapOr(null)) - return ok() + span.addEvent("moderation.performed") + await this.post(p, preDeleteRes.unwrapOr(null), span) + span.setAttribute(BotAttributes.MODERATION_RESULT, "applied") + return ok() + }) } public async ban( diff --git a/src/telemetry/attributes.ts b/src/telemetry/attributes.ts new file mode 100644 index 0000000..853586a --- /dev/null +++ b/src/telemetry/attributes.ts @@ -0,0 +1,48 @@ +/** Semantic attribute keys for the bot domain */ +export const BotAttributes = { + /** Importance level: "high" = always sampled, "low" = rate-sampled */ + IMPORTANCE: "bot.importance", + + // Update attributes + UPDATE_ID: "bot.update.id", + UPDATE_TYPE: "bot.update.type", + + // Chat/User attributes + CHAT_ID: "bot.chat.id", + CHAT_TYPE: "bot.chat.type", + USER_ID: "bot.user.id", + USERNAME: "bot.user.username", + + // Command attributes + COMMAND_NAME: "bot.command.name", + COMMAND_SCOPE: "bot.command.scope", + COMMAND_PERMITTED: "bot.command.permitted", + + // Automoderation attributes + AUTOMOD_CHECK: "bot.automod.check", + AUTOMOD_RESULT: "bot.automod.result", + AUTOMOD_ACTION: "bot.automod.action", + AUTOMOD_REASON: "bot.automod.reason", + + // Moderation attributes + MODERATION_ACTION: "bot.moderation.action", + MODERATION_RESULT: "bot.moderation.result", + MODERATION_REASON: "bot.moderation.reason", + MODERATION_ERROR_CODE: "bot.moderation.error_code", + MODERATION_MODERATOR_ID: "bot.moderation.moderator_id", + MODERATION_TARGET_ID: "bot.moderation.target_id", + MODERATION_CHAT_COUNT: "bot.moderation.chat_count", + MODERATION_TARGET_COUNT: "bot.moderation.target_count", + MESSAGE_COUNT: "bot.message.count", + + // Storage attributes + STORAGE_OPERATION: "bot.storage.operation", + STORAGE_COUNT: "bot.storage.count", + + // Cache attributes + CACHE_OPERATION: "bot.cache.operation", + + // tRPC attributes + TRPC_PROCEDURE: "bot.trpc.procedure", + TRPC_SUCCESS: "bot.trpc.success", +} as const diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts new file mode 100644 index 0000000..6c6a3fe --- /dev/null +++ b/src/telemetry/index.ts @@ -0,0 +1,3 @@ +export { BotAttributes } from "./attributes" +export { botMetrics } from "./metrics" +export { recordException, startSpan, tracer, withSpan } from "./spans" diff --git a/src/telemetry/metrics.ts b/src/telemetry/metrics.ts new file mode 100644 index 0000000..f225427 --- /dev/null +++ b/src/telemetry/metrics.ts @@ -0,0 +1,30 @@ +import { metrics } from "@opentelemetry/api" + +const meter = metrics.getMeter("polinetwork-telegram-bot") + +export const botMetrics = { + commandsCount: meter.createCounter("bot.commands.count", { + description: "Number of bot commands processed", + unit: "{command}", + }), + + automodActions: meter.createCounter("bot.automod.actions", { + description: "Number of automoderation actions taken", + unit: "{action}", + }), + + updatesCount: meter.createCounter("bot.updates.count", { + description: "Number of bot updates processed", + unit: "{update}", + }), + + storageBufferSize: meter.createUpDownCounter("bot.storage.buffer_size", { + description: "Current size of the message storage buffer", + unit: "{message}", + }), + + trpcDuration: meter.createHistogram("bot.trpc.duration", { + description: "Duration of tRPC calls", + unit: "ms", + }), +} diff --git a/src/telemetry/spans.ts b/src/telemetry/spans.ts new file mode 100644 index 0000000..db6b2b5 --- /dev/null +++ b/src/telemetry/spans.ts @@ -0,0 +1,53 @@ +import type { Attributes } from "@opentelemetry/api" +import { context, type Span, SpanStatusCode, trace } from "@opentelemetry/api" + +const tracer = trace.getTracer("polinetwork-telegram-bot") + +export { tracer } + +/** + * Wraps a function in an OpenTelemetry span. Automatically records exceptions + * and sets span status on error. + */ +export async function withSpan(name: string, attributes: Attributes, fn: (span: Span) => Promise): Promise { + return tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + return await fn(span) + } catch (error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error) }) + span.recordException(error instanceof Error ? error : new Error(String(error))) + throw error + } finally { + span.end() + } + }) +} + +/** + * Starts a new span as a child of the current active span, without creating + * a new async context. Useful for fire-and-forget spans or when you need + * to manually control the span lifecycle. + */ +export function startSpan(name: string, attributes: Attributes): Span { + return tracer.startSpan(name, { attributes }, context.active()) +} + +/** Records an exception on the currently active span (if any). */ +export function recordException( + error: unknown, + options?: { + name?: string + attributes?: Attributes + } +): void { + const exception = error instanceof Error ? error : new Error(String(error)) + const span = + trace.getActiveSpan() ?? tracer.startSpan(options?.name ?? "bot.exception", { attributes: options?.attributes }) + + span.recordException(exception) + span.setStatus({ code: SpanStatusCode.ERROR, message: exception.message }) + + if (!trace.getActiveSpan()) { + span.end() + } +} diff --git a/src/utils/deferred-middleware.ts b/src/utils/deferred-middleware.ts index a4d8091..b48b404 100644 --- a/src/utils/deferred-middleware.ts +++ b/src/utils/deferred-middleware.ts @@ -1,4 +1,6 @@ import type { Context, MiddlewareFn } from "grammy" +import { logger } from "@/logger" +import { BotAttributes, recordException } from "@/telemetry" /** * Defer middleware execution as to not halt the execution of the main stack. @@ -8,7 +10,13 @@ import type { Context, MiddlewareFn } from "grammy" */ export function defer(middleware: (ctx: C) => Promise): MiddlewareFn { return (context, next) => { - void middleware(context) + void middleware(context).catch((error) => { + recordException(error, { + name: "bot.deferred.error", + attributes: { [BotAttributes.IMPORTANCE]: "high" }, + }) + logger.error({ error }, "Deferred middleware failed") + }) return next() } } diff --git a/src/utils/telegram-id.ts b/src/utils/telegram-id.ts index 1026720..d51c453 100644 --- a/src/utils/telegram-id.ts +++ b/src/utils/telegram-id.ts @@ -1,6 +1,7 @@ import { RedisFallbackAdapter } from "@/lib/redis-fallback-adapter" import { logger } from "@/logger" import { redis } from "@/redis" +import { BotAttributes, withSpan } from "@/telemetry" const usernameRedis = new RedisFallbackAdapter({ redis, @@ -10,10 +11,29 @@ const usernameRedis = new RedisFallbackAdapter({ export async function getTelegramId(username: string): Promise { const key = `${username.toLowerCase().replaceAll("@", "")}:id` - return (await usernameRedis.read(key)) ?? null + return await withSpan( + "bot.cache.username_get", + { + [BotAttributes.IMPORTANCE]: "low", + [BotAttributes.CACHE_OPERATION]: "username_get", + [BotAttributes.USERNAME]: username, + }, + async () => (await usernameRedis.read(key)) ?? null + ) } export async function setTelegramId(username: string, id: number) { const key = `${username.toLowerCase()}:id` - await usernameRedis.write(key, id) + await withSpan( + "bot.cache.username_set", + { + [BotAttributes.IMPORTANCE]: "low", + [BotAttributes.CACHE_OPERATION]: "username_set", + [BotAttributes.USERNAME]: username, + [BotAttributes.USER_ID]: id, + }, + async () => { + await usernameRedis.write(key, id) + } + ) } diff --git a/tsup.config.js b/tsup.config.js index 060f49e..35a3e3f 100644 --- a/tsup.config.js +++ b/tsup.config.js @@ -1,7 +1,7 @@ import { defineConfig } from "tsup" export default defineConfig({ - entry: ["src/bot.ts"], + entry: ["src/bot.ts", "src/instrumentation.ts"], outDir: "./dist", dts: false, format: ["esm"],