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
This commit is contained in:
1 parent
faabf7ba7a
commit
27da62afc9
5 files changed
+268
-11
No files matched your search
Generated
+26
-11
@@ -112,6 +112,7 @@
|
|||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"code-inspector-plugin": "^1.3.3",
|
"code-inspector-plugin": "^1.3.3",
|
||||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^37.3.1",
|
"electron": "^37.3.1",
|
||||||
"electron-devtools-installer": "^4.0.0",
|
"electron-devtools-installer": "^4.0.0",
|
||||||
"electron-packager-languages": "^0.6.0",
|
"electron-packager-languages": "^0.6.0",
|
||||||
@@ -1997,6 +1998,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -14272,6 +14280,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -20668,7 +20694,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20689,7 +20714,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20710,7 +20734,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20731,7 +20754,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20752,7 +20774,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20773,7 +20794,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20794,7 +20814,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20815,7 +20834,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20836,7 +20854,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20857,7 +20874,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20878,7 +20894,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"start": "electron-forge start",
|
||||||
|
"dev": "cross-env DEBUG=\"electron-forge:*\" electron-forge start",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"code-inspector-plugin": "^1.3.3",
|
"code-inspector-plugin": "^1.3.3",
|
||||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^37.3.1",
|
"electron": "^37.3.1",
|
||||||
"electron-devtools-installer": "^4.0.0",
|
"electron-devtools-installer": "^4.0.0",
|
||||||
"electron-packager-languages": "^0.6.0",
|
"electron-packager-languages": "^0.6.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { TokenManagerService } from './modules/proxy/token-manager.service';
|
|||||||
|
|
||||||
import { ProxyConfig } from '../types/config';
|
import { ProxyConfig } from '../types/config';
|
||||||
import { setServerConfig } from './server-config';
|
import { setServerConfig } from './server-config';
|
||||||
|
import { registerApiLogging } from './logging/api-logging.plugin';
|
||||||
|
|
||||||
let app: NestFastifyApplication | null = null;
|
let app: NestFastifyApplication | null = null;
|
||||||
let currentPort: number = 0;
|
let currentPort: number = 0;
|
||||||
@@ -28,6 +29,10 @@ export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean>
|
|||||||
// Enable CORS
|
// Enable CORS
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
|
||||||
|
// Register detailed API request/response logging
|
||||||
|
const fastifyInstance = app.getHttpAdapter().getInstance();
|
||||||
|
await registerApiLogging(fastifyInstance);
|
||||||
|
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
currentPort = port;
|
currentPort = port;
|
||||||
logger.info(`NestJS Proxy Server running on http://localhost:${port}`);
|
logger.info(`NestJS Proxy Server running on http://localhost:${port}`);
|
||||||
|
|||||||
Reference in new issue
Block a user