From 27da62afc9e4e1ebdfe32c78db6eb1876a339010 Mon Sep 17 00:00:00 2001 From: Gemini Nexus Bot Date: Wed, 22 Apr 2026 00:07:30 -0300 Subject: [PATCH] feat(logging): add comprehensive API request/response logging with structured winston logs - Add dedicated API logger (api-logger.ts) with winston daily rotation - Create Fastify plugin (api-logging.plugin.ts) to intercept all API requests/responses - Log request start, body, response, errors, and summary with precise timestamps - Auto-mask sensitive data using existing sanitizeObject utility - Register API logging plugin in NestJS bootstrap (server/main.ts) - Add npm run dev script with cross-env for automatic DEBUG on Windows - Install cross-env as devDependency for cross-platform env vars --- package-lock.json | 37 ++++-- package.json | 2 + src/server/logging/api-logger.ts | 94 +++++++++++++++ src/server/logging/api-logging.plugin.ts | 141 +++++++++++++++++++++++ src/server/main.ts | 5 + 5 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 src/server/logging/api-logger.ts create mode 100644 src/server/logging/api-logging.plugin.ts diff --git a/package-lock.json b/package-lock.json index bc034a2..3924e7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,6 +112,7 @@ "babel-plugin-react-compiler": "1.0.0", "code-inspector-plugin": "^1.3.3", "conventional-changelog-conventionalcommits": "^9.1.0", + "cross-env": "^10.1.0", "electron": "^37.3.1", "electron-devtools-installer": "^4.0.0", "electron-packager-languages": "^0.6.0", @@ -1997,6 +1998,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -14272,6 +14280,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -20668,7 +20694,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20689,7 +20714,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20710,7 +20734,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20731,7 +20754,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20752,7 +20774,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20773,7 +20794,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20794,7 +20814,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20815,7 +20834,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20836,7 +20854,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20857,7 +20874,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20878,7 +20894,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 3337bb3..35b7134 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "private": true, "scripts": { "start": "electron-forge start", + "dev": "cross-env DEBUG=\"electron-forge:*\" electron-forge start", "package": "electron-forge package", "make": "electron-forge make", "publish": "electron-forge publish", @@ -62,6 +63,7 @@ "babel-plugin-react-compiler": "1.0.0", "code-inspector-plugin": "^1.3.3", "conventional-changelog-conventionalcommits": "^9.1.0", + "cross-env": "^10.1.0", "electron": "^37.3.1", "electron-devtools-installer": "^4.0.0", "electron-packager-languages": "^0.6.0", diff --git a/src/server/logging/api-logger.ts b/src/server/logging/api-logger.ts new file mode 100644 index 0000000..82bd82b --- /dev/null +++ b/src/server/logging/api-logger.ts @@ -0,0 +1,94 @@ +import fs from 'fs'; +import path from 'path'; +import winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; +import { getAgentDir } from '../../utils/paths'; +import { sanitizeObject } from '../../utils/sensitiveDataMasking'; + +const LOG_RETENTION = '30d'; +const LOG_MAX_SIZE = '50m'; + +const agentDir = getAgentDir(); + +if (!fs.existsSync(agentDir)) { + try { + fs.mkdirSync(agentDir, { recursive: true }); + } catch (e) { + console.error('Failed to create agent directory for API logs', e); + } +} + +const fileFormat = winston.format.combine( + winston.format.timestamp(), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + const logEntry = { + timestamp, + level, + message, + ...meta, + }; + return JSON.stringify(logEntry); + }), +); + +const consoleFormat = winston.format.combine( + winston.format.colorize({ all: true }), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + const logEntry = { + timestamp, + level, + message, + ...meta, + }; + return JSON.stringify(logEntry); + }), +); + +const rotateTransport = new DailyRotateFile({ + filename: path.join(agentDir, 'api-%DATE%.log'), + datePattern: 'YYYY-MM-DD-HH', + maxSize: LOG_MAX_SIZE, + maxFiles: LOG_RETENTION, + zippedArchive: false, + auditFile: path.join(agentDir, '.api-log-audit.json'), + level: 'debug', + format: fileFormat, +}); + +rotateTransport.on('error', (error) => { + console.error('DailyRotateFile API transport error', error); +}); + +const consoleTransport = new winston.transports.Console({ + level: 'debug', + format: consoleFormat, +}); + +consoleTransport.on('error', (error) => { + console.error('Console API transport error', error); +}); + +export const apiLogger = winston.createLogger({ + level: 'debug', + transports: [consoleTransport, rotateTransport], + exitOnError: false, +}); + +/** + * Safely serialize a value for API logging. + * Truncates very large bodies to avoid excessive log sizes. + */ +export function safeApiLogValue(value: unknown, maxLength = 50_000): unknown { + const sanitized = sanitizeObject(value); + + if (typeof sanitized === 'string' && sanitized.length > maxLength) { + return sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]`; + } + + const stringified = JSON.stringify(sanitized); + if (stringified.length > maxLength) { + return stringified.substring(0, maxLength) + `... [truncated ${stringified.length - maxLength} chars]`; + } + + return sanitized; +} diff --git a/src/server/logging/api-logging.plugin.ts b/src/server/logging/api-logging.plugin.ts new file mode 100644 index 0000000..fb1c7a6 --- /dev/null +++ b/src/server/logging/api-logging.plugin.ts @@ -0,0 +1,141 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { apiLogger, safeApiLogValue } from './api-logger'; + +declare module 'fastify' { + interface FastifyRequest { + apiLogContext?: { + startTime: number; + requestId: string; + }; + } +} + +const MAX_BODY_LOG_LENGTH = 100_000; + +function truncateBody(body: unknown): unknown { + return safeApiLogValue(body, MAX_BODY_LOG_LENGTH); +} + +function isStreamResponse(reply: FastifyReply): boolean { + const contentType = reply.getHeader('content-type'); + if (typeof contentType === 'string') { + return ( + contentType.includes('text/event-stream') || contentType.includes('application/octet-stream') + ); + } + return false; +} + +export async function registerApiLogging(fastify: FastifyInstance): Promise { + fastify.addHook('onRequest', async (request: FastifyRequest) => { + request.apiLogContext = { + startTime: Date.now(), + requestId: request.id, + }; + + apiLogger.debug({ + type: 'request-start', + requestId: request.id, + method: request.method, + url: request.url, + query: safeApiLogValue(request.query), + headers: safeApiLogValue(request.headers), + ip: request.ip, + remoteAddress: request.socket?.remoteAddress, + timestamp: new Date().toISOString(), + }); + }); + + fastify.addHook('preHandler', async (request: FastifyRequest) => { + if (request.body) { + apiLogger.debug({ + type: 'request-body', + requestId: request.id, + method: request.method, + url: request.url, + body: truncateBody(request.body), + timestamp: new Date().toISOString(), + }); + } + }); + + fastify.addHook( + 'onSend', + async (request: FastifyRequest, reply: FastifyReply, payload: unknown) => { + const duration = Date.now() - (request.apiLogContext?.startTime || Date.now()); + + if (isStreamResponse(reply)) { + apiLogger.debug({ + type: 'response-stream', + requestId: request.id, + method: request.method, + url: request.url, + statusCode: reply.statusCode, + headers: safeApiLogValue(reply.getHeaders()), + durationMs: duration, + note: 'Streaming response body not logged', + timestamp: new Date().toISOString(), + }); + return payload; + } + + let parsedPayload: unknown = payload; + if (typeof payload === 'string') { + try { + parsedPayload = JSON.parse(payload); + } catch { + parsedPayload = payload; + } + } + + apiLogger.debug({ + type: 'response', + requestId: request.id, + method: request.method, + url: request.url, + statusCode: reply.statusCode, + headers: safeApiLogValue(reply.getHeaders()), + body: truncateBody(parsedPayload), + durationMs: duration, + timestamp: new Date().toISOString(), + }); + + return payload; + }, + ); + + fastify.addHook('onResponse', async (request: FastifyRequest, reply: FastifyReply) => { + const duration = Date.now() - (request.apiLogContext?.startTime || Date.now()); + + apiLogger.info({ + type: 'request-summary', + requestId: request.id, + method: request.method, + url: request.url, + statusCode: reply.statusCode, + durationMs: duration, + ip: request.ip, + timestamp: new Date().toISOString(), + }); + }); + + fastify.addHook('onError', async (request: FastifyRequest, reply: FastifyReply, error: Error) => { + const duration = Date.now() - (request.apiLogContext?.startTime || Date.now()); + + apiLogger.error({ + type: 'error', + requestId: request.id, + method: request.method, + url: request.url, + statusCode: reply.statusCode, + error: { + name: error.name, + message: error.message, + stack: error.stack, + code: (error as any).code, + }, + durationMs: duration, + timestamp: new Date().toISOString(), + }); + }); +} diff --git a/src/server/main.ts b/src/server/main.ts index 3a0d158..5b4df4e 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -7,6 +7,7 @@ import { TokenManagerService } from './modules/proxy/token-manager.service'; import { ProxyConfig } from '../types/config'; import { setServerConfig } from './server-config'; +import { registerApiLogging } from './logging/api-logging.plugin'; let app: NestFastifyApplication | null = null; let currentPort: number = 0; @@ -28,6 +29,10 @@ export async function bootstrapNestServer(config: ProxyConfig): Promise // Enable CORS app.enableCors(); + // Register detailed API request/response logging + const fastifyInstance = app.getHttpAdapter().getInstance(); + await registerApiLogging(fastifyInstance); + await app.listen(port, '0.0.0.0'); currentPort = port; logger.info(`NestJS Proxy Server running on http://localhost:${port}`);