librato-ts
Version:
Client for Librato Metrics (https://metrics.librato.com/)
387 lines (377 loc) • 11.9 kB
JavaScript
'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;