Skip to content

Conversation

@wylited
Copy link

@wylited wylited commented Jan 7, 2026

How this monitoring system will work is that each API service will expose a fastify-metrics endpoint at /metrics.
this is a Prometheus scrapable endpoint for our monitoring platform.

Furthermore, if provided, it will fastify will log automatically to a Loki logging server using pino-loki.

The rest of the setup for monitoring will be done on the server.

@wylited wylited self-assigned this Jan 7, 2026
@wylited wylited added the enhancement New feature or request label Jan 7, 2026
@wylited wylited requested a review from Copilot January 7, 2026 16:15
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds comprehensive API monitoring capabilities to the Fastify-based template service by integrating Prometheus metrics collection and optional Loki logging.

Key Changes:

  • Adds fastify-metrics plugin to expose a /metrics endpoint for Prometheus scraping with default metrics enabled
  • Integrates pino-loki transport for optional centralized logging to a Loki server
  • Introduces optional LOKI_HOST environment variable for conditional logging configuration

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 5 comments.

File Description
yarn.lock Adds dependency resolutions for fastify-metrics (v12.1.0), pino-loki (v3.0.0), prom-client (v15.1.3), and their transitive dependencies
package.json Adds fastify-metrics and pino-loki as runtime dependencies
src/app.ts Configures metrics endpoint registration and conditional Loki logging transport based on LOKI_HOST environment variable
.env.example Documents the optional LOKI_HOST environment variable with example configuration

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@wylited wylited added this to the 26-early-spring milestone Jan 7, 2026
@wylited wylited marked this pull request as draft January 10, 2026 09:11
@wylited wylited marked this pull request as ready for review January 10, 2026 17:50
@wylited wylited requested a review from Copilot January 10, 2026 17:50
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 5 to 67
export default fp(async (fastify, opts) => {
fastify.addHook("onRequest", async (request, reply) => {
// Only check IP on the /metrics endpoint
// Since we don't register this endpoint, we have to make this check.
if (request.url.split("?")[0] === "/metrics") {
const ip = request.ip;

let remoteIP;
try {
remoteIP = ipaddr.parse(ip);
} catch {
request.log.warn({ ip }, "Invalid IP address accessing metrics");
return reply.code(403).send("Forbidden");
}

// Handle IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1)
if (
remoteIP.kind() === "ipv6" &&
(remoteIP as ipaddr.IPv6).isIPv4MappedAddress()
) {
remoteIP = (remoteIP as ipaddr.IPv6).toIPv4Address();
}

const allowedIPv4 = [
ipaddr.parseCIDR("127.0.0.0/8"), // localhost
ipaddr.parseCIDR("10.0.0.0/8"), // private A
ipaddr.parseCIDR("172.16.0.0/12"), // private B (includes 172.17.0.0/16 docker)
];

const allowedIPv6 = [
ipaddr.parseCIDR("::1/128"), // localhost
ipaddr.parseCIDR("fc00::/7"), // unique local
];

let allowed = false;
if (remoteIP.kind() === "ipv4") {
for (const range of allowedIPv4) {
if (remoteIP.match(range)) {
allowed = true;
break;
}
}
} else if (remoteIP.kind() === "ipv6") {
for (const range of allowedIPv6) {
if (remoteIP.match(range)) {
allowed = true;
break;
}
}
}

if (!allowed) {
request.log.warn({ ip }, "Access to metrics denied");
return reply.code(403).send("Forbidden");
}
}
});

await fastify.register(fastifyMetrics.default, {
endpoint: "/metrics",
defaultMetrics: { enabled: true },
});
});
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The new metrics plugin lacks test coverage. Other plugins in the codebase (support.ts, auth.ts) have corresponding test files. This plugin should have tests covering the IP validation logic, access control behavior (both allowed and denied cases), and successful metrics endpoint registration.

Copilot uses AI. Check for mistakes.
fastify.addHook("onRequest", async (request, reply) => {
// Only check IP on the /metrics endpoint
// Since we don't register this endpoint, we have to make this check.
if (request.url.split("?")[0] === "/metrics") {
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The URL parsing logic request.url.split("?")[0] is fragile and doesn't handle all edge cases. For example, URLs with fragments or encoded characters might not be handled correctly. Consider using a more robust URL parsing approach or leveraging Fastify's built-in URL parsing capabilities (e.g., request.routeOptions.url or path matching).

Suggested change
if (request.url.split("?")[0] === "/metrics") {
const url = new URL(request.raw.url, "http://localhost");
if (url.pathname === "/metrics") {

Copilot uses AI. Check for mistakes.
Comment on lines 6 to 61
fastify.addHook("onRequest", async (request, reply) => {
// Only check IP on the /metrics endpoint
// Since we don't register this endpoint, we have to make this check.
if (request.url.split("?")[0] === "/metrics") {
const ip = request.ip;

let remoteIP;
try {
remoteIP = ipaddr.parse(ip);
} catch {
request.log.warn({ ip }, "Invalid IP address accessing metrics");
return reply.code(403).send("Forbidden");
}

// Handle IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1)
if (
remoteIP.kind() === "ipv6" &&
(remoteIP as ipaddr.IPv6).isIPv4MappedAddress()
) {
remoteIP = (remoteIP as ipaddr.IPv6).toIPv4Address();
}

const allowedIPv4 = [
ipaddr.parseCIDR("127.0.0.0/8"), // localhost
ipaddr.parseCIDR("10.0.0.0/8"), // private A
ipaddr.parseCIDR("172.16.0.0/12"), // private B (includes 172.17.0.0/16 docker)
];

const allowedIPv6 = [
ipaddr.parseCIDR("::1/128"), // localhost
ipaddr.parseCIDR("fc00::/7"), // unique local
];

let allowed = false;
if (remoteIP.kind() === "ipv4") {
for (const range of allowedIPv4) {
if (remoteIP.match(range)) {
allowed = true;
break;
}
}
} else if (remoteIP.kind() === "ipv6") {
for (const range of allowedIPv6) {
if (remoteIP.match(range)) {
allowed = true;
break;
}
}
}

if (!allowed) {
request.log.warn({ ip }, "Access to metrics denied");
return reply.code(403).send("Forbidden");
}
}
});
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The onRequest hook runs for every single request to the server, adding overhead to all routes. While the IP check only executes for /metrics, the URL string comparison happens on every request. Additionally, the CIDR range arrays are recreated on every /metrics request. Consider moving the allowed IP range definitions outside the hook as constants, and ideally use a route-specific preHandler hook or configure security through the fastify-metrics plugin options if available.

Copilot uses AI. Check for mistakes.
import fp from "fastify-plugin";
import ipaddr from "ipaddr.js";

export default fp(async (fastify, opts) => {
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The plugin doesn't follow the established pattern seen in other plugins. It's missing an interface for plugin options (like SupportPluginOptions or AuthPluginOptions), and doesn't type the plugin's generic parameter. This makes the plugin less type-safe and harder to configure with custom options.

Copilot uses AI. Check for mistakes.
Comment on lines 5 to 67
export default fp(async (fastify, opts) => {
fastify.addHook("onRequest", async (request, reply) => {
// Only check IP on the /metrics endpoint
// Since we don't register this endpoint, we have to make this check.
if (request.url.split("?")[0] === "/metrics") {
const ip = request.ip;

let remoteIP;
try {
remoteIP = ipaddr.parse(ip);
} catch {
request.log.warn({ ip }, "Invalid IP address accessing metrics");
return reply.code(403).send("Forbidden");
}

// Handle IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1)
if (
remoteIP.kind() === "ipv6" &&
(remoteIP as ipaddr.IPv6).isIPv4MappedAddress()
) {
remoteIP = (remoteIP as ipaddr.IPv6).toIPv4Address();
}

const allowedIPv4 = [
ipaddr.parseCIDR("127.0.0.0/8"), // localhost
ipaddr.parseCIDR("10.0.0.0/8"), // private A
ipaddr.parseCIDR("172.16.0.0/12"), // private B (includes 172.17.0.0/16 docker)
];

const allowedIPv6 = [
ipaddr.parseCIDR("::1/128"), // localhost
ipaddr.parseCIDR("fc00::/7"), // unique local
];

let allowed = false;
if (remoteIP.kind() === "ipv4") {
for (const range of allowedIPv4) {
if (remoteIP.match(range)) {
allowed = true;
break;
}
}
} else if (remoteIP.kind() === "ipv6") {
for (const range of allowedIPv6) {
if (remoteIP.match(range)) {
allowed = true;
break;
}
}
}

if (!allowed) {
request.log.warn({ ip }, "Access to metrics denied");
return reply.code(403).send("Forbidden");
}
}
});

await fastify.register(fastifyMetrics.default, {
endpoint: "/metrics",
defaultMetrics: { enabled: true },
});
});
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

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

The metrics plugin has hardcoded IP access control with no configuration options. Consider adding an interface for MetricsPluginOptions that allows customizing the allowed IP ranges or disabling access control entirely. This would make the plugin more flexible for different deployment scenarios (e.g., allowing metrics access from specific monitoring server IPs).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants