2025-08-22 17:02:49 +09:00
|
|
|
import type { Params } from "nestjs-pino";
|
|
|
|
|
import type { Options as PinoHttpOptions } from "pino-http";
|
|
|
|
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
|
|
|
import type { ConfigService } from "@nestjs/config";
|
|
|
|
|
import { join } from "path";
|
|
|
|
|
import { mkdir } from "fs/promises";
|
2025-08-21 15:24:40 +09:00
|
|
|
|
|
|
|
|
export class LoggingConfig {
|
|
|
|
|
static async createPinoConfig(configService: ConfigService): Promise<Params> {
|
2025-08-22 17:02:49 +09:00
|
|
|
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");
|
|
|
|
|
|
2025-08-21 15:24:40 +09:00
|
|
|
// Ensure logs directory exists for production
|
2025-08-22 17:02:49 +09:00
|
|
|
if (nodeEnv === "production") {
|
2025-08-21 15:24:40 +09:00
|
|
|
try {
|
2025-08-22 17:02:49 +09:00
|
|
|
await mkdir("logs", { recursive: true });
|
|
|
|
|
} catch {
|
2025-08-21 15:24:40 +09:00
|
|
|
// Directory might already exist
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Base Pino configuration
|
2025-08-22 17:02:49 +09:00
|
|
|
const pinoConfig: PinoHttpOptions = {
|
2025-08-21 15:24:40 +09:00
|
|
|
level: logLevel,
|
|
|
|
|
name: appName,
|
|
|
|
|
base: {
|
|
|
|
|
service: appName,
|
|
|
|
|
environment: nodeEnv,
|
|
|
|
|
pid: process.pid,
|
|
|
|
|
},
|
|
|
|
|
timestamp: true,
|
2025-08-22 17:02:49 +09:00
|
|
|
// Ensure sensitive fields are redacted across all logs
|
|
|
|
|
redact: {
|
|
|
|
|
paths: [
|
|
|
|
|
// Common headers
|
|
|
|
|
"req.headers.authorization",
|
|
|
|
|
"req.headers.cookie",
|
|
|
|
|
// Auth
|
|
|
|
|
"password",
|
|
|
|
|
"password2",
|
|
|
|
|
"token",
|
|
|
|
|
"secret",
|
|
|
|
|
"jwt",
|
|
|
|
|
"apiKey",
|
|
|
|
|
// Custom params that may carry secrets
|
|
|
|
|
"params.password",
|
|
|
|
|
"params.password2",
|
|
|
|
|
"params.secret",
|
|
|
|
|
"params.token",
|
|
|
|
|
],
|
|
|
|
|
remove: true,
|
|
|
|
|
},
|
2025-08-21 15:24:40 +09:00
|
|
|
formatters: {
|
|
|
|
|
level: (label: string) => ({ level: label }),
|
|
|
|
|
bindings: () => ({}), // Remove default hostname/pid from every log
|
|
|
|
|
},
|
|
|
|
|
serializers: {
|
2025-08-22 17:02:49 +09:00
|
|
|
// Keep logs concise: omit headers by default
|
2025-08-23 17:24:37 +09:00
|
|
|
req: (req: {
|
|
|
|
|
method?: string;
|
|
|
|
|
url?: string;
|
|
|
|
|
remoteAddress?: string;
|
|
|
|
|
remotePort?: number;
|
|
|
|
|
}) => ({
|
2025-08-21 15:24:40 +09:00
|
|
|
method: req.method,
|
|
|
|
|
url: req.url,
|
|
|
|
|
remoteAddress: req.remoteAddress,
|
|
|
|
|
remotePort: req.remotePort,
|
|
|
|
|
}),
|
2025-08-22 17:02:49 +09:00
|
|
|
res: (res: { statusCode: number }) => ({
|
2025-08-21 15:24:40 +09:00
|
|
|
statusCode: res.statusCode,
|
|
|
|
|
}),
|
2025-08-23 17:24:37 +09:00
|
|
|
err: (err: {
|
|
|
|
|
constructor: { name: string };
|
|
|
|
|
message: string;
|
|
|
|
|
stack?: string;
|
|
|
|
|
code?: string;
|
|
|
|
|
status?: number;
|
|
|
|
|
}) => ({
|
2025-08-21 15:24:40 +09:00
|
|
|
type: err.constructor.name,
|
|
|
|
|
message: err.message,
|
|
|
|
|
stack: err.stack,
|
|
|
|
|
...(err.code && { code: err.code }),
|
|
|
|
|
...(err.status && { status: err.status }),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Development: Pretty printing
|
2025-08-22 17:02:49 +09:00
|
|
|
if (nodeEnv === "development") {
|
2025-08-21 15:24:40 +09:00
|
|
|
pinoConfig.transport = {
|
2025-08-22 17:02:49 +09:00
|
|
|
target: "pino-pretty",
|
2025-08-21 15:24:40 +09:00
|
|
|
options: {
|
|
|
|
|
colorize: true,
|
2025-08-22 17:02:49 +09:00
|
|
|
translateTime: "yyyy-mm-dd HH:MM:ss",
|
|
|
|
|
ignore: "pid,hostname",
|
2025-08-21 15:24:40 +09:00
|
|
|
singleLine: false,
|
|
|
|
|
hideObject: false,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Production: File logging with rotation
|
2025-08-22 17:02:49 +09:00
|
|
|
if (nodeEnv === "production") {
|
2025-08-21 15:24:40 +09:00
|
|
|
pinoConfig.transport = {
|
|
|
|
|
targets: [
|
|
|
|
|
// Console output for container logs
|
|
|
|
|
{
|
2025-08-22 17:02:49 +09:00
|
|
|
target: "pino/file",
|
2025-08-21 15:24:40 +09:00
|
|
|
level: logLevel,
|
|
|
|
|
options: { destination: 1 }, // stdout
|
|
|
|
|
},
|
|
|
|
|
// Combined log file
|
|
|
|
|
{
|
2025-08-22 17:02:49 +09:00
|
|
|
target: "pino/file",
|
|
|
|
|
level: "info",
|
2025-08-21 15:24:40 +09:00
|
|
|
options: {
|
2025-08-22 17:02:49 +09:00
|
|
|
destination: join("logs", `${appName}-combined.log`),
|
2025-08-21 15:24:40 +09:00
|
|
|
mkdir: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
// Error log file
|
|
|
|
|
{
|
2025-08-22 17:02:49 +09:00
|
|
|
target: "pino/file",
|
|
|
|
|
level: "error",
|
2025-08-21 15:24:40 +09:00
|
|
|
options: {
|
2025-08-22 17:02:49 +09:00
|
|
|
destination: join("logs", `${appName}-error.log`),
|
2025-08-21 15:24:40 +09:00
|
|
|
mkdir: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
pinoHttp: {
|
|
|
|
|
...pinoConfig,
|
|
|
|
|
// Auto-generate correlation IDs
|
2025-08-22 17:02:49 +09:00
|
|
|
genReqId: (req: IncomingMessage, res: ServerResponse) => {
|
|
|
|
|
const existingIdHeader = req.headers["x-correlation-id"];
|
2025-08-23 17:24:37 +09:00
|
|
|
const existingId = Array.isArray(existingIdHeader)
|
|
|
|
|
? existingIdHeader[0]
|
|
|
|
|
: existingIdHeader;
|
2025-08-21 15:24:40 +09:00
|
|
|
if (existingId) return existingId;
|
2025-08-22 17:02:49 +09:00
|
|
|
|
2025-08-21 15:24:40 +09:00
|
|
|
const correlationId = LoggingConfig.generateCorrelationId();
|
2025-08-22 17:02:49 +09:00
|
|
|
res.setHeader("x-correlation-id", correlationId);
|
2025-08-21 15:24:40 +09:00
|
|
|
return correlationId;
|
|
|
|
|
},
|
2025-08-22 17:02:49 +09:00
|
|
|
// Custom log levels: only warn on 4xx and error on 5xx
|
|
|
|
|
customLogLevel: (_req: IncomingMessage, res: ServerResponse, err?: unknown) => {
|
|
|
|
|
if (res.statusCode >= 400 && res.statusCode < 500) return "warn";
|
|
|
|
|
if (res.statusCode >= 500 || err) return "error";
|
|
|
|
|
return "silent" as any;
|
2025-08-21 15:24:40 +09:00
|
|
|
},
|
2025-08-22 17:02:49 +09:00
|
|
|
// Suppress success messages entirely
|
|
|
|
|
customSuccessMessage: () => "",
|
2025-08-23 17:24:37 +09:00
|
|
|
customErrorMessage: (
|
|
|
|
|
req: IncomingMessage,
|
|
|
|
|
res: ServerResponse,
|
|
|
|
|
err: { message?: string }
|
|
|
|
|
) => {
|
2025-08-22 17:02:49 +09:00
|
|
|
const method = req.method ?? "";
|
|
|
|
|
const url = req.url ?? "";
|
|
|
|
|
return `${method} ${url} ${res.statusCode} - ${err.message ?? "error"}`;
|
2025-08-21 15:24:40 +09:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sanitize headers to remove sensitive information
|
|
|
|
|
*/
|
|
|
|
|
private static sanitizeHeaders(headers: any): any {
|
2025-08-22 17:02:49 +09:00
|
|
|
if (!headers || typeof headers !== "object") {
|
2025-08-21 15:24:40 +09:00
|
|
|
return headers;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sensitiveKeys = [
|
2025-08-22 17:02:49 +09:00
|
|
|
"authorization",
|
|
|
|
|
"cookie",
|
|
|
|
|
"set-cookie",
|
|
|
|
|
"x-api-key",
|
|
|
|
|
"x-auth-token",
|
|
|
|
|
"password",
|
|
|
|
|
"secret",
|
|
|
|
|
"token",
|
|
|
|
|
"jwt",
|
|
|
|
|
"bearer",
|
2025-08-21 15:24:40 +09:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const sanitized = { ...headers };
|
|
|
|
|
|
|
|
|
|
for (const key in sanitized) {
|
2025-08-22 17:02:49 +09:00
|
|
|
if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive.toLowerCase()))) {
|
|
|
|
|
sanitized[key] = "[REDACTED]";
|
2025-08-21 15:24:40 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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[]> = {
|
2025-08-22 17:02:49 +09:00
|
|
|
error: ["error"],
|
|
|
|
|
warn: ["error", "warn"],
|
|
|
|
|
info: ["error", "warn", "info"],
|
|
|
|
|
debug: ["error", "warn", "info", "debug"],
|
|
|
|
|
verbose: ["error", "warn", "info", "debug", "verbose"],
|
2025-08-21 15:24:40 +09:00
|
|
|
};
|
2025-08-22 17:02:49 +09:00
|
|
|
|
2025-08-21 15:24:40 +09:00
|
|
|
return logLevels[level] || logLevels.info;
|
|
|
|
|
}
|
|
|
|
|
}
|