diff --git a/package-lock.json b/package-lock.json index b84d32a..bf5889e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "license": "ISC", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", + "@sentry/node": "^7.77.0", "axios": "^1.6.0", "dotenv": "^16.3.1", "express": "^4.18.2", "mongoose": "^7.6.3", + "node-cron": "^3.0.2", "nodemon": "^3.0.1", "typescript": "^5.2.2", "websocket": "^1.0.34" @@ -21,6 +23,7 @@ "devDependencies": { "@types/express": "^4.17.20", "@types/node": "^20.8.9", + "@types/node-cron": "^3.0.10", "@types/websocket": "^1.0.8" } }, @@ -38,6 +41,65 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@sentry-internal/tracing": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.77.0.tgz", + "integrity": "sha512-8HRF1rdqWwtINqGEdx8Iqs9UOP/n8E0vXUu3Nmbqj4p5sQPA7vvCfq+4Y4rTqZFc7sNdFpDsRION5iQEh8zfZw==", + "dependencies": { + "@sentry/core": "7.77.0", + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/core": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.77.0.tgz", + "integrity": "sha512-Tj8oTYFZ/ZD+xW8IGIsU6gcFXD/gfE+FUxUaeSosd9KHwBQNOLhZSsYo/tTVf/rnQI/dQnsd4onPZLiL+27aTg==", + "dependencies": { + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/node": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.77.0.tgz", + "integrity": "sha512-Ob5tgaJOj0OYMwnocc6G/CDLWC7hXfVvKX/ofkF98+BbN/tQa5poL+OwgFn9BA8ud8xKzyGPxGU6LdZ8Oh3z/g==", + "dependencies": { + "@sentry-internal/tracing": "7.77.0", + "@sentry/core": "7.77.0", + "@sentry/types": "7.77.0", + "@sentry/utils": "7.77.0", + "https-proxy-agent": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/types": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.77.0.tgz", + "integrity": "sha512-nfb00XRJVi0QpDHg+JkqrmEBHsqBnxJu191Ded+Cs1OJ5oPXEW6F59LVcBScGvMqe+WEk1a73eH8XezwfgrTsA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/utils": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.77.0.tgz", + "integrity": "sha512-NmM2kDOqVchrey3N5WSzdQoCsyDkQkiRxExPaNI2oKQ/jMWHs9yt0tSy7otPBcXs0AP59ihl75Bvm1tDRcsp5g==", + "dependencies": { + "@sentry/types": "7.77.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@types/body-parser": { "version": "1.19.4", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.4.tgz", @@ -101,6 +163,12 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-cron": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.10.tgz", + "integrity": "sha512-8thdLSpV7na8+bsmiyMH/KKQWOBg4WmoLInlGxWz7JJn8Mie+53QWaygSlc7f/AsMsCB5bBTVjoueAU6tRutTw==", + "dev": true + }, "node_modules/@types/qs": { "version": "6.9.9", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.9.tgz", @@ -174,6 +242,38 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -795,6 +895,39 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1087,6 +1220,17 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, + "node_modules/node-cron": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.2.tgz", + "integrity": "sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.1.tgz", @@ -1568,6 +1712,14 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index ba52619..39ad9cd 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "license": "ISC", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", + "@sentry/node": "^7.77.0", "axios": "^1.6.0", "dotenv": "^16.3.1", "express": "^4.18.2", "mongoose": "^7.6.3", + "node-cron": "^3.0.2", "nodemon": "^3.0.1", "typescript": "^5.2.2", "websocket": "^1.0.34" @@ -23,6 +25,7 @@ "devDependencies": { "@types/express": "^4.17.20", "@types/node": "^20.8.9", + "@types/node-cron": "^3.0.10", "@types/websocket": "^1.0.8" } } diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 0000000..3620ccd --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,47 @@ +import * as Sentry from "@sentry/node"; +import express from "express"; +import analyticsRoute from "./routes/analytics"; + +const app = express(); +const port = process.env.PORT || 3000; + +Sentry.init({ + dsn: "https://19a8f6e661f41ed26ec9c221954594cf@sentry.fascinated.cc/3", + integrations: [ + // enable HTTP calls tracing + new Sentry.Integrations.Http({ tracing: true }), + // enable Express.js middleware tracing + new Sentry.Integrations.Express({ app }), + new Sentry.Integrations.OnUncaughtException(), + new Sentry.Integrations.OnUnhandledRejection(), + new Sentry.Integrations.Mongo({ + useMongoose: true, + }), + ], + // Performance Monitoring + tracesSampleRate: 1.0, +}); + +// Routes +analyticsRoute(app); + +// The request handler must be the first middleware on the app +app.use(Sentry.Handlers.requestHandler()); + +// TracingHandler creates a trace for every incoming request +app.use(Sentry.Handlers.tracingHandler()); + +// The error handler must be registered before any other error middleware and after all controllers +app.use(Sentry.Handlers.errorHandler()); + +// Optional fallthrough error handler +app.use(function onError(err: any, req: any, res: any, next: any) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + "\n"); +}); + +app.listen(port, () => { + console.log(`API Server is running on http://localhost:${port}`); +}); diff --git a/src/api/routes/analytics.ts b/src/api/routes/analytics.ts new file mode 100644 index 0000000..65a0bf4 --- /dev/null +++ b/src/api/routes/analytics.ts @@ -0,0 +1,85 @@ +import { INFLUXDB_BUCKET, InfluxQueryAPI } from "../.."; +import { formatString } from "../../utils/stringUtils"; +import { parseTimeToMilliseconds } from "../../utils/timeUtils"; + +// Query to get the player count history for tge last 24 hours in 1 hour intervals +const getPlayerHistoryQuery = `from(bucket: "${INFLUXDB_BUCKET}") + |> range(start: -{}) + |> filter(fn: (r) => r["_measurement"] == "scoresaber") + |> filter(fn: (r) => r["_field"] == "value") + |> filter(fn: (r) => r["type"] == "player_count") + |> aggregateWindow(every: {}, fn: mean) + |> yield() +`; + +const getScoreCountHistoryQuery = `from(bucket: "${INFLUXDB_BUCKET}") + |> range(start: -{}) + |> filter(fn: (r) => r["_measurement"] == "scoresaber") + |> filter(fn: (r) => r["_field"] == "value") + |> filter(fn: (r) => r["type"] == "score_count") + |> aggregateWindow(every: {}, fn: spread, createEmpty: true) +`; + +export default function analyticsRoute(app: any) { + app.get("/analytics", async (req: any, res: any) => { + const before = new Date().getTime(); + + const timeQuery = req.query.time || "30d"; + const timeInMs = parseTimeToMilliseconds(timeQuery.toString()); + if (timeInMs > 30 * 24 * 60 * 60 * 1000) { + return res.status(400).json({ + error: "Time range too large. Max time range is 30 days.", + }); + } + const shouldUseLongerIntervals = timeInMs > 24 * 60 * 60 * 1000 * 7; // 7 days + + const getActivePlayersHistory = async () => { + const rows = await InfluxQueryAPI.collectRows( + formatString( + getPlayerHistoryQuery, + false, + timeQuery, + shouldUseLongerIntervals ? "1d" : "1h" + ) + ); + let history = rows.map((row: any) => ({ + time: row._time, + value: row._value !== null ? row._value.toFixed(0) : null, + })); + return history.sort( + (a: any, b: any) => + new Date(a.time).getTime() - new Date(b.time).getTime() + ); + }; + + const getScoreCountHistory = async () => { + const rows = await InfluxQueryAPI.collectRows( + formatString( + getScoreCountHistoryQuery, + false, + timeQuery, + shouldUseLongerIntervals ? "1d" : "1h" + ) + ); + let history = rows.map((row: any) => ({ + time: row._time, + value: row._value !== null ? row._value.toFixed(0) : null, + })); + return history.sort( + (a: any, b: any) => + new Date(a.time).getTime() - new Date(b.time).getTime() + ); + }; + + const [activePlayersHistory, scoreCountHistory] = await Promise.all([ + getActivePlayersHistory(), + getScoreCountHistory(), + ]); + + return res.json({ + serverTimeTaken: new Date().getTime() - before + "ms", + activePlayersHistory, + scoreCountHistory, + }); + }); +} diff --git a/src/index.ts b/src/index.ts index 89dde27..78b8024 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,4 +23,4 @@ export const InfluxWriteAPI = influxClient.getWriteApi( export const InfluxQueryAPI = influxClient.getQueryApi(INFLUXDB_ORG); require("./services/updateData"); -require("./services/api"); +require("./api/api"); diff --git a/src/services/api.ts b/src/services/api.ts deleted file mode 100644 index ad09520..0000000 --- a/src/services/api.ts +++ /dev/null @@ -1,95 +0,0 @@ -import express from "express"; -import { INFLUXDB_BUCKET, InfluxQueryAPI } from "../index"; -import { formatString } from "../utils/stringUtils"; -import { parseTimeToMilliseconds } from "../utils/timeUtils"; - -const app = express(); -const port = process.env.PORT || 3000; - -// Query to get the player count history for tge last 24 hours in 1 hour intervals -const getPlayerHistoryQuery = `from(bucket: "${INFLUXDB_BUCKET}") - |> range(start: -{}) - |> filter(fn: (r) => r["_measurement"] == "scoresaber") - |> filter(fn: (r) => r["_field"] == "value") - |> filter(fn: (r) => r["type"] == "player_count") - |> aggregateWindow(every: {}, fn: mean) - |> yield() -`; - -const getScoreCountHistoryQuery = `from(bucket: "${INFLUXDB_BUCKET}") - |> range(start: -{}) - |> filter(fn: (r) => r["_measurement"] == "scoresaber") - |> filter(fn: (r) => r["_field"] == "value") - |> filter(fn: (r) => r["type"] == "score_count") - |> aggregateWindow(every: {}, fn: spread, createEmpty: true) -`; - -app.get("/", (req, res) => { - res.send("Hello!"); -}); - -app.get("/analytics", async (req, res) => { - const before = new Date().getTime(); - - const timeQuery = req.query.time || "30d"; - const timeInMs = parseTimeToMilliseconds(timeQuery.toString()); - if (timeInMs > 30 * 24 * 60 * 60 * 1000) { - return res.status(400).json({ - error: "Time range too large. Max time range is 30 days.", - }); - } - const shouldUseLongerIntervals = timeInMs > 24 * 60 * 60 * 1000 * 7; // 7 days - - const getActivePlayersHistory = async () => { - const rows = await InfluxQueryAPI.collectRows( - formatString( - getPlayerHistoryQuery, - false, - timeQuery, - shouldUseLongerIntervals ? "1d" : "1h" - ) - ); - let history = rows.map((row: any) => ({ - time: row._time, - value: row._value !== null ? row._value.toFixed(0) : null, - })); - return history.sort( - (a: any, b: any) => - new Date(a.time).getTime() - new Date(b.time).getTime() - ); - }; - - const getScoreCountHistory = async () => { - const rows = await InfluxQueryAPI.collectRows( - formatString( - getScoreCountHistoryQuery, - false, - timeQuery, - shouldUseLongerIntervals ? "1d" : "1h" - ) - ); - let history = rows.map((row: any) => ({ - time: row._time, - value: row._value !== null ? row._value.toFixed(0) : null, - })); - return history.sort( - (a: any, b: any) => - new Date(a.time).getTime() - new Date(b.time).getTime() - ); - }; - - const [activePlayersHistory, scoreCountHistory] = await Promise.all([ - getActivePlayersHistory(), - getScoreCountHistory(), - ]); - - return res.json({ - serverTimeTaken: new Date().getTime() - before + "ms", - activePlayersHistory, - scoreCountHistory, - }); -}); - -app.listen(port, () => { - console.log(`API Server is running on http://localhost:${port}`); -}); diff --git a/src/services/updateData.ts b/src/services/updateData.ts index ccf6e1e..9bf9f08 100644 --- a/src/services/updateData.ts +++ b/src/services/updateData.ts @@ -1,5 +1,6 @@ import { Point } from "@influxdata/influxdb-client"; import axios from "axios"; +import cron from "node-cron"; import { w3cwebsocket as WebsocketClient } from "websocket"; import { InfluxWriteAPI } from ".."; import { connectMongo } from "../db/mongo"; @@ -18,6 +19,7 @@ async function update() { .intField("value", parseInt(count)) .timestamp(new Date()); InfluxWriteAPI.writePoint(point); + console.log(`Updated player count to ${count}`); if (totalScores) { InfluxWriteAPI.writePoint( @@ -26,9 +28,8 @@ async function update() { .intField("value", totalScores) .timestamp(new Date()) ); + console.log(`Updated score count to ${totalScores}`); } - - console.log(`Updated player count to ${count}`); } async function connectWebsocket() { @@ -83,7 +84,7 @@ async function connectWebsocket() { leaderboardPlayerInfo: player, pp, } = score; - const { maxScore, stars, id: leaderboardId } = leaderboard; + const { maxScore, stars } = leaderboard; const hmdName = Headsets[hmd] || "Unknown"; const countryId = normalizedRegionName(player.country) || "Unknown"; @@ -144,4 +145,4 @@ async function connectWebsocket() { update(); connectWebsocket(); -setInterval(update, 60_000); // 1 minute +cron.schedule("*/5 * * * *", update);