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
20 changes: 20 additions & 0 deletions libs/database/src/database-health.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Inject, Injectable } from '@nestjs/common';
import { SQL_CLIENT } from '@libs/database/constants';
import { Sql } from 'postgres';

@Injectable()
export class DatabaseHealthService {
constructor(
@Inject(SQL_CLIENT)
private readonly sql: Sql,
) {}

async isAlive() {
try {
await this.sql`SELECT 1`;
return true;
} catch {
return false;
}
}
}
4 changes: 3 additions & 1 deletion libs/database/src/database.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
} from './database.module-definition';
import { DatabaseHealthService } from '@libs/database/database-health.service';

@Module({
providers: [
MigrationService,
DatabaseHealthService,
{
provide: SQL_CLIENT,
inject: [ConfigService, MODULE_OPTIONS_TOKEN],
Expand Down Expand Up @@ -61,7 +63,7 @@ import {
},
},
],
exports: [DATABASE_SERVICE],
exports: [DATABASE_SERVICE, DatabaseHealthService],
})
export class DatabaseModule extends ConfigurableModuleClass implements OnApplicationShutdown {
private readonly logger = new Logger(DatabaseModule.name);
Expand Down
1 change: 1 addition & 0 deletions libs/database/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './database.module';
export { DATABASE_SERVICE } from './constants';
export type { DatabaseService } from './interfaces';
export { DatabaseHealthService } from './database-health.service';
17 changes: 7 additions & 10 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 } from '@nestjs/common';
import { Controller, Get, HttpStatus } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { HealthService } from '../health.service';
import { GetHealthSwagger, GetPingSwagger } from './health.swagger';
Expand All @@ -9,24 +9,21 @@ import { BaseException } from '@shared/error';
@Controller()
@ApiTags('System')
export class HealthController {
constructor(
private readonly healthService: HealthService,
@Inject('SERVICE_NAME') private readonly serviceName: string,
) {}
constructor(private readonly service: HealthService) {}

@Get('health')
@GetHealthSwagger()
async checkHealth() {
const pingData = await this.healthService.getHealthData();
const pingData = await this.service.getHealthData();

if (pingData.status !== 'up') {
if (!pingData.status) {
throw new BaseException(
{
code: 'SERVICE_UNHEALTHY',
message: `Сервис ${this.serviceName} временно недоступен или работает некорректно`,
message: `Сервис ${pingData.service} временно недоступен или работает некорректно`,
details: [
{
target: this.serviceName,
target: pingData.service,
status: pingData.status,
timestamp: new Date().toISOString(),
},
Expand All @@ -42,6 +39,6 @@ export class HealthController {
@Get('ping')
@GetPingSwagger()
async ping() {
return this.healthService.getHealthData();
return this.service.getHealthData();
}
}
30 changes: 25 additions & 5 deletions libs/health/src/controller/health.controlller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@ import { HttpStatus, Logger } from '@nestjs/common';
describe('HealthController', () => {
let controller: HealthController;
let healthServiceMock: { getHealthData: ReturnType<typeof vi.fn> };

const SERVICE_NAME = 'MyService';

beforeEach(() => {
healthServiceMock = {
getHealthData: vi.fn(),
};
controller = new HealthController(healthServiceMock as any, SERVICE_NAME);

controller = new HealthController(healthServiceMock as any);

vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});

it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => {
healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' });
it('should throw SERVICE_UNAVAILABLE when service status is false (down)', async () => {
healthServiceMock.getHealthData.mockResolvedValue({
service: SERVICE_NAME,
status: false,
components: { database: 'down' },
});

await expect(controller.checkHealth()).rejects.toMatchObject({
status: HttpStatus.SERVICE_UNAVAILABLE,
Expand All @@ -25,17 +32,30 @@ describe('HealthController', () => {
message: expect.stringContaining(SERVICE_NAME),
details: expect.arrayContaining([
expect.objectContaining({
status: 'down',
target: SERVICE_NAME,
status: false,
}),
]),
},
});
});

it('should return "healthy" when status is true', async () => {
healthServiceMock.getHealthData.mockResolvedValue({ service: SERVICE_NAME, status: true });

const result = await controller.checkHealth();

expect(result).toBe('healthy');
});

describe('ping', () => {
it('should return the full health payload', async () => {
const mockPayload = { status: 'up' };
const mockPayload = {
service: SERVICE_NAME,
status: true,
components: {},
time: { uptime: '1h 0m 0s' },
};
healthServiceMock.getHealthData.mockResolvedValue(mockPayload);

const result = await controller.ping();
Expand Down
18 changes: 8 additions & 10 deletions libs/health/src/dtos/health.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,21 @@ import { z } from 'zod/v4';

const HealthResponseSchema = z.object({
service: z.string().describe('Название сервиса'),
status: z.enum(['up', 'down']).describe('Текущий статус'),
status: z.boolean().describe('Общий статус работоспособности (true — ок, false — есть сбои)'),
components: z
.record(z.string(), z.enum(['up', 'down']))
.describe('Статусы отдельных компонентов (например, database, redis)'),
info: z.object({
version: z.string().describe('Версия приложения'),
node: z.string().describe('Версия Node.js'),
pid: z.number().describe('ID процесса'),
}),
time: z.object({
now: z.string().datetime().describe('Текущее время сервера'),
startedAt: z.string().datetime().describe('Время старта сервера'),
uptime: z.string().describe('Аптайм в формате ч/м/с'),
now: z.string().datetime().describe('Текущее время сервера (ISO)'),
startedAt: z.string().datetime().describe('Время старта сервера (ISO)'),
uptime: z.string().describe('Аптайм в читаемом формате (h m s)'),
uptimeSeconds: z.number().describe('Аптайм в секундах'),
}),
metrics: z.object({
rss: z.string().describe('Resident Set Size (общая память)'),
heapUsed: z.string().describe('Использованная память в куче'),
loadAverage: z.string().describe('Средняя нагрузка на CPU'),
}),
loaded: z.string().describe('Средняя нагрузка на CPU за последнюю минуту (Load Average)'),
});

export class HealthResponse extends createZodDto(HealthResponseSchema) {}
16 changes: 16 additions & 0 deletions libs/health/src/health.module-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { HealthModuleOptions } from './interfaces';

export const { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } =
new ConfigurableModuleBuilder<HealthModuleOptions>()
.setClassMethodName('register')
.setExtras(
{
global: false,
},
(definition, extras) => ({
...definition,
global: extras.global,
}),
)
.build();
26 changes: 8 additions & 18 deletions libs/health/src/health.module.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
import { type DynamicModule, Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { HealthController } from './controller/health.controller';
import { HealthService } from './health.service';
import { ConfigurableModuleClass } from './health.module-definition';

@Global()
@Module({})
export class HealthModule {
static register(serviceName: string): DynamicModule {
return {
module: HealthModule,
providers: [
{
provide: 'SERVICE_NAME',
useValue: serviceName,
},
HealthService,
],
controllers: [HealthController],
};
}
}
@Module({
controllers: [HealthController],
providers: [HealthService],
exports: [HealthService],
})
export class HealthModule extends ConfigurableModuleClass {}
92 changes: 92 additions & 0 deletions libs/health/src/health.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('os', async () => {
const actual = await vi.importActual<typeof import('os')>('os');
return {
...actual,
loadavg: () => [1.23, 0.5, 0.1],
};
});
import { HealthService } from './health.service';
import type { HealthModuleOptions } from './interfaces';

describe('HealthService', () => {
const BASE_TIME = new Date('2026-05-15T10:00:00.000Z');

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(BASE_TIME);
vi.spyOn(process, 'uptime').mockReturnValue(3661);
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('returns healthy payload when all indicators are ok', async () => {
const options: HealthModuleOptions = {
serviceName: 'MyService',
version: 'v2.0.0',
indicators: {
database: () => true,
redis: async () => true,
},
};

const service = new HealthService(options);
const data = await service.getHealthData();

expect(data).toMatchObject({
service: 'MyService',
status: true,
components: { database: 'up', redis: 'up' },
info: { version: 'v2.0.0', node: process.version },
time: {
now: BASE_TIME.toISOString(),
startedAt: BASE_TIME.toISOString(),
uptime: '1h 1m 1s',
uptimeSeconds: 3661,
},
loaded: '1.23',
});
});

it('marks status as false when any indicator fails or throws', async () => {
const options: HealthModuleOptions = {
serviceName: 'MyService',
indicators: {
cache: () => true,
database: () => false,
storage: () => {
throw new Error('boom');
},
},
};

const service = new HealthService(options);
const data = await service.getHealthData();

expect(data.status).toBe(false);
expect(data.components).toEqual({ database: 'down', storage: 'down', cache: 'up' });
});

it('marks indicator as down on timeout', async () => {
const options: HealthModuleOptions = {
serviceName: 'MyService',
indicators: {
http: () => new Promise<boolean>(() => undefined),
},
};

const service = new HealthService(options);
const resultPromise = service.getHealthData();

await vi.advanceTimersByTimeAsync(5000);

const data = await resultPromise;

expect(data.status).toBe(false);
expect(data.components).toEqual({ http: 'down' });
});
});
Loading
Loading