From 5211b0d7ef091db2fb49a1c1c5d2c8866024b891 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Tue, 28 Jul 2026 15:35:21 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/index.ts | 17 +++- packages/backend/src/logger.ts | 92 +++++++++++++++++++ packages/backend/src/routes/documents.ts | 12 ++- packages/backend/src/services/onlyOffice.ts | 12 ++- packages/backend/src/services/sessionStore.ts | 15 ++- 5 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/logger.ts diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 192eab0..c4a9020 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser'; import path from 'node:path'; import fs from 'node:fs'; import { config } from './config.js'; +import { logger, requestLogger } from './logger.js'; import { sessionsRouter } from './routes/sessions.js'; import { documentsRouter } from './routes/documents.js'; import { @@ -18,6 +19,7 @@ async function main() { const app = express(); + app.use(requestLogger); app.use( cors({ origin: true, @@ -61,16 +63,23 @@ async function main() { }); } + app.use((err: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => { + logger.error('Unhandled request error', { err, path: req.path }); + if (!res.headersSent) { + res.status(500).json({ error: 'internal_server_error' }); + } + }); + startSweeper(); app.listen(config.port, () => { - console.log(`[sejong-office] listening on :${config.port}`); - console.log(`[sejong-office] tempDir=${config.tempDir}`); - console.log(`[sejong-office] publicUrl=${config.publicUrl}`); + logger.info(`Backend listening on :${config.port}`); + logger.info(`Temp directory: ${config.tempDir}`); + logger.info(`Public URL: ${config.publicUrl}`); }); } main().catch((err) => { - console.error(err); + logger.error('Backend failed to start', { err }); process.exit(1); }); diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts new file mode 100644 index 0000000..d90cdd0 --- /dev/null +++ b/packages/backend/src/logger.ts @@ -0,0 +1,92 @@ +import type { RequestHandler } from 'express'; + +type LogLevel = 'INFO' | 'WARN' | 'ERROR'; +type LogDetails = Record; + +function formatDetails(details?: LogDetails): string { + if (!details) return ''; + const normalized = Object.fromEntries( + Object.entries(details).map(([key, value]) => [ + key, + value instanceof Error ? value.stack ?? value.message : value, + ]), + ); + return ` ${JSON.stringify(normalized)}`; +} + +function write(level: LogLevel, message: string, details?: LogDetails) { + const line = `[${new Date().toISOString()}] [${level}] ${message}${formatDetails(details)}`; + if (level === 'ERROR') { + console.error(line); + } else if (level === 'WARN') { + console.warn(line); + } else { + console.log(line); + } +} + +export const logger = { + info: (message: string, details?: LogDetails) => + write('INFO', message, details), + warn: (message: string, details?: LogDetails) => + write('WARN', message, details), + error: (message: string, details?: LogDetails) => + write('ERROR', message, details), +}; + +function browserSummary(userAgent = ''): string { + const browsers: [RegExp, string][] = [ + [/Edg\/([\d.]+)/, 'Edge'], + [/OPR\/([\d.]+)/, 'Opera'], + [/Chrome\/([\d.]+)/, 'Chrome'], + [/Firefox\/([\d.]+)/, 'Firefox'], + [/Version\/([\d.]+).*Safari\//, 'Safari'], + [/curl\/([\d.]+)/, 'curl'], + ]; + const browser = browsers + .map(([pattern, name]) => { + const match = userAgent.match(pattern); + return match ? `${name} ${match[1]}` : null; + }) + .find(Boolean) ?? 'Unknown'; + + const os = /Windows NT/.test(userAgent) + ? 'Windows' + : /Android/.test(userAgent) + ? 'Android' + : /iPhone|iPad/.test(userAgent) + ? 'iOS' + : /Mac OS X/.test(userAgent) + ? 'macOS' + : /Linux/.test(userAgent) + ? 'Linux' + : 'Unknown'; + + return `${browser} / ${os}`; +} + +function requestIp(forwardedFor: string | string[] | undefined, remote?: string) { + const forwarded = Array.isArray(forwardedFor) + ? forwardedFor[0] + : forwardedFor?.split(',')[0]?.trim(); + return (forwarded || remote || '-').replace(/^::ffff:/, ''); +} + +export const requestLogger: RequestHandler = (req, res, next) => { + const startedAt = performance.now(); + res.on('finish', () => { + const durationMs = Math.round(performance.now() - startedAt); + const ip = requestIp( + req.headers['x-forwarded-for'], + req.socket.remoteAddress, + ); + const browser = browserSummary(req.headers['user-agent']); + const message = + `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs}ms` + + ` ip=${ip} browser="${browser}"`; + if (res.statusCode >= 500) logger.error(message); + else if (res.statusCode >= 400) logger.warn(message); + else logger.info(message); + }); + next(); +}; diff --git a/packages/backend/src/routes/documents.ts b/packages/backend/src/routes/documents.ts index 1a20933..31d3929 100644 --- a/packages/backend/src/routes/documents.ts +++ b/packages/backend/src/routes/documents.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import multer from 'multer'; import fs from 'node:fs/promises'; import { config, OFFICE_EXTENSIONS } from '../config.js'; +import { logger } from '../logger.js'; import { requireSession } from '../middleware/session.js'; import { addDocument, @@ -50,7 +51,7 @@ documentsRouter.post('/new', requireSession, async (req, res) => { token: doc.token, }); } catch (err) { - console.error(err); + logger.error('Failed to create blank document', { err }); res.status(500).json({ error: 'create_failed' }); } }); @@ -96,7 +97,7 @@ documentsRouter.post( documentType: doc.documentType, }); } catch (err) { - console.error(err); + logger.error('Failed to upload document', { err }); res.status(500).json({ error: 'upload_failed' }); } }, @@ -147,7 +148,7 @@ documentsRouter.post('/:docId/callback', async (req, res) => { verifyOnlyOfficeToken(token); } } catch (err) { - console.error('[callback] jwt invalid', err); + logger.warn('Invalid callback JWT', { err, docId: req.params.docId }); res.status(403).json({ error: 1 }); return; } @@ -188,7 +189,10 @@ documentsRouter.post('/:docId/callback', async (req, res) => { try { await persistDocumentFromUrl(doc, fileUrl, filetype); } catch (err) { - console.error('[callback] save error', err); + logger.error('Failed to save callback document', { + err, + docId: req.params.docId, + }); res.json({ error: 1 }); return; } diff --git a/packages/backend/src/services/onlyOffice.ts b/packages/backend/src/services/onlyOffice.ts index 976e166..6b8d684 100644 --- a/packages/backend/src/services/onlyOffice.ts +++ b/packages/backend/src/services/onlyOffice.ts @@ -1,4 +1,5 @@ import { config } from '../config.js'; +import { logger } from '../logger.js'; import type { SessionDoc } from '../types/session.js'; import { overwriteDocument } from './sessionStore.js'; import { signOnlyOfficePayload } from '../utils/jwt.js'; @@ -134,7 +135,9 @@ export async function convertToOriginalFormat( }); if (!res.ok) { - console.error('[convert] HTTP', res.status); + logger.error('OnlyOffice conversion HTTP error', { + statusCode: res.status, + }); return null; } @@ -145,7 +148,10 @@ export async function convertToOriginalFormat( }; if (data.error || !data.fileUrl) { - console.error('[convert] failed', data); + logger.error('OnlyOffice conversion failed', { + errorCode: data.error, + hasFileUrl: Boolean(data.fileUrl), + }); return null; } @@ -153,7 +159,7 @@ export async function convertToOriginalFormat( if (!fileRes.ok) return null; return Buffer.from(await fileRes.arrayBuffer()); } catch (err) { - console.error('[convert] error', err); + logger.error('OnlyOffice conversion error', { err }); return null; } } diff --git a/packages/backend/src/services/sessionStore.ts b/packages/backend/src/services/sessionStore.ts index 8ceeed9..801c729 100644 --- a/packages/backend/src/services/sessionStore.ts +++ b/packages/backend/src/services/sessionStore.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { randomBytes } from 'node:crypto'; import { v4 as uuidv4 } from 'uuid'; import { config } from '../config.js'; +import { logger } from '../logger.js'; import type { Session, SessionDoc } from '../types/session.js'; import { documentTypeFor, mimeFor } from '../utils/formats.js'; @@ -151,10 +152,14 @@ export async function sweepExpired(): Promise { export function startSweeper(intervalMs = 60_000): NodeJS.Timeout { return setInterval(() => { - void sweepExpired().then((n) => { - if (n > 0) { - console.log(`[session] swept ${n} expired session(s)`); - } - }); + void sweepExpired() + .then((n) => { + if (n > 0) { + logger.info(`Swept ${n} expired session(s)`); + } + }) + .catch((err) => { + logger.error('Failed to sweep expired sessions', { err }); + }); }, intervalMs); }