diff --git a/.env.example b/.env.example index f98700d..44b4e59 100644 --- a/.env.example +++ b/.env.example @@ -49,4 +49,4 @@ S3_ACCESS_KEY='' S3_SECRET_KEY='' IMAGOR_SECRET='' -IMAGOR_URL='' \ No newline at end of file +IMAGOR_URL='' diff --git a/Dockerfile.prod b/Dockerfile.prod index e645b06..e6a6c37 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,4 +1,4 @@ -FROM node:20-alpine AS base +FROM node:22-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" @@ -9,7 +9,7 @@ WORKDIR /app FROM base AS fetch -COPY pnpm-lock.yaml ./ +COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./ # Загружаем всё в виртуальное хранилище. # Если lock-файл не менялся, этот слой будет взят из кэша @@ -21,7 +21,7 @@ FROM fetch AS build COPY package.json ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --frozen-lockfile --offline + pnpm install -w --frozen-lockfile --offline COPY . . @@ -30,7 +30,7 @@ RUN pnpm run build RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm prune --prod --ignore-scripts -FROM node:20-alpine AS runner +FROM node:22-alpine AS runner WORKDIR /app diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 3a88ff9..50a3818 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -1,9 +1,8 @@ import { Logger, VersioningType } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; -import { setupThrottler } from './setups/throttler'; import { DEFAULT_THROTTLER_OPTIONS } from './configs/throttler'; -import { setupCors, setupSwagger } from './setups'; +import { setupCors, setupLogger, setupThrottler, setupSwagger } from './setups'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import type { BootstrapOptions } from './interfaces/options.interface'; import fastifyCookie from '@fastify/cookie'; @@ -16,6 +15,7 @@ export async function bootstrapApp(options: BootstrapOptions) { const startTime = performance.now(); const adapter = new FastifyAdapter({ requestIdHeader: 'x-request-id', + requestIdLogLabel: 'request', genReqId: (req) => { return (req.headers['x-request-id'] as string) || createId(); }, @@ -43,7 +43,9 @@ export async function bootstrapApp(options: BootstrapOptions) { const app = await NestFactory.create(rootModule, adapter, { rawBody: true, + bufferLogs: true, }); + const logger = new Logger(serviceName[0].toUpperCase() + serviceName.slice(1)); const configService = app.get(ConfigService); const port = configService.getOrThrow(portEnvKey, defaultPort); @@ -51,6 +53,15 @@ export async function bootstrapApp(options: BootstrapOptions) { app.enableShutdownHooks(); + app.getHttpAdapter() + .getInstance() + .addHook('onSend', async (request, reply, payload) => { + reply.header('x-request-id', request.id); + return payload; + }); + + await setupLogger(app, options.serviceName); + await app.register(fastifyCompress, { global: true, threshold: 1024, diff --git a/libs/bootstrap/src/setups/index.ts b/libs/bootstrap/src/setups/index.ts index 2cfe699..e5d3f07 100644 --- a/libs/bootstrap/src/setups/index.ts +++ b/libs/bootstrap/src/setups/index.ts @@ -1,3 +1,4 @@ export { setupCors } from './cors'; export { setupThrottler } from './throttler'; export { setupSwagger } from './swagger'; +export { setupLogger } from './logger'; diff --git a/libs/bootstrap/src/setups/logger.ts b/libs/bootstrap/src/setups/logger.ts new file mode 100644 index 0000000..c0456b3 --- /dev/null +++ b/libs/bootstrap/src/setups/logger.ts @@ -0,0 +1,223 @@ +import { + Injectable, + NestInterceptor, + type ExecutionContext, + type CallHandler, + Logger, +} from '@nestjs/common'; +import { Observable, throwError } from 'rxjs'; +import { tap, catchError } from 'rxjs/operators'; +import type { FastifyRequest } from 'fastify'; +import { WinstonModule, utilities } from 'nest-winston'; +import { format, transports } from 'winston'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; +import { ConfigService } from '@nestjs/config'; + +export function setupLogger(app: NestFastifyApplication, service: string) { + const cfg = app.get(ConfigService); + const isProduction = cfg.get('NODE_ENV') === 'production'; + + const logger = WinstonModule.createLogger({ + level: isProduction ? 'info' : 'debug', + format: format.combine( + format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }), + format.errors({ stack: true }), + isProduction + ? format.json() + : format.combine(format.ms(), utilities.format.nestLike(service, { colors: true })), + ), + transports: [new transports.Console()], + }); + + app.useLogger(logger); + app.useGlobalInterceptors(new LoggingInterceptor()); +} + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + private readonly sensitiveFields = ['password', 'token', 'access', 'refresh', 'code', 'secret']; + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const startTime = Date.now(); + + const baseCtx = { + request_id: request.id || request.headers['x-request-id'] || 'unknown', + method: request.method, + url: request.url, + path: request.url.split('?')[0], + controller: context.getClass().name, + handler: context.getHandler().name, + ip: request.ip, + referer: request.headers['referer'] || 'direct', + user_agent: request.headers['user-agent'] || 'unknown', + triggered_by: 'interceptor', + }; + + this.logger.log(`Incoming ${baseCtx.method} ${baseCtx.url}`, { + ...baseCtx, + type: 'request', + body: this.sanitize(request.body), + query: request.query, + }); + + return next.handle().pipe( + tap(() => { + const delay_num = Date.now() - startTime; + + this.logger.log(`${baseCtx.method} ${baseCtx.path} | 200 | ${delay_num}ms`, { + ...baseCtx, + type: 'response', + status_code: 200, + delay_num, + }); + }), + catchError((err) => { + const delay_num = Date.now() - startTime; + const status_code = err.status || err.statusCode || 500; + + this.logger.error( + `${baseCtx.method} ${baseCtx.path} | ${status_code} | ${delay_num}ms`, + { + ...baseCtx, + type: 'error', + status_code, + delay_num, + stack: err.stack, + error_details: err.response || err.message, + }, + ); + + return throwError(() => err); + }), + ); + } + + private sanitize(data: T) { + if (!data || typeof data !== 'object') return data; + if (Array.isArray(data)) return data.map((v) => this.sanitize(v)); + + const cleanData = JSON.parse(JSON.stringify(data)); + + return Object.keys(cleanData).reduce((acc, key) => { + const isSensitive = this.sensitiveFields.some((field) => + key.toLowerCase().includes(field), + ); + + if (isSensitive) { + acc[key] = '***'; + } else if (typeof cleanData[key] === 'object') { + acc[key] = this.sanitize(cleanData[key]); + } else { + acc[key] = cleanData[key]; + } + return acc; + }, {}); + } +} + +/** + * Represents a structured application log payload for Grafana Loki. + * This object is flattened to ensure each property is indexed as a top-level label/column. + * + * @typedef {Object} TLog + */ +export type TLog = { + /** + * The severity level of the log. + * Used by Grafana to color-code rows and for alerting. + * @type {'info' | 'error' | 'warn'} + */ + level: 'info' | 'error' | 'warn'; + /** + * Human-readable summary of the event. + * @example 'Request completed POST /v1/auth/sign-in | 200 | 145ms' + * @type {string} + */ + message: string; + /** + * Event occurrence time in ISO 8601 format. + * @example '2026-05-09T01:17:29.000Z' + * @type {string} + */ + timestamp: string; + /** + * Unique identifier for the HTTP request (e.g., UUID, NanoID). + * Used to correlate all logs produced within a single request lifecycle. + * @type {string} + */ + request_id: string; + /** + * The system component that triggered the log entry. + * @type {'interceptor' | 'filter_exception' | 'guard' | 'service'} + */ + triggered_by: 'interceptor' | 'filter_exception' | 'guard' | 'service'; + /** + * The logical type of the event within the request/response flow. + * @type {'request' | 'response' | 'error' | 'system'} + */ + type: 'request' | 'response' | 'error' | 'system'; + /** + * The HTTP method used for the request. + * @type {'POST' | 'GET' | 'DELETE' | 'PATCH' | 'PUT' | 'OPTIONS' | 'HEAD'} + */ + method: 'POST' | 'GET' | 'DELETE' | 'PATCH' | 'PUT' | 'OPTIONS' | 'HEAD'; + /** + * The full URL of the request, including query parameters. + * @example '/v1/auth/sign-in?source=mobile' + * @type {string} + */ + url: string; + /** + * The sanitized API path, including versioning but excluding query parameters. + * Ideal for aggregating statistics per endpoint. + * @example '/v1/auth/sign-in' + * @type {string} + */ + path: string; + /** + * The HTTP status code returned to the client. + * @example 200 + * @type {number} + */ + status_code: number; + /** + * Request processing time in milliseconds. + * Note: Typically undefined for entries with type 'request'. + * @type {number} + */ + delay_num?: number; + /** + * The client's IP address. + * @type {string} + */ + ip: string; + /** + * The client's application or browser identification string. + * @type {string} + */ + user_agent: string; + /** + * The name of the NestJS controller handling the request. + * @example 'AuthController' + * @type {string} + */ + controller: string; + /** + * The name of the specific controller method (handler). + * @example 'signIn' + * @type {string} + */ + handler: string; + /** + * The error stack trace. Only populated when level is 'error'. + * @type {string} + */ + stack?: string; + /** + * Additional contextual data for debugging (e.g., Zod validation issues, DB error details). + * @type {any} + */ + error_details?: any; +}; diff --git a/libs/health/src/controller/health.controller.ts b/libs/health/src/controller/health.controller.ts index e29e304..0551121 100644 --- a/libs/health/src/controller/health.controller.ts +++ b/libs/health/src/controller/health.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpStatus, Inject, Logger } from '@nestjs/common'; +import { Controller, Get, HttpStatus, Inject } from '@nestjs/common'; import { SkipThrottle } from '@nestjs/throttler'; import { HealthService } from '../health.service'; import { GetHealthSwagger, GetPingSwagger } from './health.swagger'; @@ -9,8 +9,6 @@ import { BaseException } from '@shared/error'; @Controller() @ApiTags('System') export class HealthController { - private logger = new Logger(HealthController.name); - constructor( private readonly healthService: HealthService, @Inject('SERVICE_NAME') private readonly serviceName: string, @@ -22,7 +20,6 @@ export class HealthController { const pingData = await this.healthService.getHealthData(); if (pingData.status !== 'up') { - this.logger.error(`${this.serviceName} is unhealthy!`); throw new BaseException( { code: 'SERVICE_UNHEALTHY', diff --git a/package.json b/package.json index 118c4ac..c7d6c1d 100644 --- a/package.json +++ b/package.json @@ -60,12 +60,14 @@ "argon2": "^0.44.0", "axios": "^1.16.0", "bullmq": "^5.73.4", + "cls-rtracer": "^2.6.3", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", "fastify": "^5.8.4", "handlebars": "^4.7.9", "ioredis": "^5.10.1", + "nest-winston": "^1.10.2", "nestjs-zod": "^5.3.0", "nodemailer": "^8.0.5", "otplib": "^13.4.0", @@ -76,6 +78,7 @@ "rxjs": "^7.8.1", "transliteration": "^2.6.1", "ua-parser-js": "^2.0.9", + "winston": "^3.19.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce40199..94c94b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: bullmq: specifier: ^5.73.4 version: 5.73.4 + cls-rtracer: + specifier: ^2.6.3 + version: 2.6.3 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -110,6 +113,9 @@ importers: ioredis: specifier: ^5.10.1 version: 5.10.1 + nest-winston: + specifier: ^1.10.2 + version: 1.10.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(winston@3.19.0) nestjs-zod: specifier: ^5.3.0 version: 5.3.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6) @@ -140,6 +146,9 @@ importers: ua-parser-js: specifier: ^2.0.9 version: 2.0.9 + winston: + specifier: ^3.19.0 + version: 3.19.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -466,6 +475,10 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + '@commitlint/cli@20.5.0': resolution: {integrity: sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==} engines: {node: '>=v18'} @@ -547,6 +560,9 @@ packages: conventional-commits-parser: optional: true + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1902,6 +1918,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2080,6 +2099,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/ua-parser-js@0.7.39': resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} @@ -2504,6 +2526,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cls-rtracer@2.6.3: + resolution: {integrity: sha512-O7M/m2M/KfT9v+q7ka9nmsadS67ce9P8+1Zgm6VFamK56oFd1iCoJ9m8hYKUQpK4+RofyaexxHJlOBkxqCDs3Q==} + engines: {node: '>=12.17.0 <13.0.0 || >=13.14.0 <14.0.0 || >=14.0.0'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -2512,9 +2538,25 @@ packages: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -2812,6 +2854,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2999,6 +3044,9 @@ packages: picomatch: optional: true + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3029,6 +3077,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -3248,6 +3299,10 @@ packages: is-standalone-pwa@0.1.1: resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -3339,6 +3394,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3503,6 +3561,10 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + lru-cache@11.3.3: resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} engines: {node: 20 || >=22} @@ -3615,6 +3677,12 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nest-winston@1.10.2: + resolution: {integrity: sha512-Z9IzL/nekBOF/TEwBHUJDiDPMaXUcFquUQOFavIRet6xF0EbuWnOzslyN/ksgzG+fITNgXhMdrL/POp9SdaFxA==} + peerDependencies: + '@nestjs/common': ^5.0.0 || ^6.6.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + winston: ^3.0.0 + nestjs-zod@5.3.0: resolution: {integrity: sha512-QY6imXm9heMOpWigjFHgMWPvc1ZQHeNQ7pdogo9Q5xj5F8HpqZ972vKlVdkaTyzYlOXJP/yVy3wlF1EjubDQPg==} peerDependencies: @@ -3661,6 +3729,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -4053,6 +4124,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4157,6 +4231,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -4203,6 +4280,10 @@ packages: engines: {node: '>=20.0.0'} hasBin: true + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -4292,6 +4373,11 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vite@8.0.8: resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4415,6 +4501,14 @@ packages: resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} engines: {node: '>=8'} + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5039,6 +5133,8 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@colors/colors@1.6.0': {} + '@commitlint/cli@20.5.0(@types/node@20.19.39)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.5.0 @@ -5161,6 +5257,12 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.9.2': @@ -6379,6 +6481,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.15.24': @@ -6553,6 +6660,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 20.19.39 + '@types/triple-beam@1.3.5': {} + '@types/ua-parser-js@0.7.39': {} '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': @@ -7042,14 +7151,33 @@ snapshots: clone@1.0.4: {} + cls-rtracer@2.6.3: + dependencies: + uuid: 9.0.1 + cluster-key-slot@1.1.2: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + color-name@1.1.4: {} + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + colorette@2.0.20: {} combined-stream@1.0.8: @@ -7235,6 +7363,8 @@ snapshots: emoji-regex@8.0.0: {} + enabled@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -7522,6 +7652,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fecha@4.2.3: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -7562,6 +7694,8 @@ snapshots: flatted@3.4.2: {} + fn.name@1.1.0: {} + follow-redirects@1.16.0: {} fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): @@ -7787,6 +7921,8 @@ snapshots: is-standalone-pwa@0.1.1: {} + is-stream@2.0.1: {} + is-unicode-supported@0.1.0: {} isarray@1.0.0: {} @@ -7882,6 +8018,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kuler@2.0.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -8017,6 +8155,15 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + lru-cache@11.3.3: {} luxon@3.7.2: {} @@ -8114,6 +8261,12 @@ snapshots: neo-async@2.6.2: {} + nest-winston@1.10.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(winston@3.19.0): + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + winston: 3.19.0 + nestjs-zod@5.3.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6): dependencies: '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -8150,6 +8303,10 @@ snapshots: dependencies: wrappy: 1.0.2 + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -8541,6 +8698,8 @@ snapshots: split2@4.2.0: {} + stack-trace@0.0.10: {} + stackback@0.0.2: {} standard-as-callback@2.1.0: {} @@ -8634,6 +8793,8 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-hex@1.0.0: {} + text-table@0.2.0: {} thread-stream@4.0.0: @@ -8672,6 +8833,8 @@ snapshots: transliteration@2.6.1: {} + triple-beam@1.4.1: {} + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8753,6 +8916,8 @@ snapshots: uuid@11.1.0: {} + uuid@9.0.1: {} + vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -8857,6 +9022,26 @@ snapshots: string-width: 4.2.3 optional: true + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index 1d6724b..1fa7f9b 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -4,6 +4,7 @@ import { ExceptionFilter, HttpException, HttpStatus, + Logger, } from '@nestjs/common'; import { ZodValidationException } from 'nestjs-zod'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -15,6 +16,7 @@ import { DATABASE_ERRORS } from './swagger'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); private isDev = process.env.NODE_ENV === 'development'; catch(exception: unknown, host: ArgumentsHost) { @@ -44,6 +46,11 @@ export class GlobalExceptionFilter implements ExceptionFilter { const zodError = exception.getZodError() as ZodError; const issues: ZodIssue[] = zodError.issues || []; + this.log(exception, host, status, { + validationIssues: issues, + body: request.body, + }); + return response.status(status).send( this.formatErrorResponse(request, status, { code: 'VALIDATION_FAILED', @@ -76,6 +83,13 @@ export class GlobalExceptionFilter implements ExceptionFilter { } } + this.log(exception, host, status, { + dbCode: error?.code, + dbTable: error?.table, + dbDetail: error?.detail, + query: this.isDev ? exception.message : undefined, + }); + return response.status(status).send( this.formatErrorResponse(request, status, { code: errorCode, @@ -93,6 +107,12 @@ export class GlobalExceptionFilter implements ExceptionFilter { const error = exception.getResponse() as IErrorOptions; + this.log(exception, host, status, { + errorCode: error.code, + details: error.details, + type: 'BUSINESS_EXCEPTION', + }); + return response.status(status).send( this.formatErrorResponse(request, status, { code: error.code, @@ -116,6 +136,12 @@ export class GlobalExceptionFilter implements ExceptionFilter { ? res['error'].toUpperCase().replace(/\s+/g, '_') : 'HTTP_EXCEPTION'; + this.log(exception, host, status, { + httpCode: code, + nestResponse: res, + type: 'NEST_HTTP_EXCEPTION', + }); + return response.status(status).send( this.formatErrorResponse(request, status, { code, @@ -130,6 +156,8 @@ export class GlobalExceptionFilter implements ExceptionFilter { const { request, response } = this.getCtxBase(host); const status = HttpStatus.INTERNAL_SERVER_ERROR; + this.log(exception, host, status, { type: 'UNKNOWN_SERVER_ERROR' }); + return response.status(status).send( this.formatErrorResponse(request, status, { code: 'INTERNAL_SERVER_ERROR', @@ -180,4 +208,37 @@ export class GlobalExceptionFilter implements ExceptionFilter { request: ctx.getRequest(), }; } + + private log( + exception: any, + host: ArgumentsHost, + status: number, + extraData: Record = {}, + ) { + const { request } = this.getCtxBase(host); + + const logData = { + request_id: request.id || request.headers['x-request-id'] || 'unknown', + triggered_by: 'filter_exception', + type: 'error', + method: request.method ?? 'Unknown', + url: request.url, + path: request.url.split('?')[0], + status_code: status, + ip: request.ip, + user_agent: request.headers['user-agent'] || 'unknown', + controller: 'Unknown', + handler: 'Unknown', + stack: exception instanceof Error ? exception.stack : undefined, + error_details: extraData, + }; + + const message = `Exception Filter: ${logData.method} ${logData.path} | ${status} | ${exception?.message || 'Unknown Error'}`; + + if (status >= 500) { + this.logger.error(message, logData); + } else { + this.logger.warn(message, logData); + } + } } diff --git a/src/shared/media/workers/media.worker.ts b/src/shared/media/workers/media.worker.ts index 4088624..00ccf2e 100644 --- a/src/shared/media/workers/media.worker.ts +++ b/src/shared/media/workers/media.worker.ts @@ -4,12 +4,9 @@ import { MEDIA_JOBS, MEDIA_QUEUES, MEDIA_SPECS } from '../media.constant'; import { Job } from 'bullmq'; import { S3Service } from '@libs/s3'; import { dirname } from 'path'; -import { Logger } from '@nestjs/common'; @Processor(MEDIA_QUEUES.RESIZE) export class MediaProcessor extends WorkerHost { - private logger = new Logger(MediaProcessor.name); - constructor( private readonly imagor: ImagorService, private readonly s3: S3Service, @@ -21,7 +18,6 @@ export class MediaProcessor extends WorkerHost { if (job.name !== MEDIA_JOBS.RESIZE_IMAGES) return; const { original: originalFilePath, context } = job.data; - const jobId = job.id; try { await job.updateProgress(5); @@ -59,7 +55,6 @@ export class MediaProcessor extends WorkerHost { }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`[Job:${jobId}] Resize failed: ${errorMessage}`); await job.log(`Error during resizing: ${errorMessage}`); throw error; diff --git a/src/teams/infrastructure/listeners/update-media.listener.ts b/src/teams/infrastructure/listeners/update-media.listener.ts index 28db45d..3a9f2fd 100644 --- a/src/teams/infrastructure/listeners/update-media.listener.ts +++ b/src/teams/infrastructure/listeners/update-media.listener.ts @@ -1,15 +1,13 @@ -import { Inject, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; import { ITeamsRepository } from '@core/teams/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { Job, UnrecoverableError } from 'bullmq'; +import { type Job, UnrecoverableError } from 'bullmq'; import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaTeam } from '@shared/media'; import { TeamMemberPolicy } from '@core/teams/domain/policy'; import type { TeamRole } from '@shared/entities'; @Processor(MEDIA_QUEUES.SAVE_ENTITY) export class UpdateTeamMediaListener extends WorkerHost { - private readonly logger = new Logger(UpdateTeamMediaListener.name); - constructor( @Inject('ITeamsRepository') private readonly repository: ITeamsRepository, @@ -28,9 +26,9 @@ export class UpdateTeamMediaListener extends WorkerHost { await this.executeMediaUpdate(teamId, type, path); - this.logger.log(`Successfully updated ${type} for team ${entity.slug}`); + await job.log(`Successfully updated ${type} for team ${entity.slug}`); } catch (error) { - this.logger.error( + await job.log( `Failed to update ${type} for team ${entity.slug}: ${error instanceof Error ? error.message : String(error)}`, ); throw error; diff --git a/src/user/infrastructure/listeners/update-avatar.listener.ts b/src/user/infrastructure/listeners/update-avatar.listener.ts index c386443..7563c22 100644 --- a/src/user/infrastructure/listeners/update-avatar.listener.ts +++ b/src/user/infrastructure/listeners/update-avatar.listener.ts @@ -1,13 +1,11 @@ import { IUserRepository } from '@core/user/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { Inject, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaUser } from '@shared/media'; import { UnrecoverableError, type Job } from 'bullmq'; @Processor(MEDIA_QUEUES.SAVE_ENTITY) export class UpdateAvatarListener extends WorkerHost { - private readonly logger = new Logger(UpdateAvatarListener.name); - constructor( @Inject('IUserRepository') private readonly repository: IUserRepository, @@ -19,7 +17,6 @@ export class UpdateAvatarListener extends WorkerHost { if (job.name !== MEDIA_JOBS.UPDATE_USER_AVATAR) return; const { entity, path } = job.data; - const jobId = job.id; try { await job.updateProgress(10); @@ -35,7 +32,6 @@ export class UpdateAvatarListener extends WorkerHost { const userAccount = await this.repository.findById(entity.id); if (!userAccount) { - this.logger.warn(`[Job:${jobId}] User ${entity.id} not found. Skipping update.`); await job.log(`User ${entity.id} missing in database.`); return { status: 'aborted', reason: 'USER_NOT_FOUND' }; } @@ -46,12 +42,10 @@ export class UpdateAvatarListener extends WorkerHost { await job.updateProgress(100); - this.logger.log( - `[Job:${jobId}] Successfully updated avatar for user ${userAccount.user.id}`, - ); + await job.log(`Successfully updated avatar for user ${userAccount.user.id}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - this.logger.error(`[Job:${jobId}] Critical failure: ${errorMessage}`); + await job.log(`Critical failure: ${errorMessage}`); throw error; } diff --git a/tsconfig.json b/tsconfig.json index 12de8aa..c260ad5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2021", + "esModuleInterop": true, "sourceMap": true, "outDir": "./dist", "incremental": true,