Skip to content

Commit fa0fce9

Browse files
authored
Merge pull request #493 from RomainNeup/feat/add-prometheus-metrics
Feat(server) add prometheus metrics
2 parents cb80346 + 0ece3be commit fa0fce9

File tree

11 files changed

+197
-1
lines changed

11 files changed

+197
-1
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ You can follow the instructions [here](https://github.com/Yooooomi/your_spotify/
9191
| SPOTIFY_SECRET | REQUIRED | The secret key of your Spotify application (cf [Creating the Spotify Application](#creating-the-spotify-application)) |
9292
| TIMEZONE | Europe/Paris | The timezone of your stats, only affects read requests since data is saved with UTC time |
9393
| MONGO_ENDPOINT | mongodb://mongo:27017/your_spotify | The endpoint of the Mongo database, where **mongo** is the name of your service in the compose file |
94+
| PROMETHEUS_USERNAME | _not defined_ | Prometheus basic auth username (see [here](https://github.com/Yooooomi/your_spotify/tree/master/apps/server#prometheus)) |
95+
| PROMETHEUS_PASSWORD | _not defined_ | Prometheus basic auth password |
9496
| LOG_LEVEL | info | The log level, debug is useful if you encouter any bugs |
9597
| CORS | _not defined_ | List of comma-separated origin allowed (defaults to CLIENT_ENDPOINT) |
9698
| COOKIE_VALIDITY_MS | 1h | Validity time of the authentication cookie, following [this pattern](https://github.com/vercel/ms) |

apps/server/README.md

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Your Spotify server
2+
3+
# Table of contents
4+
5+
- [Prometheus](#prometheus)
6+
7+
## Prometheus
8+
9+
You can access various prometheus metrics through `/metrics`:
10+
11+
- **http_requests_total**: Total number of HTTP requests
12+
- **http_request_duration_nanoseconds**: Duration of HTTP requests in nanoseconds
13+
- **imports_total**: Total number of imports
14+
- **ingested_tracks_total**: Total number of ingested tracks from Spotify API
15+
- **ingested_albums_total**: Total number of ingested albums from Spotify API
16+
- **ingested_artists_total**: Total number of ingested artists from Spotify API
17+
18+
You will need to define to environment variable in the server:
19+
20+
- **PROMETHEUS_USERNAME**: the basic auth username expected by the server to authorize the request
21+
- **PROMETHEUS_PASSWORD**: the expected password
22+
23+
Those values are to be referenced in the configuration of your Prometheus instance:
24+
25+
```yml
26+
scrape_configs:
27+
- job_name: "your_spotify"
28+
basic_auth:
29+
username: "myuser" # PROMETHEUS_USERNAME
30+
password: "mypassword" # PROMETHEUS_PASSWORD
31+
static_configs:
32+
- targets: ["example.com:443"]
33+
```

apps/server/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
"cookie-parser": "^1.4.5",
1717
"cors": "^2.8.5",
1818
"express": "^4.21.2",
19+
"express-basic-auth": "^1.2.1",
1920
"jsonwebtoken": "^9.0.2",
2021
"migrate": "^2.1.0",
2122
"mongodb": "^6.15.0",
2223
"mongoose": "^8.12.2",
2324
"morgan": "^1.10.0",
2425
"multer": "^1.4.5-lts.2",
26+
"prom-client": "^15.1.3",
2527
"uuid": "^11.1.0",
2628
"zod": "^3.24.2"
2729
},

apps/server/src/app.ts

+5
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import { router as albumRouter } from "./routes/album";
1313
import { router as importRouter } from "./routes/importer";
1414
import { router as trackRouter } from "./routes/track";
1515
import { router as searchRouter } from "./routes/search";
16+
import { router as metricsRouter } from "./routes/metrics";
1617
import { get } from "./tools/env";
1718
import { LogLevelAccepts } from "./tools/logger";
19+
import { measureRequestDuration } from "./tools/middleware";
1820

1921
const app = express();
2022
const ALLOW_ALL_CORS =
@@ -27,6 +29,8 @@ if (corsValue?.[0] === ALLOW_ALL_CORS) {
2729
corsValue = undefined;
2830
}
2931

32+
app.use(measureRequestDuration);
33+
3034
app.use(
3135
cors({
3236
origin: corsValue ?? true,
@@ -70,5 +74,6 @@ app.use("/album", albumRouter);
7074
app.use("/track", trackRouter);
7175
app.use("/search", searchRouter);
7276
app.use("/", importRouter);
77+
app.use("/", metricsRouter);
7378

7479
export { app };

apps/server/src/routes/metrics.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Router } from "express";
2+
import { register } from "prom-client";
3+
import basicAuth from "express-basic-auth";
4+
import { get } from "../tools/env";
5+
6+
export const router = Router();
7+
8+
router.get(
9+
"/metrics",
10+
basicAuth({
11+
authorizer: (username: string, password: string) => {
12+
const expectedUsername = get("PROMETHEUS_USERNAME");
13+
const expectedPassword = get("PROMETHEUS_PASSWORD");
14+
15+
if (!expectedUsername || !expectedPassword) {
16+
return false;
17+
}
18+
19+
const userMatches = basicAuth.safeCompare(username, expectedUsername);
20+
const passwordMatches = basicAuth.safeCompare(password, expectedPassword);
21+
22+
return userMatches && passwordMatches;
23+
},
24+
}),
25+
async (_, res) => {
26+
try {
27+
res.set("Content-Type", register.contentType);
28+
res.end(await register.metrics());
29+
} catch (e) {
30+
res.status(500).end();
31+
}
32+
},
33+
);

apps/server/src/spotify/dbTools.ts

+4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "../database";
1414
import { Infos } from "../database/schemas/info";
1515
import { longWriteDbLock } from "../tools/lock";
16+
import { Metrics } from "../tools/metrics";
1617

1718
const getIdsHandlingMax = async <
1819
T extends SpotifyTrack | SpotifyAlbum | SpotifyArtist,
@@ -68,6 +69,7 @@ export const getTracks = async (userId: string, ids: string[]) => {
6869
artists: track.artists.map(e => e.id),
6970
};
7071
});
72+
Metrics.ingestedTracksTotal.inc({ user: userId }, tracks.length);
7173

7274
return tracks;
7375
};
@@ -93,6 +95,7 @@ export const getAlbums = async (userId: string, ids: string[]) => {
9395
artists: alb.artists.map(art => art.id),
9496
};
9597
});
98+
Metrics.ingestedAlbumsTotal.inc({ user: userId }, albums.length);
9699

97100
return albums;
98101
};
@@ -111,6 +114,7 @@ export const getArtists = async (userId: string, ids: string[]) => {
111114
artists.forEach(artist =>
112115
logger.info(`Storing non existing artist ${artist.name}`),
113116
);
117+
Metrics.ingestedArtistsTotal.inc({ user: userId }, artists.length);
114118

115119
return artists;
116120
};

apps/server/src/tools/env.ts

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const validators = {
1616
OFFLINE_DEV_ID: z.string().optional(),
1717
COOKIE_VALIDITY_MS: z.string().optional(),
1818
MONGO_NO_ADMIN_RIGHTS: z.preprocess(toBoolean, z.boolean().optional()),
19+
PROMETHEUS_USERNAME: z.string().optional(),
20+
PROMETHEUS_PASSWORD: z.string().optional(),
1921
} as const;
2022

2123
const validatedEnv: Record<string, any> = {};

apps/server/src/tools/importers/importer.ts

+16
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../../database/queries/importer";
88
import { User } from "../../database/schemas/user";
99
import { logger } from "../logger";
10+
import { Metrics } from "../metrics";
1011
import { clearCache } from "./cache";
1112
import { FullPrivacyImporter } from "./full_privacy";
1213
import { PrivacyImporter } from "./privacy";
@@ -66,15 +67,24 @@ export async function runImporter<T extends ImporterStateTypes>(
6667
const user = await getUserFromField("_id", new Types.ObjectId(userId), true);
6768
if (!user) {
6869
logger.error(`User with id ${userId} was not found`);
70+
Metrics.importsTotal
71+
.labels({ status: "failure", user: userId, type: name })
72+
.inc();
6973
return initDone(false);
7074
}
7175
const importerClass = importers[name];
7276
if (!importerClass) {
7377
logger.error(`${name} importer was not found`);
78+
Metrics.importsTotal
79+
.labels({ status: "failure", user: userId, type: name })
80+
.inc();
7481
return initDone(false);
7582
}
7683
if (!user.accessToken || !user.refreshToken) {
7784
logger.error(`User ${user.username} has no accessToken or no refreshToken`);
85+
Metrics.importsTotal
86+
.labels({ status: "failure", user: userId, type: name })
87+
.inc();
7888
return initDone(false);
7989
}
8090
const instance = importerClass(user) as unknown as HistoryImporter<T>;
@@ -115,9 +125,15 @@ export async function runImporter<T extends ImporterStateTypes>(
115125
await instance.run(existingState._id.toString());
116126
await instance.cleanup(requiredInitData);
117127
await setImporterStateStatus(existingState._id.toString(), "success");
128+
Metrics.importsTotal
129+
.labels({ status: "success", user: userId, type: name })
130+
.inc();
118131
} catch (e) {
119132
if (existingState) {
120133
await setImporterStateStatus(existingState._id.toString(), "failure");
134+
Metrics.importsTotal
135+
.labels({ status: "failure", user: userId, type: name })
136+
.inc();
121137
}
122138
logger.error(e);
123139
logger.error(

apps/server/src/tools/metrics.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Gauge, Counter, collectDefaultMetrics } from "prom-client";
2+
3+
export class Metrics {
4+
static httpRequestsTotal = new Counter({
5+
name: "http_requests_total",
6+
help: "Total number of HTTP requests",
7+
labelNames: ["method", "endpoint", "status"] as const,
8+
});
9+
10+
static httpRequestDurationNanoseconds = new Gauge({
11+
name: "http_request_duration_nanoseconds",
12+
help: "Duration of HTTP requests in nanoseconds",
13+
labelNames: ["method", "endpoint", "status"] as const,
14+
});
15+
16+
static importsTotal = new Counter({
17+
name: "imports_total",
18+
help: "Total number of imports",
19+
labelNames: ["type", "user", "status"] as const,
20+
});
21+
22+
static ingestedTracksTotal = new Counter({
23+
name: "ingested_tracks_total",
24+
help: "Total number of ingested tracks from Spotify API",
25+
labelNames: ["user"] as const,
26+
});
27+
28+
static ingestedAlbumsTotal = new Counter({
29+
name: "ingested_albums_total",
30+
help: "Total number of ingested albums from Spotify API",
31+
labelNames: ["user"] as const,
32+
});
33+
34+
static ingestedArtistsTotal = new Counter({
35+
name: "ingested_artists_total",
36+
help: "Total number of ingested artists from Spotify API",
37+
labelNames: ["user"] as const,
38+
});
39+
}
40+
41+
collectDefaultMetrics();

apps/server/src/tools/middleware.ts

+26
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { hrtime } from "process";
12
import { NextFunction, Request, Response } from "express";
23
import { z } from "zod";
34
import { verify } from "jsonwebtoken";
@@ -13,6 +14,7 @@ import {
1314
SpotifyRequest,
1415
} from "./types";
1516
import { SpotifyAPI } from "./apis/spotifyApi";
17+
import { Metrics } from "./metrics";
1618

1719
type Location = "body" | "params" | "query";
1820

@@ -190,3 +192,27 @@ export const notAlreadyImporting = async (
190192
}
191193
next();
192194
};
195+
196+
const MEASURE_METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"];
197+
198+
export const measureRequestDuration = (
199+
req: Request,
200+
res: Response,
201+
next: NextFunction,
202+
) => {
203+
if (!MEASURE_METHODS.includes(req.method)) {
204+
return next();
205+
}
206+
const endpoint = req.path;
207+
const start = hrtime.bigint();
208+
res.on("finish", () => {
209+
const duration = Number(hrtime.bigint() - start);
210+
Metrics.httpRequestDurationNanoseconds
211+
.labels(req.method, endpoint, res.statusCode.toString())
212+
.set(duration);
213+
Metrics.httpRequestsTotal
214+
.labels(req.method, endpoint, res.statusCode.toString())
215+
.inc();
216+
});
217+
next();
218+
};

yarn.lock

+33-1
Original file line numberDiff line numberDiff line change
@@ -1923,6 +1923,11 @@
19231923
"@nodelib/fs.scandir" "2.1.5"
19241924
fastq "^1.6.0"
19251925

1926+
"@opentelemetry/api@^1.4.0":
1927+
version "1.9.0"
1928+
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe"
1929+
integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==
1930+
19261931
"@pkgjs/parseargs@^0.11.0":
19271932
version "0.11.0"
19281933
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
@@ -3608,7 +3613,7 @@ balanced-match@^1.0.0:
36083613
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
36093614
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
36103615

3611-
basic-auth@~2.0.1:
3616+
basic-auth@^2.0.1, basic-auth@~2.0.1:
36123617
version "2.0.1"
36133618
resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a"
36143619
integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==
@@ -3641,6 +3646,11 @@ binary-extensions@^2.0.0:
36413646
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
36423647
integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
36433648

3649+
3650+
version "1.0.2"
3651+
resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8"
3652+
integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==
3653+
36443654
bluebird@^3.7.2:
36453655
version "3.7.2"
36463656
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
@@ -5635,6 +5645,13 @@ expect@^27.5.1:
56355645
jest-matcher-utils "^27.5.1"
56365646
jest-message-util "^27.5.1"
56375647

5648+
express-basic-auth@^1.2.1:
5649+
version "1.2.1"
5650+
resolved "https://registry.yarnpkg.com/express-basic-auth/-/express-basic-auth-1.2.1.tgz#d31241c03a915dd55db7e5285573049cfcc36381"
5651+
integrity sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==
5652+
dependencies:
5653+
basic-auth "^2.0.1"
5654+
56385655
express@^4.17.3:
56395656
version "4.21.1"
56405657
resolved "https://registry.yarnpkg.com/express/-/express-4.21.1.tgz#9dae5dda832f16b4eec941a4e44aa89ec481b281"
@@ -9279,6 +9296,14 @@ process-nextick-args@~2.0.0:
92799296
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
92809297
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
92819298

9299+
prom-client@^15.1.3:
9300+
version "15.1.3"
9301+
resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2"
9302+
integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==
9303+
dependencies:
9304+
"@opentelemetry/api" "^1.4.0"
9305+
tdigest "^0.1.1"
9306+
92829307
promise@^8.1.0:
92839308
version "8.3.0"
92849309
resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a"
@@ -10776,6 +10801,13 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1:
1077610801
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0"
1077710802
integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==
1077810803

10804+
tdigest@^0.1.1:
10805+
version "0.1.2"
10806+
resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced"
10807+
integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==
10808+
dependencies:
10809+
bintrees "1.0.2"
10810+
1077910811
temp-dir@^2.0.0:
1078010812
version "2.0.0"
1078110813
resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e"

0 commit comments

Comments
 (0)