diff --git a/README.md b/README.md index d1e59fb..3aba7f6 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ We recommend using [The Things Network](https://www.thethingsnetwork.org/) (TTN) - [Database](#database) - [Configuration](#configuration) 2. [TTN Integration](#ttn-integration) -3. [Add a Location Tracker](#add-a-location-tracker) +3. [Prometheus Metrics](#prometheus-metrics) +4. [Add a Location Tracker](#add-a-location-tracker) - [Onboard SenseCAP T1000-B](#onboard-sensecap-t1000-b) - [Register SenseCAP T1000-B](#register-sensecap-t1000-b) -4. [Testing](#testing) +5. [Testing](#testing) - [Testing Webhook](#testing-webhook) - [Emulating Wigle API](#emulating-wigle-api) @@ -49,6 +50,9 @@ Add a addidtional header: - Type: `authorization` - Value: `Bearer your-very-secure-token-from-the-env-file` +### Prometheus Metrics +Use `https://your.domain.tld/api/metrics` to retrieve useful insides for monitoring. + ## Add a Location Tracker We use the [SenseCAP T1000-B](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-B-p-5698.html) from seeedstudio because of the fair price and multiple location providers. However, you can use any LoRaWAN-enabled tracker that is compatible with TTN and supports the required payload fields. diff --git a/server/package-lock.json b/server/package-lock.json index b8236af..7e49a45 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,6 +14,7 @@ "express": "^4.21.2", "http-status-codes": "^2.3.0", "mariadb": "^3.4.0", + "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "sequelize": "^6.37.5", "swagger-jsdoc": "^6.2.8", @@ -120,6 +121,15 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -386,6 +396,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -1434,6 +1450,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1873,6 +1902,15 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/server/package.json b/server/package.json index d2fde30..533fd05 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "express": "^4.21.2", "http-status-codes": "^2.3.0", "mariadb": "^3.4.0", + "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "sequelize": "^6.37.5", "swagger-jsdoc": "^6.2.8", diff --git a/server/src/controller/metricsController.ts b/server/src/controller/metricsController.ts new file mode 100644 index 0000000..4e0b1ff --- /dev/null +++ b/server/src/controller/metricsController.ts @@ -0,0 +1,63 @@ +import express, { Request, Response } from "express"; +import { container } from "tsyringe"; +import { Counter, Gauge, collectDefaultMetrics, register } from "prom-client"; +import { LocationService } from "../services/locationService"; +import { WifiLocationService } from "../services/wifiLocationService"; +import { TtnGatewayReceptionService } from "../services/ttnGatewayReceptionService"; +import { MetricsService } from "../services/metricsService"; + +const router = express.Router(); +const locationService = container.resolve(LocationService); +const wifiLocationService = container.resolve(WifiLocationService); +const ttnGatewayReceptionService = container.resolve(TtnGatewayReceptionService); +const metricsService = container.resolve(MetricsService); + +const requestCounter = metricsService.getRequestCounter(); +const locationsTotal = metricsService.getLocationsTotal(); +const gnssLocationsTotal = metricsService.getGnssLocationsTotal() +const wifiLocationTotal = metricsService.getWifiLocationTotal(); +const wifiLocationNotResolvable = metricsService.getWifiLocationResolvable(); +const wifiLocationRequestLimitExceeded = metricsService.getLWifiLocationRequestLimitExceeded(); +const ttnGatewayReceptions = metricsService.getTnnGatewayReceptions(); + +// Define the metrics endpoint +router.get("/", async (req: Request, res: Response) => { + try { + const [ + allLocations, + gnssLocations, + allWifiLocations, + wifiLocationsNotResolvable, + wifiLocationsRequestLimitExceeded, + allTtnGatewayReceptions + ] = await Promise.all([ + locationService.getAllLocations(), + locationService.getAllGnssLocations(), + wifiLocationService.getAllWifiLocations(), + wifiLocationService.getAllWifiLocationsByNotResolvable(), + wifiLocationService.getAllWifiLocationsByRequestLimitExceeded(), + ttnGatewayReceptionService.getAllGatewayReceptions() + ]); + + locationsTotal.set(allLocations.length); + gnssLocationsTotal.set(gnssLocations.length); + wifiLocationTotal.set(allWifiLocations.length); + wifiLocationNotResolvable.set(wifiLocationsNotResolvable.length); + wifiLocationRequestLimitExceeded.set(wifiLocationsRequestLimitExceeded.length); + ttnGatewayReceptions.set(allTtnGatewayReceptions.length); + + // Increment the counter with labels + requestCounter.inc({ method: req.method, route: req.route.path, status: 200 }); + + // Expose metrics in Prometheus format + res.set("Content-Type", register.contentType); + res.send(await register.metrics()); + } catch (error) { + // Increment the counter for errors + requestCounter.inc({ method: req.method, route: req.route.path, status: 500 }); + console.error("Error running metrics endpoint:", error); + res.status(500).json({ error: "Error running metrics endpoint" }); + } +}); + +export default router; diff --git a/server/src/index.ts b/server/src/index.ts index a1f4292..708eae7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,6 +10,7 @@ import ttnGatewayReceptionRoutes from "./controller/ttnGatewayReceptionControlle import wifiLocationRoutes from "./controller/wifiLocationController"; import wifiLocationHistoryRoutes from "./controller/wifiLocationHistoryController"; import wifiScanRoutes from "./controller/wifiScanController"; +import metricsRoutes from "./controller/metricsController"; dotenv.config(); @@ -26,6 +27,7 @@ app.use("/api/wifi-location", wifiLocationRoutes); app.use("/api/wifi-scans", wifiScanRoutes); app.use("/api/locations", locationRoutes); app.use("/api/ttn", ttnRoutes); +app.use("/api/metrics", metricsRoutes); app.listen(PORT, () => { console.log(`🚀 Server runs here: http://localhost:${PORT}`); diff --git a/server/src/repositories/locationRepository.ts b/server/src/repositories/locationRepository.ts index 5f0a563..e5b4cb4 100644 --- a/server/src/repositories/locationRepository.ts +++ b/server/src/repositories/locationRepository.ts @@ -1,10 +1,11 @@ +import { Attributes, FindOptions } from "sequelize"; import { injectable } from "tsyringe"; import { Location } from "../models/location"; @injectable() export class LocationRepository { - public async findAll() { - return await Location.findAll(); + public async findAll(options?: FindOptions>) { + return await Location.findAll(options); } public async findById(id: string) { diff --git a/server/src/services/locationService.ts b/server/src/services/locationService.ts index 9cc90ab..24dd51b 100644 --- a/server/src/services/locationService.ts +++ b/server/src/services/locationService.ts @@ -1,4 +1,5 @@ import { inject, injectable } from "tsyringe"; +import { Op } from 'sequelize'; import { Location } from "../models/location"; import { LocationRepository } from "../repositories/locationRepository"; import { WifiLocationService } from "./wifiLocationService"; @@ -54,6 +55,15 @@ export class LocationService { return this.repository.findAll(); } + public async getAllGnssLocations() { + return this.repository.findAll({ + where: { + gnss_latitude: { [Op.ne]: null }, + gnss_longitude: { [Op.ne]: null } + } + }); + } + public async getLocationById(id: string) { return this.repository.findById(id); } diff --git a/server/src/services/metricsService.ts b/server/src/services/metricsService.ts new file mode 100644 index 0000000..3b22898 --- /dev/null +++ b/server/src/services/metricsService.ts @@ -0,0 +1,70 @@ +import {injectable } from "tsyringe"; +import { Counter, Gauge, collectDefaultMetrics} from "prom-client"; + +// Collect default system metrics (e.g., CPU, memory usage) +const prefix = 'locationhub_'; +collectDefaultMetrics({ prefix }); + +@injectable() +export class MetricsService { + constructor( + ) { } + + public getRequestCounter() { + const requestCounter = new Counter({ + name: `${prefix}http_requests_total`, + help: "Total number of HTTP requests", + labelNames: ["method", "route", "status"], + }); + return requestCounter; + } + + public getLocationsTotal() { + const locationsTotal = new Gauge({ + name: `${prefix}locations_total`, + help: "Total number of location entries in database", + labelNames: ["database"], + }); + return locationsTotal; + } + public getGnssLocationsTotal() { + const gnssLocationsTotal = new Gauge({ + name: `${prefix}gnss_locations_total`, + help: "Total number of location entries with GNSS in database", + labelNames: ["database"], + }); + return gnssLocationsTotal; + } + public getWifiLocationTotal() { + const wifiLocationTotal = new Gauge({ + name: `${prefix}wifi_locations_total`, + help: "Total number of wifi location entries in database", + labelNames: ["database"], + }); + return wifiLocationTotal; + } + public getWifiLocationResolvable() { + const wifiLocationNotResolvable = new Gauge({ + name: `${prefix}wifi_locations_not_resolvable`, + help: "Unresolved number of wifi location entries in database", + labelNames: ["database"], + }); + return wifiLocationNotResolvable; + } + public getLWifiLocationRequestLimitExceeded() { + const wifiLocationRequestLimitExceeded = new Gauge({ + name: `${prefix}wifi_locations_request_limit_exceeded`, + help: "Unresolved number of wifi location because request limit exceeded entries in database", + labelNames: ["database"], + }); + return wifiLocationRequestLimitExceeded; + } + public getTnnGatewayReceptions() { + const ttnGatewayReceptions = new Gauge({ + name: `${prefix}ttn_gateway_receptions`, + help: "Total number of TTN Gateway receptions entries in database", + labelNames: ["database"], + }); + return ttnGatewayReceptions; + } +} diff --git a/server/src/services/wifiLocationService.ts b/server/src/services/wifiLocationService.ts index 2617db8..a635f5f 100644 --- a/server/src/services/wifiLocationService.ts +++ b/server/src/services/wifiLocationService.ts @@ -29,6 +29,18 @@ export class WifiLocationService { }); } + public async getAllWifiLocationsByNotResolvable() { + return this.repository.findAll({ + where: { location_not_resolvable: true }, + }); + } + + public async getAllWifiLocationsByRequestLimitExceeded() { + return this.repository.findAll({ + where: { request_limit_exceeded: true }, + }); + } + public async getWifiLocationByMac(mac: string) { let wifiLocation = await this.repository.findById(mac);