diff --git a/package.json b/package.json index f285c41..b25a38f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "markdown-it": "^14.1.0", "marked": "^15.0.7", "normalize.css": "^8.0.1", + "pino": "^9.6.0", "prismjs": "^1.29.0", "react": "^18.0.0", "react-dom": "^18.0.0", diff --git a/pages-common/apiHandler.ts b/pages-common/apiHandler.ts index c99869f..73de37a 100644 --- a/pages-common/apiHandler.ts +++ b/pages-common/apiHandler.ts @@ -1,6 +1,7 @@ import {NextApiRequest, NextApiResponse} from "next"; import {handleExceptions} from "./handleExceptions"; import {AuthContext, readJWTBearer} from "./auth"; +import {wrapAPILogging} from "./logger"; type APIHandler = (req: NextApiRequest, res: NextApiResponse) => Promise; @@ -12,6 +13,7 @@ export function createAPIHandler(methodHandlers: { PATCH?: MethodHandler; PUT?: MethodHandler; DELETE?: MethodHandler; + _all?: MethodHandler; }): APIHandler { const handlers: {[key: string]: {handler: APIHandler; noAuth: boolean}} = {}; for (const [method, handler] of Object.entries(methodHandlers)) { @@ -21,34 +23,35 @@ export function createAPIHandler(methodHandlers: { : {...handler, noAuth: handler.noAuth ?? false}; } - return async (req: NextApiRequest, res: NextApiResponse) => { - const methodHandler = handlers[req.method!]; + return async (req: NextApiRequest, res: NextApiResponse) => + wrapAPILogging(req, res, async () => { + const methodHandler = handlers[req.method!] ?? handlers._all; - if (!methodHandler) { - res.status(405).end(`method ${req.method} not allowed`); - return; - } - - // Auth can be disabled on a per method basis because some admin api - // routes can still be public: - // for example those that return the list of agreements (GET), - // even though cherry-picking endpoints that require authentication is - // not the best developer's UX. - if (!methodHandler.noAuth) { - /** - * Requires an authenticated user for an API request. Inspects request - * headers to look for a valid JWT Bearer token. Stops request handling - * and returns 401 for unauthorized users. - */ - try { - const user = await readJWTBearer(req); - (req as unknown as AuthContext).user = user; - } catch (err: any) { - res.status(401).end(err); + if (!methodHandler) { + res.status(405).end(`method ${req.method} not allowed`); return; } - } - await handleExceptions(res, () => methodHandler.handler(req, res)); - }; + // Auth can be disabled on a per method basis because some admin api + // routes can still be public: + // for example those that return the list of agreements (GET), + // even though cherry-picking endpoints that require authentication is + // not the best developer's UX. + if (!methodHandler.noAuth) { + /** + * Requires an authenticated user for an API request. Inspects request + * headers to look for a valid JWT Bearer token. Stops request handling + * and returns 401 for unauthorized users. + */ + try { + const user = await readJWTBearer(req); + (req as unknown as AuthContext).user = user; + } catch (err: any) { + res.status(401).end(err); + return; + } + } + + await handleExceptions(res, () => methodHandler.handler(req, res)); + }); } diff --git a/pages-common/logger.ts b/pages-common/logger.ts new file mode 100644 index 0000000..2abf4a1 --- /dev/null +++ b/pages-common/logger.ts @@ -0,0 +1,53 @@ +import {NextApiRequest, NextApiResponse} from "next"; +import pino from "pino"; + +const logger = pino({ + timestamp: pino.stdTimeFunctions.isoTime, +}); + +const reqContext = new AsyncLocalStorage(); + +export async function wrapAPILogging( + req: NextApiRequest, + res: NextApiResponse, + handler: () => Promise +): Promise { + const logData: object = {}; + const startTime = performance.now(); + try { + await reqContext.run(logData, handler); + logger.info({ + method: req.method, + url: req.url, + duration_ms: performance.now() - startTime, + status_code: res.statusCode, + ...logData, + }); + } catch (err) { + logger.error({ + method: req.method, + url: req.url, + duration_ms: performance.now() - startTime, + status_code: 500, + ...(err instanceof Error + ? { + error_name: err.name, + message: err.message, + cause: err.cause, + stack: err.stack, + } + : { + message: String(err), + }), + ...logData, + }); + res.status(500).end("internal server error"); + } +} + +export function addLogContext(data: object): void { + const ctxData = reqContext.getStore(); + if (ctxData) { + Object.assign(ctxData, data); + } +} diff --git a/pages/api/health.ts b/pages/api/health.ts index ee9de1b..deec511 100644 --- a/pages/api/health.ts +++ b/pages/api/health.ts @@ -1,10 +1,16 @@ -import {NextApiRequest, NextApiResponse} from "next"; +import {NextApiResponse} from "next"; +import {createAPIHandler} from "../../pages-common/apiHandler"; interface AliveContract { alive: boolean; timestamp: Date; } -export default (req: NextApiRequest, res: NextApiResponse) => { - res.status(200).json({alive: true, timestamp: new Date()}); -}; +export default createAPIHandler({ + _all: { + handler: async (req, res: NextApiResponse) => { + res.status(200).json({alive: true, timestamp: new Date()}); + }, + noAuth: true, + }, +}); diff --git a/pages/api/pullrequesthook/index.ts b/pages/api/pullrequesthook/index.ts index 3f51cbe..f60778d 100644 --- a/pages/api/pullrequesthook/index.ts +++ b/pages/api/pullrequesthook/index.ts @@ -9,6 +9,7 @@ import * as bodyParser from "body-parser"; import * as httpErrors from "http-errors"; import {NextHandleFunction} from "connect"; import {createAPIHandler} from "../../../pages-common/apiHandler"; +import {addLogContext} from "../../../pages-common/logger"; export const config = { api: { @@ -113,6 +114,8 @@ async function _handler( // Next.js enforces lowercase header names const event = req.headers["x-github-event"]; + addLogContext({github_event: event}); + if (!event) { res.status(400).end("Missing X-GitHub-Event header"); return; @@ -127,6 +130,7 @@ async function _handler( const {body} = req; const action = body.action; + addLogContext({github_pr_action: action}); if (["opened", "reopened", "synchronize"].indexOf(action) === -1) { res .status(200) @@ -195,6 +199,8 @@ async function _handler( }, }; + addLogContext({cla_check_input: input}); + await claHandler.checkCla(input); res.status(200).end("OK"); return; diff --git a/yarn.lock b/yarn.lock index f3b9ec9..8ec1f1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1108,6 +1108,11 @@ assertion-error@^2.0.1: resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + babel-plugin-macros@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1" @@ -1682,6 +1687,11 @@ eventemitter3@^4.0.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +fast-redact@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.5.0.tgz#e9ea02f7e57d0cd8438180083e93077e496285e4" + integrity sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A== + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -2579,6 +2589,11 @@ object-keys@~0.4.0: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336" integrity sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw== +on-exit-leak-free@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" + integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== + on-finished@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -2713,6 +2728,35 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pino-abstract-transport@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz#de241578406ac7b8a33ce0d77ae6e8a0b3b68a60" + integrity sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw== + dependencies: + split2 "^4.0.0" + +pino-std-serializers@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" + integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== + +pino@^9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.6.0.tgz#6bc628159ba0cc81806d286718903b7fc6b13169" + integrity sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^2.0.0" + pino-std-serializers "^7.0.0" + process-warning "^4.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + pkg-dir@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -2800,6 +2844,11 @@ process-on-spawn@^1.0.0: dependencies: fromentries "^1.2.0" +process-warning@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-4.0.1.tgz#5c1db66007c67c756e4e09eb170cdece15da32fb" + integrity sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q== + prop-types@^15.6.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" @@ -2838,6 +2887,11 @@ querystringify@^2.1.1: resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -2948,6 +3002,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +real-require@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" + integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== + redux-thunk@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" @@ -3038,6 +3097,11 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + "safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -3205,6 +3269,13 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +sonic-boom@^4.0.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-4.2.0.tgz#e59a525f831210fa4ef1896428338641ac1c124d" + integrity sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww== + dependencies: + atomic-sleep "^1.0.0" + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -3232,6 +3303,11 @@ spawn-wrap@^2.0.0: signal-exit "^3.0.2" which "^2.0.1" +split2@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -3364,6 +3440,13 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +thread-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" + integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== + dependencies: + real-require "^0.2.0" + through2@~0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/through2/-/through2-0.4.2.tgz#dbf5866031151ec8352bb6c4db64a2292a840b9b"