diff --git a/src/stats-server.mjs b/src/stats-server.mjs index 5d7221a..36cc45e 100644 --- a/src/stats-server.mjs +++ b/src/stats-server.mjs @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import { logDatabaseSize } from "./telemetry.mjs"; import { computeMemoryStats } from "./memory-stats.mjs"; const LOOPBACK_ADDRS = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); @@ -19,7 +20,7 @@ export function isLoopbackAddress(addr) { * @param {{ loadMemories: () => Promise, now?: () => number }} options * @returns {import("node:http").Server} */ -export function createStatsServer({ loadMemories, now = Date.now } = {}) { + export function createStatsServer({ loadMemories, now = Date.now, logger = console.log } = {}) { if (typeof loadMemories !== "function") { throw new TypeError("createStatsServer requires loadMemories to be a function"); } @@ -35,6 +36,7 @@ export function createStatsServer({ loadMemories, now = Date.now } = {}) { try { const memories = await loadMemories(); + logDatabaseSize({ memories }, logger); const stats = computeMemoryStats(memories, { now: now() }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(stats)); diff --git a/src/telemetry.mjs b/src/telemetry.mjs new file mode 100644 index 0000000..f38cd68 --- /dev/null +++ b/src/telemetry.mjs @@ -0,0 +1,53 @@ +// Instrumentation for database size and query performance metrics. +// Pure/side-effect-light: logging is injectable so tests don't need to +// capture real console output. + +/** + * Computes the on-disk size of a memory store, in bytes. + * @param {Object} memoryStore + * @returns {number} + */ +export function computeDatabaseSizeBytes(memoryStore = {}) { + return Buffer.byteLength(JSON.stringify(memoryStore), "utf8"); +} + +/** + * Logs the current database size and memory count. + * @param {Object} memoryStore + * @param {(entry: object) => void} [logger] - Defaults to console.log + * @returns {{ event: string, bytes: number, memoryCount: number, timestamp: string }} + */ +export function logDatabaseSize(memoryStore = {}, logger = console.log) { + const entry = { + event: "database_size", + bytes: computeDatabaseSizeBytes(memoryStore), + memoryCount: Array.isArray(memoryStore.memories) ? memoryStore.memories.length : 0, + timestamp: new Date().toISOString(), + }; + logger(JSON.stringify(entry)); + return entry; +} + +/** + * Wraps a query function so every call logs its duration and result count. + * @param {Function} queryFn - e.g. retrieveMemories + * @param {(entry: object) => void} [logger] - Defaults to console.log + * @returns {Function} A wrapped version of queryFn with identical behavior + */ +export function instrumentQuery(queryFn, logger = console.log) { + return function instrumented(...args) { + const startedAt = Date.now(); + const result = queryFn(...args); + const durationMs = Date.now() - startedAt; + const resultCount = Array.isArray(result) ? result.length : (Array.isArray(result?.memories) ? result.memories.length : undefined); + + logger(JSON.stringify({ + event: "query_metrics", + durationMs, + resultCount, + timestamp: new Date().toISOString(), + })); + + return result; + }; +} \ No newline at end of file diff --git a/test/memory-stats.test.mjs b/test/memory-stats.test.mjs index 5b374a0..8eb651b 100644 --- a/test/memory-stats.test.mjs +++ b/test/memory-stats.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { computeMemoryStats } from "../src/memory-stats.mjs"; -import { isLoopbackAddress } from "../src/stats-server.mjs"; +import { isLoopbackAddress, createStatsServer } from "../src/stats-server.mjs"; // --------------------------------------------------------------------------- // activeCount @@ -178,3 +178,30 @@ test("isLoopbackAddress returns false for 0.0.0.0", () => { test("isLoopbackAddress returns false for empty string", () => { assert.ok(!isLoopbackAddress("")); }); + +// --------------------------------------------------------------------------- +// createStatsServer telemetry logging +// --------------------------------------------------------------------------- + +test("createStatsServer logs database size on each stats request", async () => { + const logs = []; + const fakeMemories = [{ id: "m_01", status: "active" }, { id: "m_02", status: "active" }]; + + const server = createStatsServer({ + loadMemories: async () => fakeMemories, + logger: (line) => logs.push(line), + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + + const response = await fetch(`http://127.0.0.1:${port}/`); + await response.json(); + + await new Promise((resolve) => server.close(resolve)); + + assert.equal(logs.length, 1); + const parsed = JSON.parse(logs[0]); + assert.equal(parsed.event, "database_size"); + assert.equal(parsed.memoryCount, 2); +}); diff --git a/test/telemetry.test.mjs b/test/telemetry.test.mjs new file mode 100644 index 0000000..77c309d --- /dev/null +++ b/test/telemetry.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert"; +import { computeDatabaseSizeBytes, logDatabaseSize, instrumentQuery } from "../src/telemetry.mjs"; + +test("Memory Consumption Telemetry Logs", async (t) => { + + await t.test("should compute the byte size of a memory store", () => { + const store = { memories: [{ id: "m_01" }] }; + const size = computeDatabaseSizeBytes(store); + assert.strictEqual(size, Buffer.byteLength(JSON.stringify(store), "utf8")); + assert.ok(size > 0); + }); + + await t.test("should log database size with memory count and return the logged entry", () => { + const logs = []; + const store = { memories: [{ id: "m_01" }, { id: "m_02" }] }; + + const entry = logDatabaseSize(store, (line) => logs.push(line)); + + assert.strictEqual(logs.length, 1); + const parsed = JSON.parse(logs[0]); + assert.strictEqual(parsed.event, "database_size"); + assert.strictEqual(parsed.memoryCount, 2); + assert.strictEqual(entry.memoryCount, 2); + assert.ok(parsed.bytes > 0); + }); + + await t.test("should handle a store with no memories array", () => { + const entry = logDatabaseSize({}, () => {}); + assert.strictEqual(entry.memoryCount, 0); + }); + + await t.test("should log query duration and result count without changing the result", () => { + const logs = []; + const fakeQuery = (query) => [{ id: "m_01" }, { id: "m_02" }].filter((m) => query !== "none"); + + const wrapped = instrumentQuery(fakeQuery, (line) => logs.push(line)); + const result = wrapped("health"); + + assert.deepStrictEqual(result, [{ id: "m_01" }, { id: "m_02" }]); + assert.strictEqual(logs.length, 1); + + const parsed = JSON.parse(logs[0]); + assert.strictEqual(parsed.event, "query_metrics"); + assert.strictEqual(parsed.resultCount, 2); + assert.ok(typeof parsed.durationMs === "number"); + }); + + await t.test("should still log correctly when the wrapped query returns zero results", () => { + const logs = []; + const fakeQuery = () => []; + + const wrapped = instrumentQuery(fakeQuery, (line) => logs.push(line)); + wrapped("nothing matches"); + + const parsed = JSON.parse(logs[0]); + assert.strictEqual(parsed.resultCount, 0); + }); +}); \ No newline at end of file