tighten server types
This commit is contained in:
1 parent
290adf024c
commit
e834402672
3 files changed
+191
-135
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { Mongoose } from "mongoose";
|
||||
import MongoDB from "../db/MongoDB.js";
|
||||
import { IDb } from "@/db/IDb.js";
|
||||
import {
|
||||
@@ -125,7 +126,7 @@ import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
export type InitializedServices = {
|
||||
settingsService: ISettingsService;
|
||||
db: IDb;
|
||||
db: IDb<Mongoose>;
|
||||
networkService: INetworkService;
|
||||
emailService: IEmailService;
|
||||
bufferService: IBufferService;
|
||||
@@ -173,7 +174,7 @@ export const initializeServices = async ({
|
||||
}): Promise<InitializedServices> => {
|
||||
// Create DB
|
||||
|
||||
let db: IDb | null = null;
|
||||
let db: IDb<Mongoose> | null = null;
|
||||
db = new MongoDB(logger, envSettings);
|
||||
await db.connect();
|
||||
|
||||
|
||||
@@ -6,16 +6,39 @@ import { AppError } from "@/utils/AppError.js";
|
||||
|
||||
const SERVICE_NAME = "diagnosticService";
|
||||
|
||||
export interface CollectionDiagnostics {
|
||||
name: string;
|
||||
documentCount: number;
|
||||
storageSize: number;
|
||||
totalIndexSize: number;
|
||||
totalSize: number;
|
||||
bucketCount?: number;
|
||||
}
|
||||
|
||||
export interface MongoDiagnostics {
|
||||
readyState: number;
|
||||
host: string;
|
||||
port: number;
|
||||
dbName: string;
|
||||
totalSize: number;
|
||||
collections: CollectionDiagnostics[];
|
||||
}
|
||||
|
||||
export interface Diagnostics {
|
||||
osStats: { totalMemoryBytes: number };
|
||||
cpuUsage: { usagePercentage: number };
|
||||
v8HeapStats: {
|
||||
totalHeapSizeBytes: number;
|
||||
usedHeapSizeBytes: number;
|
||||
heapSizeLimitBytes: number;
|
||||
};
|
||||
eventLoopDelayMs: number;
|
||||
uptimeMs: number;
|
||||
mongoStats: MongoDiagnostics;
|
||||
}
|
||||
|
||||
export interface IDiagnosticService {
|
||||
getCPUUsage(): Promise<{ userUsageMs: number; systemUsageMs: number; usagePercentage: number }>;
|
||||
getSystemStats(): Promise<{
|
||||
osStats: { freeMemoryBytes: number; totalMemoryBytes: number };
|
||||
memoryUsage: Record<keyof NodeJS.MemoryUsage, number>;
|
||||
cpuUsage: { userUsageMs: number; systemUsageMs: number; usagePercentage: number };
|
||||
v8HeapStats: { totalHeapSizeBytes: number; usedHeapSizeBytes: number; heapSizeLimitBytes: number };
|
||||
eventLoopDelayMs: number;
|
||||
uptimeMs: number;
|
||||
}>;
|
||||
getSystemStats(): Promise<Diagnostics>;
|
||||
}
|
||||
|
||||
export class DiagnosticService implements IDiagnosticService {
|
||||
@@ -37,117 +60,86 @@ export class DiagnosticService implements IDiagnosticService {
|
||||
return DiagnosticService.SERVICE_NAME;
|
||||
}
|
||||
|
||||
getCPUUsage = async () => {
|
||||
private getCPUUsagePercentage = async (): Promise<number> => {
|
||||
const startUsage = process.cpuUsage();
|
||||
const timingPeriod = 1000; // measured in ms
|
||||
await new Promise((resolve) => setTimeout(resolve, timingPeriod));
|
||||
const endUsage = process.cpuUsage(startUsage);
|
||||
const cpuUsage = {
|
||||
userUsageMs: endUsage.user / 1000,
|
||||
systemUsageMs: endUsage.system / 1000,
|
||||
usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100,
|
||||
};
|
||||
return cpuUsage;
|
||||
return ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100;
|
||||
};
|
||||
|
||||
private getMongoDBStats = async () => {
|
||||
private getMongoDBStats = async (): Promise<MongoDiagnostics> => {
|
||||
const mongo = await this.db.getConnection();
|
||||
const db = mongo.connection.db;
|
||||
if (!db) {
|
||||
throw new AppError({ message: "Database connection is not available", service: SERVICE_NAME, method: "getMongoDBStats" });
|
||||
}
|
||||
|
||||
const readyState = mongo.connection.readyState;
|
||||
const host = mongo.connection.host;
|
||||
const port = mongo.connection.port;
|
||||
const dbName = mongo.connection.name;
|
||||
const dbStats = await db.stats();
|
||||
const rawCollections = await db.collections();
|
||||
|
||||
const serverStatus = await db.admin().serverStatus();
|
||||
const serverInfo = await db.admin().serverInfo();
|
||||
|
||||
const stats = await db.stats();
|
||||
const collections = await db.collections();
|
||||
|
||||
const collectionStats = await Promise.all(
|
||||
(collections || []).map(async (collection) => {
|
||||
const stats = await db.command({ collStats: collection.collectionName });
|
||||
const documentCount = await db.collection(collection.collectionName).countDocuments({});
|
||||
return { name: collection.collectionName, documentCount, ...stats };
|
||||
})
|
||||
const collections: CollectionDiagnostics[] = await Promise.all(
|
||||
rawCollections
|
||||
.filter((collection) => !collection.collectionName.startsWith("system"))
|
||||
.map(async (collection) => {
|
||||
const stats = await db.command({ collStats: collection.collectionName });
|
||||
const documentCount = await db.collection(collection.collectionName).countDocuments({});
|
||||
const entry: CollectionDiagnostics = {
|
||||
name: collection.collectionName,
|
||||
documentCount,
|
||||
storageSize: stats.storageSize,
|
||||
totalIndexSize: stats.totalIndexSize,
|
||||
totalSize: stats.totalSize,
|
||||
};
|
||||
if (stats.timeseries?.bucketCount !== undefined) {
|
||||
entry.bucketCount = stats.timeseries.bucketCount;
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
readyState,
|
||||
host,
|
||||
port,
|
||||
dbName,
|
||||
serverStatus,
|
||||
serverInfo,
|
||||
stats,
|
||||
collectionStats,
|
||||
readyState: mongo.connection.readyState,
|
||||
host: mongo.connection.host,
|
||||
port: mongo.connection.port,
|
||||
dbName: mongo.connection.name,
|
||||
totalSize: dbStats.totalSize,
|
||||
collections,
|
||||
};
|
||||
};
|
||||
|
||||
getSystemStats = async () => {
|
||||
// Memory Usage
|
||||
const totalMemory = os.totalmem();
|
||||
const freeMemory = os.freemem();
|
||||
getSystemStats = async (): Promise<Diagnostics> => {
|
||||
const osStats = { totalMemoryBytes: os.totalmem() };
|
||||
|
||||
const osStats = {
|
||||
freeMemoryBytes: freeMemory, // bytes
|
||||
totalMemoryBytes: totalMemory, // bytes
|
||||
};
|
||||
const cpuUsage = { usagePercentage: await this.getCPUUsagePercentage() };
|
||||
|
||||
const used = process.memoryUsage();
|
||||
|
||||
// In MB
|
||||
const memoryUsage: Record<keyof NodeJS.MemoryUsage, number> = {
|
||||
rss: Math.round((used.rss / 1024 / 1024) * 100) / 100,
|
||||
heapTotal: Math.round((used.heapTotal / 1024 / 1024) * 100) / 100,
|
||||
heapUsed: Math.round((used.heapUsed / 1024 / 1024) * 100) / 100,
|
||||
external: Math.round((used.external / 1024 / 1024) * 100) / 100,
|
||||
arrayBuffers: Math.round((used.arrayBuffers / 1024 / 1024) * 100) / 100,
|
||||
};
|
||||
|
||||
// CPU Usage
|
||||
const cpuMetrics = await this.getCPUUsage();
|
||||
|
||||
// V8 Heap Statistics
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
const v8Metrics = {
|
||||
totalHeapSizeBytes: heapStats.total_heap_size, // bytes
|
||||
usedHeapSizeBytes: heapStats.used_heap_size, // bytes
|
||||
heapSizeLimitBytes: heapStats.heap_size_limit, // bytes
|
||||
const v8HeapStats = {
|
||||
totalHeapSizeBytes: heapStats.total_heap_size,
|
||||
usedHeapSizeBytes: heapStats.used_heap_size,
|
||||
heapSizeLimitBytes: heapStats.heap_size_limit,
|
||||
};
|
||||
|
||||
// Event Loop Delay
|
||||
let eventLoopDelay = 0;
|
||||
let eventLoopDelayMs = 0;
|
||||
performance.mark("start");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
performance.mark("end");
|
||||
performance.measure("eventLoopDelay", "start", "end");
|
||||
const entries = performance.getEntriesByName("eventLoopDelay");
|
||||
if (entries.length > 0 && entries[0] !== undefined) {
|
||||
eventLoopDelay = entries[0].duration;
|
||||
eventLoopDelayMs = entries[0].duration;
|
||||
}
|
||||
|
||||
// Uptime
|
||||
const uptimeMs = process.uptime() * 1000; // ms
|
||||
|
||||
// Mongo
|
||||
const uptimeMs = process.uptime() * 1000;
|
||||
const mongoStats = await this.getMongoDBStats();
|
||||
|
||||
// Combine Metrics
|
||||
const diagnostics = {
|
||||
return {
|
||||
osStats,
|
||||
memoryUsage,
|
||||
cpuUsage: cpuMetrics,
|
||||
v8HeapStats: v8Metrics,
|
||||
eventLoopDelayMs: eventLoopDelay,
|
||||
cpuUsage,
|
||||
v8HeapStats,
|
||||
eventLoopDelayMs,
|
||||
uptimeMs,
|
||||
mongoStats,
|
||||
};
|
||||
|
||||
return diagnostics;
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,49 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts";
|
||||
import type { IDb } from "../../../src/db/IDb.ts";
|
||||
import type { Mongoose } from "mongoose";
|
||||
|
||||
// ── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const makeDb = (overrides?: {
|
||||
collections?: Array<{ collectionName: string }>;
|
||||
collStats?: Record<string, unknown>;
|
||||
dbStats?: Record<string, unknown>;
|
||||
countDocuments?: number;
|
||||
noDb?: boolean;
|
||||
}): IDb<Mongoose> => {
|
||||
const collections = overrides?.collections ?? [];
|
||||
const collStats = overrides?.collStats ?? {
|
||||
storageSize: 100,
|
||||
totalIndexSize: 50,
|
||||
totalSize: 150,
|
||||
};
|
||||
const dbStats = overrides?.dbStats ?? { totalSize: 1000 };
|
||||
const countDocuments = overrides?.countDocuments ?? 42;
|
||||
|
||||
const dbApi = overrides?.noDb
|
||||
? null
|
||||
: {
|
||||
stats: jest.fn<() => Promise<Record<string, unknown>>>().mockResolvedValue(dbStats),
|
||||
collections: jest.fn<() => Promise<Array<{ collectionName: string }>>>().mockResolvedValue(collections),
|
||||
command: jest.fn<() => Promise<Record<string, unknown>>>().mockResolvedValue(collStats),
|
||||
collection: jest.fn().mockReturnValue({
|
||||
countDocuments: jest.fn<() => Promise<number>>().mockResolvedValue(countDocuments),
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
getConnection: jest.fn<() => Promise<Mongoose>>().mockResolvedValue({
|
||||
connection: {
|
||||
db: dbApi,
|
||||
readyState: 1,
|
||||
host: "localhost",
|
||||
port: 27017,
|
||||
name: "uptime_db",
|
||||
},
|
||||
} as unknown as Mongoose),
|
||||
} as unknown as IDb<Mongoose>;
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,7 +62,7 @@ describe("DiagnosticService", () => {
|
||||
});
|
||||
|
||||
it("returns diagnosticService from instance getter", () => {
|
||||
const service = new DiagnosticService();
|
||||
const service = new DiagnosticService(makeDb());
|
||||
expect(service.serviceName).toBe("diagnosticService");
|
||||
});
|
||||
});
|
||||
@@ -37,12 +81,11 @@ describe("DiagnosticService", () => {
|
||||
}) as any;
|
||||
|
||||
const clearMarksSpy = jest.spyOn(performance, "clearMarks");
|
||||
const service = new DiagnosticService();
|
||||
const service = new DiagnosticService(makeDb());
|
||||
|
||||
expect(mockObserve).toHaveBeenCalledWith({ entryTypes: ["measure"] });
|
||||
expect(capturedCallback).not.toBeNull();
|
||||
|
||||
// Invoke the callback manually
|
||||
const mockEntryList = { getEntries: jest.fn().mockReturnValue([]) } as unknown as PerformanceObserverEntryList;
|
||||
capturedCallback!(mockEntryList);
|
||||
|
||||
@@ -55,68 +98,88 @@ describe("DiagnosticService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── getCPUUsage ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getCPUUsage", () => {
|
||||
it("returns cpu usage metrics after timing period", async () => {
|
||||
const service = new DiagnosticService();
|
||||
const promise = service.getCPUUsage();
|
||||
jest.advanceTimersByTime(1000);
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toHaveProperty("userUsageMs");
|
||||
expect(result).toHaveProperty("systemUsageMs");
|
||||
expect(result).toHaveProperty("usagePercentage");
|
||||
expect(typeof result.userUsageMs).toBe("number");
|
||||
expect(typeof result.systemUsageMs).toBe("number");
|
||||
expect(typeof result.usagePercentage).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSystemStats ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getSystemStats", () => {
|
||||
it("returns all diagnostic sections", async () => {
|
||||
const service = new DiagnosticService();
|
||||
it("returns only the slimmed diagnostic shape (no serverStatus/serverInfo/memoryUsage)", async () => {
|
||||
const service = new DiagnosticService(makeDb());
|
||||
const promise = service.getSystemStats();
|
||||
// getCPUUsage has 1s timeout, then event loop delay has 0ms timeout
|
||||
// getCPUUsagePercentage waits 1s, then event loop delay waits 0ms
|
||||
jest.advanceTimersByTime(1000);
|
||||
// Need to flush the microtask queue and advance again for the nested setTimeout(0)
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toHaveProperty("osStats");
|
||||
expect(result.osStats).toHaveProperty("freeMemoryBytes");
|
||||
expect(result.osStats).toHaveProperty("totalMemoryBytes");
|
||||
|
||||
expect(result).toHaveProperty("memoryUsage");
|
||||
expect(result.memoryUsage).toHaveProperty("rss");
|
||||
expect(result.memoryUsage).toHaveProperty("heapTotal");
|
||||
expect(result.memoryUsage).toHaveProperty("heapUsed");
|
||||
expect(result.memoryUsage).toHaveProperty("external");
|
||||
expect(result.memoryUsage).toHaveProperty("arrayBuffers");
|
||||
|
||||
expect(result).toHaveProperty("cpuUsage");
|
||||
expect(result.cpuUsage).toHaveProperty("userUsageMs");
|
||||
|
||||
expect(result).toHaveProperty("v8HeapStats");
|
||||
expect(result.v8HeapStats).toHaveProperty("totalHeapSizeBytes");
|
||||
expect(result.v8HeapStats).toHaveProperty("usedHeapSizeBytes");
|
||||
expect(result.v8HeapStats).toHaveProperty("heapSizeLimitBytes");
|
||||
expect(Object.keys(result).sort()).toEqual(["cpuUsage", "eventLoopDelayMs", "mongoStats", "osStats", "uptimeMs", "v8HeapStats"].sort());
|
||||
|
||||
expect(result.osStats).toEqual({ totalMemoryBytes: expect.any(Number) });
|
||||
expect(result.cpuUsage).toEqual({ usagePercentage: expect.any(Number) });
|
||||
expect(result.v8HeapStats).toEqual({
|
||||
totalHeapSizeBytes: expect.any(Number),
|
||||
usedHeapSizeBytes: expect.any(Number),
|
||||
heapSizeLimitBytes: expect.any(Number),
|
||||
});
|
||||
expect(typeof result.eventLoopDelayMs).toBe("number");
|
||||
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
|
||||
expect(typeof result.uptimeMs).toBe("number");
|
||||
expect(result.uptimeMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns slimmed mongoStats with `collections` (not `collectionStats`) and no serverStatus/serverInfo", async () => {
|
||||
const db = makeDb({
|
||||
collections: [{ collectionName: "monitors" }, { collectionName: "checks" }],
|
||||
collStats: { storageSize: 200, totalIndexSize: 80, totalSize: 280 },
|
||||
dbStats: { totalSize: 9999 },
|
||||
countDocuments: 7,
|
||||
});
|
||||
const service = new DiagnosticService(db);
|
||||
const promise = service.getSystemStats();
|
||||
jest.advanceTimersByTime(1000);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
const result = await promise;
|
||||
|
||||
expect(Object.keys(result.mongoStats).sort()).toEqual(["collections", "dbName", "host", "port", "readyState", "totalSize"].sort());
|
||||
expect(result.mongoStats.totalSize).toBe(9999);
|
||||
expect(result.mongoStats.collections).toHaveLength(2);
|
||||
expect(result.mongoStats.collections[0]).toEqual({
|
||||
name: "monitors",
|
||||
documentCount: 7,
|
||||
storageSize: 200,
|
||||
totalIndexSize: 80,
|
||||
totalSize: 280,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes bucketCount on timeseries collections, omits it elsewhere", async () => {
|
||||
const db = makeDb({
|
||||
collections: [{ collectionName: "checks" }],
|
||||
collStats: { storageSize: 1, totalIndexSize: 2, totalSize: 3, timeseries: { bucketCount: 12 } },
|
||||
});
|
||||
const service = new DiagnosticService(db);
|
||||
const promise = service.getSystemStats();
|
||||
jest.advanceTimersByTime(1000);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.mongoStats.collections[0]?.bucketCount).toBe(12);
|
||||
|
||||
const db2 = makeDb({ collections: [{ collectionName: "monitors" }] });
|
||||
const service2 = new DiagnosticService(db2);
|
||||
const promise2 = service2.getSystemStats();
|
||||
jest.advanceTimersByTime(1000);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
const result2 = await promise2;
|
||||
expect(result2.mongoStats.collections[0]).not.toHaveProperty("bucketCount");
|
||||
});
|
||||
|
||||
it("throws AppError when the mongo connection has no db handle", async () => {
|
||||
jest.useRealTimers();
|
||||
const service = new DiagnosticService(makeDb({ noDb: true }));
|
||||
await expect(service.getSystemStats()).rejects.toThrow("Database connection is not available");
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it("reads eventLoopDelay from real performance entries", async () => {
|
||||
jest.useRealTimers();
|
||||
const service = new DiagnosticService();
|
||||
|
||||
// Call getSystemStats with real timers — slow but guarantees real perf entries
|
||||
const service = new DiagnosticService(makeDb());
|
||||
const result = await service.getSystemStats();
|
||||
|
||||
expect(typeof result.eventLoopDelayMs).toBe("number");
|
||||
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
|
||||
jest.useFakeTimers();
|
||||
@@ -126,7 +189,7 @@ describe("DiagnosticService", () => {
|
||||
{ label: "empty entries array", entries: [] },
|
||||
{ label: "undefined entry in array", entries: [undefined] },
|
||||
])("defaults eventLoopDelayMs to 0 when $label", async ({ entries }) => {
|
||||
const service = new DiagnosticService();
|
||||
const service = new DiagnosticService(makeDb());
|
||||
const originalGetEntries = performance.getEntriesByName;
|
||||
const originalMeasure = performance.measure;
|
||||
const originalMark = performance.mark;
|
||||
|
||||
Reference in new issue
Block a user