mega-framework 0.1.8 → 0.1.10

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 (27) hide show
  1. package/package.json +1 -1
  2. package/sample/crud/apps/main/controllers/jobs-controller.js +22 -2
  3. package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
  4. package/sample/crud/apps/main/jobs/email-job.js +37 -2
  5. package/sample/crud/apps/main/locales/server/en.json +5 -0
  6. package/sample/crud/apps/main/locales/server/ko.json +5 -0
  7. package/sample/crud/apps/main/routes/upload.js +20 -1
  8. package/sample/crud/apps/main/services/guide-service.js +4 -3
  9. package/sample/crud/apps/main/services/jobs-demo-service.js +1 -1
  10. package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
  11. package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
  12. package/sample/crud/apps/main/views/upload/index.ejs +4 -1
  13. package/sample/crud/docs/guide/01-cli.md +587 -0
  14. package/sample/crud/docs/guide/02-router-controller.md +497 -0
  15. package/sample/crud/docs/guide/03-service-model-db.md +929 -0
  16. package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
  17. package/sample/crud/docs/guide/05-scheduler-job-worker.md +425 -0
  18. package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
  19. package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
  20. package/sample/crud/docs/guide/08-observability.md +373 -0
  21. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbnwq5v-d2125aa8.txt" +1 -0
  22. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbo0nbf-842b6135.txt" +1 -0
  23. package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
  24. package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
  25. package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
  26. package/sample/crud/var/uploads//341/204/200/341/205/247/341/206/274/341/204/200/341/205/265/341/204/211/341/205/265/341/206/257/341/204/214/341/205/245/341/206/250/341/204/214/341/205/263/341/206/274/341/204/206/341/205/247/341/206/274/341/204/211/341/205/245-mqbo5yxh-5288d8ef.pdf +0 -0
  27. package/src/cli/index.js +41 -0
@@ -0,0 +1,373 @@
1
+ # 관측 (Metrics + Swagger + Tracing + Logger)
2
+
3
+ 서버가 "지금 잘 돌고 있는가"를 **밖에서 들여다보는** 네 가지 도구를 다룬다.
4
+
5
+ - **Metrics** — 숫자 집계(요청 초당 몇 건, 평균 얼마 걸림). Prometheus 가 긁어간다.
6
+ - **Swagger/OpenAPI** — API 설명서를 자동 생성해 `/docs` 웹페이지로 보여준다.
7
+ - **Tracing** — 요청 하나가 어디를 거쳐갔는지 분산 추적(span 트리). Zipkin/OTLP collector 로 보낸다.
8
+ - **Logger** — 구조적 JSON 로그(pino). 콘솔·파일·텔레그램으로 동시 출력.
9
+
10
+ 네 가지 모두 **옵트인**이다. 켜지 않으면 코드 경로가 아예 안 붙어 **성능 비용 0**이다. 이 설계가 관측 모듈의 공통 뼈대다.
11
+
12
+ ---
13
+
14
+ ## 1. Metrics (Prometheus)
15
+
16
+ > 소스: `src/lib/mega-metrics.js`, `src/core/cluster-metrics.js`, 라우트 배선 `src/core/mega-app.js`, boot 배선 `src/core/boot.js`. (ADR-131 / ADR-132 / ADR-170)
17
+
18
+ ### 1.1 무엇인가
19
+
20
+ 트레이싱이 "요청 하나가 어디를 거쳐갔나"를 **개별 추적**한다면, 메트릭은 "요청이 초당 몇 건 왔고 평균 얼마 걸렸나"를 **숫자로 집계**한다. 운영자는 이 집계 숫자를 Prometheus 로 scrape 해 대시보드·알람을 만든다.
21
+
22
+ 내부적으로 OpenTelemetry metrics SDK 를 쓰되, `PrometheusExporter` 를 `preventServerStart: true` 로 띄워 **자체 :9464 서버를 끄고** MetricReader 로만 쓴다. `/metrics` 라우트가 `reader.collect()` → `PrometheusSerializer` 로 텍스트를 만들어 **메인 포트로** 응답한다(`/health` 면제 패턴 정합).
23
+
24
+ ### 1.2 켜기
25
+
26
+ 메트릭은 `mega.config.js` 의 `health.exposeMetrics: true` 로 켠다. 그러면 boot 가 `MegaMetrics.init()` 을 호출하고, 모든 공유 어댑터의 `onCallEnd` 에 일괄 구독한다.
27
+
28
+ ```js
29
+ // mega.config.js
30
+ export default {
31
+ health: {
32
+ exposeMetrics: true, // /metrics 라우트 등록 + SDK 초기화
33
+ metricsPath: '/metrics', // 디폴트 '/metrics'
34
+ metricsAllowList: ['127.0.0.1', '::1'], // 접근 허용 IP/CIDR (빈 배열이면 전체 노출)
35
+ },
36
+ }
37
+ ```
38
+
39
+ > boot 배선(`src/core/boot.js`): `health.exposeMetrics === true` 일 때만 `MegaMetrics.init({ serviceName, version, environment })` + `attachToManager`. serviceName 은 `health.serviceName` → `server.serviceName` → `MEGA_OTEL_SERVICE_NAME` → `'mega-framework'` 순으로 폴백한다.
40
+
41
+ ### 1.3 `/metrics` 엔드포인트 (IP allowList)
42
+
43
+ `exposeMetrics:true` 일 때만 라우트가 생긴다(디폴트는 Fastify 404). `/health` 와 같은 보안 면제(`HEALTH_EXEMPT`)이지만, 그 위에 **IP allowList** 를 한 겹 더 둔다.
44
+
45
+ - allowList 항목 = **정확한 IP**(IPv4/IPv6) 또는 **IPv4 CIDR**(`10.0.0.0/8`).
46
+ - **빈 배열/미지정 = 전부 허용**(사이드카·내부망 전제, 운영자 결정).
47
+ - 불허 IP 는 `403 forbidden`(raw text). IPv4-mapped IPv6(`::ffff:1.2.3.4`)는 IPv4 로 정규화해 비교.
48
+ - 잘못된 CIDR 항목은 매치 실패로 간주(fail-closed).
49
+
50
+ 응답은 문자열(Prometheus 텍스트)이라 Fastify 가 envelope JSON 으로 안 감싼다(raw 전송). Content-Type 은 `MegaMetrics.PROM_CONTENT_TYPE`(`text/plain; version=0.0.4`).
51
+
52
+ ### 1.4 인스트루먼트 (무엇을 재나)
53
+
54
+ 이름은 `mega_` prefix + snake_case + base-unit suffix(Prometheus 컨벤션). 히스토그램 latency 버킷은 **초 단위**(5ms~10s).
55
+
56
+ | 메트릭 | 타입 | 주요 라벨 |
57
+ |---|---|---|
58
+ | `mega_http_requests_total` | counter | method, route, status_code, app |
59
+ | `mega_http_request_duration_seconds` | histogram | method, route, app |
60
+ | `mega_ws_messages_total` / `mega_ws_message_duration_seconds` | counter / histogram | type, ns, app |
61
+ | `mega_adapter_calls_total` / `mega_adapter_call_duration_seconds` | counter / histogram | domain, driver, call, status |
62
+ | `mega_jobs_total` | counter | queue, event(enqueued\|processed\|retried\|dlq) |
63
+ | `mega_sessions_total` | counter | driver, event(created\|destroyed) |
64
+ | `mega_bruteforce_events_total` | counter | namespace, event(check\|fail\|lockout\|reset) |
65
+ | `mega_upload_files_total` / `mega_upload_file_bytes` | counter / histogram | app, result |
66
+ | `mega_i18n_events_total` | counter | app, lang, scope, event(request\|missing) |
67
+ | `mega_template_renders_total` / `mega_template_render_duration_seconds` | counter / histogram | app, result |
68
+ | `mega_process_memory_bytes` / `mega_process_uptime_seconds` / `mega_process_cpu_seconds_total` | gauge / gauge / counter | kind, type |
69
+
70
+ `process_*` 게이지는 scrape 시점 콜백으로 현재값을 관측(observable)한다.
71
+
72
+ ### 1.5 잡 메트릭 자동 수집 (subscribeJobs, ADR-132)
73
+
74
+ 잡 워커/큐의 이벤트를 구독해 `mega_jobs_total` 로 변환한다. 큐가 방출하는 6종(`dispatch/start/done/retry/fail/dlq`) 중 **카운터로 의미 있는 4종만** 매핑한다:
75
+
76
+ ```
77
+ dispatch → enqueued done → processed retry → retried dlq → dlq
78
+ ```
79
+
80
+ `MegaJobWorker` 는 하부 `MegaJobQueue` 의 이벤트를 그대로 재방출하므로 **워커 1곳만 구독하면** 그 워커가 든 모든 큐의 잡이 집계된다. 같은 emitter 를 다시 구독하면 기존 해제 함수를 반환해 중복 부착을 막는다.
81
+
82
+ ### 1.6 WS 메트릭
83
+
84
+ WS 수신 배선(`src/core/ws-upgrade.js`)이 메시지마다 `MegaMetrics.recordWs({ type, ns, durationMs, app })` 를 호출한다. `type` 은 라우트 키처럼 bounded 한 값만 — raw payload 는 라벨에 절대 안 넣는다.
85
+
86
+ ### 1.7 클러스터 집계 (cluster-metrics, ADR-170)
87
+
88
+ `mega start` 클러스터 모드는 같은 포트를 여러 워커가 나눠 받는다. 메트릭은 **프로세스마다 따로** 쌓이므로, `/metrics` 를 한 번 긁으면 그때 응답한 워커 숫자만 나온다(새로고침마다 들쭉날쭉). OTel SDK 엔 프로세스 간 합산이 없어 우리가 한다(prom-client `AggregatorRegistry` 패턴).
89
+
90
+ 흐름(마스터가 IPC relay — 워커끼리 직통 불가):
91
+
92
+ 1. `/metrics` 가 한 워커에 도착 → 그 워커가 마스터에 `request`(`collectCluster`).
93
+ 2. 마스터가 전 워커에 `collect` fan-out → 각 워커가 자기 `collect()` 텍스트를 `collected` 로 회신.
94
+ 3. 마스터가 전원 응답(또는 timeout)까지 모아 `mergeExposition` 으로 합산 → 요청 워커에 `aggregated` 회신.
95
+ 4. 요청 워커가 합산 텍스트로 응답.
96
+
97
+ `mergeExposition` 규칙: counter/histogram 은 **합산**, gauge 도 합산(메모리·CPU 클러스터 합이 유의미), `_info`/`target_info` 메타는 첫 값 유지(N배 방지). 단일 프로세스면 `collectCluster` 가 그냥 로컬 `collect()` 를 돌려준다. timeout/마스터 부재 시 로컬 폴백(스크레이프가 안 멈추도록).
98
+
99
+ 라우트는 항상 `collectCluster()` 를 부르므로 단일/클러스터 양쪽에서 동일하게 동작한다(`src/core/mega-app.js`).
100
+
101
+ ---
102
+
103
+ ## 2. Swagger / OpenAPI
104
+
105
+ > 소스: `src/core/openapi.js`, 라우트 메타 `src/core/router.js`. (ADR-070 / ADR-140)
106
+
107
+ ### 2.1 무엇인가
108
+
109
+ "이 API 가 어떤 주소로 뭘 받고 뭘 주는지" 설명서를 자동으로 만들어 `/docs` 웹페이지로 보여준다. 라우트에 적어둔 JSON Schema 와 `openapi` 메타를 `@fastify/swagger` 가 `onRoute` 로 긁어 OpenAPI 3.x 명세로 만든다 — 손으로 문서 쓰다 어긋날 일이 없다.
110
+
111
+ **디폴트 OFF** 다. 옵트인이 아니면 명세·UI 라우트 자체가 없어 프로덕션 API surface 가 외부에 안 드러난다.
112
+
113
+ ### 2.2 켜기 (app.config.js)
114
+
115
+ 앱별 `app.config.js` 의 `openapi` 를 `enabled:true` 로 켠다.
116
+
117
+ ```js
118
+ // apps/main/app.config.js
119
+ import { webRequireAuth } from './middleware/web-auth.js'
120
+
121
+ export default {
122
+ openapi: {
123
+ enabled: true, // 디폴트 OFF — 명시적으로 켜야 함
124
+ path: '/docs', // swagger-ui 마운트 경로 (디폴트 '/docs')
125
+ info: {
126
+ title: 'sample-crud API',
127
+ version: '0.1.0',
128
+ description: 'JSON REST API 명세 (users/notes/redis).',
129
+ },
130
+ auth: [webRequireAuth], // docs 접근 가드 (빈 배열이면 공개)
131
+ },
132
+ }
133
+ ```
134
+
135
+ > `enabled: process.env.NODE_ENV !== 'production'` 패턴으로 dev 만 노출할 수 있다. `@fastify/swagger` 는 라우트 등록보다 **먼저** 등록돼야 onRoute 로 스키마를 수집하므로 코어가 보안·정적 자산 뒤·`/health` 앞에서 등록한다. 멀티앱은 각자 Fastify 인스턴스라 docs 경로 충돌이 없다.
136
+
137
+ ### 2.3 라우트 메타
138
+
139
+ 라우트 옵션의 `openapi:{ tags, summary, description, deprecated }` 가 라우트 schema 의 비검증 메타 필드로 병합돼 명세에 반영된다. 검증용 `schema`(body/params/response)와 함께 쓴다.
140
+
141
+ ```js
142
+ // apps/main/routes/users.js
143
+ export default (router) => {
144
+ const guarded = { before: [requireAuth] }
145
+
146
+ router.http.get('/users', UserController.index, {
147
+ ...guarded,
148
+ openapi: { tags: ['users'], summary: '사용자 목록', description: '모든 사용자를 반환한다.' },
149
+ })
150
+
151
+ router.http.post('/users', UserController.create, {
152
+ ...guarded,
153
+ schema: { body: userBody }, // 검증 + 명세 둘 다에 반영
154
+ openapi: { tags: ['users'], summary: '사용자 생성', description: 'name·email 로 사용자를 생성한다.' },
155
+ })
156
+ }
157
+ ```
158
+
159
+ ### 2.4 인증 가드
160
+
161
+ `auth` 배열은 swagger-ui 의 `preHandler` 로 걸린다. 핸들러와 **동일한 `(req, reply, ctx)` 시그니처**(빈 배열=공개). 샘플은 `webRequireAuth` 로 보호해 비로그인 브라우저를 로그인 페이지로 보낸다 — 데모 API 문서도 로그인이 필요하다.
162
+
163
+ ---
164
+
165
+ ## 3. Tracing (OpenTelemetry)
166
+
167
+ > 소스: `src/lib/mega-tracing.js`, WS 통합 `src/core/ws-upgrade.js`, boot 배선 `src/core/boot.js`. (ADR-104 / ADR-114 / ADR-126)
168
+
169
+ ### 3.1 무엇인가
170
+
171
+ 요청 하나가 어떤 어댑터 호출(DB query, cache get …)을 거쳐갔는지 **span 트리**로 추적한다. 어댑터의 관찰성 hook(`onCallStart`/`onCallEnd`) 위에 자동 span 을 얹는다 — 코어·어댑터 코드는 한 줄도 안 바꾼다. 옵트인 OFF 면 어댑터에 리스너가 안 붙어 **0 비용**.
172
+
173
+ ### 3.2 켜기 (env)
174
+
175
+ 트레이싱은 **환경변수**로 켠다. boot 가 `MegaTracing.fromEnv()` 를 호출해 `MEGA_OTEL_ENABLED=true` 면 초기화 + 어댑터 일괄 구독한다.
176
+
177
+ ```bash
178
+ MEGA_OTEL_ENABLED=true # true 일 때만 활성 (serviceName 필수)
179
+ MEGA_OTEL_SERVICE_NAME=mega-app # service.name (활성 시 필수)
180
+ MEGA_OTEL_ENDPOINT=http://localhost:4318/v1/traces # exporter endpoint
181
+ MEGA_OTEL_EXPORTER=otlp # otlp | zipkin | console | inmemory
182
+ MEGA_OTEL_SAMPLING_RATIO=1.0 # 0~1 비율, 또는 always_on / always_off
183
+ MEGA_OTEL_VERSION= # service.version (옵션)
184
+ MEGA_OTEL_ENVIRONMENT=production # deployment.environment.name (옵션)
185
+ ```
186
+
187
+ > ⚠️ prefix 없는 `OTEL_ENABLED`/`OTEL_ENDPOINT` 는 죽은 키다 — 반드시 `MEGA_OTEL_*`. Zipkin 으로 보내려면 `MEGA_OTEL_EXPORTER=zipkin` + `MEGA_OTEL_ENDPOINT=http://localhost:9411/api/v2/spans`.
188
+
189
+ ### 3.3 exporter / sampler / processor
190
+
191
+ | 옵션 | 값 | 폴백 |
192
+ |---|---|---|
193
+ | exporter | `console` / `otlp` / `zipkin` / `inmemory` / SpanExporter 인스턴스 | endpoint 있으면 `otlp`, 없으면 `console` |
194
+ | sampler | `always_on` / `always_off` / `traceidratio:0.1` / 숫자 `0.1` | `always_on` |
195
+ | processor | `simple` / `batch` | otlp·zipkin=`batch`, 그 외=`simple` |
196
+
197
+ 비율 샘플러는 `ParentBasedSampler` 로 감싸 자식 span 이 부모 결정을 따른다. 잘못된 값은 `MegaConfigError`(`tracing.invalid_sampling` / `tracing.invalid_exporter`)로 fail-fast.
198
+
199
+ ### 3.4 context 전파: AsyncLocalStorage (ALS)
200
+
201
+ 두 개의 ALS 가 협력한다.
202
+
203
+ - **요청 단위 active context**(`mega-tracing.js` 내부) — HTTP/WS 루트 span 과 `ctx.tracer.span` 이 "지금 열린 span" 을 비동기 흐름 전반에 전파.
204
+ - **per-adapter `#callScope`**(`MegaAdapter`) — 어댑터 자동 span(ADR-114)의 부모-자식 추적.
205
+
206
+ 어댑터 스코프가 없을 때(핸들러 최상위의 어댑터 호출)는 요청 ALS 를 부모로 폴백해, 어댑터 span 이 HTTP/WS 루트 span 의 자식으로 정확히 중첩된다. `context-async-hooks` 패키지를 도입하지 않고 우리 ALS 로 같은 효과를 낸다.
207
+
208
+ ### 3.5 run() seam (옵트인 OFF 면 NOOP_SPAN)
209
+
210
+ `onCallStart` 이 **스코프 토큰**(OTel span context)을 반환하면, 베이스 `_instrument` 가 도메인 `fn` 을 `#callScope.run(token, fn)` 안에서 실행한다. 그래서 `fn` 내부의 중첩 호출은 부모 토큰을 읽어 자기 span 의 부모로 삼는다. `run()` 은 각 호출을 독립 스코프로 격리하므로 **순차·중첩·동시(`Promise.all`) 전부 정확**하다 — 과거 `enterWith` 가 공유 동기 실행을 오염시켜 sibling 을 잘못 묶던 문제를 구조적으로 제거한다.
211
+
212
+ 옵트인 OFF 면 `ctx.tracer.span(name, fn)` 이 `fn(NOOP_SPAN)` 만 호출한다 — span 없이 함수만 실행, **0 비용**. NOOP_SPAN 은 메서드가 다 있지만 아무것도 기록 안 한다.
213
+
214
+ ### 3.6 사용자 직접 span — `ctx.tracer.span`
215
+
216
+ 핸들러 안에서 임의 작업을 span 으로 감쌀 수 있다. 활성 HTTP/WS span 의 자식으로 열리고, 안의 어댑터 호출도 자동으로 자식 span 으로 중첩된다.
217
+
218
+ ```js
219
+ // 데모: ctx.tracer.span 으로 'demo.tracing.work' 자식 span + DB 핑
220
+ await ctx.tracer.span('demo.tracing.work', async (span) => {
221
+ span.setAttribute('demo.kind', 'manual-generate')
222
+ await ctx.db('db').query('SELECT 1') // 어댑터 자동 span 도 자식으로 중첩
223
+ })
224
+ ```
225
+
226
+ `fn` 이 throw/reject 하면 span 에 ERROR + recordException 후 **그대로 재전파**(silent 금지). `ctx.tracer.traceId` / `ctx.tracer.spanId` / `ctx.tracer.activeSpan()` 로 현재 ID 도 읽는다.
227
+
228
+ ### 3.7 HTTP/WS 루트 span
229
+
230
+ `enterHttpSpan` 이 `onRequest` 에서 열고(`enterWith` 로 활성화), `onError` 에서 `setError`, `onResponse` 에서 `finish(statusCode)` 로 닫는다. OTel 시맨틱(SERVER span 은 **5xx 만 ERROR**, 4xx 는 클라이언트 잘못이라 UNSET 유지)을 한곳에 모아 배선 코드가 OTel enum 을 안 만지게 한다.
231
+
232
+ ### 3.8 shutdown
233
+
234
+ `shutdown()` 은 리스너 해제 + 남은 span flush + provider 종료 + ALS 비활성화를 멱등하게 한다. boot 가 `MegaShutdown` 에 등록한다. `state`(활성) 여부와 무관하게 토큰의 span 을 닫아 in-flight span 누수를 막는다(shutdown 직후 케이스).
235
+
236
+ ---
237
+
238
+ ## 4. Logger (pino)
239
+
240
+ > 소스: `src/lib/mega-logger.js`, `src/lib/logger/telegram-transport.js`, `src/lib/logger/telegram-core.js`. (ADR-023 / ADR-141 / ADR-166)
241
+
242
+ ### 4.1 무엇인가
243
+
244
+ 검증된 `pino`(아주 빠른 JSON 로거) 위에, `mega.config.js` 의 `logger` 설정대로 **여러 출력처(sink)** 를 동시에 연결한다. 무거운 IO(파일·텔레그램)는 pino transport 가 **worker thread 에서** 돌려 이벤트루프를 보호한다.
245
+
246
+ ### 4.2 켜기 (config — env 아님)
247
+
248
+ 로거는 `mega.config.js` 의 `logger` 객체로 설정한다. **`MEGA_LOG_*` 같은 env 키는 코드가 읽지 않는다** — `LOG_LEVEL` env 도 안 읽는다(레벨은 `logger.level` 로). boot 가 한 번 만들어(`buildLogger`) 모든 앱이 공유한다(worker thread·파일 핸들 1벌).
249
+
250
+ ```js
251
+ // mega.config.js
252
+ export default {
253
+ logger: {
254
+ level: 'debug', // 전역 레벨
255
+ sinks: [{ type: 'console', pretty: true }], // 여러 sink 동시 가능
256
+ // 시크릿 마스킹 — sink 출력 전 메인스레드에서 적용(transport 엔 이미 시크릿 없음)
257
+ redact: ['*.password', '*.token', '*.secret', '*.authorization'],
258
+ },
259
+ }
260
+ ```
261
+
262
+ `sinks` 가 비었거나 `logger` 미설정이면 `null` → 앱은 `logger:false`(무로그).
263
+
264
+ ### 4.3 sink 종류
265
+
266
+ | type | 동작 | 옵션 |
267
+ |---|---|---|
268
+ | `console` | `pretty:true` → pino-pretty(dev), 아니면 stdout JSON 한 줄(prod) | `pretty` |
269
+ | `file` | pino-roll 날짜별 로테이션 + keep N개 | `path`, `rotation`(디폴트 `daily`), `keep` |
270
+ | `telegram` | warn 이상만, worker thread 비동기 전송 + 실패 시 disk retry | `botToken`, `chatId`, `throttleMax`, `throttleWindowMs`, `retryDir` |
271
+
272
+ 각 sink 는 자기 `level` 을 줄 수 있다(미지정 시 전역 `level`). 텔레그램은 디폴트가 `warn`.
273
+
274
+ > 시크릿(botToken 등)은 `.env` 에서 읽어 config 로 주입한다 — 코드·config 파일에 직접 적지 않는다.
275
+
276
+ ### 4.4 trace_id 자동 주입 (ALS 통합)
277
+
278
+ pino `mixin` 에 `MegaTracing.logMixin` 이 배선돼, 활성 span 이 있으면 **모든 로그 라인에 `{ trace_id, span_id }`** 가 자동 첨부된다. 로그와 trace 가 상관(correlate)된다. 활성 span 없거나 트레이싱 OFF 면 빈 객체(0 비용). Fastify 는 `req.log` 에 `reqId` 도 자동 바인딩한다(요청별 상관).
279
+
280
+ ### 4.5 텔레그램 transport (alarm)
281
+
282
+ `warn` 이상 로그를 텔레그램으로 보내는 알람 sink. 순수 로직은 `telegram-core.js`(단위 테스트), worker glue 는 `telegram-transport.js`.
283
+
284
+ - **throttle**: 슬라이딩 윈도우 — 최근 `windowMs` 안 `max` 건까지(폭주 시 텔레그램 rate limit 직격 방지). 초과분 드롭.
285
+ - **RetryQueue (disk-backed)**: 전송 실패분을 JSONL 파일에 쌓고 주기 드레인(기본 30초)으로 재전송 — 전송 실패해도 메시지를 잃지 않는다.
286
+ - **httpsPost timeout/error 핸들러** (ADR-166): 응답이 안 오면 `timeoutMs`(기본 10초) 후 끊어 retry queue 로 넘긴다. 응답 스트림 `error` 도 reject 로 흡수해 워커가 안 죽게 한다.
287
+
288
+ #### drain race 정정 (ADR-166)
289
+
290
+ RetryQueue 가 큐를 비울 때 `read → writeFile('')` 를 쓰면 두 await 사이에 끼어든 `append`(write 경로의 재시도)를 통째로 지운다. 그래서 `rename` 으로 **단일 syscall** 가로채기를 쓴다 — 가로챈 뒤 들어오는 append 는 새 파일에 쌓여 유실되지 않는다. 동시 drain 이 한 번 더 들어오면 파일이 이미 옮겨져 `ENOENT` → 빈 배열(중복 처리 없음). **RetryQueue 에 의존하는 코드는 이 race 정정을 깨지 않도록 주의.**
291
+
292
+ ---
293
+
294
+ ## 5. sample/crud 데모 (ADR-163)
295
+
296
+ > 소스: `sample/crud/apps/main/routes/{metrics,tracing,logs}.js`, 동명 controllers. 모두 `webRequireAuth` 로 보호(비로그인은 로그인 페이지로), 자동 라우트 로딩(`loadRoutes`).
297
+
298
+ | 데모 | 라우트 | 보여주는 것 |
299
+ |---|---|---|
300
+ | `/demo/metrics` | GET | `MegaMetrics.collect()` 를 파싱한 사람 친화 카드(HTTP/잡/WS/process) + raw `/metrics` 링크 |
301
+ | `/demo/tracing` | GET, POST `/generate` | 현재 trace_id(응답 헤더 `x-trace-id` 에도) + Zipkin 딥링크. "생성" 버튼이 `ctx.tracer.span` 으로 자식 span + DB 핑 → Zipkin 에 span 트리 |
302
+ | `/demo/logs` | GET, POST `/emit` | 선택 레벨로 실제 pino 로그 emit → 마스킹·trace_id 첨부·구조적 NDJSON 은 서버 콘솔에서 확인 |
303
+
304
+ 데모 데이터는 `ctx.services.{metricsDemo,tracingDemo,logsDemo}`(자동 DI)가 만든다. 폼은 POST(PRG) + CSRF 토큰.
305
+
306
+ > `/demo/upload`(multipart)는 별도 가이드(업로드 편)에서 다룬다.
307
+
308
+ ---
309
+
310
+ ## 6. 환경변수 (관측 관련, 정본)
311
+
312
+ > ⚠️ 여기 적힌 키가 **소스가 실제로 읽는 키**다(`src/lib/mega-tracing.js`·`mega-metrics.js` `fromEnv`, `.env.example`). 짧은 별칭(`OTEL_ENDPOINT`, `ZIPKIN_URL`, `MEGA_OTEL_SAMPLING_RATE`, `MEGA_LOG_*`, `MEGA_METRICS_ALLOWED_IPS`)은 **존재하지 않는다** — 혼동 주의.
313
+
314
+ ### 트레이싱 (MegaTracing.fromEnv 가 읽음)
315
+
316
+ ```bash
317
+ MEGA_OTEL_ENABLED=false # true 면 활성 (serviceName 필수)
318
+ MEGA_OTEL_SERVICE_NAME=mega-app # service.name
319
+ MEGA_OTEL_ENDPOINT=... # exporter endpoint (otlp: /v1/traces, zipkin: /api/v2/spans)
320
+ MEGA_OTEL_EXPORTER=otlp # otlp | zipkin | console | inmemory
321
+ MEGA_OTEL_SAMPLING_RATIO=1.0 # 0~1 또는 always_on / always_off ← RATE 아님 RATIO
322
+ MEGA_OTEL_VERSION= # 옵션
323
+ MEGA_OTEL_ENVIRONMENT= # 옵션
324
+ ```
325
+
326
+ ### 메트릭
327
+
328
+ ```bash
329
+ # 표준 boot 경로: config(mega.config.js)의 health.exposeMetrics 로 켠다.
330
+ # 샘플 관례: exposeMetrics: process.env.METRICS_ENABLED === 'true'
331
+ METRICS_ENABLED=true # 위 관례를 쓰는 config 에서만 의미 있음 (아래 6.1 갭 참조)
332
+
333
+ # MEGA_METRICS_* 는 MegaMetrics.fromEnv()(public API) 전용 키 — 표준 boot 는 호출 안 함:
334
+ # MEGA_METRICS_ENABLED=true
335
+ # MEGA_METRICS_SERVICE_NAME=mega # 없으면 MEGA_OTEL_SERVICE_NAME 폴백
336
+ # MEGA_METRICS_VERSION=
337
+ # MEGA_METRICS_ENVIRONMENT=
338
+ ```
339
+
340
+ **메트릭 IP allowList 는 env 가 아니다** — `mega.config.js` 의 `health.metricsAllowList: ['127.0.0.1', '::1']` 로 설정한다.
341
+
342
+ ### 로거
343
+
344
+ env 키 없음 — 전부 `mega.config.js` 의 `logger:{ level, sinks, redact }` 로 설정한다(텔레그램 botToken/chatId 만 `.env` 에서 읽어 config 로 주입).
345
+
346
+ ### collector 인프라 (앱이 아니라 docker collector 가 읽음)
347
+
348
+ ```bash
349
+ MEGA_OTEL_OTLP_ENDPOINT=http://localhost:4318/v1/traces # 테스트/인프라용 (앱 exporter 는 MEGA_OTEL_ENDPOINT)
350
+ MEGA_OTEL_ZIPKIN_API=http://localhost:9411/api/v2 # 테스트가 span 도달 폴링
351
+ MEGA_OTEL_HEALTH_URL=http://localhost:13133 # collector health_check
352
+ ```
353
+
354
+ ### 6.1 METRICS_ENABLED 문서/코드 갭 (OQ 칩 참조)
355
+
356
+ `MegaMetrics.fromEnv()` 는 `METRICS_ENABLED`/`MEGA_METRICS_ENABLED` 를 읽도록 구현돼 있지만, **표준 boot 경로(`src/core/boot.js`)는 `MegaMetrics.fromEnv()` 를 호출하지 않는다**(트레이싱과 달리 메트릭은 config `health.exposeMetrics` 로만 배선). 따라서 `METRICS_ENABLED=true` 만 설정해도 boot 가 직접 켜지는 않는다 — `mega.config.js` 에서 `exposeMetrics: process.env.METRICS_ENABLED === 'true'` 처럼 **config 가 그 env 를 읽어 와이어**해야 동작한다. `MegaMetrics.fromEnv()` 는 라이브러리를 직접 임베드하는 경우의 public API 다. (잔존 OQ 칩 — `docs/09` 참조)
357
+
358
+ ---
359
+
360
+ ## 7. 함정
361
+
362
+ - **`/metrics` 카디널리티 폭발**: HTTP route 라벨은 **매칭된 라우트 패턴**(`/users/:id`)만 쓴다. 매칭 안 된 404 의 raw path 를 라벨에 넣으면 무한 카디널리티(공격자가 임의 경로 폭격 → 메모리 폭증)가 되므로 고정 라벨 `__unmatched__` 로 접는다. 파일명·MIME·이메일·view 경로 같은 unbounded 값은 라벨에 절대 안 넣는다(PII·카디널리티).
363
+ - **트레이싱 옵트인 OFF**: `ctx.tracer.span` 은 `NOOP_SPAN` 으로 깨지지 않고 함수만 실행한다(성능 영향 0). 코드에서 트레이싱 분기 처리할 필요 없다.
364
+ - **`enterWith` 격리 조건**: 시작/종료 분리형 span(`enterSpan`/`enterHttpSpan`)의 `enterWith` 는 **요청별로 격리된 비동기 흐름**(HTTP 요청·WS 메시지 콜백)에서만 호출해야 한다. 공유 동기 스코프에서 부르면 sibling 오염 위험 — 어댑터 자동 span 은 그래서 `run()` seam 을 쓴다.
365
+ - **logger drain race**: RetryQueue 는 `rename` 단일 syscall 로 큐를 가로챈다(ADR-166). `read → writeFile('')` 로 바꾸면 동시 append 가 유실된다 — 의존 코드 수정 시 주의.
366
+ - **telegram httpsPost**: timeout(기본 10초)·response `error` 핸들러가 없으면 무응답/스트림 끊김에 워커가 영구 정지하거나 죽는다(ADR-166). 주입 시그니처를 바꿀 때 이 두 핸들러를 유지할 것.
367
+ - **클러스터 gauge 합산 한계**: `mergeExposition` 은 gauge 를 합산한다 — 프로세스별 gauge(uptime 등)는 클러스터에서 N배로 보이는 문서화된 한계(prom-client 기본도 gauge=sum). `_info`/`target_info` 만 첫 값 유지.
368
+
369
+ ---
370
+
371
+ ## 관련 ADR
372
+
373
+ ADR-104(트레이싱), ADR-114·ADR-126(run/ALS), ADR-131(메트릭), ADR-132(subscribeJobs), ADR-140(swagger), ADR-141(pino), ADR-163(관측 sample), ADR-166(logger drain race + httpsPost timeout), ADR-170(cluster-metrics IPC seam).
package/src/cli/index.js CHANGED
@@ -691,6 +691,39 @@ async function wireHostLogger(global, injectedLogger) {
691
691
  return appLogger
692
692
  }
693
693
 
694
+ /**
695
+ * 잡 워커의 큐 길목 이벤트를 호스트 로거로 흘린다(ADR-223). `MegaJobWorker` 는 순수 런타임이라 logger 를
696
+ * 모르고 이벤트만 재방출하므로, 호스트가 구독하지 않으면 production 에서 잡 실패·DLQ 격리가 로그에 안 남는다.
697
+ * 레벨: `fail`=error(최종 실패), `dlq`=warn(격리됨), `retry`=warn(재시도), `start`/`done`=debug(prod silent).
698
+ * `dispatch`(enqueue)는 producer(웹) 측 이벤트라 워커 호스트에선 발화하지 않아 구독하지 않는다. 핵심 식별
699
+ * 필드만 박는다(전체 페이로드·결과 금지, P5). `log` 가 없으면(로거 미구성) 옵셔널 체이닝으로 no-op.
700
+ * @param {import('../lib/mega-job-worker.js').MegaJobWorker} worker
701
+ * @param {{ debug?: Function, warn?: Function, error?: Function }|null|undefined} log
702
+ * @returns {void}
703
+ */
704
+ function subscribeJobLogging(worker, log) {
705
+ worker.on('fail', (e) => log?.error?.({ err: e?.error, subject: e?.subject, seq: e?.seq, phase: e?.phase }, 'job failed'))
706
+ worker.on('dlq', (e) => log?.warn?.({ subject: e?.subject, dlqSubject: e?.dlqSubject, seq: e?.seq }, 'job routed to DLQ'))
707
+ worker.on('retry', (e) => log?.warn?.({ err: e?.error, subject: e?.subject, seq: e?.seq, attempt: e?.attempt, retriesLeft: e?.retriesLeft }, 'job retry'))
708
+ worker.on('start', (e) => log?.debug?.({ subject: e?.subject, seq: e?.seq }, 'job start'))
709
+ worker.on('done', (e) => log?.debug?.({ subject: e?.subject, seq: e?.seq }, 'job done'))
710
+ }
711
+
712
+ /**
713
+ * 스케줄러의 길목 이벤트를 호스트 로거로 흘린다(ADR-223 — 잡 워커와 같은 갭). `MegaScheduler` 도 이벤트
714
+ * (run/skip/done/fail)만 노출하므로 호스트가 구독해 로그로 남긴다. 레벨: `fail`=error, 나머지는 debug
715
+ * (특히 `skip`=락 미획득은 클러스터 정상 동작이라 debug). `key`(락 키)는 클러스터 leader 추적에 유용.
716
+ * @param {import('../lib/mega-schedule.js').MegaScheduler} scheduler
717
+ * @param {{ debug?: Function, warn?: Function, error?: Function }|null|undefined} log
718
+ * @returns {void}
719
+ */
720
+ function subscribeScheduleLogging(scheduler, log) {
721
+ scheduler.on('fail', (e) => log?.error?.({ err: e?.error, name: e?.name, key: e?.key, phase: e?.phase }, 'schedule failed'))
722
+ scheduler.on('skip', (e) => log?.debug?.({ name: e?.name, key: e?.key, reason: e?.reason }, 'schedule skipped'))
723
+ scheduler.on('run', (e) => log?.debug?.({ name: e?.name, key: e?.key }, 'schedule run'))
724
+ scheduler.on('done', (e) => log?.debug?.({ name: e?.name, key: e?.key }, 'schedule done'))
725
+ }
726
+
694
727
  /**
695
728
  * `mega worker` 호스트 골격 — config + 어댑터 connect + ctx + `MegaJobWorker` 인스턴스 + graceful.
696
729
  * 등록 소스 = `config.jobs` + 플러그인 `mega.jobs.register` 분(`collectRegistrations`, ADR-123).
@@ -712,6 +745,11 @@ export async function runWorkerHost(projectRoot, logger) {
712
745
  // 옵트인 OFF 면 subscribeJobs 는 no-op() 을 반환한다. start 전에 구독해 enqueue(dispatch)부터 잡는다. 구독은
713
746
  // MegaMetrics.shutdown(boot 가 'mega-metrics' MegaShutdown hook 으로 등록)에서 일괄 해제되므로 별도 등록 불필요.
714
747
  MegaMetrics.subscribeJobs(worker)
748
+ // 잡 길목 로깅 (ADR-223) — MegaJobWorker 는 큐 이벤트를 재방출만 하고 logger 를 모른다(순수 런타임). 호스트가
749
+ // 구독해 로그로 남기지 않으면 production 에서 잡 영구 실패·DLQ 격리가 로그에 0 줄로 남아(메트릭/데모 페이지로만
750
+ // 보임) 운영자가 실패 신호를 못 받는다(mega-job-queue 설계: "fail/dlq 구독이 사실상 필수"). P5(에러 핸들러·
751
+ // async 경계 로그). 메트릭(subscribeJobs)과 독립적이라 둘 다 둔다. 페이로드 전체가 아닌 핵심 필드만 박는다(P5).
752
+ subscribeJobLogging(worker, log)
715
753
  // 등록 소스(ADR-123) = config.jobs(정적) + 플러그인 host.listJobs()(동적). register 가 subject/bus
716
754
  // 미선언·중복을 부팅 시 fail-fast.
717
755
  const jobs = collectRegistrations(global, host, 'jobs')
@@ -740,6 +778,9 @@ export async function runSchedulerHost(projectRoot, logger) {
740
778
  const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
741
779
  const log = await wireHostLogger(global, logger)
742
780
  const scheduler = new MegaScheduler({ ctx })
781
+ // 스케줄 길목 로깅 (ADR-223) — 워커와 같은 갭: MegaScheduler 도 이벤트(run/skip/done/fail)를 노출만 하고
782
+ // logger 를 모른다. 호스트가 구독하지 않으면 production 에서 스케줄 실패가 로그에 안 남는다. P5.
783
+ subscribeScheduleLogging(scheduler, log)
743
784
  // 등록 소스(ADR-123) = config.schedules(정적) + 플러그인 host.listSchedules()(동적). register 가
744
785
  // cron 미선언·중복을 부팅 시 fail-fast.
745
786
  const schedules = collectRegistrations(global, host, 'schedules')