UNPKG

snapmetrics

Version:

Lightweight library for tracking real-time metrics and events with rolling statistics over configurable time windows.

511 lines (510 loc) 21.7 kB
import Denque from "denque"; import { throttle } from "throttle-debounce"; import { calculateAverage, calculatePercentile, calculateMinimum, calculateMaximum, calculateStandardDeviation, } from "./stats.js"; import { parseTimeWindow, swapLevels } from "./utils.js"; var SortRequirement; (function (SortRequirement) { SortRequirement["SORTED"] = "sorted"; SortRequirement["PREFER_SORTED"] = "prefer_sorted"; SortRequirement["UNSORTED"] = "unsorted"; SortRequirement["ANY"] = "any"; })(SortRequirement || (SortRequirement = {})); export class SnapMetrics { windows; timeWindowDurations; debug; throttledRemoveExpiredRecords = () => this.removeExpiredRecords(); /** * Constructs an instance of the class. * * @param timeWindowsOrOptions - Either an array of time windows or a configuration options object: * - If an array is provided, it is treated as `timeWindows`, formatted as `<integer><unit>` * where unit is `s`, `m`, or `h`. Defaults to ["1m", "5m", "15m"]. * - If an object is provided, it may contain the following options: * - `timeWindows` (Array<string>): Array of time windows, formatted as `<integer><unit>`. * Defaults to ["1m", "5m", "15m"]. * - `removeExpiredRecordsThrottlingMS` (number | false): Time in milliseconds to throttle * the removal of expired records. Must be a non-negative number (>= 0) or `false` to * disable throttling. Defaults to `100` ms. * - `debug` (boolean): Enables logging for debugging. Defaults to `false`. * @example * const metrics = new SnapMetrics({ timeWindows: ["1m", "5m", "15m"], removeExpiredRecordsThrottlingMS: 100, debug: true }); */ constructor(timeWindowsOrOptions) { let timeWindows; let options; // Check if the argument is an array (timeWindows) or an options object. if (Array.isArray(timeWindowsOrOptions)) { timeWindows = timeWindowsOrOptions; // User passed an array of time windows. options = {}; // Default options. } else { options = timeWindowsOrOptions || {}; // User passed an options object or nothing. timeWindows = options.timeWindows || ["1m", "5m", "15m"]; // Default time windows. } const { removeExpiredRecordsThrottlingMS = 100, // Default throttling interval. debug = false, // Default debug setting. } = options; this.debug = debug; this.timeWindowDurations = Object.fromEntries(timeWindows.map((key) => [key, parseTimeWindow(key)])); this.windows = Object.fromEntries(timeWindows.map((key) => [ key, { values: { sum: 0, count: 0, queue: new Denque(), }, events: { counts: new Map(), timestamps: new Denque(), }, }, ])); if (removeExpiredRecordsThrottlingMS !== false) { this.throttledRemoveExpiredRecords = throttle(removeExpiredRecordsThrottlingMS, () => this.removeExpiredRecords()); } } /** * Removes expired records from all time windows. */ removeExpiredRecords() { const now = performance.now(); for (const [key, window] of Object.entries(this.windows)) { const expiryTime = this.timeWindowDurations[key]; let expired = false; // Remove expired numeric values while (!window.values.queue.isEmpty() && now - window.values.queue.peekFront().timestamp > expiryTime) { const expiredValue = window.values.queue.shift(); window.values.sum -= expiredValue.value; window.values.count--; expired = true; } if (expired) { delete window.values.sortedValues; // Invalidate cached sorted values if any records expired } // Remove expired events and update their counts while (!window.events.timestamps.isEmpty() && now - window.events.timestamps.peekFront().timestamp > expiryTime) { const expiredEvent = window.events.timestamps.shift(); const currentCount = window.events.counts.get(expiredEvent.event) || 0; if (currentCount <= 1) { window.events.counts.delete(expiredEvent.event); } else { window.events.counts.set(expiredEvent.event, currentCount - 1); } } } } /** * Maps a function over all time windows and returns the results. * @param mapper Function that transforms a window's data into a result * @returns Record mapping each time window to its transformed value */ mapWindows(mapper) { return Object.fromEntries(Object.entries(this.windows).map(([key, window]) => [ key, mapper(window, key), ])); } /** * Gets values from a window, optionally sorted, with efficient caching. * @param windowKey - The time window key * @param sortRequirement - Optional sorting requirement, defaults to ANY * @returns Object containing values array and isSorted flag, or null if window doesn't exist */ getValues(windowKey, sortRequirement = SortRequirement.ANY) { const window = this.windows[windowKey]; if (!window) { return null; } if (window.values.count === 0) { return { values: [], isSorted: true }; } if (window.values.sortedValues && window.values.sortedValues.length === window.values.queue.length) { // Return cached sorted values unless UNSORTED is specifically required if (sortRequirement !== SortRequirement.UNSORTED) { return { values: window.values.sortedValues, isSorted: true }; } } const values = window.values.queue.toArray().map((v) => v.value); switch (sortRequirement) { case SortRequirement.SORTED: const sortedValues = values.sort((a, b) => a - b); window.values.sortedValues = sortedValues; return { values: sortedValues, isSorted: true }; case SortRequirement.PREFER_SORTED: return { values, isSorted: false }; case SortRequirement.UNSORTED: return { values, isSorted: false }; case SortRequirement.ANY: return { values, isSorted: false }; } } /** * Records a value into all active time windows. * @param value - Value. * @example * const metrics = new SnapMetrics(); * metrics.record(1); // Records a value of 1 into all active time windows */ record(value) { if (this.debug) console.log(`Recording value: ${value}`); const timestamp = performance.now(); for (const window of Object.values(this.windows)) { window.values.queue.push({ timestamp, value }); window.values.sum += value; window.values.count++; } this.throttledRemoveExpiredRecords(); if (this.debug) console.log("Updated windows:", JSON.stringify(this.windows, null, 2)); } /** * Measures the execution time of a synchronous or asynchronous function and records the duration in all time windows. * * @template T The return type of the provided function. * @param fn The function to be executed, which can be synchronous or return a Promise for asynchronous execution. * @returns The result of the executed function. If the function returns a Promise, the result will also be a Promise. * @example * const metrics = new SnapMetrics(); * const result = metrics.recordDuration(() => { * // Your function logic here * return someValue; * }); */ recordDuration(fn) { const startTime = performance.now(); const result = fn(); if (result instanceof Promise) { return result.then((value) => { this.record(performance.now() - startTime); return value; }); } else { this.record(performance.now() - startTime); return result; } } /** * Returns the count of values for all time windows. * @returns Record mapping each time window to its count of recorded values. * @example * const metrics = new SnapMetrics(); * metrics.record(1); * metrics.record(2); * metrics.getCounts(); // { "1m": 2, "5m": 2, "15m": 2 } */ getCounts() { if (this.debug) console.log("Calculating counts..."); this.throttledRemoveExpiredRecords(); const counts = this.mapWindows((window) => window.values.count); if (this.debug) console.log("Counts calculated:", JSON.stringify(counts, null, 2)); return counts; } /** * Returns the sum of values for all time windows. * @returns Record mapping each time window to its sum value. Returns null for empty windows. * @example * const metrics = new SnapMetrics(); * metrics.record(1); * metrics.record(2); * metrics.getSums(); // { "1m": 3, "5m": 3, "15m": 3 } * * // Empty window returns null * const emptyMetrics = new SnapMetrics(); * emptyMetrics.getSums(); // { "1m": null, "5m": null, "15m": null } */ getSums() { if (this.debug) console.log("Calculating sums..."); this.throttledRemoveExpiredRecords(); const sums = this.mapWindows((window) => window.values.count === 0 ? null : window.values.sum); if (this.debug) console.log("Sums calculated:", JSON.stringify(sums, null, 2)); return sums; } /** * Returns the rolling averages for all time windows. * @returns Record mapping each time window to its average value. Returns null for empty windows. * @example * const metrics = new SnapMetrics(); * metrics.record(1); * metrics.record(2); * metrics.getAverages(); // { "1m": 1.5, "5m": 1.5, "15m": 1.5 } * * // Empty window returns null * const emptyMetrics = new SnapMetrics(); * emptyMetrics.getAverages(); // { "1m": null, "5m": null, "15m": null } */ getAverages() { if (this.debug) console.log("Calculating averages..."); this.throttledRemoveExpiredRecords(); const averages = this.mapWindows((window, key) => { if (window.values.count === 0) return null; const { values } = this.getValues(key); return calculateAverage(values, window.values.sum); }); if (this.debug) console.log("Averages calculated:", JSON.stringify(averages, null, 2)); return averages; } /** * Returns the middle value for each time window using linear interpolation. * @description For an even number of values, uses linear interpolation between the two middle values. * @returns Record mapping each time window to its median value. Returns null for empty windows. * @example * const metrics = new SnapMetrics(); * metrics.record(1); * metrics.record(2); * metrics.record(3); * metrics.getMedians(); // { "1m": 2, "5m": 2, "15m": 2 } */ getMedians() { if (this.debug) console.log("Calculating medians..."); this.throttledRemoveExpiredRecords(); const medians = this.mapWindows((window, key) => { if (window.values.count === 0) return null; const { values } = this.getValues(key, SortRequirement.SORTED); return calculatePercentile(values, 50); }); if (this.debug) console.log("Medians calculated:", JSON.stringify(medians, null, 2)); return medians; } /** * Returns the value below which the given percentage of observations fall. * @param percentile The percentile to calculate (0-100). For example, 95 for 95th percentile. * @description Uses Hyndman and Fan type 7 linear interpolation method for accurate results. * @returns Record mapping each time window to its percentile value. Returns null for empty windows. * @throws {Error} If percentile is not between 0 and 100. * @example * const metrics = new SnapMetrics(); * // Record some response times... * metrics.getPercentiles(95); // Get 95th percentile response times */ getPercentiles(percentile) { if (percentile < 0 || percentile > 100) { throw new Error("Percentile must be between 0 and 100"); } if (this.debug) console.log("Calculating percentiles..."); this.throttledRemoveExpiredRecords(); const percentiles = this.mapWindows((window, key) => { if (window.values.count === 0) return null; const { values } = this.getValues(key, SortRequirement.SORTED); return calculatePercentile(values, percentile); }); if (this.debug) console.log("Percentiles calculated:", JSON.stringify(percentiles, null, 2)); return percentiles; } /** * Returns the smallest value recorded within each time window. * @returns Record mapping each time window to its minimum value. Returns null for empty windows. * @example * const metrics = new SnapMetrics(); * metrics.record(1); * metrics.record(2); * metrics.getMinimums(); // { "1m": 1, "5m": 1, "15m": 1 } */ getMinimums() { if (this.debug) console.log("Calculating minimums..."); this.throttledRemoveExpiredRecords(); const minimums = this.mapWindows((window, key) => { if (window.values.count === 0) return null; const { values, isSorted } = this.getValues(key, SortRequirement.PREFER_SORTED); return isSorted ? values[0] : calculateMinimum(values); }); if (this.debug) console.log("Minimums calculated:", JSON.stringify(minimums, null, 2)); return minimums; } /** * Returns the largest value recorded within each time window. * @returns Record mapping each time window to its maximum value. Returns null for empty windows. * @example * const metrics = new SnapMetrics(); * metrics.record(1); * metrics.record(2); * metrics.getMaximums(); // { "1m": 2, "5m": 2, "15m": 2 } */ getMaximums() { if (this.debug) console.log("Calculating maximums..."); this.throttledRemoveExpiredRecords(); const maximums = this.mapWindows((window, key) => { if (window.values.count === 0) return null; const { values, isSorted } = this.getValues(key, SortRequirement.PREFER_SORTED); return isSorted ? values[values.length - 1] : calculateMaximum(values); }); if (this.debug) console.log("Maximums calculated:", JSON.stringify(maximums, null, 2)); return maximums; } /** * Returns the standard deviation (square root of variance) for each time window. * @description Indicates how spread out values are from their mean. * @returns Record mapping each time window to its standard deviation. Returns null for empty windows. * @example * const metrics = new SnapMetrics(); * metrics.record(2); * metrics.record(4); * metrics.record(6); * metrics.getStandardDeviations(); // { "1m": 1.63, "5m": 1.63, "15m": 1.63 } */ getStandardDeviations() { if (this.debug) console.log("Calculating standard deviations..."); this.throttledRemoveExpiredRecords(); const stdDevs = this.mapWindows((window, key) => { if (window.values.count === 0) return null; const { values } = this.getValues(key); const mean = window.values.sum / window.values.count; return calculateStandardDeviation(values, mean); }); if (this.debug) console.log("Standard deviations calculated:", JSON.stringify(stdDevs, null, 2)); return stdDevs; } /** * Returns all metrics for each time window. * @param options Configuration options * @param options.percentiles Array of percentiles to calculate (0-100). Defaults to [90, 95]. * @returns Record mapping each time window to its metrics: * - count: Number of values in the window * - sum: Sum of all values * - average: Mean value * - median: 50th percentile * - percentile{N}: Nth percentile values for each requested percentile * - minimum: Smallest value * - maximum: Largest value * - standardDeviation: Standard deviation from mean * @example * const metrics = new SnapMetrics(); * metrics.record(1); * metrics.record(2); * metrics.record(3); * * metrics.getMetrics(); * // Returns: * // { * // "1m": { * // count: 3, * // sum: 6, * // average: 2, * // median: 2, * // percentile90: 2.8, * // percentile95: 2.9, * // minimum: 1, * // maximum: 3, * // standardDeviation: 0.816 * // } * // } */ getMetrics({ percentiles = [90, 95] } = { percentiles: [90, 95], }) { if (this.debug) console.log("Calculating metrics..."); const metrics = { count: this.getCounts(), sum: this.getSums(), average: this.getAverages(), median: this.getMedians(), ...Object.fromEntries(percentiles.map((p) => [`percentile${p}`, this.getPercentiles(p)])), minimum: this.getMinimums(), maximum: this.getMaximums(), standardDeviation: this.getStandardDeviations(), }; // Transform structure from metric-first to window-first const transformedMetrics = swapLevels(metrics); if (this.debug) console.log("Metrics calculated:", JSON.stringify(transformedMetrics, null, 2)); return transformedMetrics; } /** * Increments a named counter for tracking frequency across time windows. * @param name - The name of the counter to increment * @param value - Optional amount to increment by (defaults to 1) * @example * const metrics = new SnapMetrics(); * metrics.increment('api_calls'); // Increment by 1 * metrics.increment('bytes_sent', 1024); // Increment by specific amount */ increment(name, value = 1) { if (this.debug) console.log(`Incrementing counter ${name} by ${value}`); const timestamp = performance.now(); for (const window of Object.values(this.windows)) { window.events.timestamps.push({ timestamp, event: name }); const currentCount = window.events.counts.get(name) || 0; window.events.counts.set(name, currentCount + value); } this.throttledRemoveExpiredRecords(); if (this.debug) { console.log("Updated counter values:", JSON.stringify(Object.fromEntries(Object.entries(this.windows).map(([key, window]) => [ key, Object.fromEntries(window.events.counts), ])), null, 2)); } } /** * Returns the current value of all counters for each time window. * @returns Record mapping each time window to a map of counter values. * @example * const metrics = new SnapMetrics(); * metrics.increment('api_calls'); * metrics.increment('errors'); * metrics.getCounters(); * // Returns: * // { * // "1m": { "api_calls": 1, "errors": 1 }, * // "5m": { "api_calls": 1, "errors": 1 }, * // "15m": { "api_calls": 1, "errors": 1 } * // } */ getCounters() { if (this.debug) console.log("Getting counter values..."); this.throttledRemoveExpiredRecords(); const counters = this.mapWindows((window) => Object.fromEntries(window.events.counts)); if (this.debug) console.log("Counter values:", JSON.stringify(counters, null, 2)); return counters; } /** * Returns the current value of a specific counter for each time window. * @param name The name of the counter to retrieve * @returns Record mapping each time window to the counter's value. Returns null if counter doesn't exist. * @example * const metrics = new SnapMetrics(); * metrics.increment('api_calls'); * metrics.getCounter('api_calls'); // { "1m": 1, "5m": 1, "15m": 1 } */ getCounter(name) { if (this.debug) console.log(`Getting counter values for ${name}...`); this.throttledRemoveExpiredRecords(); const counters = this.mapWindows((window) => window.events.counts.get(name) ?? null); if (this.debug) console.log(`Counter values for ${name}:`, JSON.stringify(counters, null, 2)); return counters; } }