Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ S3_ACCESS_KEY=''
S3_SECRET_KEY=''

IMAGOR_SECRET=''
IMAGOR_URL=''
IMAGOR_URL=''
8 changes: 4 additions & 4 deletions Dockerfile.prod
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -9,7 +9,7 @@ WORKDIR /app

FROM base AS fetch

COPY pnpm-lock.yaml ./
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./

# Загружаем всё в виртуальное хранилище.
# Если lock-файл не менялся, этот слой будет взят из кэша
Expand All @@ -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 . .

Expand All @@ -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

Expand Down
15 changes: 13 additions & 2 deletions libs/bootstrap/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
},
Expand Down Expand Up @@ -43,14 +43,25 @@ export async function bootstrapApp(options: BootstrapOptions) {

const app = await NestFactory.create<NestFastifyApplication>(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<number>(portEnvKey, defaultPort);
const origins = configService.getOrThrow('CORS_ALLOWED_ORIGINS');

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,
Expand Down
1 change: 1 addition & 0 deletions libs/bootstrap/src/setups/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { setupCors } from './cors';
export { setupThrottler } from './throttler';
export { setupSwagger } from './swagger';
export { setupLogger } from './logger';
223 changes: 223 additions & 0 deletions libs/bootstrap/src/setups/logger.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
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<T>(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;
};
5 changes: 1 addition & 4 deletions libs/health/src/controller/health.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
Loading
Loading