diff --git a/README.md b/README.md index 20bfd70..c2b00cb 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ npm install ### 2. Run the Bot -**Basic:** +**With default parameters:** ```bash node index.js ``` @@ -55,7 +55,7 @@ cp .env.example .env # THRESHOLD=0.01 # Start services -docker compose up -d +docker compose up -d --build # View logs docker compose logs -f bot diff --git a/compose.yml b/compose.yml index 84d6afb..aa5a420 100644 --- a/compose.yml +++ b/compose.yml @@ -1,10 +1,13 @@ services: bot: build: . + ports: + - "3000:3000" depends_on: db: condition: service_healthy environment: + - PORT=3000 - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} - PAIRS=${PAIRS:-BTC-USD} - INTERVAL=${INTERVAL:-5000} @@ -21,7 +24,7 @@ services: volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U uphold -d uphold_alerts"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-uphold} -d ${POSTGRES_DB:-uphold_alerts}"] interval: 5s timeout: 5s retries: 5 diff --git a/index.js b/index.js index 78e6f9e..5e3f2fc 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ import { prefetchRates } from "./src/api.js"; import { Bot } from "./src/bot.js"; import logger from "./src/logger.js"; import { initDB, insertIntoDB, closePool } from "./src/db.js"; +import { startServer } from "./src/server.js"; process.on("uncaughtException", (err) => { logger.fatal(err, "Uncaught exception. Application crashing..."); @@ -69,6 +70,7 @@ async function main() { ); await initDB(); + startServer(); try { await prefetchRates(pairs); diff --git a/src/api.js b/src/api.js index ad729bc..a82a6a5 100644 --- a/src/api.js +++ b/src/api.js @@ -1,6 +1,7 @@ import { BigNumber } from "bignumber.js"; import PQueue from "p-queue"; import logger from "./logger.js"; +import { incrementReq } from "./stats.js"; const queue = new PQueue({ interval: 1000, intervalCap: 10 }); @@ -19,8 +20,17 @@ export async function fetchRate(pair) { clearTimeout(timeout); - if (res.status === 429) throw new Error("Rate limited"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (res.status === 429) { + incrementReq("ratelimit"); + throw new Error("Rate limited"); + } + + if (!res.ok) { + incrementReq("fail"); + throw new Error(`HTTP ${res.status}`); + } + + incrementReq("success"); const data = await res.json(); diff --git a/src/bot.js b/src/bot.js index ad9532e..455fcaa 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,5 +1,6 @@ import { fetchRate } from "./api.js"; import logger from "./logger.js"; +import { updatePair, incrementAlerts } from "./stats.js"; export class Bot { constructor(config, onAlert) { @@ -32,6 +33,8 @@ export class Bot { try { const price = await fetchRate(this.pair); + updatePair(this.pair, price.toFixed(6)); + if (!this.lastPrice) { this.lastPrice = price; logger.info(`[${this.pair}] Initial price: ${price.toFixed(2)}`); @@ -42,6 +45,7 @@ export class Bot { .multipliedBy(100); if (change.abs().gte(this.threshold)) { + incrementAlerts(); const dir = change.gt(0) ? "UP" : "DOWN"; logger.info( { diff --git a/src/db.js b/src/db.js index 39eea47..911fb47 100644 --- a/src/db.js +++ b/src/db.js @@ -111,6 +111,14 @@ export async function insertIntoDB(data) { } } +export async function getRecentAlerts(limit = 50) { + const res = await pool.query( + "SELECT * FROM alerts ORDER BY created_at DESC LIMIT $1", + [limit], + ); + return res.rows; +} + export async function closePool() { if (pool) { await pool.end(); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..e5e8686 --- /dev/null +++ b/src/server.js @@ -0,0 +1,46 @@ +import http from "node:http"; +import { URL } from "node:url"; +import { stats } from "./stats.js"; +import { getRecentAlerts } from "./db.js"; +import logger from "./logger.js"; + +const PORT = process.env.PORT || 3000; + +const sendJSON = (res, data, status = 200) => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data, null, 2)); +}; + +const server = http.createServer(async (req, res) => { + try { + const parsedUrl = new URL(req.url, `http://${req.headers.host}`); + const { pathname, searchParams } = parsedUrl; + + if (req.method === "GET" && pathname === "/health") { + return sendJSON(res, { status: "ok", uptime: process.uptime() }); + } + + if (req.method === "GET" && pathname === "/stats") { + return sendJSON(res, stats); + } + + if (req.method === "GET" && pathname === "/alerts") { + const limit = parseInt(searchParams.get("limit")) || 50; + const alerts = await getRecentAlerts(limit); + return sendJSON(res, { count: alerts.length, data: alerts }); + } + + // 404 + sendJSON(res, { error: "Not Found" }, 404); + } catch (err) { + logger.error(err, "API Error"); + sendJSON(res, { error: "Internal Server Error" }, 500); + } +}); + +export const startServer = () => { + server.listen(PORT, () => { + logger.info(`[API] Server listening on port ${PORT}`); + }); + return server; +}; diff --git a/src/stats.js b/src/stats.js new file mode 100644 index 0000000..51563cc --- /dev/null +++ b/src/stats.js @@ -0,0 +1,31 @@ +export const stats = { + startedAt: new Date().toISOString(), + api: { + totalRequests: 0, + successful: 0, + failed: 0, + rateLimited: 0, + }, + bot: { + totalAlerts: 0, + activePairs: {}, + }, +}; + +export const incrementReq = (type) => { + stats.api.totalRequests++; + if (type === "success") stats.api.successful++; + if (type === "fail") stats.api.failed++; + if (type === "ratelimit") stats.api.rateLimited++; +}; + +export const updatePair = (pair, price) => { + stats.bot.activePairs[pair] = { + currentPrice: price, + lastUpdate: new Date().toISOString(), + }; +}; + +export const incrementAlerts = () => { + stats.bot.totalAlerts++; +};