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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
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 @@
// Place your custom options for app below here.
// MongoDB URI (Optional)
// mongoUri: string;
lokiHost?: string;
prometheusKey?: string;
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

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

The variable name "prometheusKey" is misleading as it's actually used as an authentication secret/token. Consider renaming to "prometheusAuthToken" or "prometheusSecret" to better reflect its purpose and security implications.

Copilot uses AI. Check for mistakes.
} & FastifyServerOptions &
Partial<AutoloadPluginOptions> &
AuthPluginOptions;
Expand Down Expand Up @@ -49,6 +55,8 @@
// 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) {
Expand All @@ -59,6 +67,43 @@
})(),
};

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;

Check failure on line 84 in src/app.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type

Check failure on line 84 in src/app.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type

let mergedTransport: any;

Check failure on line 86 in src/app.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type

Check failure on line 86 in src/app.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type
if (Array.isArray(existingTransport)) {
mergedTransport = [...existingTransport, lokiTransport];
} else if (existingTransport) {
mergedTransport = [existingTransport, lokiTransport];
} else {
mergedTransport = lokiTransport;
}

options.logger = {
...(existingLogger as any),

Check failure on line 96 in src/app.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type

Check failure on line 96 in src/app.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type
transport: mergedTransport,
} as any;

Check failure on line 98 in src/app.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type

Check failure on line 98 in src/app.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type
} else {
options.logger = {
level: "info",
transport: lokiTransport,
};
}
}

// Support Typebox
export type FastifyTypebox = FastifyInstance<
RawServerDefault,
Expand All @@ -83,6 +128,29 @@
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}`
) {
Comment on lines +138 to +140
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

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

The authentication check is vulnerable to timing attacks. Consider using a constant-time comparison function to compare the authorization header with the expected value to prevent attackers from determining the correct key through timing analysis.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

This is okay... I guess.

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
6 changes: 4 additions & 2 deletions test/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ async function config(): Promise<AppOptions> {
}

// Automatically build and tear down our instance
async function build(t: TestContext) {
async function build(t: TestContext, options?: Partial<AppOptions>) {
// 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());
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);
});
78 changes: 78 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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:."
Expand All @@ -4185,13 +4261,15 @@ __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"
husky: "npm:^9.1.7"
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"
Expand Down
Loading