@mandujs/core 0.13.0 → 0.13.2

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 (157) hide show
  1. package/README.ko.md +4 -4
  2. package/README.md +653 -653
  3. package/package.json +1 -1
  4. package/src/bundler/build.ts +91 -91
  5. package/src/bundler/css.ts +302 -302
  6. package/src/client/Link.tsx +227 -227
  7. package/src/client/globals.ts +44 -44
  8. package/src/client/hooks.ts +267 -267
  9. package/src/client/index.ts +5 -5
  10. package/src/client/island.ts +8 -8
  11. package/src/client/router.ts +435 -435
  12. package/src/client/runtime.ts +23 -23
  13. package/src/client/serialize.ts +404 -404
  14. package/src/client/window-state.ts +101 -101
  15. package/src/config/mandu.ts +9 -0
  16. package/src/config/validate.ts +12 -0
  17. package/src/config/watcher.ts +311 -311
  18. package/src/constants.ts +40 -40
  19. package/src/content/content-layer.ts +314 -314
  20. package/src/content/content.test.ts +433 -433
  21. package/src/content/data-store.ts +245 -245
  22. package/src/content/digest.ts +133 -133
  23. package/src/content/index.ts +164 -164
  24. package/src/content/loader-context.ts +172 -172
  25. package/src/content/loaders/api.ts +216 -216
  26. package/src/content/loaders/file.ts +169 -169
  27. package/src/content/loaders/glob.ts +252 -252
  28. package/src/content/loaders/index.ts +34 -34
  29. package/src/content/loaders/types.ts +137 -137
  30. package/src/content/meta-store.ts +209 -209
  31. package/src/content/types.ts +282 -282
  32. package/src/content/watcher.ts +135 -135
  33. package/src/contract/client-safe.test.ts +42 -42
  34. package/src/contract/client-safe.ts +114 -114
  35. package/src/contract/client.ts +16 -16
  36. package/src/contract/define.ts +459 -459
  37. package/src/contract/handler.ts +10 -10
  38. package/src/contract/normalize.test.ts +276 -276
  39. package/src/contract/normalize.ts +404 -404
  40. package/src/contract/registry.test.ts +206 -206
  41. package/src/contract/registry.ts +568 -568
  42. package/src/contract/schema.ts +48 -48
  43. package/src/contract/types.ts +58 -58
  44. package/src/contract/validator.ts +32 -32
  45. package/src/devtools/ai/context-builder.ts +375 -375
  46. package/src/devtools/ai/index.ts +25 -25
  47. package/src/devtools/ai/mcp-connector.ts +465 -465
  48. package/src/devtools/client/catchers/error-catcher.ts +327 -327
  49. package/src/devtools/client/catchers/index.ts +18 -18
  50. package/src/devtools/client/catchers/network-proxy.ts +363 -363
  51. package/src/devtools/client/components/index.ts +39 -39
  52. package/src/devtools/client/components/kitchen-root.tsx +362 -362
  53. package/src/devtools/client/components/mandu-character.tsx +241 -241
  54. package/src/devtools/client/components/overlay.tsx +368 -368
  55. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  56. package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
  57. package/src/devtools/client/components/panel/index.ts +32 -32
  58. package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
  59. package/src/devtools/client/components/panel/network-panel.tsx +292 -292
  60. package/src/devtools/client/components/panel/panel-container.tsx +259 -259
  61. package/src/devtools/client/filters/context-filters.ts +282 -282
  62. package/src/devtools/client/filters/index.ts +16 -16
  63. package/src/devtools/client/index.ts +63 -63
  64. package/src/devtools/client/persistence.ts +335 -335
  65. package/src/devtools/client/state-manager.ts +478 -478
  66. package/src/devtools/design-tokens.ts +263 -263
  67. package/src/devtools/hook/create-hook.ts +207 -207
  68. package/src/devtools/hook/index.ts +13 -13
  69. package/src/devtools/index.ts +439 -439
  70. package/src/devtools/init.ts +266 -266
  71. package/src/devtools/protocol.ts +237 -237
  72. package/src/devtools/server/index.ts +17 -17
  73. package/src/devtools/server/source-context.ts +444 -444
  74. package/src/devtools/types.ts +319 -319
  75. package/src/devtools/worker/index.ts +25 -25
  76. package/src/devtools/worker/redaction-worker.ts +222 -222
  77. package/src/devtools/worker/worker-manager.ts +409 -409
  78. package/src/error/domains.ts +265 -265
  79. package/src/error/result.ts +46 -46
  80. package/src/error/types.ts +6 -6
  81. package/src/errors/extractor.ts +409 -409
  82. package/src/errors/index.ts +19 -19
  83. package/src/filling/auth.ts +308 -308
  84. package/src/filling/context.ts +24 -1
  85. package/src/filling/deps.ts +238 -238
  86. package/src/filling/index.ts +4 -0
  87. package/src/filling/sse-catchup.test.ts +56 -0
  88. package/src/filling/sse-catchup.ts +67 -0
  89. package/src/filling/sse.test.ts +168 -0
  90. package/src/filling/sse.ts +162 -0
  91. package/src/generator/index.ts +3 -3
  92. package/src/guard/analyzer.ts +360 -360
  93. package/src/guard/ast-analyzer.ts +806 -806
  94. package/src/guard/contract-guard.ts +9 -9
  95. package/src/guard/file-type.test.ts +24 -24
  96. package/src/guard/presets/atomic.ts +70 -70
  97. package/src/guard/presets/clean.ts +77 -77
  98. package/src/guard/presets/fsd.ts +79 -79
  99. package/src/guard/presets/hexagonal.ts +68 -68
  100. package/src/guard/presets/index.ts +291 -291
  101. package/src/guard/reporter.ts +445 -445
  102. package/src/guard/rules.ts +12 -12
  103. package/src/guard/statistics.ts +578 -578
  104. package/src/guard/suggestions.ts +358 -358
  105. package/src/guard/types.ts +348 -348
  106. package/src/guard/validator.ts +834 -834
  107. package/src/guard/watcher.ts +404 -404
  108. package/src/index.ts +6 -1
  109. package/src/intent/index.ts +310 -310
  110. package/src/island/index.ts +304 -304
  111. package/src/logging/index.ts +22 -22
  112. package/src/logging/transports.ts +365 -365
  113. package/src/plugins/index.ts +38 -38
  114. package/src/plugins/registry.ts +377 -377
  115. package/src/plugins/types.ts +363 -363
  116. package/src/report/index.ts +1 -1
  117. package/src/router/fs-patterns.ts +387 -387
  118. package/src/router/fs-scanner.ts +497 -497
  119. package/src/runtime/boundary.tsx +232 -232
  120. package/src/runtime/compose.ts +222 -222
  121. package/src/runtime/escape.ts +44 -0
  122. package/src/runtime/lifecycle.ts +381 -381
  123. package/src/runtime/logger.test.ts +345 -345
  124. package/src/runtime/logger.ts +677 -677
  125. package/src/runtime/router.test.ts +476 -476
  126. package/src/runtime/router.ts +105 -105
  127. package/src/runtime/security.ts +155 -155
  128. package/src/runtime/server.ts +257 -0
  129. package/src/runtime/session-key.ts +328 -328
  130. package/src/runtime/ssr.ts +16 -21
  131. package/src/runtime/streaming-ssr.ts +24 -33
  132. package/src/runtime/trace.ts +144 -144
  133. package/src/seo/index.ts +214 -214
  134. package/src/seo/integration/ssr.ts +307 -307
  135. package/src/seo/render/basic.ts +427 -427
  136. package/src/seo/render/index.ts +143 -143
  137. package/src/seo/render/jsonld.ts +539 -539
  138. package/src/seo/render/opengraph.ts +191 -191
  139. package/src/seo/render/robots.ts +116 -116
  140. package/src/seo/render/sitemap.ts +137 -137
  141. package/src/seo/render/twitter.ts +126 -126
  142. package/src/seo/resolve/index.ts +353 -353
  143. package/src/seo/resolve/opengraph.ts +143 -143
  144. package/src/seo/resolve/robots.ts +73 -73
  145. package/src/seo/resolve/title.ts +94 -94
  146. package/src/seo/resolve/twitter.ts +73 -73
  147. package/src/seo/resolve/url.ts +97 -97
  148. package/src/seo/routes/index.ts +290 -290
  149. package/src/seo/types.ts +575 -575
  150. package/src/slot/validator.ts +39 -39
  151. package/src/spec/index.ts +3 -3
  152. package/src/spec/load.ts +76 -76
  153. package/src/spec/lock.ts +56 -56
  154. package/src/utils/bun.ts +8 -8
  155. package/src/utils/lru-cache.ts +75 -75
  156. package/src/utils/safe-io.ts +188 -188
  157. package/src/utils/string-safe.ts +298 -298
@@ -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
+ }