diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 363e699d9..4eb6c1081 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -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; networkService: INetworkService; emailService: IEmailService; bufferService: IBufferService; @@ -173,7 +174,7 @@ export const initializeServices = async ({ }): Promise => { // Create DB - let db: IDb | null = null; + let db: IDb | null = null; db = new MongoDB(logger, envSettings); await db.connect(); diff --git a/server/src/service/business/diagnosticService.ts b/server/src/service/business/diagnosticService.ts index 49c691175..2d9968240 100644 --- a/server/src/service/business/diagnosticService.ts +++ b/server/src/service/business/diagnosticService.ts @@ -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; - cpuUsage: { userUsageMs: number; systemUsageMs: number; usagePercentage: number }; - v8HeapStats: { totalHeapSizeBytes: number; usedHeapSizeBytes: number; heapSizeLimitBytes: number }; - eventLoopDelayMs: number; - uptimeMs: number; - }>; + getSystemStats(): Promise; } export class DiagnosticService implements IDiagnosticService { @@ -37,117 +60,86 @@ export class DiagnosticService implements IDiagnosticService { return DiagnosticService.SERVICE_NAME; } - getCPUUsage = async () => { + private getCPUUsagePercentage = async (): Promise => { 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 => { 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 => { + 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 = { - 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; }; } diff --git a/server/test/unit/services/diagnosticService.test.ts b/server/test/unit/services/diagnosticService.test.ts index a76f28893..ae57613a1 100644 --- a/server/test/unit/services/diagnosticService.test.ts +++ b/server/test/unit/services/diagnosticService.test.ts @@ -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; + dbStats?: Record; + countDocuments?: number; + noDb?: boolean; +}): IDb => { + 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>>().mockResolvedValue(dbStats), + collections: jest.fn<() => Promise>>().mockResolvedValue(collections), + command: jest.fn<() => Promise>>().mockResolvedValue(collStats), + collection: jest.fn().mockReturnValue({ + countDocuments: jest.fn<() => Promise>().mockResolvedValue(countDocuments), + }), + }; + + return { + getConnection: jest.fn<() => Promise>().mockResolvedValue({ + connection: { + db: dbApi, + readyState: 1, + host: "localhost", + port: 27017, + name: "uptime_db", + }, + } as unknown as Mongoose), + } as unknown as IDb; +}; // ── 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;