181 lines
5.2 KiB
TypeScript
181 lines
5.2 KiB
TypeScript
|
|
import { Params } from 'nestjs-pino';
|
||
|
|
import { ConfigService } from '@nestjs/config';
|
||
|
|
import { join } from 'path';
|
||
|
|
import { mkdir } from 'fs/promises';
|
||
|
|
|
||
|
|
export class LoggingConfig {
|
||
|
|
static async createPinoConfig(configService: ConfigService): Promise<Params> {
|
||
|
|
const nodeEnv = configService.get<string>('NODE_ENV', 'development');
|
||
|
|
const logLevel = configService.get<string>('LOG_LEVEL', 'info');
|
||
|
|
const appName = configService.get<string>('APP_NAME', 'customer-portal-bff');
|
||
|
|
|
||
|
|
// Ensure logs directory exists for production
|
||
|
|
if (nodeEnv === 'production') {
|
||
|
|
try {
|
||
|
|
await mkdir('logs', { recursive: true });
|
||
|
|
} catch (error) {
|
||
|
|
// Directory might already exist
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Base Pino configuration
|
||
|
|
const pinoConfig: any = {
|
||
|
|
level: logLevel,
|
||
|
|
name: appName,
|
||
|
|
base: {
|
||
|
|
service: appName,
|
||
|
|
environment: nodeEnv,
|
||
|
|
pid: process.pid,
|
||
|
|
},
|
||
|
|
timestamp: true,
|
||
|
|
formatters: {
|
||
|
|
level: (label: string) => ({ level: label }),
|
||
|
|
bindings: () => ({}), // Remove default hostname/pid from every log
|
||
|
|
},
|
||
|
|
serializers: {
|
||
|
|
// Custom serializers for sensitive data
|
||
|
|
req: (req: any) => ({
|
||
|
|
method: req.method,
|
||
|
|
url: req.url,
|
||
|
|
headers: LoggingConfig.sanitizeHeaders(req.headers),
|
||
|
|
remoteAddress: req.remoteAddress,
|
||
|
|
remotePort: req.remotePort,
|
||
|
|
}),
|
||
|
|
res: (res: any) => ({
|
||
|
|
statusCode: res.statusCode,
|
||
|
|
headers: LoggingConfig.sanitizeHeaders(res.getHeaders?.() || {}),
|
||
|
|
}),
|
||
|
|
err: (err: any) => ({
|
||
|
|
type: err.constructor.name,
|
||
|
|
message: err.message,
|
||
|
|
stack: err.stack,
|
||
|
|
...(err.code && { code: err.code }),
|
||
|
|
...(err.status && { status: err.status }),
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Development: Pretty printing
|
||
|
|
if (nodeEnv === 'development') {
|
||
|
|
pinoConfig.transport = {
|
||
|
|
target: 'pino-pretty',
|
||
|
|
options: {
|
||
|
|
colorize: true,
|
||
|
|
translateTime: 'yyyy-mm-dd HH:MM:ss',
|
||
|
|
ignore: 'pid,hostname',
|
||
|
|
singleLine: false,
|
||
|
|
hideObject: false,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Production: File logging with rotation
|
||
|
|
if (nodeEnv === 'production') {
|
||
|
|
pinoConfig.transport = {
|
||
|
|
targets: [
|
||
|
|
// Console output for container logs
|
||
|
|
{
|
||
|
|
target: 'pino/file',
|
||
|
|
level: logLevel,
|
||
|
|
options: { destination: 1 }, // stdout
|
||
|
|
},
|
||
|
|
// Combined log file
|
||
|
|
{
|
||
|
|
target: 'pino/file',
|
||
|
|
level: 'info',
|
||
|
|
options: {
|
||
|
|
destination: join('logs', `${appName}-combined.log`),
|
||
|
|
mkdir: true,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
// Error log file
|
||
|
|
{
|
||
|
|
target: 'pino/file',
|
||
|
|
level: 'error',
|
||
|
|
options: {
|
||
|
|
destination: join('logs', `${appName}-error.log`),
|
||
|
|
mkdir: true,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
pinoHttp: {
|
||
|
|
...pinoConfig,
|
||
|
|
// Auto-generate correlation IDs
|
||
|
|
genReqId: (req: any, res: any) => {
|
||
|
|
const existingId = req.headers['x-correlation-id'];
|
||
|
|
if (existingId) return existingId;
|
||
|
|
|
||
|
|
const correlationId = LoggingConfig.generateCorrelationId();
|
||
|
|
res.setHeader('x-correlation-id', correlationId);
|
||
|
|
return correlationId;
|
||
|
|
},
|
||
|
|
// Custom log messages
|
||
|
|
customLogLevel: (req: any, res: any, err: any) => {
|
||
|
|
if (res.statusCode >= 400 && res.statusCode < 500) return 'warn';
|
||
|
|
if (res.statusCode >= 500 || err) return 'error';
|
||
|
|
if (res.statusCode >= 300 && res.statusCode < 400) return 'debug';
|
||
|
|
return 'info';
|
||
|
|
},
|
||
|
|
customSuccessMessage: (req: any, res: any) => {
|
||
|
|
return `${req.method} ${req.url} ${res.statusCode}`;
|
||
|
|
},
|
||
|
|
customErrorMessage: (req: any, res: any, err: any) => {
|
||
|
|
return `${req.method} ${req.url} ${res.statusCode} - ${err.message}`;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sanitize headers to remove sensitive information
|
||
|
|
*/
|
||
|
|
private static sanitizeHeaders(headers: any): any {
|
||
|
|
if (!headers || typeof headers !== 'object') {
|
||
|
|
return headers;
|
||
|
|
}
|
||
|
|
|
||
|
|
const sensitiveKeys = [
|
||
|
|
'authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token',
|
||
|
|
'password', 'secret', 'token', 'jwt', 'bearer'
|
||
|
|
];
|
||
|
|
|
||
|
|
const sanitized = { ...headers };
|
||
|
|
|
||
|
|
for (const key in sanitized) {
|
||
|
|
if (sensitiveKeys.some(sensitive =>
|
||
|
|
key.toLowerCase().includes(sensitive.toLowerCase())
|
||
|
|
)) {
|
||
|
|
sanitized[key] = '[REDACTED]';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return sanitized;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate correlation ID
|
||
|
|
*/
|
||
|
|
private static generateCorrelationId(): string {
|
||
|
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get log levels for different environments
|
||
|
|
*/
|
||
|
|
static getLogLevels(level: string): string[] {
|
||
|
|
const logLevels: Record<string, string[]> = {
|
||
|
|
error: ['error'],
|
||
|
|
warn: ['error', 'warn'],
|
||
|
|
info: ['error', 'warn', 'info'],
|
||
|
|
debug: ['error', 'warn', 'info', 'debug'],
|
||
|
|
verbose: ['error', 'warn', 'info', 'debug', 'verbose'],
|
||
|
|
};
|
||
|
|
|
||
|
|
return logLevels[level] || logLevels.info;
|
||
|
|
}
|
||
|
|
}
|