@mandujs/core 0.12.1 → 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 (177) hide show
  1. package/README.ko.md +304 -304
  2. package/README.md +653 -653
  3. package/package.json +8 -8
  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/healing.ts +2 -0
  106. package/src/guard/index.ts +2 -0
  107. package/src/guard/negotiation.ts +430 -4
  108. package/src/guard/presets/atomic.ts +70 -70
  109. package/src/guard/presets/clean.ts +77 -77
  110. package/src/guard/presets/cqrs.test.ts +175 -0
  111. package/src/guard/presets/cqrs.ts +107 -0
  112. package/src/guard/presets/fsd.ts +79 -79
  113. package/src/guard/presets/hexagonal.ts +68 -68
  114. package/src/guard/presets/index.ts +291 -288
  115. package/src/guard/reporter.ts +445 -445
  116. package/src/guard/rules.ts +12 -12
  117. package/src/guard/statistics.ts +578 -578
  118. package/src/guard/suggestions.ts +358 -352
  119. package/src/guard/types.ts +348 -347
  120. package/src/guard/validator.ts +834 -834
  121. package/src/guard/watcher.ts +404 -404
  122. package/src/index.ts +1 -0
  123. package/src/intent/index.ts +310 -310
  124. package/src/island/index.ts +304 -304
  125. package/src/logging/index.ts +22 -22
  126. package/src/logging/transports.ts +365 -365
  127. package/src/paths.test.ts +47 -0
  128. package/src/paths.ts +47 -0
  129. package/src/plugins/index.ts +38 -38
  130. package/src/plugins/registry.ts +377 -377
  131. package/src/plugins/types.ts +363 -363
  132. package/src/report/build.ts +1 -1
  133. package/src/report/index.ts +1 -1
  134. package/src/router/fs-patterns.ts +387 -387
  135. package/src/router/fs-routes.ts +344 -401
  136. package/src/router/fs-scanner.ts +497 -497
  137. package/src/router/fs-types.ts +270 -278
  138. package/src/router/index.ts +81 -81
  139. package/src/runtime/boundary.tsx +232 -232
  140. package/src/runtime/compose.ts +222 -222
  141. package/src/runtime/lifecycle.ts +381 -381
  142. package/src/runtime/logger.test.ts +345 -345
  143. package/src/runtime/logger.ts +677 -677
  144. package/src/runtime/router.test.ts +476 -476
  145. package/src/runtime/router.ts +105 -105
  146. package/src/runtime/security.ts +155 -155
  147. package/src/runtime/server.ts +24 -24
  148. package/src/runtime/session-key.ts +328 -328
  149. package/src/runtime/ssr.ts +367 -367
  150. package/src/runtime/streaming-ssr.ts +1245 -1245
  151. package/src/runtime/trace.ts +144 -144
  152. package/src/seo/index.ts +214 -214
  153. package/src/seo/integration/ssr.ts +307 -307
  154. package/src/seo/render/basic.ts +427 -427
  155. package/src/seo/render/index.ts +143 -143
  156. package/src/seo/render/jsonld.ts +539 -539
  157. package/src/seo/render/opengraph.ts +191 -191
  158. package/src/seo/render/robots.ts +116 -116
  159. package/src/seo/render/sitemap.ts +137 -137
  160. package/src/seo/render/twitter.ts +126 -126
  161. package/src/seo/resolve/index.ts +353 -353
  162. package/src/seo/resolve/opengraph.ts +143 -143
  163. package/src/seo/resolve/robots.ts +73 -73
  164. package/src/seo/resolve/title.ts +94 -94
  165. package/src/seo/resolve/twitter.ts +73 -73
  166. package/src/seo/resolve/url.ts +97 -97
  167. package/src/seo/routes/index.ts +290 -290
  168. package/src/seo/types.ts +575 -575
  169. package/src/slot/validator.ts +39 -39
  170. package/src/spec/index.ts +3 -3
  171. package/src/spec/load.ts +76 -76
  172. package/src/spec/lock.ts +56 -56
  173. package/src/utils/bun.ts +8 -8
  174. package/src/utils/lru-cache.ts +75 -75
  175. package/src/utils/safe-io.ts +188 -188
  176. package/src/utils/string-safe.ts +298 -298
  177. 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
+ }