mega-framework 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/.env +127 -0
  2. package/.env.example +186 -0
  3. package/.prettierrc.json +8 -0
  4. package/CHANGELOG.md +259 -0
  5. package/LICENSE +21 -0
  6. package/README.md +153 -0
  7. package/bin/mega-ws-hub.js +15 -0
  8. package/bin/mega.js +38 -0
  9. package/docker-compose.yml +201 -0
  10. package/eslint.config.js +57 -0
  11. package/infra/otel-collector-config.yaml +43 -0
  12. package/jsconfig.json +18 -0
  13. package/package.json +121 -0
  14. package/sample/crud/.env +18 -0
  15. package/sample/crud/.env.example +50 -0
  16. package/sample/crud/README.md +85 -0
  17. package/sample/crud/apps/main/app.config.js +114 -0
  18. package/sample/crud/apps/main/channels/chat-bus.js +115 -0
  19. package/sample/crud/apps/main/channels/chat-channel.js +145 -0
  20. package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
  21. package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
  22. package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
  23. package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
  24. package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
  25. package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
  26. package/sample/crud/apps/main/controllers/note-controller.js +116 -0
  27. package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
  28. package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
  29. package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
  30. package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
  31. package/sample/crud/apps/main/controllers/user-controller.js +34 -0
  32. package/sample/crud/apps/main/controllers/web-controller.js +137 -0
  33. package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
  34. package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
  35. package/sample/crud/apps/main/jobs/email-job.js +72 -0
  36. package/sample/crud/apps/main/locales/client/en.json +3 -0
  37. package/sample/crud/apps/main/locales/client/ko.json +3 -0
  38. package/sample/crud/apps/main/locales/server/en.json +316 -0
  39. package/sample/crud/apps/main/locales/server/ko.json +316 -0
  40. package/sample/crud/apps/main/middleware/web-auth.js +40 -0
  41. package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
  42. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
  43. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
  44. package/sample/crud/apps/main/models/note.js +71 -0
  45. package/sample/crud/apps/main/models/user.js +86 -0
  46. package/sample/crud/apps/main/public/css/app.css +101 -0
  47. package/sample/crud/apps/main/public/css/guide.css +137 -0
  48. package/sample/crud/apps/main/public/js/app.js +54 -0
  49. package/sample/crud/apps/main/public/js/perf.js +129 -0
  50. package/sample/crud/apps/main/public/js/theme-init.js +12 -0
  51. package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
  52. package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
  53. package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
  54. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  55. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  56. package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
  57. package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
  58. package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
  59. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
  60. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
  61. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
  62. package/sample/crud/apps/main/routes/auth.js +15 -0
  63. package/sample/crud/apps/main/routes/cron.js +14 -0
  64. package/sample/crud/apps/main/routes/guide.js +25 -0
  65. package/sample/crud/apps/main/routes/jobs.js +14 -0
  66. package/sample/crud/apps/main/routes/logs.js +28 -0
  67. package/sample/crud/apps/main/routes/metrics.js +13 -0
  68. package/sample/crud/apps/main/routes/notes.js +19 -0
  69. package/sample/crud/apps/main/routes/perf.js +47 -0
  70. package/sample/crud/apps/main/routes/redis.js +14 -0
  71. package/sample/crud/apps/main/routes/tracing.js +14 -0
  72. package/sample/crud/apps/main/routes/upload.js +16 -0
  73. package/sample/crud/apps/main/routes/users.js +54 -0
  74. package/sample/crud/apps/main/routes/web.js +23 -0
  75. package/sample/crud/apps/main/routes/worker.js +15 -0
  76. package/sample/crud/apps/main/routes/ws.js +30 -0
  77. package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
  78. package/sample/crud/apps/main/services/auth-service.js +74 -0
  79. package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
  80. package/sample/crud/apps/main/services/guide-service.js +145 -0
  81. package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
  82. package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
  83. package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
  84. package/sample/crud/apps/main/services/note-service.js +75 -0
  85. package/sample/crud/apps/main/services/perf-service.js +302 -0
  86. package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
  87. package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
  88. package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
  89. package/sample/crud/apps/main/services/user-service.js +65 -0
  90. package/sample/crud/apps/main/views/auth/login.ejs +57 -0
  91. package/sample/crud/apps/main/views/auth/register.ejs +71 -0
  92. package/sample/crud/apps/main/views/cron/index.ejs +92 -0
  93. package/sample/crud/apps/main/views/guide/index.ejs +24 -0
  94. package/sample/crud/apps/main/views/guide/page.ejs +64 -0
  95. package/sample/crud/apps/main/views/home.ejs +82 -0
  96. package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
  97. package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
  98. package/sample/crud/apps/main/views/logs/index.ejs +80 -0
  99. package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
  100. package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
  101. package/sample/crud/apps/main/views/notes/list.ejs +74 -0
  102. package/sample/crud/apps/main/views/notes/new.ejs +45 -0
  103. package/sample/crud/apps/main/views/perf/index.ejs +90 -0
  104. package/sample/crud/apps/main/views/redis/index.ejs +65 -0
  105. package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
  106. package/sample/crud/apps/main/views/upload/index.ejs +79 -0
  107. package/sample/crud/apps/main/views/users/edit.ejs +48 -0
  108. package/sample/crud/apps/main/views/users/list.ejs +81 -0
  109. package/sample/crud/apps/main/views/users/new.ejs +48 -0
  110. package/sample/crud/apps/main/views/worker/index.ejs +70 -0
  111. package/sample/crud/apps/main/views/ws/index.ejs +62 -0
  112. package/sample/crud/apps/main/workers/hash-worker.js +17 -0
  113. package/sample/crud/apps/main/workers/hash.task.js +22 -0
  114. package/sample/crud/ecosystem.config.cjs +9 -0
  115. package/sample/crud/mega.config.js +105 -0
  116. package/sample/crud/package-lock.json +5665 -0
  117. package/sample/crud/package.json +28 -0
  118. package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
  119. package/sample/crud/test/apps/main/auth-service.test.js +93 -0
  120. package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
  121. package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
  122. package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
  123. package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
  124. package/sample/crud/test/apps/main/email-job.test.js +76 -0
  125. package/sample/crud/test/apps/main/guide-service.test.js +68 -0
  126. package/sample/crud/test/apps/main/hash-task.test.js +30 -0
  127. package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
  128. package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
  129. package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
  130. package/sample/crud/test/apps/main/note-service.test.js +68 -0
  131. package/sample/crud/test/apps/main/perf-service.test.js +121 -0
  132. package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
  133. package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
  134. package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
  135. package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
  136. package/sample/crud/test/apps/main/user-service.test.js +65 -0
  137. package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
  138. package/sample/crud/vitest.config.js +8 -0
  139. package/sample/crud/yarn.lock +2142 -0
  140. package/sample/simple/.env.example +15 -0
  141. package/sample/simple/README.md +52 -0
  142. package/sample/simple/apps/main/app.config.js +35 -0
  143. package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
  144. package/sample/simple/apps/main/locales/client/en.json +3 -0
  145. package/sample/simple/apps/main/locales/client/ko.json +3 -0
  146. package/sample/simple/apps/main/locales/server/en.json +23 -0
  147. package/sample/simple/apps/main/locales/server/ko.json +23 -0
  148. package/sample/simple/apps/main/public/css/app.css +101 -0
  149. package/sample/simple/apps/main/public/hello.txt +1 -0
  150. package/sample/simple/apps/main/public/js/app.js +54 -0
  151. package/sample/simple/apps/main/public/js/theme-init.js +12 -0
  152. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  153. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  154. package/sample/simple/apps/main/routes/index.js +9 -0
  155. package/sample/simple/apps/main/routes/pages.js +12 -0
  156. package/sample/simple/apps/main/views/index.ejs +56 -0
  157. package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
  158. package/sample/simple/ecosystem.config.cjs +10 -0
  159. package/sample/simple/mega.config.js +27 -0
  160. package/sample/simple/package-lock.json +1851 -0
  161. package/sample/simple/package.json +25 -0
  162. package/sample/simple/test/apps/main/index.test.js +13 -0
  163. package/sample/simple/vitest.config.js +8 -0
  164. package/src/adapters/adapter-manager.js +305 -0
  165. package/src/adapters/adapter-options.js +208 -0
  166. package/src/adapters/file-adapter.js +350 -0
  167. package/src/adapters/file-session-adapter.js +363 -0
  168. package/src/adapters/index.js +38 -0
  169. package/src/adapters/maria-adapter.js +425 -0
  170. package/src/adapters/mega-adapter.js +511 -0
  171. package/src/adapters/mega-bus-adapter.js +81 -0
  172. package/src/adapters/mega-cache-adapter.js +94 -0
  173. package/src/adapters/mega-db-adapter.js +72 -0
  174. package/src/adapters/mega-lock-adapter.js +118 -0
  175. package/src/adapters/mega-log-sink-adapter.js +46 -0
  176. package/src/adapters/mega-session-adapter.js +72 -0
  177. package/src/adapters/mongo-adapter.js +396 -0
  178. package/src/adapters/nats-adapter.js +370 -0
  179. package/src/adapters/postgres-adapter.js +341 -0
  180. package/src/adapters/redis-adapter.js +331 -0
  181. package/src/adapters/redis-session-adapter.js +261 -0
  182. package/src/adapters/redlock-adapter.js +385 -0
  183. package/src/adapters/registry.js +157 -0
  184. package/src/adapters/sqlite-adapter.js +309 -0
  185. package/src/auth/index.js +103 -0
  186. package/src/cli/commands/console-cmd.js +56 -0
  187. package/src/cli/commands/new.js +101 -0
  188. package/src/cli/commands/routes.js +107 -0
  189. package/src/cli/commands/scaffold.js +120 -0
  190. package/src/cli/commands/test-cmd.js +45 -0
  191. package/src/cli/generators/index.js +368 -0
  192. package/src/cli/index.js +472 -0
  193. package/src/cli/template-engine.js +72 -0
  194. package/src/cli/ws-hub.js +582 -0
  195. package/src/core/ajv-mapper.js +80 -0
  196. package/src/core/boot.js +323 -0
  197. package/src/core/cluster-metrics.js +278 -0
  198. package/src/core/config-loader.js +115 -0
  199. package/src/core/config-validator.js +322 -0
  200. package/src/core/ctx-builder.js +253 -0
  201. package/src/core/envelope.js +88 -0
  202. package/src/core/error-mapper.js +116 -0
  203. package/src/core/formbody.js +69 -0
  204. package/src/core/hub-link.js +552 -0
  205. package/src/core/i18n.js +525 -0
  206. package/src/core/index.js +63 -0
  207. package/src/core/mega-app.js +1138 -0
  208. package/src/core/mega-cluster.js +232 -0
  209. package/src/core/mega-server.js +176 -0
  210. package/src/core/mega-service.js +41 -0
  211. package/src/core/migration-runner.js +196 -0
  212. package/src/core/multipart.js +282 -0
  213. package/src/core/openapi.js +114 -0
  214. package/src/core/router.js +388 -0
  215. package/src/core/routes-loader.js +57 -0
  216. package/src/core/scope-registry.js +53 -0
  217. package/src/core/security.js +275 -0
  218. package/src/core/services-loader.js +98 -0
  219. package/src/core/session-cleanup-schedule.js +57 -0
  220. package/src/core/session-store.js +55 -0
  221. package/src/core/session.js +414 -0
  222. package/src/core/static-assets.js +126 -0
  223. package/src/core/template.js +294 -0
  224. package/src/core/workers-manager.js +193 -0
  225. package/src/core/ws-compression.js +112 -0
  226. package/src/core/ws-controller.js +109 -0
  227. package/src/core/ws-message.js +176 -0
  228. package/src/core/ws-upgrade.js +445 -0
  229. package/src/errors/config-error.js +16 -0
  230. package/src/errors/http-errors.js +130 -0
  231. package/src/errors/index.js +19 -0
  232. package/src/errors/mega-error.js +34 -0
  233. package/src/eslint-plugin/index.js +15 -0
  234. package/src/eslint-plugin/no-direct-model-import.js +113 -0
  235. package/src/index.js +131 -0
  236. package/src/lib/asp/config.js +83 -0
  237. package/src/lib/asp/crypto.js +145 -0
  238. package/src/lib/asp/errors.js +49 -0
  239. package/src/lib/asp/nonce-cache.js +94 -0
  240. package/src/lib/asp/plugin.js +263 -0
  241. package/src/lib/asp/ws-terminator.js +101 -0
  242. package/src/lib/env-mapper.js +222 -0
  243. package/src/lib/hub-protocol.js +322 -0
  244. package/src/lib/index.js +42 -0
  245. package/src/lib/logger/telegram-core.js +150 -0
  246. package/src/lib/logger/telegram-transport.js +126 -0
  247. package/src/lib/mega-brute-force.js +225 -0
  248. package/src/lib/mega-circuit-breaker.js +412 -0
  249. package/src/lib/mega-cron.js +169 -0
  250. package/src/lib/mega-hash.js +179 -0
  251. package/src/lib/mega-health.js +91 -0
  252. package/src/lib/mega-job-queue.js +600 -0
  253. package/src/lib/mega-job-worker.js +295 -0
  254. package/src/lib/mega-job.js +140 -0
  255. package/src/lib/mega-logger.js +128 -0
  256. package/src/lib/mega-metrics.js +661 -0
  257. package/src/lib/mega-plugin.js +650 -0
  258. package/src/lib/mega-retry.js +95 -0
  259. package/src/lib/mega-schedule.js +507 -0
  260. package/src/lib/mega-shutdown.js +176 -0
  261. package/src/lib/mega-tracing.js +715 -0
  262. package/src/lib/mega-worker.js +653 -0
  263. package/src/lib/worker-runner/process-entry.js +30 -0
  264. package/src/lib/worker-runner/task-dispatch.js +72 -0
  265. package/src/lib/worker-runner/thread-entry.js +26 -0
  266. package/src/models/index.js +7 -0
  267. package/src/models/mega-model.js +151 -0
  268. package/src/test/index.js +288 -0
  269. package/templates/adapter/code.tpl +40 -0
  270. package/templates/adapter/test.tpl +13 -0
  271. package/templates/app/app.config.tpl +10 -0
  272. package/templates/app/route.tpl +10 -0
  273. package/templates/app/test.tpl +13 -0
  274. package/templates/channel/code.tpl +38 -0
  275. package/templates/channel/test.tpl +19 -0
  276. package/templates/controller/code.tpl +16 -0
  277. package/templates/controller/route.tpl +9 -0
  278. package/templates/controller/test.tpl +14 -0
  279. package/templates/job/code.tpl +23 -0
  280. package/templates/job/test.tpl +17 -0
  281. package/templates/locale/code.tpl +3 -0
  282. package/templates/locale/test.tpl +13 -0
  283. package/templates/middleware/code.tpl +13 -0
  284. package/templates/middleware/test.tpl +11 -0
  285. package/templates/migration/code.tpl +20 -0
  286. package/templates/migration/test.tpl +14 -0
  287. package/templates/model/code.tpl +21 -0
  288. package/templates/model/test.tpl +29 -0
  289. package/templates/project/app.config.tpl +8 -0
  290. package/templates/project/app.config.views.tpl +37 -0
  291. package/templates/project/ecosystem.config.tpl +10 -0
  292. package/templates/project/env.tpl +12 -0
  293. package/templates/project/gitignore.tpl +8 -0
  294. package/templates/project/locales/client/en.json.tpl +3 -0
  295. package/templates/project/locales/client/ko.json.tpl +3 -0
  296. package/templates/project/locales/server/en.json.tpl +17 -0
  297. package/templates/project/locales/server/ko.json.tpl +17 -0
  298. package/templates/project/mega.config.tpl +11 -0
  299. package/templates/project/package.tpl +25 -0
  300. package/templates/project/public/css/app.css +101 -0
  301. package/templates/project/public/js/app.js +54 -0
  302. package/templates/project/public/js/theme-init.js +12 -0
  303. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  304. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
  305. package/templates/project/readme.tpl +48 -0
  306. package/templates/project/route.test.tpl +13 -0
  307. package/templates/project/route.test.views.tpl +15 -0
  308. package/templates/project/route.tpl +10 -0
  309. package/templates/project/route.views.tpl +10 -0
  310. package/templates/project/views/index.ejs.tpl +58 -0
  311. package/templates/project/views/layout.ejs.tpl +73 -0
  312. package/templates/project/vitest.config.tpl +8 -0
  313. package/templates/route/code.tpl +11 -0
  314. package/templates/route/test.tpl +26 -0
  315. package/templates/schedule/code.tpl +19 -0
  316. package/templates/schedule/test.tpl +17 -0
  317. package/templates/service/code.tpl +18 -0
  318. package/templates/service/test.tpl +17 -0
  319. package/templates/worker/code.tpl +14 -0
  320. package/templates/worker/task.tpl +13 -0
  321. package/templates/worker/test.tpl +18 -0
  322. package/vitest.config.js +33 -0
@@ -0,0 +1,661 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaMetrics — OpenTelemetry 메트릭 **옵트인** 통합 + Prometheus `/metrics` 노출 (ADR-131).
4
+ *
5
+ * # 무엇인가 (중학생용 설명)
6
+ * 트레이싱(ADR-126)이 "요청 하나가 어디를 거쳐갔나"를 **개별 추적**한다면, 메트릭은 "요청이 초당 몇 건
7
+ * 왔고 평균 얼마나 걸렸나"를 **숫자로 집계**한다. 운영자는 이 집계 숫자를 Prometheus 로 긁어가(scrape)
8
+ * 대시보드·알람을 만든다. 둘은 분리된 관심사다 — 트레이싱=분산 추적, 메트릭=집계.
9
+ *
10
+ * # 동작 한눈에 (트레이싱과 같은 옵트인 싱글톤 패턴)
11
+ * 1. `init(opts)` — OTel MeterProvider + PrometheusExporter(preventServerStart) 구성 + 인스트루먼트 생성.
12
+ * 2. 코어 배선(mega-app `onResponse`, 어댑터 hook, 세션/잡/브루트포스)이 `record*` 를 호출해 누적.
13
+ * 3. `/metrics` 라우트가 `collect()` 로 Prometheus 텍스트를 만들어 응답(보안 면제 — ADR-072).
14
+ * 4. `shutdown()` — provider 종료.
15
+ * 옵트인 OFF(`init` 미호출)면 모든 `record*` 가 즉시 return = **0 비용**.
16
+ *
17
+ * # 왜 OTel metrics SDK (옵션 A, ADR-131)
18
+ * 트레이싱이 이미 OTel(`@opentelemetry/*` 2.x)을 쓰고 docker collector 가 떠 있어,
19
+ * 메트릭도 같은 SDK 로 가면 (a) Prometheus 텍스트 직접 노출(`/metrics`)과 (b) OTLP collector 푸시를
20
+ * 둘 다 열어둘 수 있다. 신규 dep = `@opentelemetry/exporter-prometheus`(+ 트랜지티브로 이미 있던
21
+ * `@opentelemetry/sdk-metrics` 직접 등재). 기존 OTel 2.7.1/0.218.0 라인과 정확히 일치(audit 신규 0).
22
+ *
23
+ * # 서빙 = 메인 포트 + 직접 직렬화 (단일 경로)
24
+ * PrometheusExporter 는 기본적으로 자체 HTTP 서버(:9464)를 띄우지만, 우리는 `preventServerStart:true`
25
+ * 로 서버를 끄고 **MetricReader 로서만** 쓴다. `/metrics` 라우트가 `reader.collect()` → `PrometheusSerializer`
26
+ * 로 텍스트를 만들어 메인 포트로 응답한다(`/health` 면제 패턴 정합, ADR-072). 단위 테스트도 같은
27
+ * `collect()` 를 쓰므로 prod 와 포맷이 갈라지지 않는다.
28
+ *
29
+ * # 카디널리티 관리
30
+ * HTTP route 라벨은 **매칭된 라우트 패턴**(`req.routeOptions.url`, 예 `/users/:id`)만 쓴다. 매칭 안 된
31
+ * 요청(404)은 raw path 를 그대로 라벨에 넣으면 무한 카디널리티(공격자가 임의 경로 폭격 → 메모리 폭증)가
32
+ * 되므로 고정 라벨 `__unmatched__` 로 접는다. 시크릿/PII 는 라벨에 절대 안 박는다.
33
+ *
34
+ * @module lib/mega-metrics
35
+ * @see ADR-131 (/metrics 옵트인 + OTel metrics SDK)
36
+ * @see https://opentelemetry.io/docs/specs/otel/metrics/ (OTel metrics)
37
+ * @see https://prometheus.io/docs/instrumenting/exposition_formats/ (Prometheus 텍스트 포맷)
38
+ */
39
+ import { MeterProvider } from '@opentelemetry/sdk-metrics'
40
+ import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus'
41
+ import { resourceFromAttributes } from '@opentelemetry/resources'
42
+ import {
43
+ ATTR_SERVICE_NAME,
44
+ ATTR_SERVICE_VERSION,
45
+ ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
46
+ } from '@opentelemetry/semantic-conventions'
47
+ import { MegaConfigError } from '../errors/config-error.js'
48
+
49
+ /** meter 이름 (instrumentation scope) — OTel 컨벤션상 패키지명. */
50
+ const METER_NAME = 'mega-framework'
51
+
52
+ /** Prometheus 텍스트 노출 포맷 content-type (exposition format 0.0.4). */
53
+ export const PROM_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'
54
+
55
+ /**
56
+ * HTTP/WS/어댑터 latency 히스토그램 버킷(**초** 단위 — Prometheus base-unit 컨벤션). 웹 요청 지연에 맞춘
57
+ * 표준 버킷(5ms~10s). OTel 디폴트 버킷은 ms 에 맞춰져(0,5,10,…10000) 초 단위와 안 맞으므로 명시 지정.
58
+ */
59
+ const LATENCY_BUCKETS_SECONDS = Object.freeze([0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10])
60
+
61
+ /**
62
+ * 업로드 파일 크기 히스토그램 버킷(**바이트** 단위). 1KB~100MB — 일반 웹 업로드(이미지·문서·소형 첨부)
63
+ * 분포에 맞춘 표준 버킷. base-unit = bytes(Prometheus `_bytes` suffix 컨벤션).
64
+ */
65
+ const UPLOAD_SIZE_BUCKETS_BYTES = Object.freeze([
66
+ 1024, 10_240, 102_400, 1_048_576, 5_242_880, 10_485_760, 52_428_800, 104_857_600,
67
+ ])
68
+
69
+ /** 매칭 안 된 라우트(404 등)의 고정 라벨 — 카디널리티 폭증 차단(M-A). */
70
+ const UNMATCHED_ROUTE = '__unmatched__'
71
+
72
+ /**
73
+ * @typedef {object} MetricsState
74
+ * @property {import('@opentelemetry/sdk-metrics').MeterProvider} provider
75
+ * @property {PrometheusExporter} reader - MetricReader(=PrometheusExporter, preventServerStart).
76
+ * @property {PrometheusSerializer} serializer
77
+ * @property {Instruments} m - 생성된 인스트루먼트 묶음.
78
+ */
79
+
80
+ /**
81
+ * @typedef {object} Instruments
82
+ * @property {import('@opentelemetry/api').Counter} httpRequests
83
+ * @property {import('@opentelemetry/api').Histogram} httpDuration
84
+ * @property {import('@opentelemetry/api').Counter} wsMessages
85
+ * @property {import('@opentelemetry/api').Histogram} wsDuration
86
+ * @property {import('@opentelemetry/api').Counter} adapterCalls
87
+ * @property {import('@opentelemetry/api').Histogram} adapterDuration
88
+ * @property {import('@opentelemetry/api').Counter} jobs
89
+ * @property {import('@opentelemetry/api').Counter} sessions
90
+ * @property {import('@opentelemetry/api').Counter} bruteforce
91
+ * @property {import('@opentelemetry/api').Counter} uploadFiles
92
+ * @property {import('@opentelemetry/api').Histogram} uploadBytes
93
+ * @property {import('@opentelemetry/api').Counter} i18n
94
+ * @property {import('@opentelemetry/api').Counter} templateRenders
95
+ * @property {import('@opentelemetry/api').Histogram} templateDuration
96
+ */
97
+
98
+ /** @type {MetricsState | null} init 전이면 null = 비활성(모든 record* no-op). */
99
+ let state = null
100
+
101
+ /**
102
+ * 단일 어댑터 → onCallEnd 구독 해제 함수. shutdown 시 일괄 해제. (트레이싱과 달리 onCallStart 은 구독하지
103
+ * 않는다 — 메트릭은 종료 시점의 latency/err 만 필요하고, 스코프 토큰을 안 건드려 트레이싱과 무간섭.)
104
+ * @type {Map<import('../adapters/mega-adapter.js').MegaAdapter, () => void>}
105
+ */
106
+ const subscriptions = new Map()
107
+
108
+ /**
109
+ * 단일 잡 워커/큐 → 이벤트 구독 해제 함수(어댑터 subscriptions 와 분리 — emitter 키). shutdown/_reset 시 일괄 해제.
110
+ * @type {Map<import('node:events').EventEmitter, () => void>}
111
+ */
112
+ const jobSubscriptions = new Map()
113
+
114
+ /**
115
+ * 활성 여부 (Boolean — `is*`). `init` 후 `shutdown` 전이면 true.
116
+ * @returns {boolean}
117
+ */
118
+ export function isEnabled() {
119
+ return state !== null
120
+ }
121
+
122
+ /**
123
+ * OTel 메트릭 초기화. 옵트인 — 본 함수를 부르지 않으면 모든 `record*` 가 0 비용 no-op.
124
+ *
125
+ * @param {object} opts
126
+ * @param {string} opts.serviceName - **필수**. `service.name` resource 속성.
127
+ * @param {string} [opts.version] - `service.version`.
128
+ * @param {string} [opts.environment] - `deployment.environment.name`.
129
+ * @param {Record<string, any>} [opts.attributes] - 추가 resource 속성(머지). 시크릿 금지.
130
+ * @returns {void}
131
+ * @throws {MegaConfigError} `metrics.already_initialized` / `metrics.service_name_required`.
132
+ */
133
+ export function init(opts = /** @type {any} */ ({})) {
134
+ if (state !== null) {
135
+ throw new MegaConfigError(
136
+ 'metrics.already_initialized',
137
+ 'MegaMetrics.init() called twice — call shutdown() first to re-initialize.',
138
+ {},
139
+ )
140
+ }
141
+ const serviceName = opts.serviceName
142
+ if (typeof serviceName !== 'string' || serviceName.length === 0) {
143
+ throw new MegaConfigError(
144
+ 'metrics.service_name_required',
145
+ 'MegaMetrics.init({ serviceName }) is required (non-empty string).',
146
+ {},
147
+ )
148
+ }
149
+
150
+ const resource = resourceFromAttributes({
151
+ [ATTR_SERVICE_NAME]: serviceName,
152
+ ...(typeof opts.version === 'string' ? { [ATTR_SERVICE_VERSION]: opts.version } : {}),
153
+ ...(typeof opts.environment === 'string' ? { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: opts.environment } : {}),
154
+ ...(opts.attributes && typeof opts.attributes === 'object' ? opts.attributes : {}),
155
+ })
156
+
157
+ // preventServerStart — 자체 :9464 서버를 띄우지 않고 우리가 collect() 로 직접 긁는다(메인 포트 서빙).
158
+ const reader = new PrometheusExporter({ preventServerStart: true })
159
+ const provider = new MeterProvider({ resource, readers: [reader] })
160
+ const meter = provider.getMeter(METER_NAME)
161
+ const m = buildInstruments(meter)
162
+ registerSystemGauges(meter)
163
+
164
+ state = { provider, reader, serializer: new PrometheusSerializer(), m }
165
+ }
166
+
167
+ /**
168
+ * `MEGA_METRICS_*` / `METRICS_ENABLED` 환경변수로 옵트인 초기화 (12-factor). 비활성이면 **no-op**.
169
+ * `/metrics` 자체는 `health.exposeMetrics` config 로 켜지지만(ADR-072), SDK 초기화는 env 로도 가능하게
170
+ * 트레이싱(`MegaTracing.fromEnv`)과 대칭으로 둔다. 둘 중 하나라도 켜지면 활성.
171
+ *
172
+ * 매핑:
173
+ * - `METRICS_ENABLED` 또는 `MEGA_METRICS_ENABLED` (true 면 활성)
174
+ * - `MEGA_METRICS_SERVICE_NAME` → serviceName (없으면 `MEGA_OTEL_SERVICE_NAME` 폴백)
175
+ * - `MEGA_METRICS_VERSION` / `MEGA_METRICS_ENVIRONMENT` → resource 속성
176
+ *
177
+ * @param {Record<string, string|undefined>} [env=process.env]
178
+ * @returns {boolean} 활성화돼 init 했으면 true, 옵트인 OFF 면 false.
179
+ * @throws {MegaConfigError} 활성인데 serviceName 누락 시 `metrics.service_name_required`.
180
+ */
181
+ export function fromEnv(env = process.env) {
182
+ const enabled = env.METRICS_ENABLED === 'true' || env.MEGA_METRICS_ENABLED === 'true'
183
+ if (!enabled) return false
184
+ /** @type {any} */
185
+ const opts = { serviceName: env.MEGA_METRICS_SERVICE_NAME ?? env.MEGA_OTEL_SERVICE_NAME }
186
+ if (env.MEGA_METRICS_VERSION) opts.version = env.MEGA_METRICS_VERSION
187
+ if (env.MEGA_METRICS_ENVIRONMENT) opts.environment = env.MEGA_METRICS_ENVIRONMENT
188
+ init(opts)
189
+ return true
190
+ }
191
+
192
+ /**
193
+ * 현재 누적된 메트릭을 Prometheus 텍스트(exposition format)로 직렬화한다. `/metrics` 라우트와 테스트가
194
+ * **같은 경로**로 사용한다. 옵트인 OFF 면 빈 문자열.
195
+ * @returns {Promise<string>}
196
+ */
197
+ export async function collect() {
198
+ if (state === null) return ''
199
+ const { resourceMetrics, errors } = await state.reader.collect()
200
+ if (errors.length > 0) {
201
+ // 수집 중 일부 인스트루먼트 실패 — 묵시 무시 금지. 표면화하되, 모은 만큼은 그대로 노출(부분 실패가
202
+ // 전체 scrape 를 죽이지 않게). 운영자가 로그로 인지하게 한다.
203
+ console.warn('[MegaMetrics] collect() encountered errors (partial metrics served):', errors)
204
+ }
205
+ return state.serializer.serialize(resourceMetrics)
206
+ }
207
+
208
+ /**
209
+ * 트레이싱 종료 대칭 — provider 종료(+ 구독 해제). init 전이면 no-op.
210
+ * @returns {Promise<void>}
211
+ */
212
+ export async function shutdown() {
213
+ for (const unsubscribe of [...subscriptions.values()]) unsubscribe()
214
+ subscriptions.clear()
215
+ for (const unsubscribe of [...jobSubscriptions.values()]) unsubscribe()
216
+ jobSubscriptions.clear()
217
+ const s = state
218
+ state = null
219
+ if (s !== null) await s.provider.shutdown()
220
+ }
221
+
222
+ // ──────────────────────────────────────────────────────────────────────────
223
+ // 기록 API (코어 배선이 호출) — 전부 옵트인 OFF 면 즉시 return = 0 비용.
224
+ // ──────────────────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * HTTP 요청 1건 기록 — 카운터 +1 + latency 히스토그램. mega-app `onResponse` hook 이 호출.
228
+ * @param {object} info
229
+ * @param {string} info.method - HTTP 메서드(대문자).
230
+ * @param {string} [info.route] - **매칭된 라우트 패턴**(`/users/:id`). 없으면 `__unmatched__`(M-A).
231
+ * @param {number} info.statusCode - 응답 상태코드.
232
+ * @param {number} info.durationMs - 처리 시간(ms).
233
+ * @param {string} info.app - 앱 이름.
234
+ * @returns {void}
235
+ */
236
+ export function recordHttp({ method, route, statusCode, durationMs, app }) {
237
+ if (state === null) return
238
+ const labels = {
239
+ method: String(method ?? '').toUpperCase(),
240
+ route: route && route.length > 0 ? route : UNMATCHED_ROUTE,
241
+ app: app ?? '',
242
+ }
243
+ state.m.httpRequests.add(1, { ...labels, status_code: String(statusCode) })
244
+ state.m.httpDuration.record(toSeconds(durationMs), labels)
245
+ }
246
+
247
+ /**
248
+ * WebSocket 메시지 1건 기록. ws 수신 배선이 호출.
249
+ * @param {object} info
250
+ * @param {string} info.type - 메시지 타입(라우트 키처럼 bounded 한 값만 — raw payload X).
251
+ * @param {string} [info.ns] - 네임스페이스.
252
+ * @param {number} [info.durationMs] - 처리 시간(ms). 있으면 히스토그램 기록.
253
+ * @param {string} [info.app] - 앱 이름.
254
+ * @returns {void}
255
+ */
256
+ export function recordWs({ type, ns, durationMs, app }) {
257
+ if (state === null) return
258
+ const labels = { type: String(type ?? ''), ns: ns ?? '', app: app ?? '' }
259
+ state.m.wsMessages.add(1, labels)
260
+ if (typeof durationMs === 'number') state.m.wsDuration.record(toSeconds(durationMs), { type: labels.type, app: labels.app })
261
+ }
262
+
263
+ /**
264
+ * 어댑터 호출 1건 기록 — 도메인/드라이버/콜별 카운터 + latency. 보통 `subscribe`(onCallEnd)가 호출.
265
+ * @param {object} info
266
+ * @param {string} info.domain - db|cache|bus|lock.
267
+ * @param {string} info.driver - 드라이버명(postgres|redis|nats|…).
268
+ * @param {string} info.call - 도메인 콜명(query|get|publish|acquire|…).
269
+ * @param {number} info.durationMs - latency(ms).
270
+ * @param {boolean} info.isError - 실패 여부.
271
+ * @returns {void}
272
+ */
273
+ export function recordAdapterCall({ domain, driver, call, durationMs, isError }) {
274
+ if (state === null) return
275
+ const labels = { domain: domain ?? '', driver: driver ?? '', call: call ?? '' }
276
+ state.m.adapterCalls.add(1, { ...labels, status: isError ? 'error' : 'ok' })
277
+ state.m.adapterDuration.record(toSeconds(durationMs), labels)
278
+ }
279
+
280
+ /**
281
+ * 잡 큐 이벤트 1건 기록(enqueued|processed|retried|dlq). MegaJobQueue/Worker 가 호출.
282
+ * @param {object} info
283
+ * @param {string} info.queue - 큐 이름.
284
+ * @param {'enqueued'|'processed'|'retried'|'dlq'} info.event
285
+ * @returns {void}
286
+ */
287
+ export function recordJob({ queue, event }) {
288
+ if (state === null) return
289
+ state.m.jobs.add(1, { queue: queue ?? '', event: String(event) })
290
+ }
291
+
292
+ /**
293
+ * 세션 이벤트 1건 기록(created|destroyed). 세션 미들웨어가 호출.
294
+ * @param {object} info
295
+ * @param {string} [info.driver] - file|redis.
296
+ * @param {'created'|'destroyed'} info.event
297
+ * @returns {void}
298
+ */
299
+ export function recordSession({ driver, event }) {
300
+ if (state === null) return
301
+ state.m.sessions.add(1, { driver: driver ?? '', event: String(event) })
302
+ }
303
+
304
+ /**
305
+ * 브루트포스 이벤트 1건 기록(check|fail|lockout|reset). MegaBruteForce 가 호출.
306
+ * subject(이메일·IP)는 **라벨에 절대 안 넣는다** — 카디널리티 폭증 + PII. namespace 만 라벨.
307
+ * @param {object} info
308
+ * @param {string} info.namespace - 도메인 네임스페이스(login|password-reset|…).
309
+ * @param {'check'|'fail'|'lockout'|'reset'} info.event
310
+ * @returns {void}
311
+ */
312
+ export function recordBruteForce({ namespace, event }) {
313
+ if (state === null) return
314
+ state.m.bruteforce.add(1, { namespace: namespace ?? '', event: String(event) })
315
+ }
316
+
317
+ /**
318
+ * 파일 업로드 결과 1건 기록. multipart 통합(`src/core/multipart.js`)이 호출.
319
+ *
320
+ * - 카운터 `mega_upload_files_total{app,result}` +1 — result 는 **bounded enum** 만:
321
+ * `accepted`(MIME 화이트리스트 통과) / `rejected_mime`(비허용 MIME) / `saved`(디스크 저장 완료).
322
+ * 파일명·MIME 문자열은 라벨에 **안 넣는다** — 카디널리티 폭증·PII 방지. 크기 초과(413)·개수
323
+ * 초과(413)는 @fastify/multipart 가 네이티브로 throw 하므로 `mega_http_requests_total{status_code="413"}`
324
+ * 로 이미 관측된다(중복 집계 안 함).
325
+ * - `bytes` 가 주어지면 히스토그램 `mega_upload_file_bytes{app}` 에 분포 기록(저장 완료 시점).
326
+ *
327
+ * @param {object} info
328
+ * @param {string} [info.app] - 앱 이름.
329
+ * @param {'accepted'|'rejected_mime'|'saved'} info.result - 업로드 결과(bounded enum).
330
+ * @param {number} [info.bytes] - 파일 크기(바이트). 있으면 히스토그램 기록.
331
+ * @returns {void}
332
+ */
333
+ export function recordUpload({ app, result, bytes }) {
334
+ if (state === null) return
335
+ state.m.uploadFiles.add(1, { app: app ?? '', result: String(result) })
336
+ if (typeof bytes === 'number' && bytes >= 0) state.m.uploadBytes.record(bytes, { app: app ?? '' })
337
+ }
338
+
339
+ /**
340
+ * i18n 이벤트 1건 집계(ADR-135). init 전이면 no-op.
341
+ *
342
+ * - `event='request'`: 요청별 결정 언어 분포(앱·언어 라벨). onRequest 1회.
343
+ * - `event='missing'`: 누락 키(dev saveMissing 신호 — 앱·언어·scope 라벨). 키 자체는 라벨 미노출(카디널리티).
344
+ *
345
+ * lang/scope 는 `available`·{server,client} 로 bounded 라 카디널리티 안전.
346
+ *
347
+ * @param {object} info
348
+ * @param {string} [info.app] - 앱 이름.
349
+ * @param {string} info.lang - 결정/요청 언어.
350
+ * @param {string} [info.scope] - namespace scope(missing 시 server|client). request 면 생략.
351
+ * @param {'request'|'missing'} info.event
352
+ * @returns {void}
353
+ */
354
+ export function recordI18n({ app, lang, scope, event }) {
355
+ if (state === null) return
356
+ state.m.i18n.add(1, { app: app ?? '', lang: String(lang ?? ''), scope: scope ?? '', event: String(event) })
357
+ }
358
+
359
+ /**
360
+ * 템플릿 렌더 1건 기록(ADR-136). init 전이면 no-op. 템플릿 통합(`src/core/template.js`)이 호출.
361
+ *
362
+ * - 카운터 `mega_template_renders_total{app,result}` +1 — result 는 **bounded enum** 만:
363
+ * `rendered`(성공) / `error`(렌더 실패 — 문법 오류·파일 없음·참조 throw). view 이름·경로는 라벨에
364
+ * **안 넣는다** — 카디널리티 폭증·경로 노출 방지(upload/i18n 패턴 정합).
365
+ * - `durationMs` 가 주어지면 히스토그램 `mega_template_render_duration_seconds{app}` 에 분포 기록.
366
+ * - `bytes` 는 span 속성으로만 남기고 메트릭 라벨엔 안 쓴다(렌더 시간이 운영 신호 — 크기는 응답 메트릭과 중복).
367
+ *
368
+ * @param {object} info
369
+ * @param {string} [info.app] - 앱 이름.
370
+ * @param {'rendered'|'error'} info.result - 렌더 결과(bounded enum).
371
+ * @param {number} [info.durationMs] - 렌더 소요(ms). 있으면 히스토그램 기록(초 단위 변환).
372
+ * @param {number} [info.bytes] - 렌더 바이트(현재 메트릭 미사용 — 시그니처 호환·향후 확장용).
373
+ * @returns {void}
374
+ */
375
+ export function recordTemplate({ app, result, durationMs }) {
376
+ if (state === null) return
377
+ state.m.templateRenders.add(1, { app: app ?? '', result: String(result) })
378
+ if (typeof durationMs === 'number' && durationMs >= 0) state.m.templateDuration.record(durationMs / 1000, { app: app ?? '' })
379
+ }
380
+
381
+ // ──────────────────────────────────────────────────────────────────────────
382
+ // 어댑터 자동 수집 (onCallEnd 구독 — 트레이싱 subscribe 대칭, 단 onCallStart 미구독)
383
+ // ──────────────────────────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * 단일 어댑터의 `onCallEnd` 를 구독해 호출 메트릭으로 변환한다. init 전이면 no-op. 중복 구독 방지.
387
+ * @param {import('../adapters/mega-adapter.js').MegaAdapter} adapter
388
+ * @param {{ domain?: string, driver?: string, key?: string }} [meta]
389
+ * @returns {() => void} 구독 해제 함수.
390
+ */
391
+ export function subscribe(adapter, meta = {}) {
392
+ if (state === null) return () => {}
393
+ const existing = subscriptions.get(adapter)
394
+ if (existing) return existing
395
+ const domain = meta.domain ?? ''
396
+ const driver = meta.driver ?? ''
397
+ /** @type {import('../adapters/mega-adapter.js').HookListener} */
398
+ const onEnd = (callName, attrs, err) => {
399
+ // hook 은 throw 금지(M1) — 베이스가 #safeHookEnd 로 격리하지만, 여기서도 record* 가 던지지 않게 단순 유지.
400
+ const latencyMs = /** @type {any} */ (attrs)?.latencyMs
401
+ recordAdapterCall({
402
+ domain,
403
+ driver,
404
+ call: callName,
405
+ durationMs: typeof latencyMs === 'number' ? latencyMs : 0,
406
+ isError: err !== undefined && err !== null,
407
+ })
408
+ }
409
+ const unEnd = adapter.addHookListener('onCallEnd', onEnd)
410
+ const unsubscribe = () => {
411
+ unEnd()
412
+ subscriptions.delete(adapter)
413
+ }
414
+ subscriptions.set(adapter, unsubscribe)
415
+ return unsubscribe
416
+ }
417
+
418
+ /**
419
+ * 부팅된 전역 어댑터 매니저의 모든 공유 어댑터에 onCallEnd 리스너를 일괄 구독한다(트레이싱 attachToManager 대칭).
420
+ * @param {{ entries: () => Array<{ domain: string, key: string, driver: string, adapter: import('../adapters/mega-adapter.js').MegaAdapter }> }} manager
421
+ * @returns {number} 구독한 어댑터 수.
422
+ */
423
+ export function attachToManager(manager) {
424
+ if (state === null) return 0
425
+ let count = 0
426
+ for (const e of manager.entries()) {
427
+ subscribe(e.adapter, { domain: e.domain, driver: e.driver, key: e.key })
428
+ count += 1
429
+ }
430
+ return count
431
+ }
432
+
433
+ // ──────────────────────────────────────────────────────────────────────────
434
+ // 잡 워커 자동 수집 (MegaJobWorker/MegaJobQueue 이벤트 구독 — ADR-132)
435
+ // ──────────────────────────────────────────────────────────────────────────
436
+
437
+ /**
438
+ * 잡 큐 이벤트명 → `recordJob` event 라벨 매핑. 큐가 방출하는 6종(`dispatch/start/done/retry/fail/dlq`) 중
439
+ * **카운터로 의미 있는 4종만** 매핑한다: `start`(처리 시작)은 누적 카운터가 무의미하고, `fail`(중간 실패 —
440
+ * phase 로 단계 구분)은 최종 결과가 `dlq`(소진 라우팅) 또는 `done`(이후 성공)으로 귀결되므로 dlq 로 집계한다.
441
+ * @type {Readonly<Record<string, 'enqueued'|'processed'|'retried'|'dlq'>>}
442
+ */
443
+ const JOB_EVENT_MAP = Object.freeze({
444
+ dispatch: 'enqueued', // enqueue(잡 발행)
445
+ done: 'processed', // 처리 성공 + ack
446
+ retry: 'retried', // 재시도 1회 실패(다음 시도 전)
447
+ dlq: 'dlq', // DLQ 라우팅 완료(최종 실패 소진)
448
+ })
449
+
450
+ /**
451
+ * 잡 워커(또는 큐)의 이벤트를 구독해 잡 메트릭(`mega_jobs_total`)으로 변환한다(어댑터 {@link subscribe} 대칭).
452
+ * `init` 전이면 **no-op**(0 비용 — 빈 해제 함수 반환). 같은 emitter 를 다시 구독하면 기존 해제 함수를 반환해
453
+ * **중복 부착을 방지**한다(어댑터 subscribe 와 동일 정책).
454
+ *
455
+ * `MegaJobWorker` 는 하부 `MegaJobQueue` 의 이벤트를 그대로 재방출(forward)하므로, **워커 1곳만 구독하면**
456
+ * 그 워커가 든 모든 bus 큐의 잡이 집계된다. `MegaJobQueue` 를 직접 구독해도 동일하게 동작한다(둘 다 같은
457
+ * 이벤트명을 방출). queue 라벨 = 이벤트 payload 의 `subject`.
458
+ *
459
+ * 리스너가 던질 일은 없지만(`recordJob` 은 카운터 add 뿐), 만에 하나 던져도 큐의 `#safeEmit`/워커 forward
460
+ * 가 격리하므로 잡 처리 흐름(ack/nak/DLQ)에는 영향이 없다(M-3 불변식 정합).
461
+ *
462
+ * @param {import('node:events').EventEmitter} emitter - `dispatch/done/retry/dlq` 를 방출하는 MegaJobWorker/MegaJobQueue.
463
+ * @returns {() => void} 구독 해제 함수.
464
+ */
465
+ export function subscribeJobs(emitter) {
466
+ if (state === null) return () => {}
467
+ const existing = jobSubscriptions.get(emitter)
468
+ if (existing) return existing
469
+ /** @type {Array<[string, (payload: any) => void]>} 부착한 (이벤트명, 리스너) — 해제 시 그대로 off. */
470
+ const attached = []
471
+ for (const [queueEvent, metricEvent] of Object.entries(JOB_EVENT_MAP)) {
472
+ /** @param {any} payload */
473
+ const listener = (payload) => recordJob({ queue: payload?.subject, event: metricEvent })
474
+ emitter.on(queueEvent, listener)
475
+ attached.push([queueEvent, listener])
476
+ }
477
+ const unsubscribe = () => {
478
+ for (const [queueEvent, listener] of attached) emitter.off(queueEvent, listener)
479
+ jobSubscriptions.delete(emitter)
480
+ }
481
+ jobSubscriptions.set(emitter, unsubscribe)
482
+ return unsubscribe
483
+ }
484
+
485
+ // ──────────────────────────────────────────────────────────────────────────
486
+ // 내부 — 인스트루먼트 생성 + 시스템 게이지
487
+ // ──────────────────────────────────────────────────────────────────────────
488
+
489
+ /**
490
+ * 모든 인스트루먼트를 생성한다. 이름은 `mega_` prefix + snake_case + base-unit suffix(Prometheus 컨벤션).
491
+ * @param {import('@opentelemetry/api').Meter} meter
492
+ * @returns {Instruments}
493
+ */
494
+ function buildInstruments(meter) {
495
+ return {
496
+ httpRequests: meter.createCounter('mega_http_requests_total', {
497
+ description: 'HTTP 요청 총 건수 (method/route/status_code/app 라벨).',
498
+ }),
499
+ httpDuration: meter.createHistogram('mega_http_request_duration_seconds', {
500
+ description: 'HTTP 요청 처리 시간(초).',
501
+ unit: 's',
502
+ advice: { explicitBucketBoundaries: [...LATENCY_BUCKETS_SECONDS] },
503
+ }),
504
+ wsMessages: meter.createCounter('mega_ws_messages_total', {
505
+ description: 'WebSocket 수신 메시지 총 건수 (type/ns/app 라벨).',
506
+ }),
507
+ wsDuration: meter.createHistogram('mega_ws_message_duration_seconds', {
508
+ description: 'WebSocket 메시지 처리 시간(초).',
509
+ unit: 's',
510
+ advice: { explicitBucketBoundaries: [...LATENCY_BUCKETS_SECONDS] },
511
+ }),
512
+ adapterCalls: meter.createCounter('mega_adapter_calls_total', {
513
+ description: '어댑터 도메인 호출 총 건수 (domain/driver/call/status 라벨).',
514
+ }),
515
+ adapterDuration: meter.createHistogram('mega_adapter_call_duration_seconds', {
516
+ description: '어댑터 호출 latency(초).',
517
+ unit: 's',
518
+ advice: { explicitBucketBoundaries: [...LATENCY_BUCKETS_SECONDS] },
519
+ }),
520
+ jobs: meter.createCounter('mega_jobs_total', {
521
+ description: '잡 큐 이벤트 총 건수 (queue/event=enqueued|processed|retried|dlq 라벨).',
522
+ }),
523
+ sessions: meter.createCounter('mega_sessions_total', {
524
+ description: '세션 이벤트 총 건수 (driver/event=created|destroyed 라벨).',
525
+ }),
526
+ bruteforce: meter.createCounter('mega_bruteforce_events_total', {
527
+ description: '브루트포스 이벤트 총 건수 (namespace/event=check|fail|lockout|reset 라벨). subject 는 PII 라 미노출.',
528
+ }),
529
+ uploadFiles: meter.createCounter('mega_upload_files_total', {
530
+ description: '파일 업로드 결과 총 건수 (app/result=accepted|rejected_mime|saved 라벨). 파일명·MIME 은 PII/카디널리티 라 미노출.',
531
+ }),
532
+ uploadBytes: meter.createHistogram('mega_upload_file_bytes', {
533
+ description: '업로드 파일 크기 분포(바이트) — 디스크 저장 완료 시점(app 라벨).',
534
+ unit: 'By',
535
+ advice: { explicitBucketBoundaries: [...UPLOAD_SIZE_BUCKETS_BYTES] },
536
+ }),
537
+ i18n: meter.createCounter('mega_i18n_events_total', {
538
+ description: 'i18n 이벤트 총 건수 (app/lang/scope/event=request|missing 라벨). request=요청별 언어 분포, missing=누락 키(dev). lang/scope 는 bounded.',
539
+ }),
540
+ templateRenders: meter.createCounter('mega_template_renders_total', {
541
+ description: '템플릿 렌더 총 건수 (app/result=rendered|error 라벨). view 이름·경로는 카디널리티/경로 노출 방지로 미노출.',
542
+ }),
543
+ templateDuration: meter.createHistogram('mega_template_render_duration_seconds', {
544
+ description: '템플릿 렌더 소요 분포(초) — ejs-mate 렌더 1패스 기준(app 라벨).',
545
+ unit: 's',
546
+ advice: { explicitBucketBoundaries: [...LATENCY_BUCKETS_SECONDS] },
547
+ }),
548
+ }
549
+ }
550
+
551
+ /**
552
+ * 시스템(프로세스) 게이지를 등록한다 — scrape 시점에 콜백으로 현재값 관측(observable).
553
+ * 메모리/uptime/CPU 는 라벨 없거나 bounded 라 카디널리티 안전.
554
+ * @param {import('@opentelemetry/api').Meter} meter
555
+ * @returns {void}
556
+ */
557
+ function registerSystemGauges(meter) {
558
+ const memory = meter.createObservableGauge('mega_process_memory_bytes', {
559
+ description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external 라벨.',
560
+ unit: 'By',
561
+ })
562
+ memory.addCallback((result) => {
563
+ const mu = process.memoryUsage()
564
+ result.observe(mu.rss, { kind: 'rss' })
565
+ result.observe(mu.heapUsed, { kind: 'heap_used' })
566
+ result.observe(mu.heapTotal, { kind: 'heap_total' })
567
+ result.observe(mu.external, { kind: 'external' })
568
+ })
569
+
570
+ const uptime = meter.createObservableGauge('mega_process_uptime_seconds', {
571
+ description: '프로세스 가동 시간(초).',
572
+ unit: 's',
573
+ })
574
+ uptime.addCallback((result) => result.observe(process.uptime()))
575
+
576
+ const cpu = meter.createObservableCounter('mega_process_cpu_seconds_total', {
577
+ description: '프로세스 누적 CPU 시간(초) — type=user|system 라벨.',
578
+ unit: 's',
579
+ })
580
+ cpu.addCallback((result) => {
581
+ const cu = process.cpuUsage() // microseconds 단위 → 초로 환산.
582
+ result.observe(cu.user / 1e6, { type: 'user' })
583
+ result.observe(cu.system / 1e6, { type: 'system' })
584
+ })
585
+ }
586
+
587
+ /** @param {number} ms @returns {number} ms → 초(히스토그램 base-unit). */
588
+ function toSeconds(ms) {
589
+ return (typeof ms === 'number' && ms >= 0 ? ms : 0) / 1000
590
+ }
591
+
592
+ // ──────────────────────────────────────────────────────────────────────────
593
+ // /metrics IP allowList (접근 제어 — ADR-131)
594
+ // ──────────────────────────────────────────────────────────────────────────
595
+
596
+ /**
597
+ * 클라이언트 IP 가 allowList 에 허용되는지 (Boolean — `is*`). `/metrics` 엔드포인트 접근 제어용(ADR-072
598
+ * 면제 위에 추가 인증). allowList 항목은 **정확한 IP**(IPv4/IPv6) 또는 **IPv4 CIDR**(`10.0.0.0/8`).
599
+ * 빈 list/미지정이면 **모두 허용**(메인 포트 노출 — 사이드카/내부망 전제, 운영자 결정).
600
+ *
601
+ * IPv6 CIDR 는 미지원(정확 매치만) — 필요 시 후속 확장. 잘못된 CIDR 항목은 매치 실패로 간주(조용히
602
+ * 통과시키지 않음 = fail-closed 방향).
603
+ *
604
+ * @param {string} ip - 클라이언트 IP(Fastify `req.ip`). IPv4-mapped IPv6(`::ffff:1.2.3.4`)는 IPv4 로 정규화.
605
+ * @param {string[]} [allowList] - 허용 IP/CIDR 목록.
606
+ * @returns {boolean}
607
+ */
608
+ export function isIpAllowed(ip, allowList) {
609
+ if (!Array.isArray(allowList) || allowList.length === 0) return true // 빈 list = 전부 허용.
610
+ if (typeof ip !== 'string' || ip.length === 0) return false
611
+ const norm = ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip // IPv4-mapped IPv6 정규화.
612
+ for (const entry of allowList) {
613
+ if (typeof entry !== 'string' || entry.length === 0) continue
614
+ if (entry === ip || entry === norm) return true // 정확 매치(IPv4/IPv6).
615
+ if (entry.includes('/') && isIpv4InCidr(norm, entry)) return true // IPv4 CIDR.
616
+ }
617
+ return false
618
+ }
619
+
620
+ /**
621
+ * IPv4 가 CIDR 범위에 드는지. CIDR/IP 가 IPv4 형식이 아니거나 prefix 가 0~32 밖이면 false(fail-closed).
622
+ * @param {string} ip @param {string} cidr - `a.b.c.d/n`.
623
+ * @returns {boolean}
624
+ */
625
+ function isIpv4InCidr(ip, cidr) {
626
+ const [range, bitsRaw] = cidr.split('/')
627
+ const bits = Number(bitsRaw)
628
+ if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false
629
+ const ipNum = ipv4ToInt(ip)
630
+ const rangeNum = ipv4ToInt(range)
631
+ if (ipNum === null || rangeNum === null) return false
632
+ if (bits === 0) return true // /0 = 모든 IPv4.
633
+ const mask = (0xffffffff << (32 - bits)) >>> 0
634
+ return (ipNum & mask) === (rangeNum & mask)
635
+ }
636
+
637
+ /** IPv4 문자열 → 32비트 정수(unsigned). 형식 위반이면 null. @param {string} ip @returns {number|null} */
638
+ function ipv4ToInt(ip) {
639
+ const parts = ip.split('.')
640
+ if (parts.length !== 4) return null
641
+ let n = 0
642
+ for (const p of parts) {
643
+ if (!/^\d{1,3}$/.test(p)) return null
644
+ const v = Number(p)
645
+ if (v > 255) return null
646
+ n = (n << 8) | v
647
+ }
648
+ return n >>> 0
649
+ }
650
+
651
+ /**
652
+ * 테스트 격리용 reset — 동기 강제 정리(provider flush 없이 상태만 비움). 정상 종료는 `shutdown`.
653
+ * @returns {void}
654
+ */
655
+ export function _reset() {
656
+ for (const unsubscribe of [...subscriptions.values()]) unsubscribe()
657
+ subscriptions.clear()
658
+ for (const unsubscribe of [...jobSubscriptions.values()]) unsubscribe()
659
+ jobSubscriptions.clear()
660
+ state = null
661
+ }