immortal-js 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +577 -0
- package/docs/CHANGELOG.md +21 -0
- package/docs/CONTRIBUTING.md +41 -0
- package/docs/api/chaos.md +179 -0
- package/docs/api/dashboard.md +109 -0
- package/docs/api/isolation.md +191 -0
- package/docs/api/lifecycle.md +187 -0
- package/docs/api/monitoring.md +313 -0
- package/docs/api/plugins.md +217 -0
- package/docs/api/recovery.md +267 -0
- package/docs/api/safe-zone.md +236 -0
- package/docs/api/supervision.md +285 -0
- package/docs/guides/configuration.md +168 -0
- package/docs/guides/express.md +171 -0
- package/docs/guides/fastify.md +188 -0
- package/docs/guides/koa.md +102 -0
- package/docs/guides/nestjs.md +182 -0
- package/docs/guides/testing.md +91 -0
- package/examples/express-basic/index.ts +462 -0
- package/examples/express-basic/package.json +21 -0
- package/examples/express-basic/tsconfig.json +24 -0
- package/examples/fastify-microservice/index.ts +342 -0
- package/examples/fastify-microservice/package.json +22 -0
- package/examples/fastify-microservice/tsconfig.json +24 -0
- package/examples/invoice-service/data/invoices.db +0 -0
- package/examples/invoice-service/data/invoices.db-shm +0 -0
- package/examples/invoice-service/data/invoices.db-wal +0 -0
- package/examples/invoice-service/package.json +25 -0
- package/examples/invoice-service/public/index.html +5025 -0
- package/examples/invoice-service/src/db.ts +608 -0
- package/examples/invoice-service/src/pdf.ts +358 -0
- package/examples/invoice-service/src/server.ts +527 -0
- package/examples/invoice-service/src/store.ts +159 -0
- package/examples/invoice-service/src/types.ts +193 -0
- package/examples/invoice-service/tsconfig.json +23 -0
- package/examples/nestjs-enterprise/app.module.ts +561 -0
- package/examples/nestjs-enterprise/main.ts +67 -0
- package/examples/nestjs-enterprise/package.json +26 -0
- package/examples/nestjs-enterprise/tsconfig.json +27 -0
- package/immortal-js-1.0.0.tgz +0 -0
- package/package.json +33 -0
- package/packages/adapter-express/package.json +34 -0
- package/packages/adapter-express/src/index.ts +349 -0
- package/packages/adapter-express/tsconfig.json +14 -0
- package/packages/adapter-fastify/package.json +56 -0
- package/packages/adapter-fastify/src/plugin.ts +226 -0
- package/packages/adapter-fastify/tsconfig.json +14 -0
- package/packages/adapter-koa/package.json +55 -0
- package/packages/adapter-koa/src/index.ts +207 -0
- package/packages/adapter-koa/tsconfig.json +14 -0
- package/packages/adapter-nestjs/package.json +61 -0
- package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
- package/packages/adapter-nestjs/src/index.ts +14 -0
- package/packages/adapter-nestjs/tsconfig.json +16 -0
- package/packages/core/package.json +56 -0
- package/packages/core/src/chaos/ChaosEngine.ts +249 -0
- package/packages/core/src/config/defaults.ts +200 -0
- package/packages/core/src/config/schema.ts +199 -0
- package/packages/core/src/event-bus.ts +168 -0
- package/packages/core/src/index.ts +164 -0
- package/packages/core/src/isolation/BulkheadPool.ts +279 -0
- package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
- package/packages/core/src/isolation/index.ts +8 -0
- package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
- package/packages/core/src/logger.ts +104 -0
- package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
- package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
- package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
- package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
- package/packages/core/src/monitoring/index.ts +10 -0
- package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
- package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
- package/packages/core/src/recovery/FallbackCache.ts +328 -0
- package/packages/core/src/recovery/RetryEngine.ts +225 -0
- package/packages/core/src/recovery/Timeout.ts +97 -0
- package/packages/core/src/recovery/index.ts +11 -0
- package/packages/core/src/runtime.ts +242 -0
- package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
- package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
- package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
- package/packages/core/src/safe-zone/index.ts +23 -0
- package/packages/core/src/supervision/ClusterManager.ts +243 -0
- package/packages/core/src/supervision/RestartStrategy.ts +68 -0
- package/packages/core/src/supervision/Supervisor.ts +311 -0
- package/packages/core/src/supervision/index.ts +11 -0
- package/packages/core/src/types.ts +470 -0
- package/packages/core/test/bulkhead.test.ts +310 -0
- package/packages/core/test/circuit-breaker.test.ts +153 -0
- package/packages/core/test/memory-guard.test.ts +213 -0
- package/packages/core/test/retry.test.ts +110 -0
- package/packages/core/test/safe-zone.test.ts +271 -0
- package/packages/core/test/supervisor.test.ts +310 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/dashboard/package.json +56 -0
- package/packages/dashboard/server/DashboardServer.ts +454 -0
- package/packages/dashboard/tsconfig.json +14 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file DiagnosticsChannel.ts
|
|
3
|
+
* @description Node.js diagnostics_channel integration + OpenTelemetry export bridge
|
|
4
|
+
*
|
|
5
|
+
* Provides:
|
|
6
|
+
* 1. Publishing Immortal events to Node.js diagnostics_channel (for APM tool integration)
|
|
7
|
+
* 2. Prometheus-compatible metrics exposition
|
|
8
|
+
* 3. OpenTelemetry OTLP metrics export (when configured)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import diagnostics_channel from "node:diagnostics_channel";
|
|
12
|
+
import type { ImmortalEvent, SystemMetrics } from "../types.js";
|
|
13
|
+
import type { ImmortalEventBus } from "../event-bus.js";
|
|
14
|
+
import { getLogger } from "../logger.js";
|
|
15
|
+
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// CHANNEL NAMES
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export const IMMORTAL_CHANNELS = {
|
|
21
|
+
ERROR: "immortal:error",
|
|
22
|
+
CIRCUIT: "immortal:circuit",
|
|
23
|
+
RETRY: "immortal:retry",
|
|
24
|
+
BULKHEAD: "immortal:bulkhead",
|
|
25
|
+
WORKER: "immortal:worker",
|
|
26
|
+
MEMORY: "immortal:memory",
|
|
27
|
+
METRICS: "immortal:metrics",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// DIAGNOSTICS CHANNEL CLASS
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export class DiagnosticsChannel {
|
|
35
|
+
private channels: Map<string, ReturnType<typeof diagnostics_channel.channel>>;
|
|
36
|
+
private readonly bus: ImmortalEventBus;
|
|
37
|
+
private metricsInterval?: ReturnType<typeof setInterval>;
|
|
38
|
+
private otlpEndpoint?: string;
|
|
39
|
+
private prometheusEnabled: boolean;
|
|
40
|
+
private metricsSnapshot: SystemMetrics | null = null;
|
|
41
|
+
|
|
42
|
+
constructor(bus: ImmortalEventBus, options: {
|
|
43
|
+
otlpEndpoint?: string;
|
|
44
|
+
enablePrometheus?: boolean;
|
|
45
|
+
} = {}) {
|
|
46
|
+
this.bus = bus;
|
|
47
|
+
if (options.otlpEndpoint !== undefined) this.otlpEndpoint = options.otlpEndpoint;
|
|
48
|
+
this.prometheusEnabled = options.enablePrometheus ?? true;
|
|
49
|
+
|
|
50
|
+
this.channels = new Map(
|
|
51
|
+
Object.values(IMMORTAL_CHANNELS).map((name) => [
|
|
52
|
+
name,
|
|
53
|
+
diagnostics_channel.channel(name),
|
|
54
|
+
])
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
this.wireEventBus();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Update the current metrics snapshot (called by MetricsCollector)
|
|
62
|
+
*/
|
|
63
|
+
updateMetrics(metrics: SystemMetrics): void {
|
|
64
|
+
this.metricsSnapshot = metrics;
|
|
65
|
+
|
|
66
|
+
if (this.channels.get(IMMORTAL_CHANNELS.METRICS)?.hasSubscribers) {
|
|
67
|
+
this.channels.get(IMMORTAL_CHANNELS.METRICS)?.publish(metrics);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get Prometheus-format metrics text
|
|
73
|
+
* Compatible with Prometheus scraping and Grafana datasource
|
|
74
|
+
*/
|
|
75
|
+
getPrometheusMetrics(): string {
|
|
76
|
+
if (!this.prometheusEnabled || !this.metricsSnapshot) {
|
|
77
|
+
return "# Immortal.js metrics not available\n";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const m = this.metricsSnapshot;
|
|
81
|
+
const lines: string[] = [
|
|
82
|
+
"# HELP immortal_event_loop_lag_ms Event loop lag percentiles in milliseconds",
|
|
83
|
+
"# TYPE immortal_event_loop_lag_ms gauge",
|
|
84
|
+
`immortal_event_loop_lag_ms{quantile="0.5"} ${m.eventLoopLag.p50}`,
|
|
85
|
+
`immortal_event_loop_lag_ms{quantile="0.95"} ${m.eventLoopLag.p95}`,
|
|
86
|
+
`immortal_event_loop_lag_ms{quantile="0.99"} ${m.eventLoopLag.p99}`,
|
|
87
|
+
`immortal_event_loop_lag_ms{quantile="max"} ${m.eventLoopLag.max}`,
|
|
88
|
+
"",
|
|
89
|
+
"# HELP immortal_heap_used_mb Heap used in megabytes",
|
|
90
|
+
"# TYPE immortal_heap_used_mb gauge",
|
|
91
|
+
`immortal_heap_used_mb ${m.memory.heapUsedMb}`,
|
|
92
|
+
"",
|
|
93
|
+
"# HELP immortal_heap_total_mb Heap total limit in megabytes",
|
|
94
|
+
"# TYPE immortal_heap_total_mb gauge",
|
|
95
|
+
`immortal_heap_total_mb ${m.memory.heapTotalMb}`,
|
|
96
|
+
"",
|
|
97
|
+
"# HELP immortal_rss_mb Resident Set Size in megabytes",
|
|
98
|
+
"# TYPE immortal_rss_mb gauge",
|
|
99
|
+
`immortal_rss_mb ${m.memory.rssMb}`,
|
|
100
|
+
"",
|
|
101
|
+
"# HELP immortal_heap_growth_rate_mb_per_min Heap growth rate",
|
|
102
|
+
"# TYPE immortal_heap_growth_rate_mb_per_min gauge",
|
|
103
|
+
`immortal_heap_growth_rate_mb_per_min ${m.memory.heapGrowthRate}`,
|
|
104
|
+
"",
|
|
105
|
+
"# HELP immortal_cpu_percent CPU utilization percentage",
|
|
106
|
+
"# TYPE immortal_cpu_percent gauge",
|
|
107
|
+
`immortal_cpu_percent{type="user"} ${m.cpu.percentUser}`,
|
|
108
|
+
`immortal_cpu_percent{type="system"} ${m.cpu.percentSystem}`,
|
|
109
|
+
"",
|
|
110
|
+
"# HELP immortal_requests_total Total HTTP requests handled",
|
|
111
|
+
"# TYPE immortal_requests_total counter",
|
|
112
|
+
`immortal_requests_total ${m.requests.total}`,
|
|
113
|
+
"",
|
|
114
|
+
"# HELP immortal_requests_active Currently active requests",
|
|
115
|
+
"# TYPE immortal_requests_active gauge",
|
|
116
|
+
`immortal_requests_active ${m.requests.active}`,
|
|
117
|
+
"",
|
|
118
|
+
"# HELP immortal_requests_per_second Request rate per second",
|
|
119
|
+
"# TYPE immortal_requests_per_second gauge",
|
|
120
|
+
`immortal_requests_per_second ${m.requests.perSecond}`,
|
|
121
|
+
"",
|
|
122
|
+
"# HELP immortal_error_rate Request error rate (0-1)",
|
|
123
|
+
"# TYPE immortal_error_rate gauge",
|
|
124
|
+
`immortal_error_rate ${m.requests.errorRate}`,
|
|
125
|
+
"",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
// Circuit breaker metrics
|
|
129
|
+
for (const [name, circuit] of Object.entries(m.circuits)) {
|
|
130
|
+
const stateValue = circuit.state === "CLOSED" ? 0 : circuit.state === "HALF_OPEN" ? 1 : 2;
|
|
131
|
+
lines.push(`immortal_circuit_state{circuit="${name}"} ${stateValue}`);
|
|
132
|
+
lines.push(`immortal_circuit_failure_rate{circuit="${name}"} ${circuit.failureRate}`);
|
|
133
|
+
lines.push(`immortal_circuit_total_requests{circuit="${name}"} ${circuit.totalRequests}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (Object.keys(m.circuits).length > 0) {
|
|
137
|
+
lines.unshift(
|
|
138
|
+
"# HELP immortal_circuit_state Circuit state (0=CLOSED, 1=HALF_OPEN, 2=OPEN)",
|
|
139
|
+
"# TYPE immortal_circuit_state gauge"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Bulkhead metrics
|
|
144
|
+
for (const [name, bh] of Object.entries(m.bulkheads)) {
|
|
145
|
+
lines.push(`immortal_bulkhead_active{pool="${name}"} ${bh.active}`);
|
|
146
|
+
lines.push(`immortal_bulkhead_queued{pool="${name}"} ${bh.queued}`);
|
|
147
|
+
lines.push(`immortal_bulkhead_rejected{pool="${name}"} ${bh.rejected}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return lines.join("\n") + "\n";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Subscribe to a specific diagnostics channel (for APM integration)
|
|
155
|
+
*/
|
|
156
|
+
subscribe(channelName: string, callback: (data: unknown) => void): void {
|
|
157
|
+
diagnostics_channel.subscribe(channelName, callback);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Stop all periodic exports
|
|
162
|
+
*/
|
|
163
|
+
stop(): void {
|
|
164
|
+
if (this.metricsInterval) {
|
|
165
|
+
clearInterval(this.metricsInterval);
|
|
166
|
+
delete this.metricsInterval;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
171
|
+
// PRIVATE
|
|
172
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
private wireEventBus(): void {
|
|
175
|
+
// Bridge Immortal events to diagnostics channels
|
|
176
|
+
this.bus.onAny((event: ImmortalEvent) => {
|
|
177
|
+
const channelName = this.getChannelForEvent(event.type);
|
|
178
|
+
const channel = this.channels.get(channelName);
|
|
179
|
+
|
|
180
|
+
if (channel?.hasSubscribers) {
|
|
181
|
+
channel.publish({ event, timestamp: Date.now() });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private getChannelForEvent(type: string): string {
|
|
187
|
+
if (type.startsWith("error:")) return IMMORTAL_CHANNELS.ERROR;
|
|
188
|
+
if (type.startsWith("circuit:")) return IMMORTAL_CHANNELS.CIRCUIT;
|
|
189
|
+
if (type.startsWith("retry:")) return IMMORTAL_CHANNELS.RETRY;
|
|
190
|
+
if (type.startsWith("bulkhead:")) return IMMORTAL_CHANNELS.BULKHEAD;
|
|
191
|
+
if (type.startsWith("worker:")) return IMMORTAL_CHANNELS.WORKER;
|
|
192
|
+
if (type.startsWith("memory:")) return IMMORTAL_CHANNELS.MEMORY;
|
|
193
|
+
return IMMORTAL_CHANNELS.METRICS;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Export metrics to OTLP endpoint (OpenTelemetry)
|
|
198
|
+
* Format: OTLP/HTTP JSON
|
|
199
|
+
*/
|
|
200
|
+
private async exportToOTLP(metrics: SystemMetrics): Promise<void> {
|
|
201
|
+
if (!this.otlpEndpoint) return;
|
|
202
|
+
|
|
203
|
+
const otlpPayload = this.buildOTLPPayload(metrics);
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await fetch(this.otlpEndpoint, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
body: JSON.stringify(otlpPayload),
|
|
210
|
+
});
|
|
211
|
+
} catch (err) {
|
|
212
|
+
getLogger().debug("OTLP export failed", { error: (err as Error).message });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private buildOTLPPayload(metrics: SystemMetrics): Record<string, unknown> {
|
|
217
|
+
// Simplified OTLP/HTTP JSON format
|
|
218
|
+
return {
|
|
219
|
+
resourceMetrics: [{
|
|
220
|
+
resource: {
|
|
221
|
+
attributes: [
|
|
222
|
+
{ key: "service.name", value: { stringValue: "immortal-js" } },
|
|
223
|
+
{ key: "service.version", value: { stringValue: "1.0.0" } },
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
scopeMetrics: [{
|
|
227
|
+
metrics: [
|
|
228
|
+
{
|
|
229
|
+
name: "immortal.event_loop.lag.p99",
|
|
230
|
+
unit: "ms",
|
|
231
|
+
gauge: { dataPoints: [{ asDouble: metrics.eventLoopLag.p99, timeUnixNano: metrics.timestamp * 1e6 }] },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "immortal.memory.heap_used",
|
|
235
|
+
unit: "MB",
|
|
236
|
+
gauge: { dataPoints: [{ asDouble: metrics.memory.heapUsedMb, timeUnixNano: metrics.timestamp * 1e6 }] },
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "immortal.requests.per_second",
|
|
240
|
+
unit: "req/s",
|
|
241
|
+
gauge: { dataPoints: [{ asDouble: metrics.requests.perSecond, timeUnixNano: metrics.timestamp * 1e6 }] },
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
}],
|
|
245
|
+
}],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file HealthMonitor.ts
|
|
3
|
+
* @description Real-time system health monitoring
|
|
4
|
+
*
|
|
5
|
+
* Monitors:
|
|
6
|
+
* - Event Loop Delay/Lag (via perf_hooks histogram — most accurate method)
|
|
7
|
+
* - CPU utilization (via process.cpuUsage)
|
|
8
|
+
* - Open handles count (via process._getActiveHandles)
|
|
9
|
+
* - Memory usage trends
|
|
10
|
+
*
|
|
11
|
+
* Key insight on event loop lag:
|
|
12
|
+
* High event loop lag means synchronous CPU work is blocking the loop —
|
|
13
|
+
* the server literally cannot respond to new requests during that time.
|
|
14
|
+
* Detecting > threshold is a signal to restart the worker (not just log).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
18
|
+
import type { HealthMonitorConfig } from "../types.js";
|
|
19
|
+
import { DEFAULT_HEALTH_MONITOR } from "../config/defaults.js";
|
|
20
|
+
import { getLogger } from "../logger.js";
|
|
21
|
+
import type { ImmortalEventBus } from "../event-bus.js";
|
|
22
|
+
import type { MetricsCollector } from "./MetricsCollector.js";
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// HEALTH MONITOR CLASS
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export class HealthMonitor {
|
|
29
|
+
private readonly config: Required<HealthMonitorConfig>;
|
|
30
|
+
private readonly bus: ImmortalEventBus;
|
|
31
|
+
private readonly metrics: MetricsCollector;
|
|
32
|
+
private interval?: ReturnType<typeof setInterval>;
|
|
33
|
+
private histogram: ReturnType<typeof monitorEventLoopDelay>;
|
|
34
|
+
private running = false;
|
|
35
|
+
private consecutiveCriticalLag = 0;
|
|
36
|
+
private consecutiveCriticalCpu = 0;
|
|
37
|
+
private restartRequested = false;
|
|
38
|
+
private supervisorRef?: { requestGracefulRestart: (id: string, reason?: string) => void };
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
config: HealthMonitorConfig = {},
|
|
42
|
+
bus: ImmortalEventBus,
|
|
43
|
+
metrics: MetricsCollector
|
|
44
|
+
) {
|
|
45
|
+
this.config = { ...DEFAULT_HEALTH_MONITOR, ...config };
|
|
46
|
+
this.bus = bus;
|
|
47
|
+
this.metrics = metrics;
|
|
48
|
+
|
|
49
|
+
// Initialize event loop histogram (20ms resolution = balance of accuracy vs overhead)
|
|
50
|
+
this.histogram = monitorEventLoopDelay({ resolution: 20 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wire a supervisor reference for proactive restarts
|
|
55
|
+
*/
|
|
56
|
+
setSupervisor(supervisor: { requestGracefulRestart: (id: string, reason?: string) => void }): void {
|
|
57
|
+
this.supervisorRef = supervisor;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Start monitoring
|
|
62
|
+
*/
|
|
63
|
+
start(): void {
|
|
64
|
+
if (this.running) return;
|
|
65
|
+
|
|
66
|
+
this.histogram.enable();
|
|
67
|
+
this.running = true;
|
|
68
|
+
|
|
69
|
+
this.interval = setInterval(() => {
|
|
70
|
+
this.collect();
|
|
71
|
+
}, this.config.intervalMs).unref();
|
|
72
|
+
|
|
73
|
+
getLogger().info("HealthMonitor started", {
|
|
74
|
+
intervalMs: this.config.intervalMs,
|
|
75
|
+
eventLoopLagThreshold: `${this.config.eventLoopLagMs}ms`,
|
|
76
|
+
criticalLagThreshold: `${this.config.criticalLagMs}ms`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stop monitoring
|
|
82
|
+
*/
|
|
83
|
+
stop(): void {
|
|
84
|
+
if (!this.running) return;
|
|
85
|
+
|
|
86
|
+
this.running = false;
|
|
87
|
+
|
|
88
|
+
if (this.interval) {
|
|
89
|
+
clearInterval(this.interval);
|
|
90
|
+
delete this.interval;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.histogram.disable();
|
|
94
|
+
getLogger().info("HealthMonitor stopped");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Force an immediate health check (for testing)
|
|
99
|
+
*/
|
|
100
|
+
checkNow(): ReturnType<MetricsCollector["getSnapshot"]> {
|
|
101
|
+
this.collect();
|
|
102
|
+
return this.metrics.getSnapshot();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// COLLECTION LOGIC
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
private collect(): void {
|
|
110
|
+
const logger = getLogger();
|
|
111
|
+
|
|
112
|
+
// ── Event Loop Lag ────────────────────────────────────────────────────
|
|
113
|
+
const p50 = this.histogram.percentile(50) / 1e6; // Convert nanoseconds to ms
|
|
114
|
+
const p95 = this.histogram.percentile(95) / 1e6;
|
|
115
|
+
const p99 = this.histogram.percentile(99) / 1e6;
|
|
116
|
+
const maxLag = this.histogram.max / 1e6;
|
|
117
|
+
|
|
118
|
+
this.metrics.updateEventLoopMetrics({ p50, p95, p99, max: maxLag });
|
|
119
|
+
this.histogram.reset();
|
|
120
|
+
|
|
121
|
+
// ── Event Loop Thresholds ─────────────────────────────────────────────
|
|
122
|
+
if (p99 > this.config.criticalLagMs) {
|
|
123
|
+
this.consecutiveCriticalLag++;
|
|
124
|
+
|
|
125
|
+
this.bus.emit("eventloop:lag-critical", {
|
|
126
|
+
data: { p50, p95, p99, max: maxLag, consecutive: this.consecutiveCriticalLag },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
logger.error("Event loop CRITICAL lag detected", {
|
|
130
|
+
p99: `${p99.toFixed(1)}ms`,
|
|
131
|
+
threshold: `${this.config.criticalLagMs}ms`,
|
|
132
|
+
consecutive: this.consecutiveCriticalLag,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// After 2 consecutive critical readings, trigger a proactive restart
|
|
136
|
+
if (this.consecutiveCriticalLag >= 2 && !this.restartRequested) {
|
|
137
|
+
this.restartRequested = true;
|
|
138
|
+
logger.error("Requesting graceful restart due to sustained critical event loop lag");
|
|
139
|
+
this.supervisorRef?.requestGracefulRestart(
|
|
140
|
+
`worker-${process.pid}`,
|
|
141
|
+
"critical-event-loop-lag"
|
|
142
|
+
);
|
|
143
|
+
// Reset after request
|
|
144
|
+
setTimeout(() => { this.restartRequested = false; }, 30_000).unref();
|
|
145
|
+
}
|
|
146
|
+
} else if (p99 > this.config.eventLoopLagMs) {
|
|
147
|
+
this.consecutiveCriticalLag = 0;
|
|
148
|
+
|
|
149
|
+
this.bus.emit("eventloop:lag-warning", {
|
|
150
|
+
data: { p50, p95, p99, max: maxLag },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
logger.warn("Event loop lag elevated", {
|
|
154
|
+
p99: `${p99.toFixed(1)}ms`,
|
|
155
|
+
threshold: `${this.config.eventLoopLagMs}ms`,
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
this.consecutiveCriticalLag = 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── CPU Usage ────────────────────────────────────────────────────────
|
|
162
|
+
this.metrics.updateCpuMetrics();
|
|
163
|
+
const snapshot = this.metrics.getSnapshot();
|
|
164
|
+
const totalCpu = snapshot.cpu.percentUser + snapshot.cpu.percentSystem;
|
|
165
|
+
|
|
166
|
+
if (totalCpu > this.config.cpuCriticalPercent) {
|
|
167
|
+
this.consecutiveCriticalCpu++;
|
|
168
|
+
|
|
169
|
+
this.bus.emit("cpu:critical", {
|
|
170
|
+
data: { totalCpu, user: snapshot.cpu.percentUser, system: snapshot.cpu.percentSystem },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (this.consecutiveCriticalCpu >= 3) {
|
|
174
|
+
logger.error("Sustained critical CPU usage", {
|
|
175
|
+
cpu: `${totalCpu.toFixed(1)}%`,
|
|
176
|
+
consecutive: this.consecutiveCriticalCpu,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
} else if (totalCpu > this.config.cpuWarningPercent) {
|
|
180
|
+
this.consecutiveCriticalCpu = 0;
|
|
181
|
+
|
|
182
|
+
this.bus.emit("cpu:warning", {
|
|
183
|
+
data: { totalCpu },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
logger.warn("High CPU usage", { cpu: `${totalCpu.toFixed(1)}%` });
|
|
187
|
+
} else {
|
|
188
|
+
this.consecutiveCriticalCpu = 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|