@mandujs/core 0.21.0 → 0.22.1

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