diff --git a/changelog.d/added-nps-relay-request-logging.md b/changelog.d/added-nps-relay-request-logging.md new file mode 100644 index 0000000..257859a --- /dev/null +++ b/changelog.d/added-nps-relay-request-logging.md @@ -0,0 +1 @@ +- Emit one bounded structured log line per NPS relay request (route, method, status, duration) so rate-limit and auth rejections are visible in Netlify function logs. diff --git a/netlify/nps-relay/relay.test.ts b/netlify/nps-relay/relay.test.ts index 9a7dc7b..b590868 100644 --- a/netlify/nps-relay/relay.test.ts +++ b/netlify/nps-relay/relay.test.ts @@ -8,7 +8,7 @@ import { import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ACK_PATH, CAS_MAX_ATTEMPTS, @@ -989,3 +989,23 @@ describe("Netlify function wiring", () => { expect(fn).not.toContain("INSTALL_TOKENS"); }); }); + +describe("request logging", () => { + it("emits one bounded log line without request data", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = await handleRelayRequest( + new Request("https://x.test/api/nps/secret-id", { method: "BREW" }), + deps() + ); + const rec = JSON.parse(String(warn.mock.calls[0][0])); + warn.mockRestore(); + expect(res.status).toBe(404); + expect(rec).toMatchObject({ + message: "nps relay request", + route: "unknown", + method: "OTHER", + status: 404, + }); + expect(JSON.stringify(rec)).not.toContain("secret-id"); + }); +}); diff --git a/netlify/nps-relay/relay.ts b/netlify/nps-relay/relay.ts index 799cec7..fa9b110 100644 --- a/netlify/nps-relay/relay.ts +++ b/netlify/nps-relay/relay.ts @@ -86,6 +86,7 @@ import { timingSafeEqual, verify as cryptoVerify, } from "node:crypto"; +import { logger } from "../../src/lib/logger"; // ── Constants ──────────────────────────────────────────────────────── @@ -924,10 +925,37 @@ async function handleAck(req: Request, deps: RelayDeps): Promise { return json(200, { deleted: valid.length }); } -/** Routes one request. The Netlify function is a thin wrapper around this. */ +const LOG_ROUTES: Record = { + [RELAY_BASE_PATH]: "nps", + [REGISTER_PATH]: "nps-register", + [PENDING_PATH]: "nps-pending", + [ACK_PATH]: "nps-ack", +}; + +/** + * Routes one request and emits one bounded structured log line (route from a + * fixed set, method, status, duration; no paths, headers, IPs or bodies). + * The Netlify function is a thin wrapper around this. + */ export async function handleRelayRequest( req: Request, deps: RelayDeps +): Promise { + const started = performance.now(); + const res = await routeRelayRequest(req, deps); + const path = new URL(req.url).pathname.replace(/\/+$/, ""); + logger.info("nps relay request", { + route: LOG_ROUTES[path] ?? "unknown", + method: ["GET", "POST"].includes(req.method) ? req.method : "OTHER", + status: res.status, + durationMs: Math.round(performance.now() - started), + }); + return res; +} + +async function routeRelayRequest( + req: Request, + deps: RelayDeps ): Promise { const now = (deps.now ?? Date.now)(); const path = new URL(req.url).pathname.replace(/\/+$/, "");