diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index a56e7bba..18000061 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -33,6 +33,9 @@ PORT="3000" # ===== Cookie 安全(HTTPS 反代时建议 true)===== COOKIE_SECURE="false" +# ===== Metrics(Prometheus)===== +METRICS_ENABLED="true" + # ===== 安全基线(试运行建议打开)===== RATE_LIMIT_ENABLED="true" RATE_LIMIT_LOGIN_MAX="10" diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index 46c7d79f..97392e7e 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -30,6 +30,7 @@ services: condition: service_healthy environment: NODE_ENV: production + METRICS_ENABLED: ${METRICS_ENABLED} APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} DATABASE_URL: ${DATABASE_URL} @@ -77,6 +78,7 @@ services: command: ["npm", "run", "worker"] environment: NODE_ENV: production + METRICS_ENABLED: ${METRICS_ENABLED} APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} DATABASE_URL: ${DATABASE_URL} @@ -112,6 +114,7 @@ services: condition: service_healthy environment: NODE_ENV: production + METRICS_ENABLED: ${METRICS_ENABLED} APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} DATABASE_URL: ${DATABASE_URL} diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 99f1d4a1..33b0762a 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -52,6 +52,7 @@ docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ ```bash curl -fsS http://127.0.0.1:3000/health curl -fsS http://127.0.0.1:3000/ready +curl -fsS http://127.0.0.1:3000/metrics | head ``` 浏览器访问: diff --git a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv index 875f9fa3..96e53c3d 100644 --- a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv +++ b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv @@ -23,7 +23,7 @@ F19c,作业迁移(委派回收 -> pg-boss),"将委派过期回收迁移到 p F19d,作业迁移(超期归档 -> pg-boss),"将 90 天游离归档迁移到 pg-boss(替代 node-cron),统一重试/并发/可观测字段","作业可重复执行幂等;多进程不重复处理;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/src/jobs/pgBossJobs.js | server/src/jobs/invoiceArchiveJob.js | server/src/jobs/jobSystem.js | server/README.md","[""F19"",""F12""]","invoice.archive-unused 默认按 INVOICE_ARCHIVE_CRON 调度;回滚:JOB_DRIVER=cron" F20,Worker进程拆分(可选),"提供 API 进程与 Worker 进程拆分的部署方式(compose 开关),保持单机也可跑","开启后:API 不跑作业;Worker 只跑作业;两者共享 DATABASE_URL;功能不回归","manual: docker compose 起两个进程并跑回归脚本",manual,DONE,DONE,DONE,"deploy/trialrun/docker-compose.yml | server/src/index.js | server/README.md","[""F19""]","仍保持单机;为未来横向扩展铺路" F21,对象存储(MinIO/S3兼容),"存储从本地目录演进到 S3 兼容(先 MinIO),保持 signed-url 契约不变或提供兼容层","上传/预览/签名URL 在 MinIO 下可用;回归与 E2E 通过;迁移路径清晰可回滚","manual: trialrun 环境启用 MinIO 并完成一次上传预览",manual,TODO,TODO,TODO,"server/src/services/storage/* | deploy/trialrun/* | server/README.md","[""F20""]","优先用 @aws-sdk/client-s3;单机优先 MinIO" -F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,TODO,TODO,TODO,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后" +F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,DONE,DONE,DONE,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后" F23,SSO/OIDC(可选,P7+),"支持 OIDC 登录(企业 SSO),保留现有密码登录作为 fallback","可通过 OIDC 登录并绑定员工;权限模型不变;可配置开关","manual: 配置 OIDC provider 后走一次登录回调",manual,TODO,TODO,TODO,"server/src/routes/auth.js | server/src/services/oidc/* | server/README.md","[""F1""]","优先复用 openid-client/passport 策略" F24,工作流扩展到其他实体(P7+),"抽象 entity_type 扩展到请假/采购等(先骨架:definition+instance+task 复用)","新实体可创建 instance 并走同审批动作;不影响报销既有链路","manual: seed 一个 leave 定义并跑一条最小链路",manual,TODO,TODO,TODO,"server/src/routes/workflow.js | server/src/routes/leave.js | server/prisma/schema.prisma","[""F19""]","只做骨架与复用点,不做完整业务表单" F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,TODO,TODO,TODO,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略" diff --git a/server/.env.example b/server/.env.example index 913c09af..dc3c411d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -8,6 +8,9 @@ STORAGE_DIR="generated/storage" SIGNED_URL_TTL_SECONDS="600" MAX_UPLOAD_SIZE_BYTES="10485760" +# ===== Metrics(Prometheus)===== +# METRICS_ENABLED="true" + # ===== 单域托管 client/dist(试运行/生产建议)===== # SERVE_CLIENT_DIST="true" # CLIENT_DIST_DIR="../client/dist" diff --git a/server/README.md b/server/README.md index b3d5517e..c8bd1788 100644 --- a/server/README.md +++ b/server/README.md @@ -145,11 +145,13 @@ cd /home/shunli/Projects/hyb-oa/server ```bash curl -fsS http://127.0.0.1:3000/health curl -fsS http://127.0.0.1:3000/ready +curl -fsS http://127.0.0.1:3000/metrics | head ``` 说明: - `/health`:liveness(不查 DB) - `/ready`:readiness(`SELECT 1` 探活 DB,失败返回 503) + - `/metrics`:Prometheus 指标(可通过 `METRICS_ENABLED=false` 关闭) --- diff --git a/server/package-lock.json b/server/package-lock.json index 03cb4345..eb6e7d8d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -28,6 +28,7 @@ "pg": "^8.18.0", "pg-boss": "^10.3.0", "prisma": "^7.3.0", + "prom-client": "^15.1.0", "tencentcloud-sdk-nodejs": "^4.1.14", "zod": "^4.3.6", "zxcvbn": "^4.4.2" @@ -503,6 +504,15 @@ "node": ">=16" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -975,6 +985,12 @@ "node": "*" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3046,6 +3062,19 @@ ], "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -3540,6 +3569,15 @@ "node": ">=6" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/tencentcloud-sdk-nodejs": { "version": "4.1.184", "resolved": "https://registry.npmjs.org/tencentcloud-sdk-nodejs/-/tencentcloud-sdk-nodejs-4.1.184.tgz", diff --git a/server/package.json b/server/package.json index c2933a77..53b11a69 100644 --- a/server/package.json +++ b/server/package.json @@ -41,6 +41,7 @@ "fastify-plugin": "^5.1.0", "nodemailer": "^6.10.1", "node-cron": "^4.2.1", + "prom-client": "^15.1.0", "pg-boss": "^10.3.0", "pg": "^8.18.0", "prisma": "^7.3.0", diff --git a/server/src/app.js b/server/src/app.js index aa912981..aeb971a6 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -30,6 +30,7 @@ const approvalRulesRoutes = require("./routes/approvalRules"); const notificationsRoutes = require("./routes/notifications"); const exportsRoutes = require("./routes/exports"); const { startJobs } = require("./jobs/jobSystem"); +const { initMetrics } = require("./observability"); const { AppError } = require("./utils/AppError"); async function buildApp() { @@ -112,6 +113,9 @@ async function buildApp() { app.addHook("onRequest", async (request, reply) => { reply.header("x-request-id", request.id); + if (request.log && typeof request.log.child === "function") { + request.log = request.log.child({ requestId: request.id }); + } }); app.setNotFoundHandler(async (request, reply) => { @@ -189,6 +193,23 @@ async function buildApp() { } }); + if (config.metricsEnabled) { + initMetrics(app); + app.get("/metrics", async (request, reply) => { + try { + await app.prisma.$queryRaw`SELECT 1`; + app.metrics.dbReady.set(1); + } catch { + app.metrics.dbReady.set(0); + } + + const payload = await app.metrics.registry.metrics(); + reply.header("content-type", app.metrics.registry.contentType); + return reply.send(payload); + }); + app.log.info("Metrics endpoint enabled at /metrics"); + } + await app.register(authRoutes, { prefix: "/api/auth" }); await app.register(meRoutes, { prefix: "/api" }); await app.register(reimbursementRoutes, { prefix: "/api" }); diff --git a/server/src/config.js b/server/src/config.js index 421a9f75..ade70c48 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -22,6 +22,8 @@ function getConfig() { port: Number(process.env.PORT ?? 3000), host: process.env.HOST ?? "0.0.0.0", + metricsEnabled: process.env.METRICS_ENABLED !== "false", + appOrigin: process.env.APP_ORIGIN ?? "", jwtSecret: requiredEnv("JWT_SECRET"), diff --git a/server/src/jobs/delegationRevokeJob.js b/server/src/jobs/delegationRevokeJob.js index 38f71161..8763c6da 100644 --- a/server/src/jobs/delegationRevokeJob.js +++ b/server/src/jobs/delegationRevokeJob.js @@ -92,9 +92,29 @@ function startDelegationRevokeJob(fastify) { running = true; try { - await revokeExpiredDelegations(fastify.prisma, fastify.log); + const revoked = await revokeExpiredDelegations(fastify.prisma, fastify.log); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: "delegation.revoke-expired", + driver: "cron", + outcome: "success", + }); + if (revoked > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { + job: "delegation.revoke-expired", + driver: "cron", + kind: "revoked", + }, + revoked, + ); + } } catch (error) { fastify.log.error({ err: error }, "Delegation revoke job failed"); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: "delegation.revoke-expired", + driver: "cron", + outcome: "failure", + }); } finally { running = false; } diff --git a/server/src/jobs/invoiceArchiveJob.js b/server/src/jobs/invoiceArchiveJob.js index 962358ac..5a21dc62 100644 --- a/server/src/jobs/invoiceArchiveJob.js +++ b/server/src/jobs/invoiceArchiveJob.js @@ -76,12 +76,32 @@ function startInvoiceArchiveJob(fastify) { running = true; try { - await archiveUnusedInvoices(fastify.prisma, fastify.log, { + const archived = await archiveUnusedInvoices(fastify.prisma, fastify.log, { batchSize: fastify.config.invoiceArchiveBatchSize, unusedDays: fastify.config.invoiceArchiveUnusedDays, }); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: "invoice.archive-unused", + driver: "cron", + outcome: "success", + }); + if (archived > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { + job: "invoice.archive-unused", + driver: "cron", + kind: "archived", + }, + archived, + ); + } } catch (error) { fastify.log.error({ err: error }, "Invoice archive job failed"); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: "invoice.archive-unused", + driver: "cron", + outcome: "failure", + }); } finally { running = false; } @@ -107,4 +127,3 @@ module.exports = { archiveUnusedInvoices, startInvoiceArchiveJob, }; - diff --git a/server/src/jobs/ocrJob.js b/server/src/jobs/ocrJob.js index b96c29d9..96b3130b 100644 --- a/server/src/jobs/ocrJob.js +++ b/server/src/jobs/ocrJob.js @@ -301,6 +301,29 @@ function startOcrJob(fastify) { running = true; try { const result = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: "ocr.tick", + driver: "cron", + outcome: "success", + }); + if (result.available > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: "ocr.tick", driver: "cron", kind: "available" }, + result.available, + ); + } + if (result.duplicate > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: "ocr.tick", driver: "cron", kind: "duplicate" }, + result.duplicate, + ); + } + if (result.failed > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: "ocr.tick", driver: "cron", kind: "failed" }, + result.failed, + ); + } if (result.claimed > 0) { fastify.log.info( { @@ -312,6 +335,11 @@ function startOcrJob(fastify) { } } catch (error) { fastify.log.error({ err: error }, "OCR job tick failed"); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: "ocr.tick", + driver: "cron", + outcome: "failure", + }); } finally { running = false; } diff --git a/server/src/jobs/pgBossJobs.js b/server/src/jobs/pgBossJobs.js index b174c622..6d6a1df1 100644 --- a/server/src/jobs/pgBossJobs.js +++ b/server/src/jobs/pgBossJobs.js @@ -38,26 +38,64 @@ async function startPgBossJobs(fastify) { await boss.work(JOB_DELEGATION_REVOKE, { batchSize: 1 }, async (jobs) => { const job = jobs?.[0]; - const revoked = await revokeExpiredDelegations(fastify.prisma, fastify.log); - if (revoked > 0) { - fastify.log.info( - { jobId: job?.id, revoked }, - "pg-boss delegation revoke tick processed", - ); + try { + const revoked = await revokeExpiredDelegations(fastify.prisma, fastify.log); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: JOB_DELEGATION_REVOKE, + driver: "pgboss", + outcome: "success", + }); + if (revoked > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: JOB_DELEGATION_REVOKE, driver: "pgboss", kind: "revoked" }, + revoked, + ); + fastify.log.info( + { jobId: job?.id, revoked }, + "pg-boss delegation revoke tick processed", + ); + } + } catch (error) { + fastify.metrics?.jobRunsTotal?.inc?.({ + job: JOB_DELEGATION_REVOKE, + driver: "pgboss", + outcome: "failure", + }); + fastify.log.error({ err: error, jobId: job?.id }, "pg-boss delegation revoke tick failed"); + throw error; } }); await boss.work(JOB_INVOICE_ARCHIVE, { batchSize: 1 }, async (jobs) => { const job = jobs?.[0]; - const archived = await archiveUnusedInvoices(fastify.prisma, fastify.log, { - batchSize: fastify.config.invoiceArchiveBatchSize, - unusedDays: fastify.config.invoiceArchiveUnusedDays, - }); - if (archived > 0) { - fastify.log.info( - { jobId: job?.id, archived, unusedDays: fastify.config.invoiceArchiveUnusedDays }, - "pg-boss invoice archive tick processed", - ); + try { + const archived = await archiveUnusedInvoices(fastify.prisma, fastify.log, { + batchSize: fastify.config.invoiceArchiveBatchSize, + unusedDays: fastify.config.invoiceArchiveUnusedDays, + }); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: JOB_INVOICE_ARCHIVE, + driver: "pgboss", + outcome: "success", + }); + if (archived > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: JOB_INVOICE_ARCHIVE, driver: "pgboss", kind: "archived" }, + archived, + ); + fastify.log.info( + { jobId: job?.id, archived, unusedDays: fastify.config.invoiceArchiveUnusedDays }, + "pg-boss invoice archive tick processed", + ); + } + } catch (error) { + fastify.metrics?.jobRunsTotal?.inc?.({ + job: JOB_INVOICE_ARCHIVE, + driver: "pgboss", + outcome: "failure", + }); + fastify.log.error({ err: error, jobId: job?.id }, "pg-boss invoice archive tick failed"); + throw error; } }); @@ -68,12 +106,46 @@ async function startPgBossJobs(fastify) { const job = jobs?.[0]; const workerId = `pgboss:ocr:${process.pid}`; - const stats = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId); - if (stats.claimed > 0) { - fastify.log.info( - { jobId: job?.id, workerId, ...stats }, - "pg-boss OCR tick processed", - ); + try { + const stats = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId); + fastify.metrics?.jobRunsTotal?.inc?.({ + job: JOB_OCR_TICK, + driver: "pgboss", + outcome: "success", + }); + if (stats.available > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: JOB_OCR_TICK, driver: "pgboss", kind: "available" }, + stats.available, + ); + } + if (stats.duplicate > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: JOB_OCR_TICK, driver: "pgboss", kind: "duplicate" }, + stats.duplicate, + ); + } + if (stats.failed > 0) { + fastify.metrics?.jobProcessedTotal?.inc?.( + { job: JOB_OCR_TICK, driver: "pgboss", kind: "failed" }, + stats.failed, + ); + } + + if (stats.claimed > 0) { + fastify.log.info( + { jobId: job?.id, workerId, ...stats }, + "pg-boss OCR tick processed", + ); + } + } catch (error) { + fastify.metrics?.jobRunsTotal?.inc?.({ + job: JOB_OCR_TICK, + driver: "pgboss", + outcome: "failure", + }); + fastify.log.error({ err: error, jobId: job?.id }, "pg-boss OCR tick failed"); + throw error; } }); diff --git a/server/src/observability/index.js b/server/src/observability/index.js new file mode 100644 index 00000000..fd6522de --- /dev/null +++ b/server/src/observability/index.js @@ -0,0 +1,6 @@ +const { initMetrics } = require("./metrics"); + +module.exports = { + initMetrics, +}; + diff --git a/server/src/observability/metrics.js b/server/src/observability/metrics.js new file mode 100644 index 00000000..c7d13a9e --- /dev/null +++ b/server/src/observability/metrics.js @@ -0,0 +1,105 @@ +const client = require("prom-client"); + +function resolveRouteLabel(request) { + const url = request?.routeOptions?.url; + if (typeof url === "string" && url.trim()) { + return url; + } + + const routerPath = request?.routerPath; + if (typeof routerPath === "string" && routerPath.trim()) { + return routerPath; + } + + const rawUrl = request?.raw?.url; + if (typeof rawUrl === "string" && rawUrl.trim()) { + return rawUrl.split("?")[0]; + } + + return "unknown"; +} + +function initMetrics(app) { + const registry = new client.Registry(); + + client.collectDefaultMetrics({ + register: registry, + }); + + const httpRequestsTotal = new client.Counter({ + name: "hyb_http_requests_total", + help: "Total number of HTTP requests", + labelNames: ["method", "route", "status_code"], + registers: [registry], + }); + + const httpRequestDurationSeconds = new client.Histogram({ + name: "hyb_http_request_duration_seconds", + help: "HTTP request duration in seconds", + labelNames: ["method", "route", "status_code"], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [registry], + }); + + const jobRunsTotal = new client.Counter({ + name: "hyb_job_runs_total", + help: "Total number of background job runs", + labelNames: ["job", "driver", "outcome"], + registers: [registry], + }); + + const jobProcessedTotal = new client.Counter({ + name: "hyb_job_processed_total", + help: "Total number of processed items per job", + labelNames: ["job", "driver", "kind"], + registers: [registry], + }); + + const dbReady = new client.Gauge({ + name: "hyb_db_ready", + help: "Database readiness probe result (1=ready, 0=not ready)", + registers: [registry], + }); + + app.decorate("metrics", { + registry, + httpRequestsTotal, + httpRequestDurationSeconds, + jobRunsTotal, + jobProcessedTotal, + dbReady, + }); + + app.decorateRequest("metricsStart", null); + + app.addHook("onRequest", async (request) => { + request.metricsStart = process.hrtime.bigint(); + }); + + app.addHook("onResponse", async (request, reply) => { + const start = request.metricsStart; + if (typeof start !== "bigint") { + return; + } + + const end = process.hrtime.bigint(); + const durationSeconds = Number(end - start) / 1e9; + + const method = request.method; + const route = resolveRouteLabel(request); + const statusCode = String(reply.statusCode); + + httpRequestsTotal.inc({ method, route, status_code: statusCode }); + httpRequestDurationSeconds.observe( + { method, route, status_code: statusCode }, + durationSeconds, + ); + }); + + return app.metrics; +} + +module.exports = { + initMetrics, +}; + diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js index ce444dfb..9d17e3ac 100644 --- a/server/src/plugins/auth.js +++ b/server/src/plugins/auth.js @@ -99,6 +99,10 @@ async function authPlugin(fastify) { roleCodes, permissionCodes, }; + + if (request.log && typeof request.log.child === "function") { + request.log = request.log.child({ actorId: employee.id }); + } }); }