Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,7 @@ dist
!.yarn/releases
!.yarn/sdks
!.yarn/versions

# Prometheus
# Ignore the local data directory used by Prometheus
data/
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
56 changes: 56 additions & 0 deletions prometheus.yml.example
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<AutoloadPluginOptions> &
AuthPluginOptions;
Expand Down Expand Up @@ -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<FastifyServerOptions["logger"], boolean | undefined>;
} else {
options.logger = {
level: "info",
transport: lokiTransport,
};
}
Comment on lines +93 to +115
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Loki transport configuration modifies the options object at module level before the app is created. If the logger option is a boolean (true/false) rather than an object, the code in the else block (lines 111-114) will override it, potentially causing unexpected behavior. The condition on line 93 should also check if existingLogger is not a boolean value to properly handle all logger configuration types.

Copilot uses AI. Check for mistakes.
Copy link
Member

@polyipseity polyipseity Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the above comment, you can proceed as follows if the type is a boolean:

  • If the boolean is true, you existing logic should already work properly.
  • If false then you might not want to enable loki.

}

// Support Typebox
export type FastifyTypebox = FastifyInstance<
RawServerDefault,
Expand All @@ -93,6 +139,29 @@ const app: FastifyPluginAsync<AppOptions> = async (
origin: "*",
});

// Register Metrics
const metricsEndpoint: RouteOptions | string | null = opts.prometheusKey
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metricsEndpoint variable is typed as 'RouteOptions | string | null' but is only assigned either a RouteOptions object or a string. The 'null' type is never used and should be removed from the type annotation to accurately reflect the actual possible values.

Suggested change
const metricsEndpoint: RouteOptions | string | null = opts.prometheusKey
const metricsEndpoint: RouteOptions | string = opts.prometheusKey

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since opts.prometheusKey, is nullable, so is the route.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that the entire statement is always non-null:

  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";

If opts.prometheusKey is null then it goes to the falsy branch and evaluates to "/metrics". Maybe it's not what you intended?

? {
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: {
Expand Down
10 changes: 6 additions & 4 deletions test/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type TestContext = {
// needed for testing the application
async function config(): Promise<AppOptions> {
return {
pluginTimeout: options.pluginTimeout,
// mongoUri: "mongodb://localhost:27017",
authDiscoveryURL: "",
authClientID: "",
Expand All @@ -19,10 +20,11 @@ async function config(): Promise<AppOptions> {
}

// 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<AppOptions>) {
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
Expand Down
41 changes: 41 additions & 0 deletions test/routes/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
84 changes: 81 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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

Expand All @@ -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"
Expand Down