Skip to content
Merged
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.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": {
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.
65 changes: 65 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<AutoloadPluginOptions> &
InitMongoPluginOptions &
Expand Down Expand Up @@ -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<FastifyServerOptions["logger"], boolean | undefined>;
}
}

// Support Typebox
export type FastifyTypebox = FastifyInstance<
RawServerDefault,
Expand All @@ -93,6 +135,29 @@ const app: FastifyPluginAsync<AppOptions> = 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: {
Expand Down
30 changes: 22 additions & 8 deletions test/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type TestContext = {
// needed for testing the application
async function config(mongoUri: string): Promise<AppOptions> {
return {
pluginTimeout: options.pluginTimeout,
mongoUri,
authDiscoveryURL: "",
authClientID: "",
Expand All @@ -20,19 +21,32 @@ async function config(mongoUri: string): Promise<AppOptions> {
}

// 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<AppOptions>) {
const cleanups: (() => Promise<unknown>)[] = [];
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) {
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);
});
Loading