|
1 | | - |
2 | 1 | import { Controller, Get } from '@nestjs/common' |
| 2 | +import { statfs } from 'fs/promises' |
| 3 | +import { cpus, loadavg, totalmem } from 'os' |
3 | 4 | import { ApiTags } from '@nestjs/swagger' |
4 | | -import { HealthCheckService, HttpHealthIndicator, HealthCheck, DiskHealthIndicator, MemoryHealthIndicator, MongooseHealthIndicator, HealthCheckResult } from '@nestjs/terminus' |
| 5 | +import { DiskHealthIndicator, HealthCheck, HealthCheckError, HealthCheckResult, HealthCheckService, HttpHealthIndicator, MemoryHealthIndicator, MongooseHealthIndicator } from '@nestjs/terminus' |
5 | 6 | import { Public } from '~/_common/decorators/public.decorator' |
6 | 7 |
|
7 | 8 | /** |
8 | 9 | * Multiplicateur pour convertir les octets en mégaoctets. |
9 | 10 | * @constant {number} |
10 | 11 | */ |
11 | 12 | const MEMORY_MULTIPLIER = 1024 * 1024 |
| 13 | +const GIGABYTE_MULTIPLIER = 1024 * 1024 * 1024 |
| 14 | +const CPU_LOAD_THRESHOLD = 0.85 |
| 15 | +const DISK_THRESHOLD_PERCENT = 0.95 |
| 16 | +const HEAP_MEMORY_THRESHOLD_MB = 512 |
| 17 | +const RSS_MEMORY_THRESHOLD_MB = 512 |
| 18 | + |
| 19 | +type HealthResponse = HealthCheckResult & { |
| 20 | + system: { |
| 21 | + memory: { |
| 22 | + heapUsedMb: number |
| 23 | + heapTotalMb: number |
| 24 | + rssMb: number |
| 25 | + totalSystemMemoryMb: number |
| 26 | + } |
| 27 | + cpu: { |
| 28 | + load1mPerCore: number |
| 29 | + load5mPerCore: number |
| 30 | + load15mPerCore: number |
| 31 | + cores: number |
| 32 | + threshold: number |
| 33 | + } |
| 34 | + } |
| 35 | + futureChecks: { |
| 36 | + externalExposure: { |
| 37 | + enabled: boolean |
| 38 | + status: 'not_configured' |
| 39 | + note: string |
| 40 | + } |
| 41 | + leakedPasswords: { |
| 42 | + enabled: boolean |
| 43 | + status: 'not_implemented' |
| 44 | + note: string |
| 45 | + } |
| 46 | + } |
| 47 | +} |
12 | 48 |
|
13 | 49 | /** |
14 | 50 | * Contrôleur pour la vérification de l'état de santé du système. |
@@ -59,25 +95,160 @@ export class HealthController { |
59 | 95 | * et ses dépendances sont opérationnelles. Retourne un statut global |
60 | 96 | * ainsi que le détail de chaque indicateur. |
61 | 97 | * |
62 | | - * @returns {Promise<HealthCheckResult>} Résultat complet du health check |
| 98 | + * @returns {Promise<HealthResponse>} Résultat complet du health check enrichi |
63 | 99 | */ |
64 | 100 | @Get() |
65 | 101 | @HealthCheck() |
66 | | - public async check(): Promise<HealthCheckResult> { |
67 | | - return await this.health.check([ |
68 | | - () => this.mongoose.pingCheck('mongoose'), |
| 102 | + public async check(): Promise<HealthResponse> { |
| 103 | + const healthResult = await this.health.check([ |
| 104 | + () => this.checkMongoose(), |
69 | 105 |
|
70 | 106 | () => this.http.pingCheck('http-github', 'https://github.com'), |
71 | 107 |
|
72 | | - // DISK en GB |
73 | | - () => this.disk.checkStorage('storage', { |
74 | | - path: '/', |
75 | | - thresholdPercent: 0.95, |
76 | | - }), |
| 108 | + () => this.checkStorage(), |
77 | 109 |
|
78 | | - // MB pour heap et RSS |
79 | | - () => this.memory.checkHeap('memory_heap', 512 * MEMORY_MULTIPLIER), |
80 | | - () => this.memory.checkRSS('memory_rss', 512 * MEMORY_MULTIPLIER), |
| 110 | + () => this.checkMemoryHeap(), |
| 111 | + () => this.checkMemoryRss(), |
| 112 | + () => this.checkCpu(), |
81 | 113 | ]) |
| 114 | + |
| 115 | + const memoryUsage = process.memoryUsage() |
| 116 | + const cpuCount = Math.max(cpus().length, 1) |
| 117 | + const [load1m, load5m, load15m] = loadavg() |
| 118 | + |
| 119 | + return { |
| 120 | + ...healthResult, |
| 121 | + system: { |
| 122 | + memory: { |
| 123 | + heapUsedMb: Number((memoryUsage.heapUsed / MEMORY_MULTIPLIER).toFixed(2)), |
| 124 | + heapTotalMb: Number((memoryUsage.heapTotal / MEMORY_MULTIPLIER).toFixed(2)), |
| 125 | + rssMb: Number((memoryUsage.rss / MEMORY_MULTIPLIER).toFixed(2)), |
| 126 | + totalSystemMemoryMb: Number((totalmem() / MEMORY_MULTIPLIER).toFixed(2)), |
| 127 | + }, |
| 128 | + cpu: { |
| 129 | + load1mPerCore: Number((load1m / cpuCount).toFixed(3)), |
| 130 | + load5mPerCore: Number((load5m / cpuCount).toFixed(3)), |
| 131 | + load15mPerCore: Number((load15m / cpuCount).toFixed(3)), |
| 132 | + cores: cpuCount, |
| 133 | + threshold: CPU_LOAD_THRESHOLD, |
| 134 | + }, |
| 135 | + }, |
| 136 | + futureChecks: { |
| 137 | + externalExposure: { |
| 138 | + enabled: false, |
| 139 | + status: 'not_configured', |
| 140 | + note: 'Reserved for a future check against a configured public URL (ex: APP_PUBLIC_URL).', |
| 141 | + }, |
| 142 | + leakedPasswords: { |
| 143 | + enabled: false, |
| 144 | + status: 'not_implemented', |
| 145 | + note: 'Reserved for future leaked-password detection integration (k-anonymity/HIBP style).', |
| 146 | + }, |
| 147 | + }, |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + private checkCpu(): Record<string, { status: 'up' | 'down'; load1mPerCore: number; threshold: number; cores: number }> { |
| 152 | + const cpuCount = Math.max(cpus().length, 1) |
| 153 | + const perCoreLoad = loadavg()[0] / cpuCount |
| 154 | + const indicator = { |
| 155 | + status: perCoreLoad <= CPU_LOAD_THRESHOLD ? 'up' : 'down', |
| 156 | + load1mPerCore: Number(perCoreLoad.toFixed(3)), |
| 157 | + threshold: CPU_LOAD_THRESHOLD, |
| 158 | + cores: cpuCount, |
| 159 | + } as const |
| 160 | + |
| 161 | + if (indicator.status === 'down') { |
| 162 | + throw new HealthCheckError('cpu_check_failed', { cpu: indicator }) |
| 163 | + } |
| 164 | + |
| 165 | + return { cpu: indicator } |
| 166 | + } |
| 167 | + |
| 168 | + private async checkMongoose(): Promise<Record<string, { status: 'up' | 'down'; pingMs: number }>> { |
| 169 | + const start = Date.now() |
| 170 | + |
| 171 | + try { |
| 172 | + await this.mongoose.pingCheck('mongoose') |
| 173 | + return { |
| 174 | + mongoose: { |
| 175 | + status: 'up', |
| 176 | + pingMs: Date.now() - start, |
| 177 | + }, |
| 178 | + } |
| 179 | + } catch { |
| 180 | + throw new HealthCheckError('mongoose_check_failed', { |
| 181 | + mongoose: { |
| 182 | + status: 'down', |
| 183 | + pingMs: Date.now() - start, |
| 184 | + }, |
| 185 | + }) |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + private async checkStorage(): Promise<Record<string, { status: 'up' | 'down'; usedPercent: number; thresholdPercent: number; totalGb: number; usedGb: number; freeGb: number }>> { |
| 190 | + await this.disk.checkStorage('storage', { |
| 191 | + path: '/', |
| 192 | + thresholdPercent: DISK_THRESHOLD_PERCENT, |
| 193 | + }) |
| 194 | + |
| 195 | + const fsStats = await statfs('/') |
| 196 | + const totalBytes = fsStats.blocks * fsStats.bsize |
| 197 | + const freeBytes = fsStats.bavail * fsStats.bsize |
| 198 | + const usedBytes = Math.max(totalBytes - freeBytes, 0) |
| 199 | + const usedPercent = totalBytes > 0 ? usedBytes / totalBytes : 0 |
| 200 | + |
| 201 | + const indicator = { |
| 202 | + status: usedPercent <= DISK_THRESHOLD_PERCENT ? 'up' : 'down', |
| 203 | + usedPercent: Number((usedPercent * 100).toFixed(2)), |
| 204 | + thresholdPercent: Number((DISK_THRESHOLD_PERCENT * 100).toFixed(2)), |
| 205 | + totalGb: Number((totalBytes / GIGABYTE_MULTIPLIER).toFixed(2)), |
| 206 | + usedGb: Number((usedBytes / GIGABYTE_MULTIPLIER).toFixed(2)), |
| 207 | + freeGb: Number((freeBytes / GIGABYTE_MULTIPLIER).toFixed(2)), |
| 208 | + } as const |
| 209 | + |
| 210 | + if (indicator.status === 'down') { |
| 211 | + throw new HealthCheckError('storage_check_failed', { storage: indicator }) |
| 212 | + } |
| 213 | + |
| 214 | + return { storage: indicator } |
| 215 | + } |
| 216 | + |
| 217 | + private async checkMemoryHeap(): Promise<Record<string, { status: 'up' | 'down'; usedMb: number; thresholdMb: number; usedPercent: number }>> { |
| 218 | + await this.memory.checkHeap('memory_heap', HEAP_MEMORY_THRESHOLD_MB * MEMORY_MULTIPLIER) |
| 219 | + const heapUsedMb = process.memoryUsage().heapUsed / MEMORY_MULTIPLIER |
| 220 | + const usedPercent = heapUsedMb / HEAP_MEMORY_THRESHOLD_MB |
| 221 | + |
| 222 | + const indicator = { |
| 223 | + status: heapUsedMb <= HEAP_MEMORY_THRESHOLD_MB ? 'up' : 'down', |
| 224 | + usedMb: Number(heapUsedMb.toFixed(2)), |
| 225 | + thresholdMb: HEAP_MEMORY_THRESHOLD_MB, |
| 226 | + usedPercent: Number((usedPercent * 100).toFixed(2)), |
| 227 | + } as const |
| 228 | + |
| 229 | + if (indicator.status === 'down') { |
| 230 | + throw new HealthCheckError('memory_heap_check_failed', { memory_heap: indicator }) |
| 231 | + } |
| 232 | + |
| 233 | + return { memory_heap: indicator } |
| 234 | + } |
| 235 | + |
| 236 | + private async checkMemoryRss(): Promise<Record<string, { status: 'up' | 'down'; usedMb: number; thresholdMb: number; usedPercent: number }>> { |
| 237 | + await this.memory.checkRSS('memory_rss', RSS_MEMORY_THRESHOLD_MB * MEMORY_MULTIPLIER) |
| 238 | + const rssMb = process.memoryUsage().rss / MEMORY_MULTIPLIER |
| 239 | + const usedPercent = rssMb / RSS_MEMORY_THRESHOLD_MB |
| 240 | + |
| 241 | + const indicator = { |
| 242 | + status: rssMb <= RSS_MEMORY_THRESHOLD_MB ? 'up' : 'down', |
| 243 | + usedMb: Number(rssMb.toFixed(2)), |
| 244 | + thresholdMb: RSS_MEMORY_THRESHOLD_MB, |
| 245 | + usedPercent: Number((usedPercent * 100).toFixed(2)), |
| 246 | + } as const |
| 247 | + |
| 248 | + if (indicator.status === 'down') { |
| 249 | + throw new HealthCheckError('memory_rss_check_failed', { memory_rss: indicator }) |
| 250 | + } |
| 251 | + |
| 252 | + return { memory_rss: indicator } |
82 | 253 | } |
83 | 254 | } |
0 commit comments