diff --git a/.env.example b/.env.example index d098d10..b1b7de8 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,9 @@ MONGO_URI=mongodb://localhost:27017/your-database-name # Auth Plugin AUTH_DISCOVERY_URL=https://login.microsoftonline.com/c917f3e2-9322-4926-9bb3-daca730413ca/v2.0/.well-known/openid-configuration AUTH_CLIENT_ID=b4bc4b9a-7162-44c5-bb50-fe935dce1f5a + +# Loki Host +# LOKI_HOST=http://localhost:3100 + +# Prometheus Key +PROMETHEUS_KEY=prometheus diff --git a/package.json b/package.json index 5d06e4a..6307ab1 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,12 @@ "@sinclair/typebox": "^0.34.41", "fastify": "^5.6.2", "fastify-cli": "7.4.1", + "fastify-metrics": "^12.1.0", "fastify-plugin": "^5.1.0", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", - "openid-client": "^6.8.1" + "openid-client": "^6.8.1", + "pino-loki": "^3.0.0" }, "devDependencies": { "@commitlint/cli": "^20.1.0", diff --git a/src/app.ts b/src/app.ts index 7ffe4b0..b8a3efb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,7 +10,11 @@ import { RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, + FastifyReply, + FastifyRequest, + RouteOptions, } from "fastify"; +import fastifyMetrics from "fastify-metrics"; import * as path from "path"; import { fileURLToPath } from "url"; @@ -21,6 +25,8 @@ export type AppOptions = { // Place your custom options for app below here. // MongoDB URI (Optional) // mongoUri: string; + lokiHost?: string; + prometheusKey?: string; } & FastifyServerOptions & Partial & AuthPluginOptions; @@ -49,6 +55,8 @@ const options: AppOptions = { // mongoUri: getOption("MONGO_URI")!, authDiscoveryURL: getOption("AUTH_DISCOVERY_URL")!, authClientID: getOption("AUTH_CLIENT_ID")!, + lokiHost: getOption("LOKI_HOST", false), + prometheusKey: getOption("PROMETHEUS_KEY", false), authSkip: (() => { const opt = getOption("AUTH_SKIP", false); if (opt !== undefined) { @@ -59,6 +67,43 @@ const options: AppOptions = { })(), }; +if (options.lokiHost) { + const lokiTransport = { + target: "pino-loki", + options: { + batching: true, + interval: 5, // Logs are sent every 5 seconds, default. + host: options.lokiHost, + labels: { application: packageJson.name }, + }, + }; + + const existingLogger = options.logger; + + if (existingLogger && typeof existingLogger === "object") { + const existingTransport = (existingLogger as any).transport; + + let mergedTransport: any; + if (Array.isArray(existingTransport)) { + mergedTransport = [...existingTransport, lokiTransport]; + } else if (existingTransport) { + mergedTransport = [existingTransport, lokiTransport]; + } else { + mergedTransport = lokiTransport; + } + + options.logger = { + ...(existingLogger as any), + transport: mergedTransport, + } as any; + } else { + options.logger = { + level: "info", + transport: lokiTransport, + }; + } +} + // Support Typebox export type FastifyTypebox = FastifyInstance< RawServerDefault, @@ -83,6 +128,29 @@ const app: FastifyPluginAsync = async ( origin: "*", }); + // Register Metrics + const metricsEndpoint: RouteOptions | string | null = opts.prometheusKey + ? { + url: "/metrics", + method: "GET", + handler: async () => {}, // Overridden by fastify-metrics + onRequest: async (request: FastifyRequest, reply: FastifyReply) => { + if ( + request.headers.authorization !== `Bearer ${opts.prometheusKey}` + ) { + reply.code(401).send({ status: "error", message: "Unauthorized" }); + return; + } + }, + } + : "/metrics"; + + await fastify.register(fastifyMetrics.default, { + endpoint: metricsEndpoint, + defaultMetrics: { enabled: true }, + clearRegisterOnInit: true, + }); + // Register Swagger & Swagger UI & Scalar await fastify.register(import("@fastify/swagger"), { openapi: { diff --git a/test/helper.ts b/test/helper.ts index db4bdde..9edb964 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -25,14 +25,16 @@ async function config(): Promise { } // Automatically build and tear down our instance -async function build(t: TestContext) { +async function build(t: TestContext, options?: Partial) { // you can set all the options supported by the fastify CLI command const argv = [AppPath]; + const appOptions = { ...(await config()), ...options }; + // fastify-plugin ensures that all decorators // are exposed for testing purposes, this is // different from the production setup - const app = await helper.build(argv, await config(), await config()); + const app = await helper.build(argv, appOptions, appOptions); // Tear down our app after we are done t.after(() => void app.close()); diff --git a/test/routes/metrics.test.ts b/test/routes/metrics.test.ts new file mode 100644 index 0000000..25224ad --- /dev/null +++ b/test/routes/metrics.test.ts @@ -0,0 +1,41 @@ +import { build } from "../helper.js"; +import * as assert from "node:assert"; +import { test } from "node:test"; + +test("metrics route without key", async (t) => { + const app = await build(t); + + const response = await app.inject({ + url: "/metrics", + }); + + assert.equal(response.statusCode, 200); +}); + +test("metrics route with key", async (t) => { + const app = await build(t, { prometheusKey: "secret" }); + + // Without auth header + const response = await app.inject({ + url: "/metrics", + }); + assert.equal(response.statusCode, 401); + + // With correct auth header + const responseAuth = await app.inject({ + url: "/metrics", + headers: { + authorization: "Bearer secret", + }, + }); + assert.equal(responseAuth.statusCode, 200); + + // With incorrect auth header + const responseBadAuth = await app.inject({ + url: "/metrics", + headers: { + authorization: "Bearer wrong", + }, + }); + assert.equal(responseBadAuth.statusCode, 401); +}); diff --git a/yarn.lock b/yarn.lock index 250fbf5..32a194d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -686,6 +686,13 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/api@npm:^1.4.0": + version: 1.9.0 + resolution: "@opentelemetry/api@npm:1.9.0" + checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add + languageName: node + linkType: hard + "@pinojs/redact@npm:^0.4.0": version: 0.4.0 resolution: "@pinojs/redact@npm:0.4.0" @@ -1342,6 +1349,13 @@ __metadata: languageName: node linkType: hard +"bintrees@npm:1.0.2": + version: 1.0.2 + resolution: "bintrees@npm:1.0.2" + checksum: 10c0/132944b20c93c1a8f97bf8aa25980a76c6eb4291b7f2df2dbcd01cb5b417c287d3ee0847c7260c9f05f3d5a4233aaa03dec95114e97f308abe9cc3f72bed4a44 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.12 resolution: "brace-expansion@npm:1.1.12" @@ -2080,6 +2094,18 @@ __metadata: languageName: node linkType: hard +"fastify-metrics@npm:^12.1.0": + version: 12.1.0 + resolution: "fastify-metrics@npm:12.1.0" + dependencies: + fastify-plugin: "npm:^5.0.0" + prom-client: "npm:^15.1.3" + peerDependencies: + fastify: ">=5" + checksum: 10c0/b42940b8c7cbfcd86182b9a85b53dc903727c73523cf14ff465fca7a64ffa966dcf14e9af944efdecfb1be027f2cb356dbaf5126f25c789eed6cd5b6d4767e24 + languageName: node + linkType: hard + "fastify-plugin@npm:^4.5.1": version: 4.5.1 resolution: "fastify-plugin@npm:4.5.1" @@ -3611,6 +3637,27 @@ __metadata: languageName: node linkType: hard +"pino-abstract-transport@npm:^3.0.0": + version: 3.0.0 + resolution: "pino-abstract-transport@npm:3.0.0" + dependencies: + split2: "npm:^4.0.0" + checksum: 10c0/4486e1b9508110aaf963d07741ac98d660b974dd51d8ad42077d215118e27cda20c64da46c07c926898d52540aab7c6b9c37dc0f5355c203bb1d6a72b5bd8d6c + languageName: node + linkType: hard + +"pino-loki@npm:^3.0.0": + version: 3.0.0 + resolution: "pino-loki@npm:3.0.0" + dependencies: + pino-abstract-transport: "npm:^3.0.0" + pump: "npm:^3.0.3" + bin: + pino-loki: dist/cli.mjs + checksum: 10c0/d7d83b8989366ff73d461f0c39adf1c7000c54a658f282cdb0e035e0fea88ac63933bd0f84dd13fbfcc210581f54b97389ff104f501559ea635c5316a1a6b398 + languageName: node + linkType: hard + "pino-pretty@npm:^13.0.0": version: 13.0.0 resolution: "pino-pretty@npm:13.0.0" @@ -3810,6 +3857,16 @@ __metadata: languageName: node linkType: hard +"prom-client@npm:^15.1.3": + version: 15.1.3 + resolution: "prom-client@npm:15.1.3" + dependencies: + "@opentelemetry/api": "npm:^1.4.0" + tdigest: "npm:^0.1.1" + checksum: 10c0/816525572e5799a2d1d45af78512fb47d073c842dc899c446e94d17cfc343d04282a1627c488c7ca1bcd47f766446d3e49365ab7249f6d9c22c7664a5bce7021 + languageName: node + linkType: hard + "pump@npm:^3.0.0": version: 3.0.0 resolution: "pump@npm:3.0.0" @@ -3820,6 +3877,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.3": + version: 3.0.3 + resolution: "pump@npm:3.0.3" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10c0/ada5cdf1d813065bbc99aa2c393b8f6beee73b5de2890a8754c9f488d7323ffd2ca5f5a0943b48934e3fcbd97637d0337369c3c631aeb9614915db629f1c75c9 + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.3.0": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -4160,6 +4227,15 @@ __metadata: languageName: node linkType: hard +"tdigest@npm:^0.1.1": + version: 0.1.2 + resolution: "tdigest@npm:0.1.2" + dependencies: + bintrees: "npm:1.0.2" + checksum: 10c0/10187b8144b112fcdfd3a5e4e9068efa42c990b1e30cd0d4f35ee8f58f16d1b41bc587e668fa7a6f6ca31308961cbd06cd5d4a4ae1dc388335902ae04f7d57df + languageName: node + linkType: hard + "template-api@workspace:.": version: 0.0.0-use.local resolution: "template-api@workspace:." @@ -4185,6 +4261,7 @@ __metadata: eslint-plugin-prettier: "npm:^5.5.4" fastify: "npm:^5.6.2" fastify-cli: "npm:7.4.1" + fastify-metrics: "npm:^12.1.0" fastify-plugin: "npm:^5.1.0" fastify-tsconfig: "npm:^3.0.0" globals: "npm:^16.5.0" @@ -4192,6 +4269,7 @@ __metadata: jsonwebtoken: "npm:^9.0.2" jwks-rsa: "npm:^3.2.0" openid-client: "npm:^6.8.1" + pino-loki: "npm:^3.0.0" prettier: "npm:3.7.4" prettier-plugin-jsdoc: "npm:^1.7.0" typescript: "npm:^5.9.3"