Files
WeChat-AI/apps/api/src/server-options.ts
T

119 lines
4.5 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { LogController } from "fastify";
import type {
FastifyInstance,
FastifyRequest,
FastifyServerOptions,
} from "fastify";
import type { AppConfig } from "./config.js";
import { describeRequest, logPath } from "./request-log.js";
/**
* Silences Fastify's own "incoming request" / "request completed" pair while
* leaving every error path intact.
*
* The obvious `disableRequestLogging: true` cannot be used: it is routed through
* `isLogDisabled`, which `defaultErrorLog`, `streamError`, `writeHeadError` and
* `serializerError` all consult first — so it silences framework error logging
* as well, which is the single most valuable thing the logger does. (It is also
* deprecated in Fastify 5 and gone in 6.) Overriding just the two noisy methods
* gets the quiet request log without giving up error reporting.
*/
class QuietRequestLogController extends LogController {
override incomingRequest(): void {}
override requestCompleted(): void {}
}
/**
* Fastify construction options and the request-logging hook.
*
* Lives outside index.ts because index.ts self-invokes `main()` and so cannot
* be imported — and because pino throws at construction on a bad level or
* redact path, which makes this the one config in the process where a typo is a
* boot failure. Having it here means a test can build a real instance with it.
*/
export interface ServerOptionsExtras {
/** Test seam: pino destination. Omit in production to write to stdout. */
logStream?: NodeJS.WritableStream;
}
export function buildFastifyOptions(
cfg: AppConfig,
extras: ServerOptionsExtras = {},
): FastifyServerOptions {
return {
// This used to be `cfg.logLevel === "debug"`, which meant the shipped
// default of LOG_LEVEL=info produced `logger: false` — no request logs, no
// latency, and none of Fastify's own error logging. A route that threw left
// nothing behind but a 500 on the wire.
logger: {
level: cfg.logLevel,
serializers: {
/**
* Fastify's built-in `req` serializer emits `url` verbatim — query
* string and all — and `LogController.defaultErrorLog` logs
* `{ req, res, err }` on every 5xx. That means a 500 on
* `/api/v1/auth/callback?code=…&state=…` would write a live single-use
* OAuth credential to the log, defeating the whole point of logPath().
* Redacting headers does not help: the built-in serializer never emits
* headers in the first place. So replace it and strip the query here.
*/
req: (req: FastifyRequest) => ({
method: req.method,
path: logPath(req.url),
host: req.host,
remoteAddress: req.ip,
}),
},
// Belt and braces: the serializer above emits no headers, but any code
// that logs a header bag explicitly must still not leak credentials.
redact: {
paths: [
"req.headers.cookie",
"req.headers.authorization",
'req.headers["x-api-key"]',
'res.headers["set-cookie"]',
],
remove: true,
},
...(extras.logStream ? { stream: extras.logStream } : {}),
},
// Fastify's own pair of lines per request carries no latency and floods on
// health probes; registerRequestLogging emits one useful line instead.
// Errors still log — see QuietRequestLogController.
logController: new QuietRequestLogController(),
genReqId: () => randomUUID().slice(0, 8),
bodyLimit: 1024 * 1024,
requestTimeout: 60_000,
connectionTimeout: 30_000,
// Do NOT enable trustProxy: origins are reachable directly by IP, so a
// forged X-Forwarded-For would bypass the login and CDN rate limiters.
// clientIp() reads cf-connecting-ip explicitly instead.
};
}
/**
* One line per completed request. Rules (quiet paths, slow-request warnings,
* and stripping credential-bearing query strings) live in request-log.ts.
*/
export function registerRequestLogging(
app: FastifyInstance,
cfg: AppConfig,
): void {
app.addHook("onResponse", async (req, reply) => {
const line = describeRequest({
method: req.method,
url: req.url,
status: reply.statusCode,
elapsedMs: reply.elapsedTime,
cfConnectingIp: req.headers["cf-connecting-ip"] as string | undefined,
socketIp: req.ip,
slowMs: cfg.logSlowRequestMs,
});
if (!line) return;
const { level, ...fields } = line;
req.log[level](fields, "request");
});
}