From 9bc1c7c0c51fac9596bf704dc7c1a2576acc08d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 01:22:11 +0000 Subject: [PATCH 1/6] feat(utils): make Stats event-driven and fully featured Enhance the shared Stats class in @cacheable/utils so it can serve as the single statistics primitive across all cacheable libraries. - Event-driven: subscribe()/unsubscribe() attach to any duck-typed emitter (Hookified or Node EventEmitter) and update counters automatically, with built-in event maps for cacheable, node-cache, and cache-manager - Imperative: unified increment(field, amount)/decrement(field, amount) plus the existing named methods for consumers not using events - Computed hitRate/missRate (zero-division guarded) - toJSON()/snapshot() for serialization - lastUpdated/lastReset timestamps - enable()/disable()/clear() ergonomics Fully backward compatible: all existing getters and methods are preserved, so cacheable and node-cache are unaffected. 100% coverage on stats.ts. https://claude.ai/code/session_019LJSwEXR15J1d8PHrNHMrE --- packages/utils/src/index.ts | 13 +- packages/utils/src/stats.ts | 415 +++++++++++++++++++++++++----- packages/utils/test/stats.test.ts | 314 +++++++++++++++++++++- 3 files changed, 681 insertions(+), 61 deletions(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e65f0e8a..4ddd5b6b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -46,7 +46,18 @@ export { } from "./memoize.js"; export { runIfFn } from "./run-if-fn.js"; export { sleep } from "./sleep.js"; -export { Stats, type StatsOptions } from "./stats.js"; +export { + cacheableStatsEventMap, + cacheManagerStatsEventMap, + nodeCacheStatsEventMap, + type StatField, + Stats, + type StatsEmitter, + type StatsEventHandler, + type StatsEventMap, + type StatsOptions, + type StatsSnapshot, +} from "./stats.js"; export { calculateTtlFromExpiration, getCascadingTtl, diff --git a/packages/utils/src/stats.ts b/packages/utils/src/stats.ts index 63a2004d..cb97fda9 100644 --- a/packages/utils/src/stats.ts +++ b/packages/utils/src/stats.ts @@ -1,24 +1,154 @@ // biome-ignore-all lint/suspicious/noExplicitAny: allowed + +/** + * A counter field that can be incremented or decremented via the unified + * {@link Stats.increment} / {@link Stats.decrement} API or an event map. + */ +export type StatField = + | "hits" + | "misses" + | "gets" + | "sets" + | "deletes" + | "clears" + | "count"; + +/** + * A duck-typed event emitter. This intentionally matches both `Hookified` + * (used by `cacheable`, `node-cache`, `memory`, `flat-cache`) and Node's + * built-in `EventEmitter` (used by `cache-manager`, `cacheable-request`) + * without adding a hard dependency on either. + */ +export type StatsEmitter = { + on(event: string, listener: (...args: any[]) => void): unknown; + off?(event: string, listener: (...args: any[]) => void): unknown; + removeListener?(event: string, listener: (...args: any[]) => void): unknown; +}; + +/** + * A custom handler invoked when a subscribed event fires. It receives the + * {@link Stats} instance and the raw event arguments (which may be positional, + * e.g. node-cache emits `(key, value)`). + */ +export type StatsEventHandler = (stats: Stats, ...args: any[]) => void; + +/** + * Maps an event name to the stat update it should perform: a single field to + * increment, an array of fields to increment, or a custom handler. + */ +export type StatsEventMap = Record< + string, + StatField | StatField[] | StatsEventHandler +>; + +/** + * A plain-object snapshot of a {@link Stats} instance, suitable for logging, + * metrics, or serialization. Returned by {@link Stats.toJSON}. + */ +export type StatsSnapshot = { + enabled: boolean; + hits: number; + misses: number; + gets: number; + sets: number; + deletes: number; + clears: number; + vsize: number; + ksize: number; + count: number; + hitRate: number; + missRate: number; + lastUpdated?: number; + lastReset?: number; +}; + export type StatsOptions = { + /** Whether the stats are enabled. Defaults to `false`. */ enabled?: boolean; + /** Optionally subscribe to an emitter immediately on construction. */ + emitter?: StatsEmitter; + /** + * The event map to use when `emitter` is provided. Defaults to + * {@link cacheableStatsEventMap}. + */ + eventMap?: StatsEventMap; +}; + +/** + * The default event map for `cacheable` instances. `cacheable` only emits + * `cache:hit` and `cache:miss` on its instance (set/delete are distributed + * sync events, not instance events), so a hit/miss also counts as a get. + */ +export const cacheableStatsEventMap: StatsEventMap = { + "cache:hit": ["hits", "gets"], + "cache:miss": ["misses", "gets"], +}; + +/** + * Event map for `@cacheable/node-cache` instances. node-cache emits with + * positional arguments (e.g. `set(key, value)`), but counting only needs the + * event name. + */ +export const nodeCacheStatsEventMap: StatsEventMap = { + set: "sets", + del: "deletes", + flush: "clears", +}; + +/** + * Event map for `cache-manager` instances. `cache-manager` emits `get` on + * every read (hit and miss), carrying `value` on a hit; hit/miss is therefore + * derived from the payload. Note: `get` can fire multiple times per read across + * store layers, so these counts are best-effort. + */ +export const cacheManagerStatsEventMap: StatsEventMap = { + get: (stats: Stats, payload?: { value?: unknown; error?: unknown }) => { + stats.increment("gets"); + if (payload && payload.value !== undefined) { + stats.increment("hits"); + } else { + stats.increment("misses"); + } + }, + set: "sets", + mset: "sets", + del: "deletes", + mdel: "deletes", + clear: "clears", +}; + +type StatsSubscription = { + emitter: StatsEmitter; + event: string; + listener: (...args: any[]) => void; }; export class Stats { - private _hits = 0; - private _misses = 0; - private _gets = 0; - private _sets = 0; - private _deletes = 0; - private _clears = 0; + private _counters: Record = { + hits: 0, + misses: 0, + gets: 0, + sets: 0, + deletes: 0, + clears: 0, + count: 0, + }; + private _vsize = 0; private _ksize = 0; - private _count = 0; private _enabled = false; + private _lastUpdated: number | undefined; + private _lastReset: number | undefined; + private _subscriptions: StatsSubscription[] = []; constructor(options?: StatsOptions) { if (options?.enabled) { this._enabled = options.enabled; } + + if (options?.emitter) { + this.subscribe(options.emitter, options.eventMap); + } } /** @@ -40,7 +170,7 @@ export class Stats { * @readonly */ public get hits(): number { - return this._hits; + return this._counters.hits; } /** @@ -48,7 +178,7 @@ export class Stats { * @readonly */ public get misses(): number { - return this._misses; + return this._counters.misses; } /** @@ -56,7 +186,7 @@ export class Stats { * @readonly */ public get gets(): number { - return this._gets; + return this._counters.gets; } /** @@ -64,7 +194,7 @@ export class Stats { * @readonly */ public get sets(): number { - return this._sets; + return this._counters.sets; } /** @@ -72,7 +202,7 @@ export class Stats { * @readonly */ public get deletes(): number { - return this._deletes; + return this._counters.deletes; } /** @@ -80,7 +210,7 @@ export class Stats { * @readonly */ public get clears(): number { - return this._clears; + return this._counters.clears; } /** @@ -104,55 +234,101 @@ export class Stats { * @readonly */ public get count(): number { - return this._count; + return this._counters.count; } - public incrementHits(): void { - if (!this._enabled) { - return; - } + /** + * The ratio of hits to total lookups (hits + misses). Returns `0` when there + * have been no lookups. + * @returns {number} - A value between 0 and 1 + * @readonly + */ + public get hitRate(): number { + const total = this._counters.hits + this._counters.misses; + return total === 0 ? 0 : this._counters.hits / total; + } - this._hits++; + /** + * The ratio of misses to total lookups (hits + misses). Returns `0` when + * there have been no lookups. + * @returns {number} - A value between 0 and 1 + * @readonly + */ + public get missRate(): number { + const total = this._counters.hits + this._counters.misses; + return total === 0 ? 0 : this._counters.misses / total; } - public incrementMisses(): void { - if (!this._enabled) { - return; - } + /** + * The timestamp (ms since epoch) of the last mutation while enabled, or + * `undefined` if there have been none since the last reset. + * @returns {number | undefined} + * @readonly + */ + public get lastUpdated(): number | undefined { + return this._lastUpdated; + } - this._misses++; + /** + * The timestamp (ms since epoch) of the last {@link reset}/{@link clear}, or + * `undefined` if it has never been reset. + * @returns {number | undefined} + * @readonly + */ + public get lastReset(): number | undefined { + return this._lastReset; } - public incrementGets(): void { + /** + * Increment a counter field by `amount` (default `1`). No-op when disabled. + * @param {StatField} field - The counter to increment + * @param {number} amount - The amount to add (default 1) + */ + public increment(field: StatField, amount = 1): void { if (!this._enabled) { return; } - this._gets++; + this._counters[field] += amount; + this.touch(); } - public incrementSets(): void { + /** + * Decrement a counter field by `amount` (default `1`). No-op when disabled. + * @param {StatField} field - The counter to decrement + * @param {number} amount - The amount to subtract (default 1) + */ + public decrement(field: StatField, amount = 1): void { if (!this._enabled) { return; } - this._sets++; + this._counters[field] -= amount; + this.touch(); } - public incrementDeletes(): void { - if (!this._enabled) { - return; - } + public incrementHits(amount = 1): void { + this.increment("hits", amount); + } - this._deletes++; + public incrementMisses(amount = 1): void { + this.increment("misses", amount); } - public incrementClears(): void { - if (!this._enabled) { - return; - } + public incrementGets(amount = 1): void { + this.increment("gets", amount); + } + + public incrementSets(amount = 1): void { + this.increment("sets", amount); + } + + public incrementDeletes(amount = 1): void { + this.increment("deletes", amount); + } - this._clears++; + public incrementClears(amount = 1): void { + this.increment("clears", amount); } public incrementVSize(value: any): void { @@ -161,6 +337,7 @@ export class Stats { } this._vsize += this.roughSizeOfObject(value); + this.touch(); } public decreaseVSize(value: any): void { @@ -169,6 +346,7 @@ export class Stats { } this._vsize -= this.roughSizeOfObject(value); + this.touch(); } public incrementKSize(key: string): void { @@ -177,6 +355,7 @@ export class Stats { } this._ksize += this.roughSizeOfString(key); + this.touch(); } public decreaseKSize(key: string): void { @@ -185,22 +364,15 @@ export class Stats { } this._ksize -= this.roughSizeOfString(key); + this.touch(); } - public incrementCount(): void { - if (!this._enabled) { - return; - } - - this._count++; + public incrementCount(amount = 1): void { + this.increment("count", amount); } - public decreaseCount(): void { - if (!this._enabled) { - return; - } - - this._count--; + public decreaseCount(amount = 1): void { + this.decrement("count", amount); } public setCount(count: number): void { @@ -208,7 +380,8 @@ export class Stats { return; } - this._count = count; + this._counters.count = count; + this.touch(); } public roughSizeOfString(value: string): number { @@ -256,21 +429,145 @@ export class Stats { return bytes; } + /** + * Enable stat tracking. Equivalent to setting {@link enabled} to `true`. + */ + public enable(): void { + this._enabled = true; + } + + /** + * Disable stat tracking. Equivalent to setting {@link enabled} to `false`. + */ + public disable(): void { + this._enabled = false; + } + + /** + * Reset all counters to zero and record the reset timestamp. Alias of + * {@link reset}. + */ + public clear(): void { + this.reset(); + } + public reset(): void { - this._hits = 0; - this._misses = 0; - this._gets = 0; - this._sets = 0; - this._deletes = 0; - this._clears = 0; + this._counters = { + hits: 0, + misses: 0, + gets: 0, + sets: 0, + deletes: 0, + clears: 0, + count: 0, + }; this._vsize = 0; this._ksize = 0; - this._count = 0; + this._lastReset = Date.now(); + this._lastUpdated = undefined; } public resetStoreValues(): void { this._vsize = 0; this._ksize = 0; - this._count = 0; + this._counters.count = 0; + } + + /** + * @returns {StatsSnapshot} - A plain-object snapshot of the current stats, + * including computed `hitRate`/`missRate` and timestamps. + */ + public toJSON(): StatsSnapshot { + return { + enabled: this._enabled, + hits: this._counters.hits, + misses: this._counters.misses, + gets: this._counters.gets, + sets: this._counters.sets, + deletes: this._counters.deletes, + clears: this._counters.clears, + vsize: this._vsize, + ksize: this._ksize, + count: this._counters.count, + hitRate: this.hitRate, + missRate: this.missRate, + lastUpdated: this._lastUpdated, + lastReset: this._lastReset, + }; + } + + /** + * @returns {StatsSnapshot} - A plain-object snapshot of the current stats. + * Alias of {@link toJSON}. + */ + public snapshot(): StatsSnapshot { + return this.toJSON(); + } + + /** + * Subscribe to an emitter so that matching events automatically update the + * stats. Counting is gated by {@link enabled}, so you may subscribe first and + * toggle enablement later. Call {@link unsubscribe} to detach. + * @param {StatsEmitter} emitter - The emitter to listen on + * @param {StatsEventMap} eventMap - The event-to-stat mapping (defaults to + * {@link cacheableStatsEventMap}) + */ + public subscribe( + emitter: StatsEmitter, + eventMap: StatsEventMap = cacheableStatsEventMap, + ): void { + for (const [event, action] of Object.entries(eventMap)) { + const listener = (...args: any[]): void => { + this.applyEvent(action, args); + }; + + emitter.on(event, listener); + this._subscriptions.push({ emitter, event, listener }); + } + } + + /** + * Detach listeners previously attached via {@link subscribe}. When `emitter` + * is provided, only that emitter's listeners are removed; otherwise all are. + * @param {StatsEmitter} [emitter] - The emitter to detach from + */ + public unsubscribe(emitter?: StatsEmitter): void { + const remaining: StatsSubscription[] = []; + + for (const sub of this._subscriptions) { + if (emitter && sub.emitter !== emitter) { + remaining.push(sub); + continue; + } + + const off = sub.emitter.off ?? sub.emitter.removeListener; + off?.call(sub.emitter, sub.event, sub.listener); + } + + this._subscriptions = remaining; + } + + private applyEvent( + action: StatField | StatField[] | StatsEventHandler, + args: any[], + ): void { + if (typeof action === "function") { + action(this, ...args); + return; + } + + if (Array.isArray(action)) { + for (const field of action) { + this.increment(field); + } + + return; + } + + this.increment(action); + } + + private touch(): void { + this._lastUpdated = Date.now(); } } diff --git a/packages/utils/test/stats.test.ts b/packages/utils/test/stats.test.ts index 560e9323..c02752bd 100644 --- a/packages/utils/test/stats.test.ts +++ b/packages/utils/test/stats.test.ts @@ -1,5 +1,48 @@ +import { EventEmitter } from "node:events"; import { describe, expect, test } from "vitest"; -import { Stats } from "../src/stats.js"; +import { + cacheManagerStatsEventMap, + nodeCacheStatsEventMap, + Stats, +} from "../src/stats.js"; + +type Listener = (...args: unknown[]) => void; + +/** Minimal emitter with no detach methods (covers the no-op unsubscribe path). */ +class BasicEmitter { + protected listeners: Record = {}; + + on(event: string, listener: Listener): void { + this.listeners[event] ??= []; + this.listeners[event].push(listener); + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners[event] ?? []) { + listener(...args); + } + } + + protected detach(event: string, listener: Listener): void { + this.listeners[event] = (this.listeners[event] ?? []).filter( + (l) => l !== listener, + ); + } +} + +/** Emitter exposing `off` (the preferred detach method). */ +class OffEmitter extends BasicEmitter { + off(event: string, listener: Listener): void { + this.detach(event, listener); + } +} + +/** Emitter exposing only `removeListener` (Node EventEmitter compatibility). */ +class RemoveListenerEmitter extends BasicEmitter { + removeListener(event: string, listener: Listener): void { + this.detach(event, listener); + } +} describe("cacheable stats", () => { test("should be able to instantiate", () => { @@ -208,3 +251,272 @@ describe("cacheable stats", () => { expect(size).toBeLessThan(Number.POSITIVE_INFINITY); }); }); + +describe("stats unified increment/decrement", () => { + test("should increment and decrement a field by amount", () => { + const stats = new Stats({ enabled: true }); + stats.increment("hits", 5); + stats.increment("misses"); + stats.decrement("hits", 2); + expect(stats.hits).toBe(3); + expect(stats.misses).toBe(1); + }); + + test("named increments accept an optional amount", () => { + const stats = new Stats({ enabled: true }); + stats.incrementHits(3); + stats.incrementCount(4); + stats.decreaseCount(1); + expect(stats.hits).toBe(3); + expect(stats.count).toBe(3); + }); + + test("should be a no-op when disabled", () => { + const stats = new Stats(); + stats.increment("hits", 5); + stats.decrement("count", 2); + expect(stats.hits).toBe(0); + expect(stats.count).toBe(0); + }); +}); + +describe("stats computed rates", () => { + test("should return 0 rates with no lookups", () => { + const stats = new Stats({ enabled: true }); + expect(stats.hitRate).toBe(0); + expect(stats.missRate).toBe(0); + }); + + test("should compute hit and miss rates", () => { + const stats = new Stats({ enabled: true }); + stats.incrementHits(3); + stats.incrementMisses(1); + expect(stats.hitRate).toBe(0.75); + expect(stats.missRate).toBe(0.25); + }); +}); + +describe("stats timestamps", () => { + test("should be undefined initially", () => { + const stats = new Stats({ enabled: true }); + expect(stats.lastUpdated).toBeUndefined(); + expect(stats.lastReset).toBeUndefined(); + }); + + test("should set lastUpdated on an enabled mutation", () => { + const stats = new Stats({ enabled: true }); + stats.incrementHits(); + expect(typeof stats.lastUpdated).toBe("number"); + }); + + test("should not set lastUpdated when disabled", () => { + const stats = new Stats(); + stats.incrementHits(); + expect(stats.lastUpdated).toBeUndefined(); + }); + + test("reset and clear should set lastReset and clear lastUpdated", () => { + const stats = new Stats({ enabled: true }); + stats.incrementHits(); + stats.reset(); + expect(typeof stats.lastReset).toBe("number"); + expect(stats.lastUpdated).toBeUndefined(); + + stats.incrementHits(); + stats.clear(); + expect(typeof stats.lastReset).toBe("number"); + expect(stats.lastUpdated).toBeUndefined(); + }); +}); + +describe("stats enable/disable/clear", () => { + test("enable and disable should toggle tracking", () => { + const stats = new Stats(); + expect(stats.enabled).toBe(false); + stats.enable(); + expect(stats.enabled).toBe(true); + stats.incrementHits(); + expect(stats.hits).toBe(1); + stats.disable(); + stats.incrementHits(); + expect(stats.hits).toBe(1); + }); + + test("clear should reset all counters", () => { + const stats = new Stats({ enabled: true }); + stats.incrementHits(); + stats.incrementSets(); + stats.clear(); + expect(stats.hits).toBe(0); + expect(stats.sets).toBe(0); + }); +}); + +describe("stats snapshot", () => { + test("toJSON should include counters, rates, and timestamps", () => { + const stats = new Stats({ enabled: true }); + stats.incrementHits(2); + stats.incrementMisses(1); + stats.incrementSets(); + stats.incrementVSize("foo"); + stats.incrementKSize("foo"); + stats.incrementCount(); + + const json = stats.toJSON(); + expect(json.enabled).toBe(true); + expect(json.hits).toBe(2); + expect(json.misses).toBe(1); + expect(json.sets).toBe(1); + expect(json.vsize).toBe(6); + expect(json.ksize).toBe(6); + expect(json.count).toBe(1); + expect(json.hitRate).toBeCloseTo(2 / 3); + expect(json.missRate).toBeCloseTo(1 / 3); + expect(typeof json.lastUpdated).toBe("number"); + }); + + test("snapshot should equal toJSON", () => { + const stats = new Stats({ enabled: true }); + stats.incrementHits(); + expect(stats.snapshot()).toEqual(stats.toJSON()); + }); +}); + +describe("stats event subscription", () => { + test("should track cacheable events with the default map", () => { + const emitter = new EventEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(emitter); + + emitter.emit("cache:hit", { key: "a", value: 1, store: "primary" }); + emitter.emit("cache:miss", { key: "b", store: "primary" }); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.gets).toBe(2); + + stats.unsubscribe(); + emitter.emit("cache:hit", { key: "c", value: 2 }); + expect(stats.hits).toBe(1); + }); + + test("should track node-cache events (positional payloads)", () => { + const emitter = new OffEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(emitter, nodeCacheStatsEventMap); + + emitter.emit("set", "key", "value"); + emitter.emit("del", "key"); + emitter.emit("flush"); + expect(stats.sets).toBe(1); + expect(stats.deletes).toBe(1); + expect(stats.clears).toBe(1); + }); + + test("should derive hit/miss from cache-manager get payloads", () => { + const emitter = new OffEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(emitter, cacheManagerStatsEventMap); + + emitter.emit("get", { key: "a", value: 1 }); + emitter.emit("get", { key: "b", error: new Error("miss") }); + emitter.emit("get"); + expect(stats.gets).toBe(3); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(2); + + emitter.emit("set", { key: "a", value: 1 }); + emitter.emit("mset", { list: [] }); + emitter.emit("del", { key: "a" }); + emitter.emit("mdel", { keys: ["a"] }); + emitter.emit("clear"); + expect(stats.sets).toBe(2); + expect(stats.deletes).toBe(2); + expect(stats.clears).toBe(1); + }); + + test("should support string, array, and function map entries", () => { + const emitter = new OffEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(emitter, { + single: "hits", + multiple: ["misses", "gets"], + handler: (s) => { + s.incrementSets(); + }, + }); + + emitter.emit("single"); + emitter.emit("multiple"); + emitter.emit("handler"); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.gets).toBe(1); + expect(stats.sets).toBe(1); + }); + + test("should detach via removeListener when off is absent", () => { + const emitter = new RemoveListenerEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(emitter, nodeCacheStatsEventMap); + + emitter.emit("set", "key", "value"); + expect(stats.sets).toBe(1); + stats.unsubscribe(emitter); + emitter.emit("set", "key", "value"); + expect(stats.sets).toBe(1); + }); + + test("should not throw unsubscribing an emitter without detach methods", () => { + const emitter = new BasicEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(emitter, { ping: "hits" }); + expect(() => { + stats.unsubscribe(); + }).not.toThrow(); + }); + + test("should selectively unsubscribe a single emitter", () => { + const first = new OffEmitter(); + const second = new OffEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(first, { a: "hits" }); + stats.subscribe(second, { b: "misses" }); + + stats.unsubscribe(first); + first.emit("a"); + second.emit("b"); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(1); + }); + + test("should respect enabled state for subscriptions", () => { + const emitter = new OffEmitter(); + const stats = new Stats(); + stats.subscribe(emitter, { hit: "hits" }); + + emitter.emit("hit"); + expect(stats.hits).toBe(0); + stats.enable(); + emitter.emit("hit"); + expect(stats.hits).toBe(1); + }); + + test("should auto-subscribe from the constructor with the default map", () => { + const emitter = new EventEmitter(); + const stats = new Stats({ enabled: true, emitter }); + emitter.emit("cache:hit", { key: "a", value: 1 }); + expect(stats.hits).toBe(1); + expect(stats.gets).toBe(1); + }); + + test("should auto-subscribe from the constructor with a custom map", () => { + const emitter = new OffEmitter(); + const stats = new Stats({ + enabled: true, + emitter, + eventMap: nodeCacheStatsEventMap, + }); + emitter.emit("set", "key", "value"); + expect(stats.sets).toBe(1); + }); +}); From 8e52fa34b6d36c650b9d2769c8d2f22b73c5aa5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 01:28:28 +0000 Subject: [PATCH 2/6] fix(utils): accurate multi-key event counting and gate handlers when disabled Address PR review feedback on the cache-manager stats event map: - mget/mset/mdel now count by batch size via custom handlers (mset by list length, mdel by keys length, mget by values length with per-value hit/miss derivation) instead of counting a whole batch as one - mget is now tracked (previously ignored entirely) - Guard applyEvent to return early when stats are disabled, so custom event handlers no longer run (and incur cost/side effects) while off https://claude.ai/code/session_019LJSwEXR15J1d8PHrNHMrE --- packages/utils/src/stats.ts | 37 +++++++++++++++++-- packages/utils/test/stats.test.ts | 61 +++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/packages/utils/src/stats.ts b/packages/utils/src/stats.ts index cb97fda9..3812d9e5 100644 --- a/packages/utils/src/stats.ts +++ b/packages/utils/src/stats.ts @@ -98,8 +98,10 @@ export const nodeCacheStatsEventMap: StatsEventMap = { /** * Event map for `cache-manager` instances. `cache-manager` emits `get` on * every read (hit and miss), carrying `value` on a hit; hit/miss is therefore - * derived from the payload. Note: `get` can fire multiple times per read across - * store layers, so these counts are best-effort. + * derived from the payload. Multi-key operations (`mget`/`mset`/`mdel`) carry a + * list of keys/items, so they are counted by length via custom handlers. Note: + * `get` can fire multiple times per read across store layers, so these counts + * are best-effort. */ export const cacheManagerStatsEventMap: StatsEventMap = { get: (stats: Stats, payload?: { value?: unknown; error?: unknown }) => { @@ -110,10 +112,33 @@ export const cacheManagerStatsEventMap: StatsEventMap = { stats.increment("misses"); } }, + mget: (stats: Stats, payload?: { keys?: unknown[]; values?: unknown[] }) => { + const values = payload?.values; + if (!values) { + return; + } + + stats.increment("gets", values.length); + for (const value of values) { + if (value !== undefined) { + stats.increment("hits"); + } else { + stats.increment("misses"); + } + } + }, set: "sets", - mset: "sets", + mset: (stats: Stats, payload?: { list?: unknown[] }) => { + if (payload?.list) { + stats.increment("sets", payload.list.length); + } + }, del: "deletes", - mdel: "deletes", + mdel: (stats: Stats, payload?: { keys?: unknown[] }) => { + if (payload?.keys) { + stats.increment("deletes", payload.keys.length); + } + }, clear: "clears", }; @@ -551,6 +576,10 @@ export class Stats { action: StatField | StatField[] | StatsEventHandler, args: any[], ): void { + if (!this._enabled) { + return; + } + if (typeof action === "function") { action(this, ...args); return; diff --git a/packages/utils/test/stats.test.ts b/packages/utils/test/stats.test.ts index c02752bd..bcf3b156 100644 --- a/packages/utils/test/stats.test.ts +++ b/packages/utils/test/stats.test.ts @@ -425,15 +425,68 @@ describe("stats event subscription", () => { expect(stats.misses).toBe(2); emitter.emit("set", { key: "a", value: 1 }); - emitter.emit("mset", { list: [] }); emitter.emit("del", { key: "a" }); - emitter.emit("mdel", { keys: ["a"] }); emitter.emit("clear"); - expect(stats.sets).toBe(2); - expect(stats.deletes).toBe(2); + expect(stats.sets).toBe(1); + expect(stats.deletes).toBe(1); expect(stats.clears).toBe(1); }); + test("should count multi-key cache-manager operations by batch size", () => { + const emitter = new OffEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(emitter, cacheManagerStatsEventMap); + + emitter.emit("mset", { + list: [ + { key: "a", value: 1 }, + { key: "b", value: 2 }, + ], + }); + emitter.emit("mdel", { keys: ["a", "b", "c"] }); + emitter.emit("mget", { keys: ["a", "b"], values: [1, undefined] }); + + expect(stats.sets).toBe(2); + expect(stats.deletes).toBe(3); + expect(stats.gets).toBe(2); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + }); + + test("should ignore multi-key cache-manager events with missing payloads", () => { + const emitter = new OffEmitter(); + const stats = new Stats({ enabled: true }); + stats.subscribe(emitter, cacheManagerStatsEventMap); + + emitter.emit("mset"); + emitter.emit("mdel"); + emitter.emit("mget"); + emitter.emit("mget", { keys: ["a"], error: new Error("boom") }); + + expect(stats.sets).toBe(0); + expect(stats.deletes).toBe(0); + expect(stats.gets).toBe(0); + expect(stats.misses).toBe(0); + }); + + test("should not run event handlers when disabled", () => { + const emitter = new OffEmitter(); + const stats = new Stats(); + let calls = 0; + stats.subscribe(emitter, { + ping: () => { + calls += 1; + }, + }); + + emitter.emit("ping"); + expect(calls).toBe(0); + + stats.enable(); + emitter.emit("ping"); + expect(calls).toBe(1); + }); + test("should support string, array, and function map entries", () => { const emitter = new OffEmitter(); const stats = new Stats({ enabled: true }); From 7de628138343f45bdf5b6a0f64c9ce839dda6508 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 01:39:05 +0000 Subject: [PATCH 3/6] refactor(utils): drop inaccurate cacheable/cache-manager stat presets Per PR review, the cacheable and cache-manager event streams cannot be faithfully counted by a simple event map: cache-manager emits no event on a normal miss and double-emits set (per-store + aggregate), and cacheable emits per-store cache:hit/cache:miss so one L1/L2 lookup counts twice. - Remove cacheableStatsEventMap and cacheManagerStatsEventMap; these integrations belong in rollout PRs where the libraries can emit clean per-operation stat events (or be wired imperatively) - Keep the accurate nodeCacheStatsEventMap and add flush_stats -> reset so a subscribed Stats mirrors node-cache's flushStats() lifecycle - subscribe(emitter, eventMap) now requires an explicit eventMap (no universal default exists); the constructor only auto-subscribes when both emitter and eventMap are provided The generic event-driven mechanism, imperative API, computed rates, snapshot, and timestamps are unchanged. 100% coverage on stats.ts maintained. https://claude.ai/code/session_019LJSwEXR15J1d8PHrNHMrE --- packages/utils/src/index.ts | 2 - packages/utils/src/stats.ts | 85 ++++++------------------------- packages/utils/test/stats.test.ts | 79 +++++----------------------- 3 files changed, 29 insertions(+), 137 deletions(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 4ddd5b6b..31dceee7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -47,8 +47,6 @@ export { export { runIfFn } from "./run-if-fn.js"; export { sleep } from "./sleep.js"; export { - cacheableStatsEventMap, - cacheManagerStatsEventMap, nodeCacheStatsEventMap, type StatField, Stats, diff --git a/packages/utils/src/stats.ts b/packages/utils/src/stats.ts index 3812d9e5..009fe210 100644 --- a/packages/utils/src/stats.ts +++ b/packages/utils/src/stats.ts @@ -67,79 +67,29 @@ export type StatsOptions = { enabled?: boolean; /** Optionally subscribe to an emitter immediately on construction. */ emitter?: StatsEmitter; - /** - * The event map to use when `emitter` is provided. Defaults to - * {@link cacheableStatsEventMap}. - */ + /** The event map to use. Required when `emitter` is provided. */ eventMap?: StatsEventMap; }; -/** - * The default event map for `cacheable` instances. `cacheable` only emits - * `cache:hit` and `cache:miss` on its instance (set/delete are distributed - * sync events, not instance events), so a hit/miss also counts as a get. - */ -export const cacheableStatsEventMap: StatsEventMap = { - "cache:hit": ["hits", "gets"], - "cache:miss": ["misses", "gets"], -}; - /** * Event map for `@cacheable/node-cache` instances. node-cache emits with - * positional arguments (e.g. `set(key, value)`), but counting only needs the - * event name. + * positional arguments (e.g. `set(key, value)`), and emits each lifecycle + * event exactly once, so the counts map cleanly. `flush` clears the cache data + * and `flush_stats` resets the stats counters, mirroring node-cache's + * `flushAll()` / `flushStats()` lifecycle. + * + * Presets for `cacheable` and `cache-manager` are intentionally not provided: + * their event streams emit per-store probes (and, for cache-manager, do not + * emit an event on a normal miss), so a simple map cannot faithfully reproduce + * their imperative stats. Wire those up with a custom map or imperative calls. */ export const nodeCacheStatsEventMap: StatsEventMap = { set: "sets", del: "deletes", flush: "clears", -}; - -/** - * Event map for `cache-manager` instances. `cache-manager` emits `get` on - * every read (hit and miss), carrying `value` on a hit; hit/miss is therefore - * derived from the payload. Multi-key operations (`mget`/`mset`/`mdel`) carry a - * list of keys/items, so they are counted by length via custom handlers. Note: - * `get` can fire multiple times per read across store layers, so these counts - * are best-effort. - */ -export const cacheManagerStatsEventMap: StatsEventMap = { - get: (stats: Stats, payload?: { value?: unknown; error?: unknown }) => { - stats.increment("gets"); - if (payload && payload.value !== undefined) { - stats.increment("hits"); - } else { - stats.increment("misses"); - } + flush_stats: (stats: Stats) => { + stats.reset(); }, - mget: (stats: Stats, payload?: { keys?: unknown[]; values?: unknown[] }) => { - const values = payload?.values; - if (!values) { - return; - } - - stats.increment("gets", values.length); - for (const value of values) { - if (value !== undefined) { - stats.increment("hits"); - } else { - stats.increment("misses"); - } - } - }, - set: "sets", - mset: (stats: Stats, payload?: { list?: unknown[] }) => { - if (payload?.list) { - stats.increment("sets", payload.list.length); - } - }, - del: "deletes", - mdel: (stats: Stats, payload?: { keys?: unknown[] }) => { - if (payload?.keys) { - stats.increment("deletes", payload.keys.length); - } - }, - clear: "clears", }; type StatsSubscription = { @@ -171,7 +121,7 @@ export class Stats { this._enabled = options.enabled; } - if (options?.emitter) { + if (options?.emitter && options?.eventMap) { this.subscribe(options.emitter, options.eventMap); } } @@ -534,13 +484,10 @@ export class Stats { * stats. Counting is gated by {@link enabled}, so you may subscribe first and * toggle enablement later. Call {@link unsubscribe} to detach. * @param {StatsEmitter} emitter - The emitter to listen on - * @param {StatsEventMap} eventMap - The event-to-stat mapping (defaults to - * {@link cacheableStatsEventMap}) + * @param {StatsEventMap} eventMap - The event-to-stat mapping (e.g. + * {@link nodeCacheStatsEventMap} or a custom map) */ - public subscribe( - emitter: StatsEmitter, - eventMap: StatsEventMap = cacheableStatsEventMap, - ): void { + public subscribe(emitter: StatsEmitter, eventMap: StatsEventMap): void { for (const [event, action] of Object.entries(eventMap)) { const listener = (...args: any[]): void => { this.applyEvent(action, args); diff --git a/packages/utils/test/stats.test.ts b/packages/utils/test/stats.test.ts index bcf3b156..ae11f6f7 100644 --- a/packages/utils/test/stats.test.ts +++ b/packages/utils/test/stats.test.ts @@ -1,10 +1,6 @@ import { EventEmitter } from "node:events"; import { describe, expect, test } from "vitest"; -import { - cacheManagerStatsEventMap, - nodeCacheStatsEventMap, - Stats, -} from "../src/stats.js"; +import { nodeCacheStatsEventMap, Stats } from "../src/stats.js"; type Listener = (...args: unknown[]) => void; @@ -383,10 +379,13 @@ describe("stats snapshot", () => { }); describe("stats event subscription", () => { - test("should track cacheable events with the default map", () => { + test("should track events from a Node EventEmitter with a custom map", () => { const emitter = new EventEmitter(); const stats = new Stats({ enabled: true }); - stats.subscribe(emitter); + stats.subscribe(emitter, { + "cache:hit": ["hits", "gets"], + "cache:miss": ["misses", "gets"], + }); emitter.emit("cache:hit", { key: "a", value: 1, store: "primary" }); emitter.emit("cache:miss", { key: "b", store: "primary" }); @@ -399,7 +398,7 @@ describe("stats event subscription", () => { expect(stats.hits).toBe(1); }); - test("should track node-cache events (positional payloads)", () => { + test("should track node-cache events and reset on flush_stats", () => { const emitter = new OffEmitter(); const stats = new Stats({ enabled: true }); stats.subscribe(emitter, nodeCacheStatsEventMap); @@ -410,63 +409,12 @@ describe("stats event subscription", () => { expect(stats.sets).toBe(1); expect(stats.deletes).toBe(1); expect(stats.clears).toBe(1); - }); - - test("should derive hit/miss from cache-manager get payloads", () => { - const emitter = new OffEmitter(); - const stats = new Stats({ enabled: true }); - stats.subscribe(emitter, cacheManagerStatsEventMap); - - emitter.emit("get", { key: "a", value: 1 }); - emitter.emit("get", { key: "b", error: new Error("miss") }); - emitter.emit("get"); - expect(stats.gets).toBe(3); - expect(stats.hits).toBe(1); - expect(stats.misses).toBe(2); - - emitter.emit("set", { key: "a", value: 1 }); - emitter.emit("del", { key: "a" }); - emitter.emit("clear"); - expect(stats.sets).toBe(1); - expect(stats.deletes).toBe(1); - expect(stats.clears).toBe(1); - }); - - test("should count multi-key cache-manager operations by batch size", () => { - const emitter = new OffEmitter(); - const stats = new Stats({ enabled: true }); - stats.subscribe(emitter, cacheManagerStatsEventMap); - - emitter.emit("mset", { - list: [ - { key: "a", value: 1 }, - { key: "b", value: 2 }, - ], - }); - emitter.emit("mdel", { keys: ["a", "b", "c"] }); - emitter.emit("mget", { keys: ["a", "b"], values: [1, undefined] }); - - expect(stats.sets).toBe(2); - expect(stats.deletes).toBe(3); - expect(stats.gets).toBe(2); - expect(stats.hits).toBe(1); - expect(stats.misses).toBe(1); - }); - - test("should ignore multi-key cache-manager events with missing payloads", () => { - const emitter = new OffEmitter(); - const stats = new Stats({ enabled: true }); - stats.subscribe(emitter, cacheManagerStatsEventMap); - - emitter.emit("mset"); - emitter.emit("mdel"); - emitter.emit("mget"); - emitter.emit("mget", { keys: ["a"], error: new Error("boom") }); + emitter.emit("flush_stats"); expect(stats.sets).toBe(0); expect(stats.deletes).toBe(0); - expect(stats.gets).toBe(0); - expect(stats.misses).toBe(0); + expect(stats.clears).toBe(0); + expect(typeof stats.lastReset).toBe("number"); }); test("should not run event handlers when disabled", () => { @@ -554,12 +502,11 @@ describe("stats event subscription", () => { expect(stats.hits).toBe(1); }); - test("should auto-subscribe from the constructor with the default map", () => { + test("should not auto-subscribe when eventMap is omitted", () => { const emitter = new EventEmitter(); const stats = new Stats({ enabled: true, emitter }); - emitter.emit("cache:hit", { key: "a", value: 1 }); - expect(stats.hits).toBe(1); - expect(stats.gets).toBe(1); + emitter.emit("set"); + expect(stats.sets).toBe(0); }); test("should auto-subscribe from the constructor with a custom map", () => { From 852dcd5eaeacfbae494982da6838dd8594ccc27c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:21:54 +0000 Subject: [PATCH 4/6] docs(utils): document the Statistics API in the README Replace the outdated "Stats Helpers" section (which referenced a non-existent stats() function) with a complete "Statistics" section covering enabling, imperative increment/decrement, computed rates, snapshot/toJSON, timestamps, and event-driven subscription with the node-cache map and custom maps. https://claude.ai/code/session_019LJSwEXR15J1d8PHrNHMrE --- packages/utils/README.md | 179 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 171 insertions(+), 8 deletions(-) diff --git a/packages/utils/README.md b/packages/utils/README.md index 2f7640cd..aa9e931c 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -13,7 +13,7 @@ * Data Types for Caching Items * Hash Functions for Key Generation * Coalesce Async for Handling Multiple Promises -* Stats Helpers for Caching Statistics +* Statistics for Tracking Cache Metrics * Sleep / Delay for Testing and Timing * Memoization for wraping or get / set options * Time to Live (TTL) Helpers @@ -26,7 +26,7 @@ * [Hash Functions](#hash-functions) * [Shorthand Time Helpers](#shorthand-time-helpers) * [Sleep Helper](#sleep-helper) -* [Stats Helpers](#stats-helpers) +* [Statistics](#statistics) * [Time to Live (TTL) Helpers](#time-to-live-ttl-helpers) * [Run if Function Helper](#run-if-function-helper) * [Less Than Helper](#less-than-helper) @@ -193,18 +193,181 @@ await sleep(1000); // Pause for 1 second console.log('Execution resumed after 1 second'); ``` -# Stats Helpers +# Statistics -The `@cacheable/utils` package provides statistics helpers that can be used to track and analyze caching operations. These helpers can be used to gather metrics such as hit rates, miss rates, and other performance-related statistics. +The `Stats` class provides a unified, event-driven way to track caching metrics such as hits, misses, hit rate, item counts, and approximate memory usage. It can be driven two ways: + +* **Imperatively** — call `increment` / `decrement` (or the named helpers) directly from your code. +* **Event-driven** — `subscribe` it to an event emitter (such as `@cacheable/node-cache` or a Node `EventEmitter`) and let matching events update the counters automatically. + +Statistics are **opt-in**: a new `Stats` instance is disabled by default and ignores every update until enabled, so there is zero tracking overhead unless you ask for it. + +```typescript +import { Stats } from '@cacheable/utils'; + +const stats = new Stats({ enabled: true }); + +stats.incrementHits(); +stats.incrementMisses(); +stats.incrementGets(); + +console.log(stats.hits); // 1 +console.log(stats.misses); // 1 +console.log(stats.hitRate); // 0.5 +``` + +## Available Statistics + +Every counter is exposed as a read-only property: + +| Property | Type | Description | +| --- | --- | --- | +| `hits` | `number` | Number of cache hits. | +| `misses` | `number` | Number of cache misses. | +| `gets` | `number` | Number of get operations. | +| `sets` | `number` | Number of set operations. | +| `deletes` | `number` | Number of delete operations. | +| `clears` | `number` | Number of clear operations. | +| `count` | `number` | Number of items currently tracked. | +| `ksize` | `number` | Approximate size of all keys, in bytes. | +| `vsize` | `number` | Approximate size of all values, in bytes. | + +### Computed Properties + +| Property | Type | Description | +| --- | --- | --- | +| `hitRate` | `number` | `hits / (hits + misses)`, or `0` when there have been no lookups. | +| `missRate` | `number` | `misses / (hits + misses)`, or `0` when there have been no lookups. | + +### Metadata + +| Property | Type | Description | +| --- | --- | --- | +| `enabled` | `boolean` | Whether tracking is currently on. | +| `lastUpdated` | `number \| undefined` | Timestamp (ms since epoch) of the last update while enabled. | +| `lastReset` | `number \| undefined` | Timestamp (ms since epoch) of the last `reset()` / `clear()`. | + +## Enabling, Disabling, and Clearing ```typescript -import { stats } from '@cacheable/utils'; +const stats = new Stats(); // disabled by default + +stats.enable(); // start tracking (or: stats.enabled = true) +stats.incrementHits(); +console.log(stats.hits); // 1 -const cacheStats = stats(); -cacheStats.incrementHits(); -console.log(cacheStats.hits); // Get the hit rate of the cache +stats.disable(); // stop tracking (or: stats.enabled = false) +stats.incrementHits(); +console.log(stats.hits); // still 1 + +stats.clear(); // reset every counter back to 0 (alias of reset()) +console.log(stats.hits); // 0 ``` +* `reset()` / `clear()` — set every counter back to `0` and record `lastReset`. +* `resetStoreValues()` — reset only `count`, `ksize`, and `vsize`, leaving the hit/miss history intact. + +## Incrementing and Decrementing + +Use the unified `increment` / `decrement` methods with any counter field, or the named helpers. All updates are ignored while disabled. + +```typescript +const stats = new Stats({ enabled: true }); + +// Unified API — optional amount (defaults to 1) +stats.increment('hits'); +stats.increment('sets', 5); +stats.decrement('count', 2); + +// Named helpers +stats.incrementHits(); +stats.incrementMisses(); +stats.incrementGets(); +stats.incrementSets(); +stats.incrementDeletes(); +stats.incrementClears(); +stats.incrementCount(); +stats.decreaseCount(); + +// Approximate key/value sizes +stats.incrementKSize('my-key'); // adds the byte size of the key +stats.incrementVSize({ a: 1 }); // adds the byte size of the value +stats.decreaseKSize('my-key'); +stats.decreaseVSize({ a: 1 }); +stats.setCount(10); // set the item count directly +``` + +`StatField` is the union of countable fields: `'hits' | 'misses' | 'gets' | 'sets' | 'deletes' | 'clears' | 'count'`. + +## Snapshot + +`toJSON()` (aliased as `snapshot()`) returns a plain object of every counter, the computed rates, and the timestamps — handy for logging or sending to a metrics system. + +```typescript +const stats = new Stats({ enabled: true }); +stats.incrementHits(3); +stats.incrementMisses(); + +console.log(stats.toJSON()); +// { +// enabled: true, +// hits: 3, misses: 1, gets: 0, sets: 0, deletes: 0, clears: 0, +// vsize: 0, ksize: 0, count: 0, +// hitRate: 0.75, missRate: 0.25, +// lastUpdated: 1749513600000, lastReset: undefined +// } +``` + +## Event-Driven Tracking + +Instead of calling the increment methods yourself, you can `subscribe` a `Stats` instance to an emitter and have events update the counters automatically. The emitter is duck-typed — anything with `.on()` (plus `.off()` or `.removeListener()` to detach) works, including `Hookified`-based classes and Node's `EventEmitter`. + +An **event map** describes how each event name updates the stats. A map value can be: + +* a single field — `"sets"` +* an array of fields — `["hits", "gets"]` +* a custom handler — `(stats, ...args) => void` + +```typescript +import { Stats, nodeCacheStatsEventMap } from '@cacheable/utils'; +import { NodeCache } from '@cacheable/node-cache'; + +const cache = new NodeCache(); +const stats = new Stats({ enabled: true }); + +// nodeCacheStatsEventMap maps set -> sets, del -> deletes, flush -> clears, +// and flush_stats -> reset. +stats.subscribe(cache, nodeCacheStatsEventMap); + +cache.set('key', 'value'); +console.log(stats.sets); // 1 + +stats.unsubscribe(); // detach all listeners (or pass an emitter to detach just one) +``` + +You can also provide your own map for any emitter: + +```typescript +import { EventEmitter } from 'node:events'; +import { Stats } from '@cacheable/utils'; + +const emitter = new EventEmitter(); +const stats = new Stats({ enabled: true }); + +stats.subscribe(emitter, { + 'cache:hit': ['hits', 'gets'], + 'cache:miss': ['misses', 'gets'], + evicted: (s) => s.incrementDeletes(), +}); + +emitter.emit('cache:hit', { key: 'a' }); +console.log(stats.hitRate); // 1 +``` + +You can subscribe to multiple emitters from a single `Stats` instance, and pass an emitter to `unsubscribe(emitter)` to detach just that one. Counting is gated by `enabled`, so you can subscribe first and toggle tracking on later — handlers do not run at all while disabled. + +> **Note:** a built-in map is provided only where a library's events map cleanly to stats. `nodeCacheStatsEventMap` is included because `@cacheable/node-cache` emits each lifecycle event exactly once. Libraries that emit per-store probes or omit events on a miss (such as `cacheable` and `cache-manager`) should be wired with a custom map or driven imperatively so the counts stay accurate. + # Time to Live (TTL) Helpers The `@cacheable/utils` package provides helpers for managing time-to-live (TTL) values for cached items. From 69b7918cc8fb9bb6faf8bb9fb7350447781902c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:36:21 +0000 Subject: [PATCH 5/6] feat(utils): per-key statistics with top/bottom key rankings Add opt-in per-key tracking to Stats so consumers can identify their hottest and coldest keys: - recordKey(key, field, amount?) keeps a per-key breakdown of hits, misses, gets, sets, and deletes (no-op unless enabled and trackKeys) - topKeys(limit = 100, field?) / bottomKeys(limit = 100, field?) return ranked entries with per-key totals and hit rates; rank by total operations or a single counter - keyStats(key), trackedKeyCount, clearKeys(); reset() clears per-key stats too, and toJSON() now reports trackedKeys - trackKeys / maxTrackedKeys options; when the cap is exceeded the lowest-count keys are pruned in batches (documented: pruning keeps topKeys approximate but makes bottomKeys unreliable) - nodeCacheStatsEventMap now records keys from set/del payloads when key tracking is on Documented in the README Statistics section. 100% coverage on stats.ts. https://claude.ai/code/session_019LJSwEXR15J1d8PHrNHMrE --- packages/utils/README.md | 42 +++++ packages/utils/src/index.ts | 2 + packages/utils/src/stats.ts | 244 +++++++++++++++++++++++++++++- packages/utils/test/stats.test.ts | 150 ++++++++++++++++++ 4 files changed, 436 insertions(+), 2 deletions(-) diff --git a/packages/utils/README.md b/packages/utils/README.md index aa9e931c..bba8eede 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -366,6 +366,48 @@ console.log(stats.hitRate); // 1 You can subscribe to multiple emitters from a single `Stats` instance, and pass an emitter to `unsubscribe(emitter)` to detach just that one. Counting is gated by `enabled`, so you can subscribe first and toggle tracking on later — handlers do not run at all while disabled. +## Per-Key Tracking (Top and Bottom Keys) + +To find your hottest and coldest keys, enable per-key tracking with `trackKeys`. Each recorded key keeps its own breakdown of `hits`, `misses`, `gets`, `sets`, and `deletes`, plus a computed total `count` and per-key `hitRate`. + +```typescript +import { Stats } from '@cacheable/utils'; + +const stats = new Stats({ enabled: true, trackKeys: true }); + +stats.recordKey('user:1', 'hits'); +stats.recordKey('user:1', 'gets', 5); +stats.recordKey('user:2', 'misses'); + +// Top 100 keys by total operations (descending) +console.log(stats.topKeys(100)); +// [ +// { key: 'user:1', count: 6, hits: 1, misses: 0, gets: 5, sets: 0, deletes: 0, hitRate: 1 }, +// { key: 'user:2', count: 1, hits: 0, misses: 1, gets: 0, sets: 0, deletes: 0, hitRate: 0 } +// ] + +// Bottom 100 keys by total operations (ascending) +console.log(stats.bottomKeys(100)); + +// Rank by a single counter instead of the total +console.log(stats.topKeys(100, 'hits')); + +// Inspect one key, or how many keys are tracked +console.log(stats.keyStats('user:1')); +console.log(stats.trackedKeyCount); + +stats.clearKeys(); // clear per-key stats only (reset() clears these too) +``` + +Both `topKeys` and `bottomKeys` default to 100 entries, and `trackedKeys` is included in `toJSON()` snapshots. + +Per-key tracking is fed two ways, just like the aggregate counters: + +* **Imperatively** — call `recordKey(key, field, amount?)` wherever you already increment stats. +* **Event-driven** — `nodeCacheStatsEventMap` automatically records keys from `set`/`del` events when `trackKeys` is on, and custom event-map handlers can call `recordKey` with whatever the payload carries. + +Memory is proportional to the number of unique keys tracked, so `trackKeys` is off by default. You can also set `maxTrackedKeys` as a safety cap — when exceeded, the lowest-count keys are pruned. Note that pruning keeps `topKeys` approximately accurate but makes `bottomKeys` unreliable (the pruned keys *are* the bottom), so leave it unset if you need exact bottom-key rankings. + > **Note:** a built-in map is provided only where a library's events map cleanly to stats. `nodeCacheStatsEventMap` is included because `@cacheable/node-cache` emits each lifecycle event exactly once. Libraries that emit per-store probes or omit events on a miss (such as `cacheable` and `cache-manager`) should be wired with a custom map or driven imperatively so the counts stay accurate. # Time to Live (TTL) Helpers diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 31dceee7..7d91323a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -47,12 +47,14 @@ export { export { runIfFn } from "./run-if-fn.js"; export { sleep } from "./sleep.js"; export { + type KeyStatField, nodeCacheStatsEventMap, type StatField, Stats, type StatsEmitter, type StatsEventHandler, type StatsEventMap, + type StatsKeyEntry, type StatsOptions, type StatsSnapshot, } from "./stats.js"; diff --git a/packages/utils/src/stats.ts b/packages/utils/src/stats.ts index 009fe210..14ffeae8 100644 --- a/packages/utils/src/stats.ts +++ b/packages/utils/src/stats.ts @@ -41,6 +41,30 @@ export type StatsEventMap = Record< StatField | StatField[] | StatsEventHandler >; +/** + * A counter field that can be recorded per key via {@link Stats.recordKey}. + * This is the subset of {@link StatField} that makes sense for a single key + * (`clears` and `count` are cache-wide). + */ +export type KeyStatField = "hits" | "misses" | "gets" | "sets" | "deletes"; + +/** + * Per-key statistics returned by {@link Stats.topKeys}, + * {@link Stats.bottomKeys}, and {@link Stats.keyStats}. + */ +export type StatsKeyEntry = { + key: string; + /** Total recorded operations for this key (sum of all fields). */ + count: number; + hits: number; + misses: number; + gets: number; + sets: number; + deletes: number; + /** `hits / (hits + misses)` for this key, or `0` when there have been no lookups. */ + hitRate: number; +}; + /** * A plain-object snapshot of a {@link Stats} instance, suitable for logging, * metrics, or serialization. Returned by {@link Stats.toJSON}. @@ -58,6 +82,8 @@ export type StatsSnapshot = { count: number; hitRate: number; missRate: number; + /** Number of unique keys currently tracked (0 when key tracking is off). */ + trackedKeys: number; lastUpdated?: number; lastReset?: number; }; @@ -69,6 +95,14 @@ export type StatsOptions = { emitter?: StatsEmitter; /** The event map to use. Required when `emitter` is provided. */ eventMap?: StatsEventMap; + /** Track per-key statistics via {@link Stats.recordKey}. Defaults to `false`. */ + trackKeys?: boolean; + /** + * Safety cap on the number of unique keys tracked. When exceeded, the + * lowest-count keys are pruned, which keeps top-key rankings approximately + * accurate but makes bottom-key rankings unreliable. Unbounded when unset. + */ + maxTrackedKeys?: number; }; /** @@ -84,8 +118,18 @@ export type StatsOptions = { * their imperative stats. Wire those up with a custom map or imperative calls. */ export const nodeCacheStatsEventMap: StatsEventMap = { - set: "sets", - del: "deletes", + set: (stats: Stats, key?: unknown) => { + stats.increment("sets"); + if (typeof key === "string" || typeof key === "number") { + stats.recordKey(String(key), "sets"); + } + }, + del: (stats: Stats, key?: unknown) => { + stats.increment("deletes"); + if (typeof key === "string" || typeof key === "number") { + stats.recordKey(String(key), "deletes"); + } + }, flush: "clears", flush_stats: (stats: Stats) => { stats.reset(); @@ -98,6 +142,8 @@ type StatsSubscription = { listener: (...args: any[]) => void; }; +type KeyCounters = Record; + export class Stats { private _counters: Record = { hits: 0, @@ -115,12 +161,23 @@ export class Stats { private _lastUpdated: number | undefined; private _lastReset: number | undefined; private _subscriptions: StatsSubscription[] = []; + private _keyCounts = new Map(); + private _trackKeys = false; + private _maxTrackedKeys: number | undefined; constructor(options?: StatsOptions) { if (options?.enabled) { this._enabled = options.enabled; } + if (options?.trackKeys) { + this._trackKeys = options.trackKeys; + } + + if (options?.maxTrackedKeys !== undefined) { + this._maxTrackedKeys = options.maxTrackedKeys; + } + if (options?.emitter && options?.eventMap) { this.subscribe(options.emitter, options.eventMap); } @@ -140,6 +197,44 @@ export class Stats { this._enabled = enabled; } + /** + * @returns {boolean} - Whether per-key statistics are tracked + */ + public get trackKeys(): boolean { + return this._trackKeys; + } + + /** + * @param {boolean} trackKeys - Whether to track per-key statistics + */ + public set trackKeys(trackKeys: boolean) { + this._trackKeys = trackKeys; + } + + /** + * @returns {number | undefined} - The cap on unique keys tracked, or + * `undefined` when unbounded + */ + public get maxTrackedKeys(): number | undefined { + return this._maxTrackedKeys; + } + + /** + * @param {number | undefined} maxTrackedKeys - The cap on unique keys + * tracked. Set `undefined` for unbounded. + */ + public set maxTrackedKeys(maxTrackedKeys: number | undefined) { + this._maxTrackedKeys = maxTrackedKeys; + } + + /** + * @returns {number} - The number of unique keys currently tracked + * @readonly + */ + public get trackedKeyCount(): number { + return this._keyCounts.size; + } + /** * @returns {number} - The number of hits * @readonly @@ -438,6 +533,7 @@ export class Stats { }; this._vsize = 0; this._ksize = 0; + this._keyCounts.clear(); this._lastReset = Date.now(); this._lastUpdated = undefined; } @@ -466,6 +562,7 @@ export class Stats { count: this._counters.count, hitRate: this.hitRate, missRate: this.missRate, + trackedKeys: this._keyCounts.size, lastUpdated: this._lastUpdated, lastReset: this._lastReset, }; @@ -479,6 +576,149 @@ export class Stats { return this.toJSON(); } + /** + * Record an operation against a specific key for per-key statistics. No-op + * unless both {@link enabled} and {@link trackKeys} are `true`. + * @param {string} key - The cache key the operation touched + * @param {KeyStatField} field - The per-key counter to increment + * @param {number} amount - The amount to add (default 1) + */ + public recordKey(key: string, field: KeyStatField, amount = 1): void { + if (!this._enabled || !this._trackKeys) { + return; + } + + let counters = this._keyCounts.get(key); + if (!counters) { + counters = { hits: 0, misses: 0, gets: 0, sets: 0, deletes: 0 }; + this._keyCounts.set(key, counters); + this.pruneTrackedKeys(key); + } + + counters[field] += amount; + this.touch(); + } + + /** + * The most-used keys, sorted descending. Sorts by total recorded operations, + * or by a single field when `field` is provided. Ties order by key. + * @param {number} limit - Maximum entries to return (default 100) + * @param {KeyStatField} [field] - Optionally rank by one counter (e.g. "hits") + * @returns {StatsKeyEntry[]} + */ + public topKeys(limit = 100, field?: KeyStatField): StatsKeyEntry[] { + return this.sortedKeyEntries(field, "desc").slice(0, limit); + } + + /** + * The least-used keys, sorted ascending. Sorts by total recorded operations, + * or by a single field when `field` is provided. Ties order by key. Note: + * only keys that have been recorded at least once can be ranked, and when + * {@link maxTrackedKeys} pruning has occurred the true bottom keys may have + * been evicted. + * @param {number} limit - Maximum entries to return (default 100) + * @param {KeyStatField} [field] - Optionally rank by one counter (e.g. "gets") + * @returns {StatsKeyEntry[]} + */ + public bottomKeys(limit = 100, field?: KeyStatField): StatsKeyEntry[] { + return this.sortedKeyEntries(field, "asc").slice(0, limit); + } + + /** + * @param {string} key - The key to look up + * @returns {StatsKeyEntry | undefined} - The per-key statistics, or + * `undefined` if the key has not been recorded + */ + public keyStats(key: string): StatsKeyEntry | undefined { + const counters = this._keyCounts.get(key); + return counters ? this.toKeyEntry(key, counters) : undefined; + } + + /** + * Clear all per-key statistics without touching the aggregate counters. + */ + public clearKeys(): void { + this._keyCounts.clear(); + } + + private totalOf(counters: KeyCounters): number { + return ( + counters.hits + + counters.misses + + counters.gets + + counters.sets + + counters.deletes + ); + } + + private toKeyEntry(key: string, counters: KeyCounters): StatsKeyEntry { + const lookups = counters.hits + counters.misses; + return { + key, + count: this.totalOf(counters), + hits: counters.hits, + misses: counters.misses, + gets: counters.gets, + sets: counters.sets, + deletes: counters.deletes, + hitRate: lookups === 0 ? 0 : counters.hits / lookups, + }; + } + + private sortedKeyEntries( + field: KeyStatField | undefined, + direction: "asc" | "desc", + ): StatsKeyEntry[] { + const entries: StatsKeyEntry[] = []; + for (const [key, counters] of this._keyCounts) { + entries.push(this.toKeyEntry(key, counters)); + } + + const sign = direction === "asc" ? 1 : -1; + entries.sort((a, b) => { + const valueA = field ? a[field] : a.count; + const valueB = field ? b[field] : b.count; + if (valueA !== valueB) { + return (valueA - valueB) * sign; + } + + return a.key < b.key ? -1 : 1; + }); + + return entries; + } + + /** + * When over {@link maxTrackedKeys}, prune the lowest-count keys down to 90% + * of the cap (batched so the sort cost amortizes across inserts). The key + * that was just recorded is never pruned. + */ + private pruneTrackedKeys(protectedKey: string): void { + if ( + this._maxTrackedKeys === undefined || + this._keyCounts.size <= this._maxTrackedKeys + ) { + return; + } + + const target = Math.max(1, Math.floor(this._maxTrackedKeys * 0.9)); + const sorted = [...this._keyCounts.entries()].sort( + (a, b) => this.totalOf(a[1]) - this.totalOf(b[1]), + ); + + for (const [key] of sorted) { + if (this._keyCounts.size <= target) { + break; + } + + if (key === protectedKey) { + continue; + } + + this._keyCounts.delete(key); + } + } + /** * Subscribe to an emitter so that matching events automatically update the * stats. Counting is gated by {@link enabled}, so you may subscribe first and diff --git a/packages/utils/test/stats.test.ts b/packages/utils/test/stats.test.ts index ae11f6f7..c3bda1b8 100644 --- a/packages/utils/test/stats.test.ts +++ b/packages/utils/test/stats.test.ts @@ -520,3 +520,153 @@ describe("stats event subscription", () => { expect(stats.sets).toBe(1); }); }); + +describe("stats per-key tracking", () => { + test("should not record keys when disabled or tracking is off", () => { + const disabled = new Stats({ trackKeys: true }); + disabled.recordKey("a", "hits"); + expect(disabled.trackedKeyCount).toBe(0); + + const trackingOff = new Stats({ enabled: true }); + trackingOff.recordKey("a", "hits"); + expect(trackingOff.trackedKeyCount).toBe(0); + }); + + test("should record a per-key breakdown with optional amount", () => { + const stats = new Stats({ enabled: true, trackKeys: true }); + stats.recordKey("a", "hits", 2); + stats.recordKey("a", "misses"); + stats.recordKey("a", "gets", 3); + stats.recordKey("a", "sets"); + stats.recordKey("a", "deletes"); + + expect(stats.keyStats("a")).toEqual({ + key: "a", + count: 8, + hits: 2, + misses: 1, + gets: 3, + sets: 1, + deletes: 1, + hitRate: 2 / 3, + }); + }); + + test("keyStats should return undefined for unknown keys and 0 hitRate with no lookups", () => { + const stats = new Stats({ enabled: true, trackKeys: true }); + expect(stats.keyStats("missing")).toBeUndefined(); + stats.recordKey("write-only", "sets"); + expect(stats.keyStats("write-only")?.hitRate).toBe(0); + }); + + test("topKeys should rank by total count descending with key tie-break", () => { + const stats = new Stats({ enabled: true, trackKeys: true }); + stats.recordKey("hot", "gets", 5); + stats.recordKey("warm", "gets", 3); + stats.recordKey("a", "gets"); + stats.recordKey("b", "gets"); + + const top = stats.topKeys(3); + expect(top.map((entry) => entry.key)).toEqual(["hot", "warm", "a"]); + expect(top[0].count).toBe(5); + }); + + test("bottomKeys should rank ascending with key tie-break", () => { + const stats = new Stats({ enabled: true, trackKeys: true }); + stats.recordKey("hot", "gets", 5); + stats.recordKey("b", "gets"); + stats.recordKey("a", "gets"); + + const bottom = stats.bottomKeys(2); + expect(bottom.map((entry) => entry.key)).toEqual(["a", "b"]); + }); + + test("should rank by a specific field when provided", () => { + const stats = new Stats({ enabled: true, trackKeys: true }); + stats.recordKey("reads", "hits", 10); + stats.recordKey("writes", "sets", 20); + + expect(stats.topKeys(1, "hits")[0].key).toBe("reads"); + expect(stats.topKeys(1, "sets")[0].key).toBe("writes"); + expect(stats.bottomKeys(1, "hits")[0].key).toBe("writes"); + }); + + test("should default to 100 entries for top and bottom", () => { + const stats = new Stats({ enabled: true, trackKeys: true }); + for (let i = 0; i < 105; i++) { + stats.recordKey(`key-${i}`, "gets", i + 1); + } + + expect(stats.topKeys()).toHaveLength(100); + expect(stats.bottomKeys()).toHaveLength(100); + expect(stats.topKeys()[0].count).toBe(105); + expect(stats.bottomKeys()[0].count).toBe(1); + }); + + test("should prune lowest-count keys past maxTrackedKeys, protecting the new key", () => { + const stats = new Stats({ + enabled: true, + trackKeys: true, + maxTrackedKeys: 4, + }); + stats.recordKey("a", "gets", 5); + stats.recordKey("b", "gets", 4); + stats.recordKey("c", "gets", 3); + stats.recordKey("d", "gets", 2); + // 5th unique key exceeds the cap and prunes down to floor(4 * 0.9) = 3 + stats.recordKey("e", "gets"); + + expect(stats.trackedKeyCount).toBe(3); + expect(stats.keyStats("e")).toBeDefined(); + expect(stats.keyStats("a")).toBeDefined(); + expect(stats.keyStats("b")).toBeDefined(); + expect(stats.keyStats("c")).toBeUndefined(); + expect(stats.keyStats("d")).toBeUndefined(); + }); + + test("clearKeys should clear only per-key stats; reset clears both", () => { + const stats = new Stats({ enabled: true, trackKeys: true }); + stats.incrementHits(); + stats.recordKey("a", "hits"); + stats.clearKeys(); + expect(stats.trackedKeyCount).toBe(0); + expect(stats.hits).toBe(1); + + stats.recordKey("b", "hits"); + stats.reset(); + expect(stats.trackedKeyCount).toBe(0); + expect(stats.hits).toBe(0); + }); + + test("should expose trackKeys and maxTrackedKeys accessors and snapshot trackedKeys", () => { + const stats = new Stats({ enabled: true }); + expect(stats.trackKeys).toBe(false); + expect(stats.maxTrackedKeys).toBeUndefined(); + stats.trackKeys = true; + stats.maxTrackedKeys = 10; + expect(stats.trackKeys).toBe(true); + expect(stats.maxTrackedKeys).toBe(10); + stats.recordKey("a", "gets"); + expect(stats.toJSON().trackedKeys).toBe(1); + }); + + test("nodeCacheStatsEventMap should record keys when tracking is on", () => { + const emitter = new OffEmitter(); + const stats = new Stats({ enabled: true, trackKeys: true }); + stats.subscribe(emitter, nodeCacheStatsEventMap); + + emitter.emit("set", "user:1", "value"); + emitter.emit("set", 42, "value"); + emitter.emit("set"); // no key payload — counted but not key-tracked + emitter.emit("del", "user:1"); + emitter.emit("del", 7); + emitter.emit("del"); // no key payload + + expect(stats.sets).toBe(3); + expect(stats.deletes).toBe(3); + expect(stats.keyStats("user:1")).toMatchObject({ sets: 1, deletes: 1 }); + expect(stats.keyStats("42")?.sets).toBe(1); + expect(stats.keyStats("7")?.deletes).toBe(1); + expect(stats.trackedKeyCount).toBe(3); + }); +}); From 1e822d3a598c4e8956f9ac3e52e4bbfadda7624d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:41:30 +0000 Subject: [PATCH 6/6] refactor(utils): rename topKeys/bottomKeys to mostUsedKeys/leastUsedKeys The new names are self-documenting and align with LRU/LFU terminology; "bottomKeys" in particular was ambiguous. No behavior change. Nothing has shipped yet, so no deprecation alias is needed. https://claude.ai/code/session_019LJSwEXR15J1d8PHrNHMrE --- packages/utils/README.md | 16 ++++++++-------- packages/utils/src/stats.ts | 17 +++++++++-------- packages/utils/test/stats.test.ts | 22 +++++++++++----------- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/utils/README.md b/packages/utils/README.md index bba8eede..ef493f8e 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -366,7 +366,7 @@ console.log(stats.hitRate); // 1 You can subscribe to multiple emitters from a single `Stats` instance, and pass an emitter to `unsubscribe(emitter)` to detach just that one. Counting is gated by `enabled`, so you can subscribe first and toggle tracking on later — handlers do not run at all while disabled. -## Per-Key Tracking (Top and Bottom Keys) +## Per-Key Tracking (Most and Least Used Keys) To find your hottest and coldest keys, enable per-key tracking with `trackKeys`. Each recorded key keeps its own breakdown of `hits`, `misses`, `gets`, `sets`, and `deletes`, plus a computed total `count` and per-key `hitRate`. @@ -379,18 +379,18 @@ stats.recordKey('user:1', 'hits'); stats.recordKey('user:1', 'gets', 5); stats.recordKey('user:2', 'misses'); -// Top 100 keys by total operations (descending) -console.log(stats.topKeys(100)); +// 100 most used keys by total operations (descending) +console.log(stats.mostUsedKeys(100)); // [ // { key: 'user:1', count: 6, hits: 1, misses: 0, gets: 5, sets: 0, deletes: 0, hitRate: 1 }, // { key: 'user:2', count: 1, hits: 0, misses: 1, gets: 0, sets: 0, deletes: 0, hitRate: 0 } // ] -// Bottom 100 keys by total operations (ascending) -console.log(stats.bottomKeys(100)); +// 100 least used keys by total operations (ascending) +console.log(stats.leastUsedKeys(100)); // Rank by a single counter instead of the total -console.log(stats.topKeys(100, 'hits')); +console.log(stats.mostUsedKeys(100, 'hits')); // Inspect one key, or how many keys are tracked console.log(stats.keyStats('user:1')); @@ -399,14 +399,14 @@ console.log(stats.trackedKeyCount); stats.clearKeys(); // clear per-key stats only (reset() clears these too) ``` -Both `topKeys` and `bottomKeys` default to 100 entries, and `trackedKeys` is included in `toJSON()` snapshots. +Both `mostUsedKeys` and `leastUsedKeys` default to 100 entries, and `trackedKeys` is included in `toJSON()` snapshots. Per-key tracking is fed two ways, just like the aggregate counters: * **Imperatively** — call `recordKey(key, field, amount?)` wherever you already increment stats. * **Event-driven** — `nodeCacheStatsEventMap` automatically records keys from `set`/`del` events when `trackKeys` is on, and custom event-map handlers can call `recordKey` with whatever the payload carries. -Memory is proportional to the number of unique keys tracked, so `trackKeys` is off by default. You can also set `maxTrackedKeys` as a safety cap — when exceeded, the lowest-count keys are pruned. Note that pruning keeps `topKeys` approximately accurate but makes `bottomKeys` unreliable (the pruned keys *are* the bottom), so leave it unset if you need exact bottom-key rankings. +Memory is proportional to the number of unique keys tracked, so `trackKeys` is off by default. You can also set `maxTrackedKeys` as a safety cap — when exceeded, the lowest-count keys are pruned. Note that pruning keeps `mostUsedKeys` approximately accurate but makes `leastUsedKeys` unreliable (the pruned keys *are* the least used), so leave it unset if you need exact least-used-key rankings. > **Note:** a built-in map is provided only where a library's events map cleanly to stats. `nodeCacheStatsEventMap` is included because `@cacheable/node-cache` emits each lifecycle event exactly once. Libraries that emit per-store probes or omit events on a miss (such as `cacheable` and `cache-manager`) should be wired with a custom map or driven imperatively so the counts stay accurate. diff --git a/packages/utils/src/stats.ts b/packages/utils/src/stats.ts index 14ffeae8..1a4549fb 100644 --- a/packages/utils/src/stats.ts +++ b/packages/utils/src/stats.ts @@ -49,8 +49,8 @@ export type StatsEventMap = Record< export type KeyStatField = "hits" | "misses" | "gets" | "sets" | "deletes"; /** - * Per-key statistics returned by {@link Stats.topKeys}, - * {@link Stats.bottomKeys}, and {@link Stats.keyStats}. + * Per-key statistics returned by {@link Stats.mostUsedKeys}, + * {@link Stats.leastUsedKeys}, and {@link Stats.keyStats}. */ export type StatsKeyEntry = { key: string; @@ -99,8 +99,9 @@ export type StatsOptions = { trackKeys?: boolean; /** * Safety cap on the number of unique keys tracked. When exceeded, the - * lowest-count keys are pruned, which keeps top-key rankings approximately - * accurate but makes bottom-key rankings unreliable. Unbounded when unset. + * lowest-count keys are pruned, which keeps {@link Stats.mostUsedKeys} + * approximately accurate but makes {@link Stats.leastUsedKeys} unreliable. + * Unbounded when unset. */ maxTrackedKeys?: number; }; @@ -606,7 +607,7 @@ export class Stats { * @param {KeyStatField} [field] - Optionally rank by one counter (e.g. "hits") * @returns {StatsKeyEntry[]} */ - public topKeys(limit = 100, field?: KeyStatField): StatsKeyEntry[] { + public mostUsedKeys(limit = 100, field?: KeyStatField): StatsKeyEntry[] { return this.sortedKeyEntries(field, "desc").slice(0, limit); } @@ -614,13 +615,13 @@ export class Stats { * The least-used keys, sorted ascending. Sorts by total recorded operations, * or by a single field when `field` is provided. Ties order by key. Note: * only keys that have been recorded at least once can be ranked, and when - * {@link maxTrackedKeys} pruning has occurred the true bottom keys may have - * been evicted. + * {@link maxTrackedKeys} pruning has occurred the true least-used keys may + * have been evicted. * @param {number} limit - Maximum entries to return (default 100) * @param {KeyStatField} [field] - Optionally rank by one counter (e.g. "gets") * @returns {StatsKeyEntry[]} */ - public bottomKeys(limit = 100, field?: KeyStatField): StatsKeyEntry[] { + public leastUsedKeys(limit = 100, field?: KeyStatField): StatsKeyEntry[] { return this.sortedKeyEntries(field, "asc").slice(0, limit); } diff --git a/packages/utils/test/stats.test.ts b/packages/utils/test/stats.test.ts index c3bda1b8..0aa2920c 100644 --- a/packages/utils/test/stats.test.ts +++ b/packages/utils/test/stats.test.ts @@ -559,25 +559,25 @@ describe("stats per-key tracking", () => { expect(stats.keyStats("write-only")?.hitRate).toBe(0); }); - test("topKeys should rank by total count descending with key tie-break", () => { + test("mostUsedKeys should rank by total count descending with key tie-break", () => { const stats = new Stats({ enabled: true, trackKeys: true }); stats.recordKey("hot", "gets", 5); stats.recordKey("warm", "gets", 3); stats.recordKey("a", "gets"); stats.recordKey("b", "gets"); - const top = stats.topKeys(3); + const top = stats.mostUsedKeys(3); expect(top.map((entry) => entry.key)).toEqual(["hot", "warm", "a"]); expect(top[0].count).toBe(5); }); - test("bottomKeys should rank ascending with key tie-break", () => { + test("leastUsedKeys should rank ascending with key tie-break", () => { const stats = new Stats({ enabled: true, trackKeys: true }); stats.recordKey("hot", "gets", 5); stats.recordKey("b", "gets"); stats.recordKey("a", "gets"); - const bottom = stats.bottomKeys(2); + const bottom = stats.leastUsedKeys(2); expect(bottom.map((entry) => entry.key)).toEqual(["a", "b"]); }); @@ -586,9 +586,9 @@ describe("stats per-key tracking", () => { stats.recordKey("reads", "hits", 10); stats.recordKey("writes", "sets", 20); - expect(stats.topKeys(1, "hits")[0].key).toBe("reads"); - expect(stats.topKeys(1, "sets")[0].key).toBe("writes"); - expect(stats.bottomKeys(1, "hits")[0].key).toBe("writes"); + expect(stats.mostUsedKeys(1, "hits")[0].key).toBe("reads"); + expect(stats.mostUsedKeys(1, "sets")[0].key).toBe("writes"); + expect(stats.leastUsedKeys(1, "hits")[0].key).toBe("writes"); }); test("should default to 100 entries for top and bottom", () => { @@ -597,10 +597,10 @@ describe("stats per-key tracking", () => { stats.recordKey(`key-${i}`, "gets", i + 1); } - expect(stats.topKeys()).toHaveLength(100); - expect(stats.bottomKeys()).toHaveLength(100); - expect(stats.topKeys()[0].count).toBe(105); - expect(stats.bottomKeys()[0].count).toBe(1); + expect(stats.mostUsedKeys()).toHaveLength(100); + expect(stats.leastUsedKeys()).toHaveLength(100); + expect(stats.mostUsedKeys()[0].count).toBe(105); + expect(stats.leastUsedKeys()[0].count).toBe(1); }); test("should prune lowest-count keys past maxTrackedKeys, protecting the new key", () => {