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 1133164..fd2f62b 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "@scalar/fastify-api-reference": "^1.44.17", "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 78224ae..dfb3be9 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; @@ -66,9 +72,49 @@ 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 }, + }, + }; + + const existingLogger = options.logger; + + if (existingLogger && typeof existingLogger === "object") { + const loggerOptions = existingLogger as { transport?: unknown }; + const existingTransport = loggerOptions.transport; + + let mergedTransport: unknown; + if (Array.isArray(existingTransport)) { + mergedTransport = [...existingTransport, lokiTransport]; + } else if (existingTransport) { + mergedTransport = [existingTransport, lokiTransport]; + } else { + mergedTransport = lokiTransport; + } + + options.logger = { + ...(existingLogger as object), + transport: mergedTransport, + } as Exclude; + } else { + options.logger = { + level: "info", + transport: lokiTransport, + }; + } +} + // Support Typebox export type FastifyTypebox = FastifyInstance< RawServerDefault, @@ -93,6 +139,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 1966e76..92d86db 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -11,6 +11,7 @@ export type TestContext = { // needed for testing the application async function config(): Promise { return { + pluginTimeout: options.pluginTimeout, // mongoUri: "mongodb://localhost:27017", authDiscoveryURL: "", authClientID: "", @@ -19,10 +20,11 @@ async function config(): Promise { } // Automatically build and tear down our instance -async function build(t: TestContext) { - const fastify = Fastify({ pluginTimeout: options.pluginTimeout }); - const appConfig = await config(); - await fastify.register(app, appConfig); +async function build(t: TestContext, options?: Partial) { + const appOptions = { ...(await config()), ...options }; + + const fastify = Fastify(appOptions); + await fastify.register(app, appOptions); await fastify.ready(); // Tear down our app after we are done 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 d8d036d..313d7f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -914,6 +914,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" @@ -1528,6 +1535,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" @@ -2360,6 +2374,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" @@ -4109,6 +4135,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" @@ -4219,6 +4266,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" @@ -4239,6 +4296,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.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -4649,15 +4716,24 @@ __metadata: linkType: hard "tar@npm:^7.5.4": - version: 7.5.7 - resolution: "tar@npm:7.5.7" + version: 7.5.9 + resolution: "tar@npm:7.5.9" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10c0/51f261afc437e1112c3e7919478d6176ea83f7f7727864d8c2cce10f0b03a631d1911644a567348c3063c45abdae39718ba97abb073d22aa3538b9a53ae1e31c + checksum: 10c0/e870beb1b2477135ca2abe86b2d18f7b35d0a4e3a37bbc523d3b8f7adca268dfab543f26528a431d569897f8c53a7cac745cdfbc4411c2f89aeeacc652b81b0a + 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 @@ -4684,12 +4760,14 @@ __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" jwks-rsa: "npm:^3.2.2" lint-staged: "npm:^16.2.7" 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.2"