@mandujs/core 0.12.2 → 0.13.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 (173) hide show
  1. package/README.ko.md +304 -304
  2. package/README.md +653 -653
  3. package/package.json +1 -1
  4. package/src/brain/architecture/analyzer.ts +28 -26
  5. package/src/brain/doctor/analyzer.ts +1 -1
  6. package/src/bundler/build.ts +91 -91
  7. package/src/bundler/css.ts +302 -302
  8. package/src/bundler/dev.ts +0 -1
  9. package/src/change/history.ts +3 -3
  10. package/src/change/snapshot.ts +10 -9
  11. package/src/change/transaction.ts +2 -2
  12. package/src/client/Link.tsx +227 -227
  13. package/src/client/globals.ts +44 -44
  14. package/src/client/hooks.ts +267 -267
  15. package/src/client/index.ts +5 -5
  16. package/src/client/island.ts +8 -8
  17. package/src/client/router.ts +435 -435
  18. package/src/client/runtime.ts +23 -23
  19. package/src/client/serialize.ts +404 -404
  20. package/src/client/window-state.ts +101 -101
  21. package/src/config/mandu.ts +94 -96
  22. package/src/config/validate.ts +213 -215
  23. package/src/config/watcher.ts +311 -311
  24. package/src/constants.ts +40 -40
  25. package/src/content/content-layer.ts +314 -314
  26. package/src/content/content.test.ts +433 -433
  27. package/src/content/data-store.ts +245 -245
  28. package/src/content/digest.ts +133 -133
  29. package/src/content/index.ts +164 -164
  30. package/src/content/loader-context.ts +172 -172
  31. package/src/content/loaders/api.ts +216 -216
  32. package/src/content/loaders/file.ts +169 -169
  33. package/src/content/loaders/glob.ts +252 -252
  34. package/src/content/loaders/index.ts +34 -34
  35. package/src/content/loaders/types.ts +137 -137
  36. package/src/content/meta-store.ts +209 -209
  37. package/src/content/types.ts +282 -282
  38. package/src/content/watcher.ts +135 -135
  39. package/src/contract/client-safe.test.ts +42 -42
  40. package/src/contract/client-safe.ts +114 -114
  41. package/src/contract/client.ts +16 -16
  42. package/src/contract/define.ts +459 -459
  43. package/src/contract/handler.ts +10 -10
  44. package/src/contract/normalize.test.ts +276 -276
  45. package/src/contract/normalize.ts +404 -404
  46. package/src/contract/registry.test.ts +206 -206
  47. package/src/contract/registry.ts +568 -568
  48. package/src/contract/schema.ts +48 -48
  49. package/src/contract/types.ts +58 -58
  50. package/src/contract/validator.ts +32 -32
  51. package/src/devtools/ai/context-builder.ts +375 -375
  52. package/src/devtools/ai/index.ts +25 -25
  53. package/src/devtools/ai/mcp-connector.ts +465 -465
  54. package/src/devtools/client/catchers/error-catcher.ts +327 -327
  55. package/src/devtools/client/catchers/index.ts +18 -18
  56. package/src/devtools/client/catchers/network-proxy.ts +363 -363
  57. package/src/devtools/client/components/index.ts +39 -39
  58. package/src/devtools/client/components/kitchen-root.tsx +362 -362
  59. package/src/devtools/client/components/mandu-character.tsx +241 -241
  60. package/src/devtools/client/components/overlay.tsx +368 -368
  61. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  62. package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
  63. package/src/devtools/client/components/panel/index.ts +32 -32
  64. package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
  65. package/src/devtools/client/components/panel/network-panel.tsx +292 -292
  66. package/src/devtools/client/components/panel/panel-container.tsx +259 -259
  67. package/src/devtools/client/filters/context-filters.ts +282 -282
  68. package/src/devtools/client/filters/index.ts +16 -16
  69. package/src/devtools/client/index.ts +63 -63
  70. package/src/devtools/client/persistence.ts +335 -335
  71. package/src/devtools/client/state-manager.ts +478 -478
  72. package/src/devtools/design-tokens.ts +263 -263
  73. package/src/devtools/hook/create-hook.ts +207 -207
  74. package/src/devtools/hook/index.ts +13 -13
  75. package/src/devtools/index.ts +439 -439
  76. package/src/devtools/init.ts +266 -266
  77. package/src/devtools/protocol.ts +237 -237
  78. package/src/devtools/server/index.ts +17 -17
  79. package/src/devtools/server/source-context.ts +444 -444
  80. package/src/devtools/types.ts +319 -319
  81. package/src/devtools/worker/index.ts +25 -25
  82. package/src/devtools/worker/redaction-worker.ts +222 -222
  83. package/src/devtools/worker/worker-manager.ts +409 -409
  84. package/src/error/classifier.ts +2 -2
  85. package/src/error/domains.ts +265 -265
  86. package/src/error/formatter.ts +32 -32
  87. package/src/error/result.ts +46 -46
  88. package/src/error/stack-analyzer.ts +5 -0
  89. package/src/error/types.ts +6 -6
  90. package/src/errors/extractor.ts +409 -409
  91. package/src/errors/index.ts +19 -19
  92. package/src/filling/auth.ts +308 -308
  93. package/src/filling/context.ts +569 -569
  94. package/src/filling/deps.ts +238 -238
  95. package/src/generator/contract-glue.ts +2 -1
  96. package/src/generator/generate.ts +12 -10
  97. package/src/generator/index.ts +3 -3
  98. package/src/generator/templates.ts +80 -79
  99. package/src/guard/analyzer.ts +360 -360
  100. package/src/guard/ast-analyzer.ts +806 -806
  101. package/src/guard/auto-correct.ts +1 -1
  102. package/src/guard/check.ts +128 -128
  103. package/src/guard/contract-guard.ts +9 -9
  104. package/src/guard/file-type.test.ts +24 -24
  105. package/src/guard/presets/atomic.ts +70 -70
  106. package/src/guard/presets/clean.ts +77 -77
  107. package/src/guard/presets/cqrs.test.ts +35 -14
  108. package/src/guard/presets/fsd.ts +79 -79
  109. package/src/guard/presets/hexagonal.ts +68 -68
  110. package/src/guard/presets/index.ts +291 -291
  111. package/src/guard/reporter.ts +445 -445
  112. package/src/guard/rules.ts +12 -12
  113. package/src/guard/statistics.ts +578 -578
  114. package/src/guard/suggestions.ts +358 -358
  115. package/src/guard/types.ts +348 -348
  116. package/src/guard/validator.ts +834 -834
  117. package/src/guard/watcher.ts +404 -404
  118. package/src/index.ts +1 -0
  119. package/src/intent/index.ts +310 -310
  120. package/src/island/index.ts +304 -304
  121. package/src/logging/index.ts +22 -22
  122. package/src/logging/transports.ts +365 -365
  123. package/src/paths.test.ts +47 -0
  124. package/src/paths.ts +47 -0
  125. package/src/plugins/index.ts +38 -38
  126. package/src/plugins/registry.ts +377 -377
  127. package/src/plugins/types.ts +363 -363
  128. package/src/report/build.ts +1 -1
  129. package/src/report/index.ts +1 -1
  130. package/src/router/fs-patterns.ts +387 -387
  131. package/src/router/fs-routes.ts +344 -401
  132. package/src/router/fs-scanner.ts +497 -497
  133. package/src/router/fs-types.ts +270 -278
  134. package/src/router/index.ts +81 -81
  135. package/src/runtime/boundary.tsx +232 -232
  136. package/src/runtime/compose.ts +222 -222
  137. package/src/runtime/lifecycle.ts +381 -381
  138. package/src/runtime/logger.test.ts +345 -345
  139. package/src/runtime/logger.ts +677 -677
  140. package/src/runtime/router.test.ts +476 -476
  141. package/src/runtime/router.ts +105 -105
  142. package/src/runtime/security.ts +155 -155
  143. package/src/runtime/server.ts +24 -24
  144. package/src/runtime/session-key.ts +328 -328
  145. package/src/runtime/ssr.ts +367 -367
  146. package/src/runtime/streaming-ssr.ts +1245 -1245
  147. package/src/runtime/trace.ts +144 -144
  148. package/src/seo/index.ts +214 -214
  149. package/src/seo/integration/ssr.ts +307 -307
  150. package/src/seo/render/basic.ts +427 -427
  151. package/src/seo/render/index.ts +143 -143
  152. package/src/seo/render/jsonld.ts +539 -539
  153. package/src/seo/render/opengraph.ts +191 -191
  154. package/src/seo/render/robots.ts +116 -116
  155. package/src/seo/render/sitemap.ts +137 -137
  156. package/src/seo/render/twitter.ts +126 -126
  157. package/src/seo/resolve/index.ts +353 -353
  158. package/src/seo/resolve/opengraph.ts +143 -143
  159. package/src/seo/resolve/robots.ts +73 -73
  160. package/src/seo/resolve/title.ts +94 -94
  161. package/src/seo/resolve/twitter.ts +73 -73
  162. package/src/seo/resolve/url.ts +97 -97
  163. package/src/seo/routes/index.ts +290 -290
  164. package/src/seo/types.ts +575 -575
  165. package/src/slot/validator.ts +39 -39
  166. package/src/spec/index.ts +3 -3
  167. package/src/spec/load.ts +76 -76
  168. package/src/spec/lock.ts +56 -56
  169. package/src/utils/bun.ts +8 -8
  170. package/src/utils/lru-cache.ts +75 -75
  171. package/src/utils/safe-io.ts +188 -188
  172. package/src/utils/string-safe.ts +298 -298
  173. package/src/watcher/rules.ts +5 -5
@@ -1,677 +1,677 @@
1
- /**
2
- * Mandu Runtime Logger 📝
3
- * Trace 기반 요청/응답 로깅 레이어
4
- *
5
- * 역할 분리:
6
- * - Trace = 수집 (원본 이벤트, duration 측정)
7
- * - Logger = 출력 (포맷/필터/레드액션/샘플링)
8
- *
9
- * 기본값은 안전:
10
- * - includeHeaders: false
11
- * - includeBody: false
12
- * - redact: 민감 정보 자동 마스킹
13
- *
14
- * @example
15
- * ```typescript
16
- * import { logger } from "@mandujs/core";
17
- *
18
- * // 기본 사용
19
- * app.use(logger());
20
- * // → GET /api/users
21
- * // ← GET /api/users 200 23ms
22
- *
23
- * // 개발 모드
24
- * app.use(logger({
25
- * level: "debug",
26
- * includeHeaders: true,
27
- * }));
28
- *
29
- * // 프로덕션 (JSON 형식)
30
- * app.use(logger({
31
- * format: "json",
32
- * slowThresholdMs: 500,
33
- * }));
34
- * ```
35
- */
36
-
37
- import type { ManduContext } from "../filling/context";
38
- import {
39
- enableTrace,
40
- getTrace,
41
- buildTraceReport,
42
- type TraceReport,
43
- } from "./trace";
44
-
45
- // ============================================
46
- // Types
47
- // ============================================
48
-
49
- export type LogLevel = "debug" | "info" | "warn" | "error";
50
- export type LogFormat = "pretty" | "json";
51
-
52
- /**
53
- * Logger 옵션
54
- */
55
- export interface LoggerOptions {
56
- /**
57
- * 로그 포맷
58
- * - pretty: 개발용 컬러 출력
59
- * - json: 운영용 구조화 로그
60
- * @default "pretty"
61
- */
62
- format?: LogFormat;
63
-
64
- /**
65
- * 로그 레벨
66
- * - debug: 모든 요청 상세 출력
67
- * - info: 기본 요청/응답 (기본값)
68
- * - warn: 느린 요청 + 에러
69
- * - error: 에러만
70
- * @default "info"
71
- */
72
- level?: LogLevel;
73
-
74
- /**
75
- * 헤더 포함 여부
76
- * ⚠️ 기본 OFF - 민감 정보 노출 위험
77
- * @default false
78
- */
79
- includeHeaders?: boolean;
80
-
81
- /**
82
- * 바디 포함 여부
83
- * ⚠️ 기본 OFF - 민감 정보 노출 + 스트림 문제
84
- * @default false
85
- */
86
- includeBody?: boolean;
87
-
88
- /**
89
- * 바디 최대 바이트 (includeBody=true 시)
90
- * @default 1024
91
- */
92
- maxBodyBytes?: number;
93
-
94
- /**
95
- * 레드액션 대상 헤더/필드명 (기본값 내장)
96
- * 추가할 필드만 지정하면 기본값과 병합됨
97
- */
98
- redact?: string[];
99
-
100
- /**
101
- * Request ID 생성 방식
102
- * - "auto": crypto.randomUUID() 또는 타임스탬프 기반
103
- * - 함수: 커스텀 생성
104
- * @default "auto"
105
- */
106
- requestId?: "auto" | ((ctx: ManduContext) => string);
107
-
108
- /**
109
- * 샘플링 비율 (0-1)
110
- * 운영 환경에서 로그 양 조절
111
- * @default 1 (100%)
112
- */
113
- sampleRate?: number;
114
-
115
- /**
116
- * 느린 요청 임계값 (ms)
117
- * 이 값 초과 시 warn 레벨로 상세 출력
118
- * @default 1000
119
- */
120
- slowThresholdMs?: number;
121
-
122
- /**
123
- * Trace 리포트 포함 여부 (느린 요청 시)
124
- * @default true
125
- */
126
- includeTraceOnSlow?: boolean;
127
-
128
- /**
129
- * 커스텀 로그 싱크 (외부 시스템 연동용)
130
- * 지정 시 console 출력 대신 이 함수 호출
131
- */
132
- sink?: (entry: LogEntry) => void;
133
-
134
- /**
135
- * 로깅 제외 경로 패턴
136
- * @example ["/health", "/metrics", /^\/static\//]
137
- */
138
- skip?: (string | RegExp)[];
139
- }
140
-
141
- /**
142
- * 로그 엔트리 (JSON 출력 및 sink용)
143
- */
144
- export interface LogEntry {
145
- timestamp: string;
146
- requestId: string;
147
- method: string;
148
- path: string;
149
- status?: number;
150
- duration?: number;
151
- level: LogLevel;
152
- error?: {
153
- message: string;
154
- stack?: string;
155
- };
156
- headers?: Record<string, string>;
157
- body?: unknown;
158
- trace?: TraceReport;
159
- slow?: boolean;
160
- }
161
-
162
- // ============================================
163
- // Constants
164
- // ============================================
165
-
166
- /** 기본 레드액션 대상 (대소문자 무시) */
167
- const DEFAULT_REDACT_PATTERNS = [
168
- "authorization",
169
- "cookie",
170
- "set-cookie",
171
- "x-api-key",
172
- "api-key",
173
- "apikey",
174
- "api_key",
175
- "password",
176
- "passwd",
177
- "secret",
178
- "token",
179
- "bearer",
180
- "credential",
181
- "credentials",
182
- "private",
183
- "session",
184
- "jwt",
185
- ];
186
-
187
- /** Context 저장 키 */
188
- const LOGGER_START_KEY = "__mandu_logger_start";
189
- const LOGGER_REQUEST_ID_KEY = "__mandu_logger_request_id";
190
- const LOGGER_ERROR_KEY = "__mandu_logger_error";
191
- const LOGGER_RESPONSE_KEY = "__mandu_logger_response";
192
-
193
- /** 로그 레벨 우선순위 */
194
- const LEVEL_PRIORITY: Record<LogLevel, number> = {
195
- debug: 0,
196
- info: 1,
197
- warn: 2,
198
- error: 3,
199
- };
200
-
201
- /** ANSI 컬러 코드 */
202
- const COLORS = {
203
- reset: "\x1b[0m",
204
- dim: "\x1b[2m",
205
- cyan: "\x1b[36m",
206
- green: "\x1b[32m",
207
- yellow: "\x1b[33m",
208
- red: "\x1b[31m",
209
- magenta: "\x1b[35m",
210
- };
211
-
212
- // ============================================
213
- // Utilities
214
- // ============================================
215
-
216
- /**
217
- * Request ID 생성
218
- */
219
- function generateRequestId(): string {
220
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
221
- return crypto.randomUUID().slice(0, 8);
222
- }
223
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
224
- }
225
-
226
- /**
227
- * 현재 시간 (고해상도)
228
- */
229
- function now(): number {
230
- if (typeof performance !== "undefined" && performance.now) {
231
- return performance.now();
232
- }
233
- return Date.now();
234
- }
235
-
236
- /**
237
- * URL에서 pathname 추출
238
- */
239
- function getPathname(url: string): string {
240
- try {
241
- return new URL(url).pathname;
242
- } catch {
243
- return url;
244
- }
245
- }
246
-
247
- /**
248
- * 헤더 레드액션 처리
249
- */
250
- function redactHeaders(
251
- headers: Headers,
252
- patterns: string[]
253
- ): Record<string, string> {
254
- const result: Record<string, string> = {};
255
- const lowerPatterns = patterns.map((p) => p.toLowerCase());
256
-
257
- headers.forEach((value, key) => {
258
- const lowerKey = key.toLowerCase();
259
- const shouldRedact = lowerPatterns.some(
260
- (pattern) => lowerKey.includes(pattern) || pattern.includes(lowerKey)
261
- );
262
- result[key] = shouldRedact ? "[REDACTED]" : value;
263
- });
264
-
265
- return result;
266
- }
267
-
268
- /**
269
- * 객체 내 민감 필드 레드액션
270
- */
271
- function redactObject(
272
- obj: unknown,
273
- patterns: string[],
274
- maxBytes: number
275
- ): unknown {
276
- if (obj === null || obj === undefined) return obj;
277
-
278
- // 문자열이면 길이 제한만
279
- if (typeof obj === "string") {
280
- if (obj.length > maxBytes) {
281
- return obj.slice(0, maxBytes) + `... [truncated ${obj.length - maxBytes} bytes]`;
282
- }
283
- return obj;
284
- }
285
-
286
- // 배열
287
- if (Array.isArray(obj)) {
288
- const str = JSON.stringify(obj);
289
- if (str.length > maxBytes) {
290
- return `[Array length=${obj.length}, truncated]`;
291
- }
292
- return obj.map((item) => redactObject(item, patterns, maxBytes));
293
- }
294
-
295
- // 객체
296
- if (typeof obj === "object") {
297
- const lowerPatterns = patterns.map((p) => p.toLowerCase());
298
- const result: Record<string, unknown> = {};
299
-
300
- for (const [key, value] of Object.entries(obj)) {
301
- const lowerKey = key.toLowerCase();
302
- const shouldRedact = lowerPatterns.some(
303
- (pattern) => lowerKey.includes(pattern) || pattern.includes(lowerKey)
304
- );
305
-
306
- if (shouldRedact) {
307
- result[key] = "[REDACTED]";
308
- } else if (typeof value === "object" && value !== null) {
309
- result[key] = redactObject(value, patterns, maxBytes);
310
- } else {
311
- result[key] = value;
312
- }
313
- }
314
-
315
- return result;
316
- }
317
-
318
- return obj;
319
- }
320
-
321
- /**
322
- * 경로가 skip 패턴에 매칭되는지 확인
323
- */
324
- function shouldSkip(path: string, patterns: (string | RegExp)[]): boolean {
325
- return patterns.some((pattern) => {
326
- if (typeof pattern === "string") {
327
- return path === pattern || path.startsWith(pattern + "/");
328
- }
329
- return pattern.test(path);
330
- });
331
- }
332
-
333
- /**
334
- * 로그 레벨 필터링
335
- */
336
- function shouldLog(entryLevel: LogLevel, configLevel: LogLevel): boolean {
337
- return LEVEL_PRIORITY[entryLevel] >= LEVEL_PRIORITY[configLevel];
338
- }
339
-
340
- /**
341
- * HTTP 상태 코드에 따른 색상
342
- */
343
- function getStatusColor(status: number): string {
344
- if (status >= 500) return COLORS.red;
345
- if (status >= 400) return COLORS.yellow;
346
- if (status >= 300) return COLORS.cyan;
347
- return COLORS.green;
348
- }
349
-
350
- // ============================================
351
- // Pretty Formatter
352
- // ============================================
353
-
354
- function formatPretty(entry: LogEntry): string {
355
- const { method, path, status, duration, requestId, error, slow, headers, trace } = entry;
356
-
357
- const lines: string[] = [];
358
-
359
- // 요청 라인
360
- if (status === undefined) {
361
- // 요청 시작
362
- lines.push(
363
- `${COLORS.dim}[${requestId}]${COLORS.reset} ${COLORS.cyan}→${COLORS.reset} ${method} ${path}`
364
- );
365
- } else {
366
- // 응답
367
- const statusColor = getStatusColor(status);
368
- const durationStr = duration !== undefined ? ` ${duration.toFixed(0)}ms` : "";
369
- const slowIndicator = slow ? ` ${COLORS.yellow}[SLOW]${COLORS.reset}` : "";
370
-
371
- lines.push(
372
- `${COLORS.dim}[${requestId}]${COLORS.reset} ${COLORS.magenta}←${COLORS.reset} ${method} ${path} ${statusColor}${status}${COLORS.reset}${durationStr}${slowIndicator}`
373
- );
374
- }
375
-
376
- // 에러
377
- if (error) {
378
- lines.push(` ${COLORS.red}Error: ${error.message}${COLORS.reset}`);
379
- if (error.stack) {
380
- const stackLines = error.stack.split("\n").slice(1, 4);
381
- stackLines.forEach((line) => {
382
- lines.push(` ${COLORS.dim}${line.trim()}${COLORS.reset}`);
383
- });
384
- }
385
- }
386
-
387
- // 헤더 (debug 모드)
388
- if (headers && Object.keys(headers).length > 0) {
389
- lines.push(` ${COLORS.dim}Headers:${COLORS.reset}`);
390
- for (const [key, value] of Object.entries(headers)) {
391
- lines.push(` ${COLORS.dim}${key}:${COLORS.reset} ${value}`);
392
- }
393
- }
394
-
395
- // Trace 리포트 (느린 요청)
396
- if (trace && trace.entries.length > 0) {
397
- lines.push(` ${COLORS.dim}Trace:${COLORS.reset}`);
398
- for (const traceEntry of trace.entries) {
399
- const name = traceEntry.name ? ` (${traceEntry.name})` : "";
400
- lines.push(
401
- ` ${COLORS.dim}${traceEntry.event}${name}: ${traceEntry.duration.toFixed(1)}ms${COLORS.reset}`
402
- );
403
- }
404
- }
405
-
406
- return lines.join("\n");
407
- }
408
-
409
- // ============================================
410
- // JSON Formatter
411
- // ============================================
412
-
413
- function formatJson(entry: LogEntry): string {
414
- return JSON.stringify(entry);
415
- }
416
-
417
- // ============================================
418
- // Logger Middleware Factory
419
- // ============================================
420
-
421
- /**
422
- * Logger 미들웨어 생성
423
- *
424
- * @example
425
- * ```typescript
426
- * // 기본 사용
427
- * app.use(logger());
428
- *
429
- * // 개발 모드
430
- * app.use(logger({
431
- * level: "debug",
432
- * includeHeaders: true,
433
- * }));
434
- *
435
- * // 프로덕션
436
- * app.use(logger({
437
- * format: "json",
438
- * sampleRate: 0.1, // 10% 샘플링
439
- * slowThresholdMs: 500,
440
- * }));
441
- * ```
442
- */
443
- export function logger(options: LoggerOptions = {}) {
444
- const config = {
445
- format: options.format ?? "pretty",
446
- level: options.level ?? "info",
447
- includeHeaders: options.includeHeaders ?? false,
448
- includeBody: options.includeBody ?? false,
449
- maxBodyBytes: options.maxBodyBytes ?? 1024,
450
- redact: [...DEFAULT_REDACT_PATTERNS, ...(options.redact ?? [])],
451
- requestId: options.requestId ?? "auto",
452
- sampleRate: options.sampleRate ?? 1,
453
- slowThresholdMs: options.slowThresholdMs ?? 1000,
454
- includeTraceOnSlow: options.includeTraceOnSlow ?? true,
455
- sink: options.sink,
456
- skip: options.skip ?? [],
457
- };
458
-
459
- const formatter = config.format === "json" ? formatJson : formatPretty;
460
-
461
- /**
462
- * 로그 출력 함수
463
- */
464
- function log(entry: LogEntry): void {
465
- if (!shouldLog(entry.level, config.level)) return;
466
-
467
- if (config.sink) {
468
- config.sink(entry);
469
- } else {
470
- const output = formatter(entry);
471
- switch (entry.level) {
472
- case "error":
473
- console.error(output);
474
- break;
475
- case "warn":
476
- console.warn(output);
477
- break;
478
- default:
479
- console.log(output);
480
- }
481
- }
482
- }
483
-
484
- return {
485
- /**
486
- * onRequest 훅 - 요청 시작 기록
487
- */
488
- onRequest(ctx: ManduContext): void {
489
- const path = getPathname(ctx.url);
490
-
491
- // Skip 체크
492
- if (shouldSkip(path, config.skip)) return;
493
-
494
- // 샘플링 체크
495
- if (config.sampleRate < 1 && Math.random() > config.sampleRate) return;
496
-
497
- // Trace 활성화
498
- enableTrace(ctx);
499
-
500
- // 시작 시간 저장
501
- ctx.set(LOGGER_START_KEY, now());
502
-
503
- // Request ID 생성/저장
504
- const requestId =
505
- config.requestId === "auto"
506
- ? generateRequestId()
507
- : config.requestId(ctx);
508
- ctx.set(LOGGER_REQUEST_ID_KEY, requestId);
509
-
510
- // debug 레벨이면 요청 시작도 로깅
511
- if (config.level === "debug") {
512
- const entry: LogEntry = {
513
- timestamp: new Date().toISOString(),
514
- requestId,
515
- method: ctx.method,
516
- path,
517
- level: "debug",
518
- };
519
-
520
- if (config.includeHeaders) {
521
- entry.headers = redactHeaders(ctx.headers, config.redact);
522
- }
523
-
524
- log(entry);
525
- }
526
- },
527
-
528
- /**
529
- * onError 훅 - 에러 캡처
530
- */
531
- onError(ctx: ManduContext, error: Error): void {
532
- ctx.set(LOGGER_ERROR_KEY, error);
533
- },
534
-
535
- /**
536
- * afterHandle 훅 - 응답 캡처 (바디 로깅용)
537
- */
538
- afterHandle(ctx: ManduContext, response: Response): Response {
539
- ctx.set(LOGGER_RESPONSE_KEY, response);
540
- return response;
541
- },
542
-
543
- /**
544
- * afterResponse 훅 - 최종 로그 출력
545
- */
546
- async afterResponse(ctx: ManduContext): Promise<void> {
547
- const startTime = ctx.get<number>(LOGGER_START_KEY);
548
- const requestId = ctx.get<string>(LOGGER_REQUEST_ID_KEY);
549
-
550
- // 시작 기록이 없으면 skip된 요청
551
- if (startTime === undefined || requestId === undefined) return;
552
-
553
- const path = getPathname(ctx.url);
554
- const duration = now() - startTime;
555
- const error = ctx.get<Error>(LOGGER_ERROR_KEY);
556
- const response = ctx.get<Response>(LOGGER_RESPONSE_KEY);
557
- const status = response?.status ?? (error ? 500 : 200);
558
- const isSlow = duration > config.slowThresholdMs;
559
-
560
- // 로그 레벨 결정
561
- let level: LogLevel = "info";
562
- if (error) {
563
- level = "error";
564
- } else if (isSlow) {
565
- level = "warn";
566
- }
567
-
568
- // 로그 엔트리 생성
569
- const entry: LogEntry = {
570
- timestamp: new Date().toISOString(),
571
- requestId,
572
- method: ctx.method,
573
- path,
574
- status,
575
- duration,
576
- level,
577
- slow: isSlow,
578
- };
579
-
580
- // 에러 정보
581
- if (error) {
582
- entry.error = {
583
- message: error.message,
584
- stack: error.stack,
585
- };
586
- }
587
-
588
- // 헤더 (debug 또는 느린 요청)
589
- if (config.includeHeaders || (isSlow && config.level === "debug")) {
590
- entry.headers = redactHeaders(ctx.headers, config.redact);
591
- }
592
-
593
- // 바디 (명시적 활성화 + debug 레벨만)
594
- if (config.includeBody && config.level === "debug" && response) {
595
- try {
596
- const cloned = response.clone();
597
- const contentType = cloned.headers.get("content-type") || "";
598
- if (contentType.includes("application/json")) {
599
- const body = await cloned.json();
600
- entry.body = redactObject(body, config.redact, config.maxBodyBytes);
601
- }
602
- } catch {
603
- // 바디 파싱 실패 시 무시
604
- }
605
- }
606
-
607
- // Trace 리포트 (느린 요청)
608
- if (isSlow && config.includeTraceOnSlow) {
609
- const collector = getTrace(ctx);
610
- if (collector) {
611
- entry.trace = buildTraceReport(collector);
612
- }
613
- }
614
-
615
- log(entry);
616
- },
617
- };
618
- }
619
-
620
- /**
621
- * Logger 훅들을 LifecycleStore에 등록하는 헬퍼
622
- *
623
- * @example
624
- * ```typescript
625
- * import { createLifecycleStore } from "./lifecycle";
626
- * import { logger, applyLogger } from "./logger";
627
- *
628
- * const lifecycle = createLifecycleStore();
629
- * applyLogger(lifecycle, logger({ level: "debug" }));
630
- * ```
631
- */
632
- export function applyLogger(
633
- lifecycle: {
634
- onRequest: Array<{ fn: (ctx: ManduContext) => void | Promise<void>; scope: string }>;
635
- onError: Array<{ fn: (ctx: ManduContext, error: Error) => void | Promise<void>; scope: string }>;
636
- afterHandle: Array<{ fn: (ctx: ManduContext, response: Response) => Response | Promise<Response>; scope: string }>;
637
- afterResponse: Array<{ fn: (ctx: ManduContext) => void | Promise<void>; scope: string }>;
638
- },
639
- loggerInstance: ReturnType<typeof logger>
640
- ): void {
641
- lifecycle.onRequest.push({ fn: loggerInstance.onRequest, scope: "global" });
642
- lifecycle.onError.push({ fn: loggerInstance.onError as any, scope: "global" });
643
- lifecycle.afterHandle.push({ fn: loggerInstance.afterHandle, scope: "global" });
644
- lifecycle.afterResponse.push({ fn: loggerInstance.afterResponse, scope: "global" });
645
- }
646
-
647
- // ============================================
648
- // Convenience Presets
649
- // ============================================
650
-
651
- /**
652
- * 개발용 로거 프리셋
653
- */
654
- export function devLogger(options: Partial<LoggerOptions> = {}) {
655
- return logger({
656
- format: "pretty",
657
- level: "debug",
658
- includeHeaders: true,
659
- slowThresholdMs: 500,
660
- ...options,
661
- });
662
- }
663
-
664
- /**
665
- * 프로덕션용 로거 프리셋
666
- */
667
- export function prodLogger(options: Partial<LoggerOptions> = {}) {
668
- return logger({
669
- format: "json",
670
- level: "info",
671
- includeHeaders: false,
672
- includeBody: false,
673
- sampleRate: 1,
674
- slowThresholdMs: 1000,
675
- ...options,
676
- });
677
- }
1
+ /**
2
+ * Mandu Runtime Logger 📝
3
+ * Trace 기반 요청/응답 로깅 레이어
4
+ *
5
+ * 역할 분리:
6
+ * - Trace = 수집 (원본 이벤트, duration 측정)
7
+ * - Logger = 출력 (포맷/필터/레드액션/샘플링)
8
+ *
9
+ * 기본값은 안전:
10
+ * - includeHeaders: false
11
+ * - includeBody: false
12
+ * - redact: 민감 정보 자동 마스킹
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { logger } from "@mandujs/core";
17
+ *
18
+ * // 기본 사용
19
+ * app.use(logger());
20
+ * // → GET /api/users
21
+ * // ← GET /api/users 200 23ms
22
+ *
23
+ * // 개발 모드
24
+ * app.use(logger({
25
+ * level: "debug",
26
+ * includeHeaders: true,
27
+ * }));
28
+ *
29
+ * // 프로덕션 (JSON 형식)
30
+ * app.use(logger({
31
+ * format: "json",
32
+ * slowThresholdMs: 500,
33
+ * }));
34
+ * ```
35
+ */
36
+
37
+ import type { ManduContext } from "../filling/context";
38
+ import {
39
+ enableTrace,
40
+ getTrace,
41
+ buildTraceReport,
42
+ type TraceReport,
43
+ } from "./trace";
44
+
45
+ // ============================================
46
+ // Types
47
+ // ============================================
48
+
49
+ export type LogLevel = "debug" | "info" | "warn" | "error";
50
+ export type LogFormat = "pretty" | "json";
51
+
52
+ /**
53
+ * Logger 옵션
54
+ */
55
+ export interface LoggerOptions {
56
+ /**
57
+ * 로그 포맷
58
+ * - pretty: 개발용 컬러 출력
59
+ * - json: 운영용 구조화 로그
60
+ * @default "pretty"
61
+ */
62
+ format?: LogFormat;
63
+
64
+ /**
65
+ * 로그 레벨
66
+ * - debug: 모든 요청 상세 출력
67
+ * - info: 기본 요청/응답 (기본값)
68
+ * - warn: 느린 요청 + 에러
69
+ * - error: 에러만
70
+ * @default "info"
71
+ */
72
+ level?: LogLevel;
73
+
74
+ /**
75
+ * 헤더 포함 여부
76
+ * ⚠️ 기본 OFF - 민감 정보 노출 위험
77
+ * @default false
78
+ */
79
+ includeHeaders?: boolean;
80
+
81
+ /**
82
+ * 바디 포함 여부
83
+ * ⚠️ 기본 OFF - 민감 정보 노출 + 스트림 문제
84
+ * @default false
85
+ */
86
+ includeBody?: boolean;
87
+
88
+ /**
89
+ * 바디 최대 바이트 (includeBody=true 시)
90
+ * @default 1024
91
+ */
92
+ maxBodyBytes?: number;
93
+
94
+ /**
95
+ * 레드액션 대상 헤더/필드명 (기본값 내장)
96
+ * 추가할 필드만 지정하면 기본값과 병합됨
97
+ */
98
+ redact?: string[];
99
+
100
+ /**
101
+ * Request ID 생성 방식
102
+ * - "auto": crypto.randomUUID() 또는 타임스탬프 기반
103
+ * - 함수: 커스텀 생성
104
+ * @default "auto"
105
+ */
106
+ requestId?: "auto" | ((ctx: ManduContext) => string);
107
+
108
+ /**
109
+ * 샘플링 비율 (0-1)
110
+ * 운영 환경에서 로그 양 조절
111
+ * @default 1 (100%)
112
+ */
113
+ sampleRate?: number;
114
+
115
+ /**
116
+ * 느린 요청 임계값 (ms)
117
+ * 이 값 초과 시 warn 레벨로 상세 출력
118
+ * @default 1000
119
+ */
120
+ slowThresholdMs?: number;
121
+
122
+ /**
123
+ * Trace 리포트 포함 여부 (느린 요청 시)
124
+ * @default true
125
+ */
126
+ includeTraceOnSlow?: boolean;
127
+
128
+ /**
129
+ * 커스텀 로그 싱크 (외부 시스템 연동용)
130
+ * 지정 시 console 출력 대신 이 함수 호출
131
+ */
132
+ sink?: (entry: LogEntry) => void;
133
+
134
+ /**
135
+ * 로깅 제외 경로 패턴
136
+ * @example ["/health", "/metrics", /^\/static\//]
137
+ */
138
+ skip?: (string | RegExp)[];
139
+ }
140
+
141
+ /**
142
+ * 로그 엔트리 (JSON 출력 및 sink용)
143
+ */
144
+ export interface LogEntry {
145
+ timestamp: string;
146
+ requestId: string;
147
+ method: string;
148
+ path: string;
149
+ status?: number;
150
+ duration?: number;
151
+ level: LogLevel;
152
+ error?: {
153
+ message: string;
154
+ stack?: string;
155
+ };
156
+ headers?: Record<string, string>;
157
+ body?: unknown;
158
+ trace?: TraceReport;
159
+ slow?: boolean;
160
+ }
161
+
162
+ // ============================================
163
+ // Constants
164
+ // ============================================
165
+
166
+ /** 기본 레드액션 대상 (대소문자 무시) */
167
+ const DEFAULT_REDACT_PATTERNS = [
168
+ "authorization",
169
+ "cookie",
170
+ "set-cookie",
171
+ "x-api-key",
172
+ "api-key",
173
+ "apikey",
174
+ "api_key",
175
+ "password",
176
+ "passwd",
177
+ "secret",
178
+ "token",
179
+ "bearer",
180
+ "credential",
181
+ "credentials",
182
+ "private",
183
+ "session",
184
+ "jwt",
185
+ ];
186
+
187
+ /** Context 저장 키 */
188
+ const LOGGER_START_KEY = "__mandu_logger_start";
189
+ const LOGGER_REQUEST_ID_KEY = "__mandu_logger_request_id";
190
+ const LOGGER_ERROR_KEY = "__mandu_logger_error";
191
+ const LOGGER_RESPONSE_KEY = "__mandu_logger_response";
192
+
193
+ /** 로그 레벨 우선순위 */
194
+ const LEVEL_PRIORITY: Record<LogLevel, number> = {
195
+ debug: 0,
196
+ info: 1,
197
+ warn: 2,
198
+ error: 3,
199
+ };
200
+
201
+ /** ANSI 컬러 코드 */
202
+ const COLORS = {
203
+ reset: "\x1b[0m",
204
+ dim: "\x1b[2m",
205
+ cyan: "\x1b[36m",
206
+ green: "\x1b[32m",
207
+ yellow: "\x1b[33m",
208
+ red: "\x1b[31m",
209
+ magenta: "\x1b[35m",
210
+ };
211
+
212
+ // ============================================
213
+ // Utilities
214
+ // ============================================
215
+
216
+ /**
217
+ * Request ID 생성
218
+ */
219
+ function generateRequestId(): string {
220
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
221
+ return crypto.randomUUID().slice(0, 8);
222
+ }
223
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
224
+ }
225
+
226
+ /**
227
+ * 현재 시간 (고해상도)
228
+ */
229
+ function now(): number {
230
+ if (typeof performance !== "undefined" && performance.now) {
231
+ return performance.now();
232
+ }
233
+ return Date.now();
234
+ }
235
+
236
+ /**
237
+ * URL에서 pathname 추출
238
+ */
239
+ function getPathname(url: string): string {
240
+ try {
241
+ return new URL(url).pathname;
242
+ } catch {
243
+ return url;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * 헤더 레드액션 처리
249
+ */
250
+ function redactHeaders(
251
+ headers: Headers,
252
+ patterns: string[]
253
+ ): Record<string, string> {
254
+ const result: Record<string, string> = {};
255
+ const lowerPatterns = patterns.map((p) => p.toLowerCase());
256
+
257
+ headers.forEach((value, key) => {
258
+ const lowerKey = key.toLowerCase();
259
+ const shouldRedact = lowerPatterns.some(
260
+ (pattern) => lowerKey.includes(pattern) || pattern.includes(lowerKey)
261
+ );
262
+ result[key] = shouldRedact ? "[REDACTED]" : value;
263
+ });
264
+
265
+ return result;
266
+ }
267
+
268
+ /**
269
+ * 객체 내 민감 필드 레드액션
270
+ */
271
+ function redactObject(
272
+ obj: unknown,
273
+ patterns: string[],
274
+ maxBytes: number
275
+ ): unknown {
276
+ if (obj === null || obj === undefined) return obj;
277
+
278
+ // 문자열이면 길이 제한만
279
+ if (typeof obj === "string") {
280
+ if (obj.length > maxBytes) {
281
+ return obj.slice(0, maxBytes) + `... [truncated ${obj.length - maxBytes} bytes]`;
282
+ }
283
+ return obj;
284
+ }
285
+
286
+ // 배열
287
+ if (Array.isArray(obj)) {
288
+ const str = JSON.stringify(obj);
289
+ if (str.length > maxBytes) {
290
+ return `[Array length=${obj.length}, truncated]`;
291
+ }
292
+ return obj.map((item) => redactObject(item, patterns, maxBytes));
293
+ }
294
+
295
+ // 객체
296
+ if (typeof obj === "object") {
297
+ const lowerPatterns = patterns.map((p) => p.toLowerCase());
298
+ const result: Record<string, unknown> = {};
299
+
300
+ for (const [key, value] of Object.entries(obj)) {
301
+ const lowerKey = key.toLowerCase();
302
+ const shouldRedact = lowerPatterns.some(
303
+ (pattern) => lowerKey.includes(pattern) || pattern.includes(lowerKey)
304
+ );
305
+
306
+ if (shouldRedact) {
307
+ result[key] = "[REDACTED]";
308
+ } else if (typeof value === "object" && value !== null) {
309
+ result[key] = redactObject(value, patterns, maxBytes);
310
+ } else {
311
+ result[key] = value;
312
+ }
313
+ }
314
+
315
+ return result;
316
+ }
317
+
318
+ return obj;
319
+ }
320
+
321
+ /**
322
+ * 경로가 skip 패턴에 매칭되는지 확인
323
+ */
324
+ function shouldSkip(path: string, patterns: (string | RegExp)[]): boolean {
325
+ return patterns.some((pattern) => {
326
+ if (typeof pattern === "string") {
327
+ return path === pattern || path.startsWith(pattern + "/");
328
+ }
329
+ return pattern.test(path);
330
+ });
331
+ }
332
+
333
+ /**
334
+ * 로그 레벨 필터링
335
+ */
336
+ function shouldLog(entryLevel: LogLevel, configLevel: LogLevel): boolean {
337
+ return LEVEL_PRIORITY[entryLevel] >= LEVEL_PRIORITY[configLevel];
338
+ }
339
+
340
+ /**
341
+ * HTTP 상태 코드에 따른 색상
342
+ */
343
+ function getStatusColor(status: number): string {
344
+ if (status >= 500) return COLORS.red;
345
+ if (status >= 400) return COLORS.yellow;
346
+ if (status >= 300) return COLORS.cyan;
347
+ return COLORS.green;
348
+ }
349
+
350
+ // ============================================
351
+ // Pretty Formatter
352
+ // ============================================
353
+
354
+ function formatPretty(entry: LogEntry): string {
355
+ const { method, path, status, duration, requestId, error, slow, headers, trace } = entry;
356
+
357
+ const lines: string[] = [];
358
+
359
+ // 요청 라인
360
+ if (status === undefined) {
361
+ // 요청 시작
362
+ lines.push(
363
+ `${COLORS.dim}[${requestId}]${COLORS.reset} ${COLORS.cyan}→${COLORS.reset} ${method} ${path}`
364
+ );
365
+ } else {
366
+ // 응답
367
+ const statusColor = getStatusColor(status);
368
+ const durationStr = duration !== undefined ? ` ${duration.toFixed(0)}ms` : "";
369
+ const slowIndicator = slow ? ` ${COLORS.yellow}[SLOW]${COLORS.reset}` : "";
370
+
371
+ lines.push(
372
+ `${COLORS.dim}[${requestId}]${COLORS.reset} ${COLORS.magenta}←${COLORS.reset} ${method} ${path} ${statusColor}${status}${COLORS.reset}${durationStr}${slowIndicator}`
373
+ );
374
+ }
375
+
376
+ // 에러
377
+ if (error) {
378
+ lines.push(` ${COLORS.red}Error: ${error.message}${COLORS.reset}`);
379
+ if (error.stack) {
380
+ const stackLines = error.stack.split("\n").slice(1, 4);
381
+ stackLines.forEach((line) => {
382
+ lines.push(` ${COLORS.dim}${line.trim()}${COLORS.reset}`);
383
+ });
384
+ }
385
+ }
386
+
387
+ // 헤더 (debug 모드)
388
+ if (headers && Object.keys(headers).length > 0) {
389
+ lines.push(` ${COLORS.dim}Headers:${COLORS.reset}`);
390
+ for (const [key, value] of Object.entries(headers)) {
391
+ lines.push(` ${COLORS.dim}${key}:${COLORS.reset} ${value}`);
392
+ }
393
+ }
394
+
395
+ // Trace 리포트 (느린 요청)
396
+ if (trace && trace.entries.length > 0) {
397
+ lines.push(` ${COLORS.dim}Trace:${COLORS.reset}`);
398
+ for (const traceEntry of trace.entries) {
399
+ const name = traceEntry.name ? ` (${traceEntry.name})` : "";
400
+ lines.push(
401
+ ` ${COLORS.dim}${traceEntry.event}${name}: ${traceEntry.duration.toFixed(1)}ms${COLORS.reset}`
402
+ );
403
+ }
404
+ }
405
+
406
+ return lines.join("\n");
407
+ }
408
+
409
+ // ============================================
410
+ // JSON Formatter
411
+ // ============================================
412
+
413
+ function formatJson(entry: LogEntry): string {
414
+ return JSON.stringify(entry);
415
+ }
416
+
417
+ // ============================================
418
+ // Logger Middleware Factory
419
+ // ============================================
420
+
421
+ /**
422
+ * Logger 미들웨어 생성
423
+ *
424
+ * @example
425
+ * ```typescript
426
+ * // 기본 사용
427
+ * app.use(logger());
428
+ *
429
+ * // 개발 모드
430
+ * app.use(logger({
431
+ * level: "debug",
432
+ * includeHeaders: true,
433
+ * }));
434
+ *
435
+ * // 프로덕션
436
+ * app.use(logger({
437
+ * format: "json",
438
+ * sampleRate: 0.1, // 10% 샘플링
439
+ * slowThresholdMs: 500,
440
+ * }));
441
+ * ```
442
+ */
443
+ export function logger(options: LoggerOptions = {}) {
444
+ const config = {
445
+ format: options.format ?? "pretty",
446
+ level: options.level ?? "info",
447
+ includeHeaders: options.includeHeaders ?? false,
448
+ includeBody: options.includeBody ?? false,
449
+ maxBodyBytes: options.maxBodyBytes ?? 1024,
450
+ redact: [...DEFAULT_REDACT_PATTERNS, ...(options.redact ?? [])],
451
+ requestId: options.requestId ?? "auto",
452
+ sampleRate: options.sampleRate ?? 1,
453
+ slowThresholdMs: options.slowThresholdMs ?? 1000,
454
+ includeTraceOnSlow: options.includeTraceOnSlow ?? true,
455
+ sink: options.sink,
456
+ skip: options.skip ?? [],
457
+ };
458
+
459
+ const formatter = config.format === "json" ? formatJson : formatPretty;
460
+
461
+ /**
462
+ * 로그 출력 함수
463
+ */
464
+ function log(entry: LogEntry): void {
465
+ if (!shouldLog(entry.level, config.level)) return;
466
+
467
+ if (config.sink) {
468
+ config.sink(entry);
469
+ } else {
470
+ const output = formatter(entry);
471
+ switch (entry.level) {
472
+ case "error":
473
+ console.error(output);
474
+ break;
475
+ case "warn":
476
+ console.warn(output);
477
+ break;
478
+ default:
479
+ console.log(output);
480
+ }
481
+ }
482
+ }
483
+
484
+ return {
485
+ /**
486
+ * onRequest 훅 - 요청 시작 기록
487
+ */
488
+ onRequest(ctx: ManduContext): void {
489
+ const path = getPathname(ctx.url);
490
+
491
+ // Skip 체크
492
+ if (shouldSkip(path, config.skip)) return;
493
+
494
+ // 샘플링 체크
495
+ if (config.sampleRate < 1 && Math.random() > config.sampleRate) return;
496
+
497
+ // Trace 활성화
498
+ enableTrace(ctx);
499
+
500
+ // 시작 시간 저장
501
+ ctx.set(LOGGER_START_KEY, now());
502
+
503
+ // Request ID 생성/저장
504
+ const requestId =
505
+ config.requestId === "auto"
506
+ ? generateRequestId()
507
+ : config.requestId(ctx);
508
+ ctx.set(LOGGER_REQUEST_ID_KEY, requestId);
509
+
510
+ // debug 레벨이면 요청 시작도 로깅
511
+ if (config.level === "debug") {
512
+ const entry: LogEntry = {
513
+ timestamp: new Date().toISOString(),
514
+ requestId,
515
+ method: ctx.method,
516
+ path,
517
+ level: "debug",
518
+ };
519
+
520
+ if (config.includeHeaders) {
521
+ entry.headers = redactHeaders(ctx.headers, config.redact);
522
+ }
523
+
524
+ log(entry);
525
+ }
526
+ },
527
+
528
+ /**
529
+ * onError 훅 - 에러 캡처
530
+ */
531
+ onError(ctx: ManduContext, error: Error): void {
532
+ ctx.set(LOGGER_ERROR_KEY, error);
533
+ },
534
+
535
+ /**
536
+ * afterHandle 훅 - 응답 캡처 (바디 로깅용)
537
+ */
538
+ afterHandle(ctx: ManduContext, response: Response): Response {
539
+ ctx.set(LOGGER_RESPONSE_KEY, response);
540
+ return response;
541
+ },
542
+
543
+ /**
544
+ * afterResponse 훅 - 최종 로그 출력
545
+ */
546
+ async afterResponse(ctx: ManduContext): Promise<void> {
547
+ const startTime = ctx.get<number>(LOGGER_START_KEY);
548
+ const requestId = ctx.get<string>(LOGGER_REQUEST_ID_KEY);
549
+
550
+ // 시작 기록이 없으면 skip된 요청
551
+ if (startTime === undefined || requestId === undefined) return;
552
+
553
+ const path = getPathname(ctx.url);
554
+ const duration = now() - startTime;
555
+ const error = ctx.get<Error>(LOGGER_ERROR_KEY);
556
+ const response = ctx.get<Response>(LOGGER_RESPONSE_KEY);
557
+ const status = response?.status ?? (error ? 500 : 200);
558
+ const isSlow = duration > config.slowThresholdMs;
559
+
560
+ // 로그 레벨 결정
561
+ let level: LogLevel = "info";
562
+ if (error) {
563
+ level = "error";
564
+ } else if (isSlow) {
565
+ level = "warn";
566
+ }
567
+
568
+ // 로그 엔트리 생성
569
+ const entry: LogEntry = {
570
+ timestamp: new Date().toISOString(),
571
+ requestId,
572
+ method: ctx.method,
573
+ path,
574
+ status,
575
+ duration,
576
+ level,
577
+ slow: isSlow,
578
+ };
579
+
580
+ // 에러 정보
581
+ if (error) {
582
+ entry.error = {
583
+ message: error.message,
584
+ stack: error.stack,
585
+ };
586
+ }
587
+
588
+ // 헤더 (debug 또는 느린 요청)
589
+ if (config.includeHeaders || (isSlow && config.level === "debug")) {
590
+ entry.headers = redactHeaders(ctx.headers, config.redact);
591
+ }
592
+
593
+ // 바디 (명시적 활성화 + debug 레벨만)
594
+ if (config.includeBody && config.level === "debug" && response) {
595
+ try {
596
+ const cloned = response.clone();
597
+ const contentType = cloned.headers.get("content-type") || "";
598
+ if (contentType.includes("application/json")) {
599
+ const body = await cloned.json();
600
+ entry.body = redactObject(body, config.redact, config.maxBodyBytes);
601
+ }
602
+ } catch {
603
+ // 바디 파싱 실패 시 무시
604
+ }
605
+ }
606
+
607
+ // Trace 리포트 (느린 요청)
608
+ if (isSlow && config.includeTraceOnSlow) {
609
+ const collector = getTrace(ctx);
610
+ if (collector) {
611
+ entry.trace = buildTraceReport(collector);
612
+ }
613
+ }
614
+
615
+ log(entry);
616
+ },
617
+ };
618
+ }
619
+
620
+ /**
621
+ * Logger 훅들을 LifecycleStore에 등록하는 헬퍼
622
+ *
623
+ * @example
624
+ * ```typescript
625
+ * import { createLifecycleStore } from "./lifecycle";
626
+ * import { logger, applyLogger } from "./logger";
627
+ *
628
+ * const lifecycle = createLifecycleStore();
629
+ * applyLogger(lifecycle, logger({ level: "debug" }));
630
+ * ```
631
+ */
632
+ export function applyLogger(
633
+ lifecycle: {
634
+ onRequest: Array<{ fn: (ctx: ManduContext) => void | Promise<void>; scope: string }>;
635
+ onError: Array<{ fn: (ctx: ManduContext, error: Error) => void | Promise<void>; scope: string }>;
636
+ afterHandle: Array<{ fn: (ctx: ManduContext, response: Response) => Response | Promise<Response>; scope: string }>;
637
+ afterResponse: Array<{ fn: (ctx: ManduContext) => void | Promise<void>; scope: string }>;
638
+ },
639
+ loggerInstance: ReturnType<typeof logger>
640
+ ): void {
641
+ lifecycle.onRequest.push({ fn: loggerInstance.onRequest, scope: "global" });
642
+ lifecycle.onError.push({ fn: loggerInstance.onError as any, scope: "global" });
643
+ lifecycle.afterHandle.push({ fn: loggerInstance.afterHandle, scope: "global" });
644
+ lifecycle.afterResponse.push({ fn: loggerInstance.afterResponse, scope: "global" });
645
+ }
646
+
647
+ // ============================================
648
+ // Convenience Presets
649
+ // ============================================
650
+
651
+ /**
652
+ * 개발용 로거 프리셋
653
+ */
654
+ export function devLogger(options: Partial<LoggerOptions> = {}) {
655
+ return logger({
656
+ format: "pretty",
657
+ level: "debug",
658
+ includeHeaders: true,
659
+ slowThresholdMs: 500,
660
+ ...options,
661
+ });
662
+ }
663
+
664
+ /**
665
+ * 프로덕션용 로거 프리셋
666
+ */
667
+ export function prodLogger(options: Partial<LoggerOptions> = {}) {
668
+ return logger({
669
+ format: "json",
670
+ level: "info",
671
+ includeHeaders: false,
672
+ includeBody: false,
673
+ sampleRate: 1,
674
+ slowThresholdMs: 1000,
675
+ ...options,
676
+ });
677
+ }