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
JavaScript
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;
}
}