diff --git a/packages/utils/README.md b/packages/utils/README.md index 2f7640cd..ef493f8e 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,223 @@ 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 +const stats = new Stats(); // disabled by default + +stats.enable(); // start tracking (or: stats.enabled = true) +stats.incrementHits(); +console.log(stats.hits); // 1 + +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 -import { stats } from '@cacheable/utils'; +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 +``` -const cacheStats = stats(); -cacheStats.incrementHits(); -console.log(cacheStats.hits); // Get the hit rate of the cache +`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. + +## 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`. + +```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'); + +// 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 } +// ] + +// 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.mostUsedKeys(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 `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 `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. + # Time to Live (TTL) Helpers The `@cacheable/utils` package provides helpers for managing time-to-live (TTL) values for cached items. diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e65f0e8a..7d91323a 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 { + type KeyStatField, + nodeCacheStatsEventMap, + type StatField, + Stats, + type StatsEmitter, + type StatsEventHandler, + type StatsEventMap, + type StatsKeyEntry, + 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..1a4549fb 100644 --- a/packages/utils/src/stats.ts +++ b/packages/utils/src/stats.ts @@ -1,24 +1,187 @@ // 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 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.mostUsedKeys}, + * {@link Stats.leastUsedKeys}, 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}. + */ +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; + /** Number of unique keys currently tracked (0 when key tracking is off). */ + trackedKeys: 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. 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 {@link Stats.mostUsedKeys} + * approximately accurate but makes {@link Stats.leastUsedKeys} unreliable. + * Unbounded when unset. + */ + maxTrackedKeys?: number; +}; + +/** + * Event map for `@cacheable/node-cache` instances. node-cache emits with + * 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: (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(); + }, +}; + +type StatsSubscription = { + emitter: StatsEmitter; + event: string; + listener: (...args: any[]) => void; }; +type KeyCounters = Record; + 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[] = []; + 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); + } } /** @@ -35,12 +198,50 @@ 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 */ public get hits(): number { - return this._hits; + return this._counters.hits; } /** @@ -48,7 +249,7 @@ export class Stats { * @readonly */ public get misses(): number { - return this._misses; + return this._counters.misses; } /** @@ -56,7 +257,7 @@ export class Stats { * @readonly */ public get gets(): number { - return this._gets; + return this._counters.gets; } /** @@ -64,7 +265,7 @@ export class Stats { * @readonly */ public get sets(): number { - return this._sets; + return this._counters.sets; } /** @@ -72,7 +273,7 @@ export class Stats { * @readonly */ public get deletes(): number { - return this._deletes; + return this._counters.deletes; } /** @@ -80,7 +281,7 @@ export class Stats { * @readonly */ public get clears(): number { - return this._clears; + return this._counters.clears; } /** @@ -104,55 +305,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 +408,7 @@ export class Stats { } this._vsize += this.roughSizeOfObject(value); + this.touch(); } public decreaseVSize(value: any): void { @@ -169,6 +417,7 @@ export class Stats { } this._vsize -= this.roughSizeOfObject(value); + this.touch(); } public incrementKSize(key: string): void { @@ -177,6 +426,7 @@ export class Stats { } this._ksize += this.roughSizeOfString(key); + this.touch(); } public decreaseKSize(key: string): void { @@ -185,22 +435,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 +451,8 @@ export class Stats { return; } - this._count = count; + this._counters.count = count; + this.touch(); } public roughSizeOfString(value: string): number { @@ -256,21 +500,291 @@ 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._keyCounts.clear(); + 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, + trackedKeys: this._keyCounts.size, + 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(); + } + + /** + * 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 mostUsedKeys(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 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 leastUsedKeys(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 + * toggle enablement later. Call {@link unsubscribe} to detach. + * @param {StatsEmitter} emitter - The emitter to listen on + * @param {StatsEventMap} eventMap - The event-to-stat mapping (e.g. + * {@link nodeCacheStatsEventMap} or a custom map) + */ + public subscribe(emitter: StatsEmitter, eventMap: StatsEventMap): 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 (!this._enabled) { + return; + } + + 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..0aa2920c 100644 --- a/packages/utils/test/stats.test.ts +++ b/packages/utils/test/stats.test.ts @@ -1,5 +1,44 @@ +import { EventEmitter } from "node:events"; import { describe, expect, test } from "vitest"; -import { Stats } from "../src/stats.js"; +import { 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 +247,426 @@ 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 events from a Node EventEmitter with a custom map", () => { + const emitter = new EventEmitter(); + const stats = new Stats({ enabled: true }); + 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" }); + 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 and reset on flush_stats", () => { + 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); + + emitter.emit("flush_stats"); + expect(stats.sets).toBe(0); + expect(stats.deletes).toBe(0); + expect(stats.clears).toBe(0); + expect(typeof stats.lastReset).toBe("number"); + }); + + 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 }); + 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 not auto-subscribe when eventMap is omitted", () => { + const emitter = new EventEmitter(); + const stats = new Stats({ enabled: true, emitter }); + emitter.emit("set"); + expect(stats.sets).toBe(0); + }); + + 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); + }); +}); + +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("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.mostUsedKeys(3); + expect(top.map((entry) => entry.key)).toEqual(["hot", "warm", "a"]); + expect(top[0].count).toBe(5); + }); + + 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.leastUsedKeys(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.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", () => { + const stats = new Stats({ enabled: true, trackKeys: true }); + for (let i = 0; i < 105; i++) { + stats.recordKey(`key-${i}`, "gets", i + 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", () => { + 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); + }); +});