diff --git a/.gitignore b/.gitignore index 67f48cf..343a379 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,7 @@ dist !.yarn/releases !.yarn/sdks !.yarn/versions + +# Prometheus +# Ignore the local data directory used by Prometheus +data/ diff --git a/package.json b/package.json index 52691d7..5ef355d 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "@scalar/fastify-api-reference": "^1.44.24", "fastify": "^5.7.4", "fastify-cli": "^7.4.1", + "fastify-metrics": "^12.1.0", "fastify-plugin": "^5.1.0", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^3.2.2", "openid-client": "^6.8.2", + "pino-loki": "^3.0.0", "typebox": "^1.0.81" }, "devDependencies": { diff --git a/prometheus.yml.example b/prometheus.yml.example new file mode 100644 index 0000000..9bd7051 --- /dev/null +++ b/prometheus.yml.example @@ -0,0 +1,56 @@ +global: + scrape_interval: 15s # By default, scrape targets every 15 seconds. + + # Attach these labels to any time series or alerts when communicating with + # external systems (federation, remote storage, Alertmanager). + external_labels: + monitor: 'codelab-monitor' + +# Scrape configurations used for local development: +# - `prometheus`: Prometheus' own internal metrics endpoint. +# - `template-api`: this project's API (expects metrics at `http://localhost:3000/metrics`). +scrape_configs: + # Prometheus server metrics (job=prometheus) + - job_name: 'prometheus' + + # Short interval for local testing. + scrape_interval: 5s + + static_configs: + - targets: ['localhost:9090'] + + # The template API exposes application metrics at `/metrics`. + # Make sure the API is running locally and exposes Prometheus metrics (e.g. using `prom-client` or middleware). + - job_name: 'template-api' + # Keep a short interval for fast feedback during development. + scrape_interval: 5s + # Prometheus will request the `/metrics` path by default, but we show it here explicitly for clarity. + metrics_path: /metrics + static_configs: + - targets: ['localhost:3000'] + + # Optional: relabeling examples for common local/dev setups. Uncomment and adapt as needed. + # - Add a static label (useful for environment-specific queries) + # relabel_configs: + # - target_label: env + # replacement: dev + # + # - Set `instance` to the hostname (strip port) + # - source_labels: [__address__] + # regex: '([^:]+):.*' + # target_label: instance + # replacement: '$1' + # + # - Drop a specific target (useful to temporarily disable scraping) + # - source_labels: [__address__] + # regex: '127\\.0\\.0\\.1:3000' + # action: drop + # + # - Preserve original address in another label before rewriting (advanced example) + # - source_labels: [__address__] + # target_label: __param_target + # replacement: '$1' + # + # Notes: + # - `relabel_configs` run during target discovery and can add/modify/drop labels. + # - Keep examples commented so the default local setup continues to work. diff --git a/src/app.ts b/src/app.ts index 656aa28..e3eb6fd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,7 +11,10 @@ import { RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, + FastifyReply, + FastifyRequest, } from "fastify"; +import fastifyMetrics from "fastify-metrics"; import * as path from "path"; import { fileURLToPath } from "url"; @@ -20,6 +23,8 @@ const __dirname = path.dirname(__filename); export type AppOptions = { // Place your custom options for app below here. + lokiHost?: string; + prometheusKey?: string; } & FastifyServerOptions & Partial & InitMongoPluginOptions & @@ -66,9 +71,46 @@ 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: getBooleanOption("AUTH_SKIP", false), }; +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 }, + }, + }; + + let existingLogger = options.logger; + if (typeof existingLogger === "boolean") { + existingLogger = existingLogger ? { level: "info" } : undefined; + } + + if (existingLogger) { + const existingTransport = existingLogger.transport; + + let mergedTransport: unknown; + if (Array.isArray(existingTransport)) { + mergedTransport = [...existingTransport, lokiTransport]; + } else if (existingTransport) { + mergedTransport = [existingTransport, lokiTransport]; + } else { + mergedTransport = lokiTransport; + } + + options.logger = { + ...existingLogger, + transport: mergedTransport, + } as Exclude; + } +} + // Support Typebox export type FastifyTypebox = FastifyInstance< RawServerDefault, @@ -93,6 +135,29 @@ const app: FastifyPluginAsync = async ( origin: "*", }); + // Register Metrics + const metricsEndpoint = 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 a9c0e26..9af77a9 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -12,6 +12,7 @@ export type TestContext = { // needed for testing the application async function config(mongoUri: string): Promise { return { + pluginTimeout: options.pluginTimeout, mongoUri, authDiscoveryURL: "", authClientID: "", @@ -20,19 +21,32 @@ async function config(mongoUri: string): Promise { } // Automatically build and tear down our instance -async function build(t: TestContext) { - const mongod = await MongoMemoryServer.create(); - const fastify = Fastify({ pluginTimeout: options.pluginTimeout }); - +async function build(t: TestContext, options?: Partial) { + const cleanups: (() => Promise)[] = []; const cleanup = async () => { - await fastify.close(); - await mongod.stop(); + // Cleanup in reverse order of setup + for (const fn of cleanups.reverse()) { + try { + await fn(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } }; t.after(cleanup); try { - const appConfig = await config(mongod.getUri("example-test")); - await fastify.register(app, appConfig); + const mongod = await MongoMemoryServer.create(); + cleanups.push(async () => await mongod.stop()); + + const appOptions = { + ...(await config(mongod.getUri("example-test"))), + ...options, + }; + const fastify = Fastify(appOptions); + cleanups.push(async () => await fastify.close()); + + await fastify.register(app, appOptions); await fastify.ready(); return fastify; } catch (error) { 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 70e0542..7febd23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -909,6 +909,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" @@ -1564,6 +1571,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" @@ -2449,6 +2463,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" @@ -4346,6 +4372,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" @@ -4465,6 +4512,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 + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -4485,6 +4542,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.3": + version: 3.0.4 + resolution: "pump@npm:3.0.4" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10c0/2780e66b5471c19e3e3e1063b84f3f6a3a08367f24c5ed552f98cd5901e6ada27c7ad6495d4244f553fd03b01884a4561933064f053f47c8994d84fd352768ea + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -4938,6 +5005,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:." @@ -4961,6 +5037,7 @@ __metadata: eslint-config-prettier: "npm:^10.1.8" fastify: "npm:^5.7.4" fastify-cli: "npm:^7.4.1" + fastify-metrics: "npm:^12.1.0" fastify-plugin: "npm:^5.1.0" husky: "npm:^9.1.7" jsonwebtoken: "npm:^9.0.3" @@ -4968,6 +5045,7 @@ __metadata: lint-staged: "npm:^16.2.7" mongodb-memory-server: "npm:11.0.1" openid-client: "npm:^6.8.2" + pino-loki: "npm:^3.0.0" prettier: "npm:^3.8.1" prettier-plugin-jsdoc: "npm:^1.8.0" rimraf: "npm:^6.1.3"