Skip to content

Commit d039dbf

Browse files
SOIVclaude
andcommitted
feat(api): 구조화 로그 시스템 및 graceful shutdown 추가
- middleware/logger.ts: 타임스탬프·ANSI 색상·태그 기반 log 헬퍼 + HTTP requestLogger 미들웨어 추가 (/health 스킵) - app.ts: 세 가지 앱 팩토리 모두에 requestLogger 마운트 - index.ts: 부팅 시퀀스 log.* 헬퍼로 통일, graceful shutdown (SIGINT/SIGTERM → server.close → db.disconnect) - module-registry.ts: console.* → log.* 헬퍼로 교체 - routes/auth.ts: 로그인·TOTP·토큰 갱신·로그아웃 이벤트 로그 추가 - packages/core/postgres.ts: PostgreSQL 연결 로그에 타임스탬프·색상 적용 - package.json: pnpm --parallel → trap + & + wait 방식으로 변경 (접두어 제거) - apps/api/package.json: tsx watch → node --watch --import tsx (Ctrl+C 루프 버그 해결) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent edb35c7 commit d039dbf

8 files changed

Lines changed: 216 additions & 36 deletions

File tree

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"version": "0.0.0",
55
"main": "dist/index.js",
66
"scripts": {
7-
"dev": "pnpm exec tsx watch src/index.ts",
7+
"dev": "node --watch --import tsx src/index.ts",
88
"build": "tsc && node scripts/copy-migrations.js",
99
"start": "node dist/index.js",
1010
"test": "vitest run",

apps/api/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { SharedLinkRenderer } from '@fieldstack/core' with { "resolution-mo
1919

2020
import { validateEnv } from './config/env';
2121
import { errorHandler } from './middleware/error';
22+
import { requestLogger } from './middleware/logger';
2223
import { ModuleRegistry } from './module-registry';
2324
import { createAdminRouter } from './routes/admin';
2425
import { createAuthRouter } from './routes/auth';
@@ -52,6 +53,7 @@ export function createApp(services?: AppServices) {
5253

5354
app.use(express.json());
5455
app.use(express.urlencoded({ extended: true }));
56+
app.use(requestLogger);
5557

5658
// ── Core routes ───────────────────────────────────────────────
5759
app.use('/health', healthRouter);
@@ -87,6 +89,7 @@ export function createAppWithPublicRouter(
8789

8890
app.use(express.json());
8991
app.use(express.urlencoded({ extended: true }));
92+
app.use(requestLogger);
9093

9194
app.use('/health', healthRouter);
9295

@@ -114,6 +117,7 @@ export function createSetupApp(): express.Application {
114117
app.use(cors());
115118
app.use(express.json());
116119
app.use(express.urlencoded({ extended: true }));
120+
app.use(requestLogger);
117121

118122
app.use('/health', healthRouter);
119123
app.use('/setup', createSetupRouter());

apps/api/src/index.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dotenv/config';
22

3+
import http from 'node:http';
34
import os from 'node:os';
45
import path from 'node:path';
56

@@ -13,6 +14,7 @@ import {
1314
initServices,
1415
runMigrations,
1516
} from './app';
17+
import { log } from './middleware/logger';
1618
import {
1719
buildBackendRouteRegistrations,
1820
loadModulesFromDisk,
@@ -28,10 +30,7 @@ applyConfigToEnv();
2830
// ── 환경변수 검증 (누락·오류 시 즉시 종료) ────────────────────
2931
const env = validateEnv(process.env);
3032

31-
const BOOTSTRAP_MESSAGE = 'Fieldstack API bootstrap initialized';
32-
33-
console.log(BOOTSTRAP_MESSAGE);
34-
console.log(`[fieldstack][api] env: ${env.NODE_ENV}`);
33+
log.info('api', `bootstrap initialized — env: ${env.NODE_ENV}`);
3534
// modules/ 디렉터리는 프로젝트 루트 기준 (apps/api/src → ../../../modules)
3635
const MODULES_DIR = path.join(__dirname, '..', '..', '..', 'modules');
3736

@@ -84,29 +83,36 @@ function printSetupBanner(apiPort: number, isDev: boolean) {
8483
// ── Setup 모드 ─────────────────────────────────────────────────
8584
// installed.lock 없을 때 → Setup 마법사만 서빙
8685
async function startSetup() {
87-
console.log('[fieldstack][api] *** SETUP MODE — installation wizard active ***');
86+
log.warn('api', `SETUP MODE — installation wizard active`);
8887
const app = createSetupApp();
8988
finalizeApp(app);
90-
app.listen(env.PORT, () => {
89+
const server = http.createServer(app);
90+
server.listen(env.PORT, () => {
9191
printSetupBanner(env.PORT, env.NODE_ENV !== 'production');
9292
});
93+
process.once('SIGINT', () => { server.close(() => process.exit(0)); });
94+
process.once('SIGTERM', () => { server.close(() => process.exit(0)); });
9395
}
9496

9597
// ── 앱 모드 ────────────────────────────────────────────────────
9698
// DB 초기화 → 마이그레이션 → 서비스 초기화 → 모듈 로드 → 서버 시작
9799
async function startApp() {
98-
let services;
100+
let services: Awaited<ReturnType<typeof initServices>> | undefined;
99101

100102
if (env.DB_PROVIDER === 'postgres' && env.DATABASE_URL) {
103+
log.info('db', `connecting (postgres)…`);
101104
const db = await initDb();
105+
log.info('db', `running migrations…`);
102106
await runMigrations(db);
103107
services = await initServices(db);
104-
console.log('[fieldstack][api] DB initialized and migrations applied');
108+
log.success('db', `postgres ready`);
105109
} else if (env.DB_PROVIDER === 'sqlite') {
110+
log.info('db', `connecting (sqlite)…`);
106111
const db = await initDb();
112+
log.info('db', `running migrations…`);
107113
await runMigrations(db);
108114
services = await initServices(db);
109-
console.log('[fieldstack][api] SQLite DB initialized and migrations applied');
115+
log.success('db', `sqlite ready`);
110116
}
111117

112118
let app;
@@ -125,24 +131,42 @@ async function startApp() {
125131

126132
const depIssues = validateModuleDependencies(manifests);
127133
if (depIssues.length > 0) {
128-
console.warn('[fieldstack][loader] dependency issues detected:');
134+
log.warn('loader', `dependency issues detected`);
129135
for (const issue of depIssues) {
130-
console.warn(
131-
` - "${issue.moduleName}" missing: ${issue.missingDependencies.join(', ')}`,
132-
);
136+
log.warn('loader', ` "${issue.moduleName}" missing: ${issue.missingDependencies.join(', ')}`);
133137
}
134138
}
135139

136140
const registrations = buildBackendRouteRegistrations(manifests);
141+
const enabledCount = manifests.filter((m) => m.enabled).length;
142+
log.info('loader', `loading ${enabledCount} module(s)…`);
137143
await loadModulesIntoRegistry(registrations, manifests, MODULES_DIR, services);
144+
log.success('loader', `${enabledCount} module(s) mounted`);
138145
}
139146

140147
// ── Error handler (반드시 모든 라우트 등록 후 마지막) ────────
141148
finalizeApp(app);
142149

143-
app.listen(env.PORT, () => {
144-
console.log(`[fieldstack][api] server listening on http://localhost:${env.PORT}`);
150+
const server = http.createServer(app);
151+
152+
server.listen(env.PORT, () => {
153+
log.success('api', `server ready → http://localhost:${env.PORT}`);
145154
});
155+
156+
// ── Graceful shutdown ─────────────────────────────────────────
157+
const shutdown = () => {
158+
log.info('api', `shutting down…`);
159+
server.close(() => {
160+
if (services) {
161+
services.db.disconnect().finally(() => process.exit(0));
162+
} else {
163+
process.exit(0);
164+
}
165+
});
166+
};
167+
168+
process.once('SIGINT', shutdown);
169+
process.once('SIGTERM', shutdown);
146170
}
147171

148172
// ── 진입점 ──────────────────────────────────────────────────────
@@ -151,6 +175,6 @@ const shouldSetup = !isInstalled();
151175
const boot = shouldSetup ? startSetup : startApp;
152176

153177
boot().catch((err) => {
154-
console.error('[fieldstack][api] startup failed:', err);
178+
log.error('api', `startup failed`, err);
155179
process.exit(1);
156180
});

apps/api/src/middleware/logger.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { NextFunction, Request, Response } from 'express';
2+
3+
// ── ANSI 색상 (개발 모드용) ────────────────────────────────────
4+
5+
const IS_DEV = process.env['NODE_ENV'] !== 'production';
6+
7+
const c = IS_DEV
8+
? {
9+
reset: '\x1b[0m',
10+
dim: '\x1b[2m',
11+
bold: '\x1b[1m',
12+
green: '\x1b[32m',
13+
yellow: '\x1b[33m',
14+
red: '\x1b[31m',
15+
cyan: '\x1b[36m',
16+
magenta: '\x1b[35m',
17+
blue: '\x1b[34m',
18+
gray: '\x1b[90m',
19+
}
20+
: Object.fromEntries(
21+
['reset', 'dim', 'bold', 'green', 'yellow', 'red', 'cyan', 'magenta', 'blue', 'gray'].map(
22+
(k) => [k, ''],
23+
),
24+
);
25+
26+
// ── 타임스탬프 ────────────────────────────────────────────────
27+
28+
function ts(): string {
29+
return new Date().toISOString().replace('T', ' ').slice(0, 23);
30+
}
31+
32+
// ── 상태코드 → 색상 ───────────────────────────────────────────
33+
34+
function statusColor(status: number): string {
35+
if (status >= 500) return c['red'] as string;
36+
if (status >= 400) return c['yellow'] as string;
37+
if (status >= 300) return c['cyan'] as string;
38+
return c['green'] as string;
39+
}
40+
41+
// ── HTTP 메서드 → 색상 ────────────────────────────────────────
42+
43+
function methodColor(method: string): string {
44+
switch (method.toUpperCase()) {
45+
case 'GET':
46+
return c['blue'] as string;
47+
case 'POST':
48+
return c['green'] as string;
49+
case 'PUT':
50+
case 'PATCH':
51+
return c['yellow'] as string;
52+
case 'DELETE':
53+
return c['red'] as string;
54+
default:
55+
return c['gray'] as string;
56+
}
57+
}
58+
59+
// ── 스킵 경로 ────────────────────────────────────────────────
60+
// health 체크는 매 30초마다 호출되어 로그를 오염시키므로 제외
61+
62+
const SKIP_PATHS = new Set(['/health', '/health/']);
63+
64+
// ── HTTP 요청 로거 미들웨어 ────────────────────────────────────
65+
66+
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
67+
if (SKIP_PATHS.has(req.path)) {
68+
next();
69+
return;
70+
}
71+
72+
const start = Date.now();
73+
const method = req.method.padEnd(6);
74+
const path = req.path;
75+
const query = Object.keys(req.query).length > 0 ? `?${new URLSearchParams(req.query as Record<string, string>)}` : '';
76+
77+
// 요청 수신 로그
78+
console.log(
79+
`${c['gray']}${ts()}${c['reset']} ${c['dim']}${c['reset']} ${methodColor(req.method)}${method}${c['reset']} ${path}${c['dim']}${query}${c['reset']}`,
80+
);
81+
82+
// 응답 완료 후 로그
83+
res.on('finish', () => {
84+
const ms = Date.now() - start;
85+
const status = res.statusCode;
86+
const durationStr = ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`;
87+
88+
console.log(
89+
`${c['gray']}${ts()}${c['reset']} ${c['dim']}${c['reset']} ${statusColor(status)}${status}${c['reset']} ${methodColor(req.method)}${method}${c['reset']} ${path} ${c['dim']}(${durationStr})${c['reset']}`,
90+
);
91+
});
92+
93+
next();
94+
}
95+
96+
// ── 구조화 로그 헬퍼 ─────────────────────────────────────────
97+
// 라우트에서 이벤트성 로그를 찍을 때 사용
98+
99+
export const log = {
100+
info(tag: string, msg: string, meta?: Record<string, unknown>): void {
101+
const metaStr = meta ? ` ${c['dim']}${JSON.stringify(meta)}${c['reset']}` : '';
102+
console.log(
103+
`${c['gray']}${ts()}${c['reset']} ${c['cyan']}[${tag}]${c['reset']} ${msg}${metaStr}`,
104+
);
105+
},
106+
107+
warn(tag: string, msg: string, meta?: Record<string, unknown>): void {
108+
const metaStr = meta ? ` ${c['dim']}${JSON.stringify(meta)}${c['reset']}` : '';
109+
console.warn(
110+
`${c['gray']}${ts()}${c['reset']} ${c['yellow']}[${tag}]${c['reset']} ${c['yellow']}${msg}${c['reset']}${metaStr}`,
111+
);
112+
},
113+
114+
error(tag: string, msg: string, err?: unknown): void {
115+
const errStr =
116+
err instanceof Error
117+
? ` — ${err.message}${IS_DEV && err.stack ? `\n${c['dim']}${err.stack}${c['reset']}` : ''}`
118+
: err != null
119+
? ` — ${String(err)}`
120+
: '';
121+
console.error(
122+
`${c['gray']}${ts()}${c['reset']} ${c['red']}[${tag}]${c['reset']} ${c['red']}${msg}${c['reset']}${errStr}`,
123+
);
124+
},
125+
126+
success(tag: string, msg: string, meta?: Record<string, unknown>): void {
127+
const metaStr = meta ? ` ${c['dim']}${JSON.stringify(meta)}${c['reset']}` : '';
128+
console.log(
129+
`${c['gray']}${ts()}${c['reset']} ${c['green']}[${tag}]${c['reset']} ${c['green']}${msg}${c['reset']}${metaStr}`,
130+
);
131+
},
132+
};

apps/api/src/module-registry.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type express from 'express';
55

66
import type { AppServices } from './app';
77
import type { BackendRouteRegistration, ModuleManifest } from './loader';
8+
import { log } from './middleware/logger';
89

910
// ── ModuleRecord ──────────────────────────────────────────────
1011

@@ -51,14 +52,14 @@ export class ModuleRegistry {
5152

5253
public register(record: ModuleRecord): void {
5354
this.modules.set(record.basePath, record);
54-
console.log(`[fieldstack][registry] registered module "${record.name}" at ${record.basePath}`);
55+
log.success('registry', `module "${record.name}" mounted at ${record.basePath}`);
5556
}
5657

5758
public unregister(basePath: string): boolean {
5859
const record = this.modules.get(basePath);
5960
if (!record) return false;
6061
this.modules.delete(basePath);
61-
console.log(`[fieldstack][registry] unregistered module "${record.name}" (${basePath})`);
62+
log.info('registry', `module "${record.name}" unregistered (${basePath})`);
6263
return true;
6364
}
6465

@@ -121,13 +122,13 @@ export async function loadModulesIntoRegistry(
121122
const manifestMap = new Map(manifests.map((m) => [m.name, m]));
122123

123124
if (registrations.length === 0) {
124-
console.log('[fieldstack][registry] no enabled modules found');
125+
log.warn('registry', `no enabled modules found`);
125126
return;
126127
}
127128

128129
for (const reg of registrations) {
129130
if (!reg.apiBasePath) {
130-
console.warn(`[fieldstack][registry] module "${reg.moduleName}" has no apiBasePath, skipping`);
131+
log.warn('registry', `module "${reg.moduleName}" has no apiBasePath, skipping`);
131132
continue;
132133
}
133134

@@ -140,9 +141,7 @@ export async function loadModulesIntoRegistry(
140141

141142
const routerFile = candidatePaths.find((p) => fs.existsSync(p));
142143
if (!routerFile) {
143-
console.warn(
144-
`[fieldstack][registry] module "${reg.moduleName}" has no backend router at ${baseDir}/index.{ts,js}`,
145-
);
144+
log.warn('registry', `module "${reg.moduleName}" has no backend router at ${baseDir}/index.{ts,js}`);
146145
continue;
147146
}
148147

@@ -153,7 +152,7 @@ export async function loadModulesIntoRegistry(
153152
const { FileMigrationRunner } = await import('@fieldstack/core');
154153
const runner = new FileMigrationRunner(services.db, reg.moduleName, migrationsDir);
155154
await runner.run();
156-
console.log(`[fieldstack][registry] migrations applied for module "${reg.moduleName}"`);
155+
log.info('registry', `migrations applied for module "${reg.moduleName}"`);
157156
}
158157

159158
const mod = (await import(routerFile)) as ModuleRouterModule;
@@ -167,9 +166,7 @@ export async function loadModulesIntoRegistry(
167166
}
168167

169168
if (!router) {
170-
console.warn(
171-
`[fieldstack][registry] module "${reg.moduleName}" has no default export or createRouter`,
172-
);
169+
log.warn('registry', `module "${reg.moduleName}" has no default export or createRouter`);
173170
continue;
174171
}
175172

@@ -178,7 +175,7 @@ export async function loadModulesIntoRegistry(
178175

179176
registry.register({ name: reg.moduleName, basePath: reg.apiBasePath, manifest, router });
180177
} catch (err) {
181-
console.error(`[fieldstack][registry] failed to load module "${reg.moduleName}":`, err);
178+
log.error('registry', `failed to load module "${reg.moduleName}"`, err);
182179
}
183180
}
184181
}

0 commit comments

Comments
 (0)