UNPKG

librato-ts

Version:

Client for Librato Metrics (https://metrics.librato.com/)

387 lines (377 loc) 11.9 kB
'use strict'; const node_events = require('node:events'); const node_timers = require('node:timers'); const axios = require('axios'); const axiosRetry = require('axios-retry'); function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } const axios__default = /*#__PURE__*/_interopDefaultCompat(axios); const axiosRetry__default = /*#__PURE__*/_interopDefaultCompat(axiosRetry); function getMillisecondsFromHrTime(hrtime) { const [sec, usec] = process.hrtime(hrtime); return sec * 1e3 + Math.max(usec / 1e3 / 1e3); } function sanitizeMeasurementName(name) { return name.replace(/[^\w.:-]/g, "_").substring(0, 255); } function sanitizeAnnotationStreamName(name) { return name.toLowerCase().replace(/[^\da-z]/g, "_").substring(0, 255); } function sanitizeTagName(name) { return name.replace(/[^\w.:-]/g, "_").substring(0, 64); } function sanitizeTagValue(name) { return name.replace(/[^\w ./:?\\-]/g, "").substring(0, 255); } function sanitizeTags(tags) { if (!tags) { return tags; } const sanitizedTags = {}; for (const [key, value] of Object.entries(tags)) { sanitizedTags[sanitizeTagName(key)] = sanitizeTagValue(value); } return sanitizedTags; } const version = "1.5.4"; class CounterCollector { cache = /* @__PURE__ */ new Map(); increment(measurement) { let key = measurement.name; if (measurement.tags) { for (const [tagName, tagValue] of Object.entries(measurement.tags)) { key += `;tag_${tagName};${tagValue}`; } } if (measurement.source) { key += `;source_${measurement.source}`; } const existingItem = this.cache.get(key); if (existingItem) { existingItem.value += measurement.value ?? 1; existingItem.period ??= measurement.period; existingItem.time ??= measurement.time; } else { this.cache.set(key, { value: 1, ...measurement }); } } flush() { const measurements = []; for (const measurement of this.cache.values()) { const singleMeasurement = { ...measurement, name: sanitizeMeasurementName(measurement.name), source: measurement.source ? sanitizeMeasurementName(measurement.source) : void 0, tags: sanitizeTags(measurement.tags) }; measurements.push(singleMeasurement); } this.cache.clear(); return measurements; } } class GaugeCollector { cache = /* @__PURE__ */ new Map(); measure(measurement) { let key = measurement.name; if (measurement.tags) { for (const [tagName, tagValue] of Object.entries(measurement.tags)) { key += `;tag_${tagName};${tagValue}`; } } if (measurement.source) { key += `;source_${measurement.source}`; } const existingItem = this.cache.get(key); if (existingItem) { if (Array.isArray(existingItem)) { existingItem.push(measurement); } else { this.cache.set(key, [existingItem, measurement]); } } else { this.cache.set(key, measurement); } } flush() { const measurements = []; for (const measurement of this.cache.values()) { if (Array.isArray(measurement)) { let name = ""; let period; let time; let tags; let source; let sum = 0; const values = []; let lastValue = 0; for (const measure of measurement) { name = measure.name; period ??= measure.period; time ??= measure.time; tags ??= measure.tags; source ??= measure.source; sum += measure.value; values.push(measure.value); lastValue = measure.value; } const aggregateMeasurement = { name: sanitizeMeasurementName(name), count: measurement.length, sum, min: Math.min(...values), max: Math.max(...values), last: lastValue, period, time, source: source ? sanitizeMeasurementName(source) : void 0, tags: sanitizeTags(tags) }; const mean = sum / measurement.length; const squaredDifferences = values.map((value) => (value - mean) ** 2); let sumSquaredDifferences = 0; for (const value of squaredDifferences) { sumSquaredDifferences += value; } const variance = sumSquaredDifferences / measurement.length; aggregateMeasurement.stddev = Math.sqrt(variance); measurements.push(aggregateMeasurement); } else { const singleMeasurement = { ...measurement, name: sanitizeMeasurementName(measurement.name), source: measurement.source ? sanitizeMeasurementName(measurement.source) : void 0, tags: sanitizeTags(measurement.tags) }; measurements.push(singleMeasurement); } } this.cache.clear(); return measurements; } } class Librato extends node_events.EventEmitter { client; config; counterCollector = new CounterCollector(); gaugeCollector = new GaugeCollector(); isEnding = false; startTimeout; /** * Initializes the Librato client and starts sending measurements to Librato. * @param {object} config */ init(config) { this.isEnding = false; this.config = { period: 6e4, timeout: 59e3, retryCount: 3, ...config }; if (config.simulate) { return; } this.client = axios__default.create({ baseURL: "https://metrics-api.librato.com/v1", timeout: this.config.timeout, headers: { "user-agent": `librato-ts/${version}` }, auth: { username: this.config.email, password: this.config.token } }); axiosRetry__default(this.client, { retries: this.config.retryCount, retryDelay: (retryCount) => axiosRetry__default.exponentialDelay(retryCount), shouldResetTimeout: true, retryCondition(error) { if (axiosRetry__default.isNetworkOrIdempotentRequestError(error)) { return true; } if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") { return true; } if (error.response && error.response.status >= 500 && error.response.status <= 599) { return true; } return false; } }); const now = Date.now(); const startTime = now + (this.config.period - now % this.config.period); clearTimeout(this.startTimeout); this.startTimeout = node_timers.setTimeout(() => { this.run(startTime).catch((ex) => this.emit("error", ex)); }, startTime - now); } /** * Flushes any queued measurements to Librato and stops the process of sending measurements to Librato */ async end() { this.isEnding = true; clearTimeout(this.startTimeout); return this.flush(); } /** * Increment a value that accumulates over time – you can think of this like an odometer on a car; it only ever goes up. * @param {string} name - Name of the metric * @param {number} value - Amount to increment by * @param {object} options */ increment(name, value = 1, options) { if (this.config?.simulate) { return; } if (typeof value === "object") { options = value; value = 1; } const optionsWithDefaults = { source: this.config?.source, time: Date.now(), ...options }; this.counterCollector.increment({ name: `${this.config?.prefix ?? ""}${name}`, value, ...optionsWithDefaults }); } /** * Measures a current value at the time it is read. An example would be the fuel gauge in a vehicle. * @param {string} name - Name of the metric * @param {number} value - Value of the gauge metric * @param {object} options */ measure(name, value, options) { if (this.config?.simulate) { return; } const optionsWithDefaults = { source: this.config?.source, time: Date.now(), ...options }; this.gaugeCollector.measure({ name: `${this.config?.prefix ?? ""}${name}`, value, ...optionsWithDefaults }); } async annotate(title, options) { if (this.config?.simulate) { return; } if (!this.config || !this.client) { this.emit("error", new Error("Please call init() before calling annotate()")); return; } const streamName = options.streamName || sanitizeAnnotationStreamName(title); const startTimeSeconds = Math.round((options.startTime ?? /* @__PURE__ */ new Date()).getTime() / 1e3); const endTimeSeconds = options.endTime ? Math.round(options.endTime.getTime() / 1e3) : void 0; try { await this.client.post( `annotations/${streamName}`, { title: title.substring(0, 255), description: options.description, start_time: startTimeSeconds, end_time: endTimeSeconds, source: options.source, links: options.links }, { timeout: this.config.timeout, // Set abort signal equal to the total time of first request, all retries, and the max delay period between retries (960ms per retry) signal: AbortSignal.timeout(this.config.timeout * (this.config.retryCount + 1) + 3e3) } ); } catch (ex) { this.emit("error", ex); } } flush() { if (!this.config || !this.client || this.config.simulate) { return; } const counters = this.counterCollector.flush(); const gauges = this.gaugeCollector.flush(); if (!(counters.length || gauges.length)) { return; } return this._sendMetrics({ counters, gauges }); } async _sendMetrics({ counters, gauges }) { if (!this.config || !this.client || this.config.simulate) { return; } if (!this.config.email || !this.config.token) { this.emit("error", new Error("Librato metrics disabled: no email or token provided.")); return; } if (!counters.length && !gauges.length) { return; } this.emit("sending", { counters, gauges }); const startTime = process.hrtime(); try { await this.client.post( "metrics", { counters, gauges }, { timeout: this.config.timeout, // Set abort signal equal to the total time of first request, all retries (3), and the max delay period between retries (960ms per retry) signal: AbortSignal.timeout(this.config.timeout * 4 + 3e3) } ); const duration = getMillisecondsFromHrTime(startTime); this.emit("sent", { counters, gauges, duration }); } catch (ex) { this.emit("error", ex); } } async run(runTime) { if (this.config?.simulate) { return; } if (!this.config) { this.emit("error", new Error("Please call init()")); return; } const timeBeforeFlush = Date.now(); if (timeBeforeFlush >= runTime) { await this.flush(); } if (!this.isEnding) { let nextRunTime = runTime; while (nextRunTime <= timeBeforeFlush) { nextRunTime += this.config.period; } const now = Date.now(); if (nextRunTime > now) { this.startTimeout = node_timers.setTimeout(() => { if (!this.isEnding) { this.run(nextRunTime).catch((ex) => this.emit("error", ex)); } }, nextRunTime - Date.now()); } } } } exports.CounterCollector = CounterCollector; exports.GaugeCollector = GaugeCollector; exports.Librato = Librato; exports.getMillisecondsFromHrTime = getMillisecondsFromHrTime; exports.sanitizeAnnotationStreamName = sanitizeAnnotationStreamName; exports.sanitizeMeasurementName = sanitizeMeasurementName; exports.sanitizeTagName = sanitizeTagName; exports.sanitizeTagValue = sanitizeTagValue; exports.sanitizeTags = sanitizeTags;