@ceramicnetwork/observability
Version:
Typescript library for instrumenting ceramic networks
387 lines (386 loc) • 14.6 kB
JavaScript
/* Service metrics need to push to a collector rather than expose
metrics on an exporter */ function _define_property(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { BasicTracerProvider, TraceIdRatioBasedSampler, ParentBasedSampler, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { Resource } from '@opentelemetry/resources';
import { trace } from '@opentelemetry/api';
import { Utils } from './utils.js';
export const UNKNOWN_CALLER = 'Unknown';
export const CONCURRENCY_LIMIT = 1;
export const TRACE_CONCURRENCY_LIMIT = 1;
export const DEFAULT_TRACE_SAMPLE_RATIO = 0.1;
export const DEFAULT_EXPORT_INTERVAL_MS = 60000 // one minute, is otlp default
;
export const DEFAULT_EXPORT_TIMEOUT_MS = 30000 // 30 sec timeout, the otlp default
;
class NullSpan {
// if we start using other span methods, add null methods here
// Returns the flag whether this span will be recorded.
// @ts-ignore
end(endTime) {
return false;
}
}
export var SinceField;
(function(SinceField) {
SinceField[SinceField["CREATED_AT"] = 0] = "CREATED_AT";
SinceField[SinceField["UPDATED_AT"] = 1] = "UPDATED_AT";
SinceField[SinceField["TIMESTAMP"] = 2] = "TIMESTAMP";
})(SinceField || (SinceField = {}));
export class TimeableMetric {
reset() {
this.cnt = 0;
this.totTime = 0;
this.maxTime = 0;
}
recordAll(tasks) {
for (const task of tasks){
this.record(task);
}
}
record(task) {
this.cnt += 1;
let timeElapsed = 0;
if (this.since === 0) {
timeElapsed = Date.now() - task.createdAt.getTime();
} else if (this.since === 2) {
timeElapsed = Date.now() - Number(task.timestamp);
} else {
timeElapsed = Date.now() - task.updatedAt.getTime();
}
this.totTime += timeElapsed;
if (timeElapsed > this.maxTime) {
this.maxTime = timeElapsed;
}
}
getMeanTime() {
return this.totTime / this.cnt;
}
/**
* Publishes the accumulated statistics.
* This method can be invoked manually or automatically at set intervals.
* It publishes the total count, mean time, and maximum time for the given metric,
* over the period since the last publish.
*
* @param {string} name - The name of the metric to publish statistics for.
*/ publishStats(name) {
if (!name) {
name = this.name;
}
ServiceMetrics.count(name + '_total', this.cnt);
ServiceMetrics.observe(name + '_mean', this.getMeanTime());
ServiceMetrics.observe(name + '_max', this.maxTime);
this.reset();
}
startPublishingStats() {
if (!this.name || !this.publishIntervalMS) {
ServiceMetrics.log_err("Please set name and interval on initialization of your TimeableMetric");
return;
}
if (this.publishIntervalId) {
clearInterval(this.publishIntervalId); // Clear existing interval if it's already running
}
this.publishIntervalId = setInterval(()=>{
this.publishStats(this.name);
}, this.publishIntervalMS);
}
stopPublishingStats() {
if (this.publishIntervalId) {
clearInterval(this.publishIntervalId);
this.publishIntervalId = null;
}
}
constructor(since, name, interval){
_define_property(this, "cnt", void 0);
_define_property(this, "totTime", void 0);
_define_property(this, "maxTime", void 0);
_define_property(this, "since", void 0);
_define_property(this, "name", null);
_define_property(this, "publishIntervalId", null);
_define_property(this, "publishIntervalMS", null);
this.cnt = 0;
this.totTime = 0;
this.maxTime = 0;
this.since = since;
// for backwards compatibility name may be specified at the time of publish stats
if (name) {
this.name = name;
}
if (interval) {
this.publishIntervalMS = interval;
}
}
}
// Even though we have type number, it seems strings can get thru
function enforceNumeric(param) {
if (typeof param === 'number') {
return param;
} else if (typeof param === 'string') {
return parseInt(param);
} else {
throw new Error(`Invalid parameter type for ${param}, should be number`);
}
}
class _ServiceMetrics {
static getInstance() {
if (!_ServiceMetrics.instance) {
_ServiceMetrics.instance = new _ServiceMetrics();
}
return _ServiceMetrics.instance;
}
/* Set up the exporter at run time, after we have read the configuration */ start(collectorHost = '', caller = UNKNOWN_CALLER, sample_ratio = DEFAULT_TRACE_SAMPLE_RATIO, logger = null, append_total_to_counters = true, prometheusExportPort = 0, exportIntervalMillis = DEFAULT_EXPORT_INTERVAL_MS, exportTimeoutMillis = DEFAULT_EXPORT_TIMEOUT_MS) {
this.caller = caller;
// ensure numerics
prometheusExportPort = enforceNumeric(prometheusExportPort);
exportIntervalMillis = enforceNumeric(exportIntervalMillis);
exportTimeoutMillis = enforceNumeric(exportTimeoutMillis);
this.meterProvider = new MeterProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: caller
})
});
if (!collectorHost && prometheusExportPort <= 0) {
// If no collector URL or prometheusExportPort then the functions will be no-ops
return;
}
if (prometheusExportPort > 0) {
const promExporter = new PrometheusExporter({
port: prometheusExportPort
});
this.meterProvider.addMetricReader(promExporter);
}
if (collectorHost) {
// Check for invalid intervals
if (exportIntervalMillis < exportTimeoutMillis || exportIntervalMillis === 0) {
throw new Error(`Invalid export and timeout intervals ${exportIntervalMillis} and ${exportTimeoutMillis}. ` + `Export interval must be greater than timeout interval and nonzero.`);
}
const collectorURL = `http://${collectorHost}:4318/v1/metrics`;
const traceCollectorURL = `http://${collectorHost}:4318/v1/traces`;
const metricExporter = new OTLPMetricExporter({
url: collectorURL,
concurrencyLimit: CONCURRENCY_LIMIT
});
this.meterProvider.addMetricReader(new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: exportIntervalMillis,
exportTimeoutMillis: exportTimeoutMillis
}));
// now set up trace exporter
const traceExporter = new OTLPTraceExporter({
url: traceCollectorURL,
concurrencyLimit: TRACE_CONCURRENCY_LIMIT
});
//reference: https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-sdk-trace-base
const traceProvider = new BasicTracerProvider({
sampler: new ParentBasedSampler({
// sample_ratio represents the percentage of traces which should
// be sampled.
root: new TraceIdRatioBasedSampler(sample_ratio)
})
});
traceProvider.addSpanProcessor(new BatchSpanProcessor(traceExporter));
traceProvider.register();
// set up a tracer for the caller
this.tracer = trace.getTracer(caller);
}
// Meter for calling application
this.meter = this.meterProvider.getMeter(caller);
// accept a logger from the caller
this.logger = logger;
// behavior about counter naming to be backward-compatible
this.append_total_to_counters = append_total_to_counters;
// set the shutdown behavior to flush metrics on exit
process.on('SIGTERM', ()=>this.shutdown());
process.on('SIGINT', ()=>this.shutdown());
return Boolean(this.meter);
}
/*
* Shutdown gracefully exporting all final metrics
*
*/ async shutdown() {
if (this.meterProvider) {
await this.meterProvider.shutdown();
}
}
/*
* Set an instance identifier for all metrics going forward
*/ setInstanceIdentifier(instanceId) {
this.instanceId = instanceId;
}
/*
* adjust metric parameters to include instanceIdentifier, if any
*/ adjustParams(params) {
// if we have an instance identifier, include it
if (this.instanceId) {
if (!params) {
return {
'instanceId': this.instanceId
};
}
params['instanceId'] = this.instanceId;
}
return params;
}
// could have subclasses or specific functions with set params, but we want to
// easily and quickly change what is recorded, there are no code dependencies on it
startSpan(name, params) {
if (!this.tracer) {
return new NullSpan();
}
try {
const span = this.tracer.startSpan(name);
for(const key in params){
span.setAttribute(key, params[key]);
}
return span;
} catch (e) {
this.logger.warn(`Error starting span ${name}: ${e}`);
return new NullSpan();
}
}
count(name, value, params) {
// If not initialized, just return
if (!this.meter) {
return;
}
const finalParams = this.adjustParams(params);
// Create this counter if we have not already
if (!(name in this.counters)) {
const full_name = this.append_total_to_counters ? `${this.caller}_${name}_total` : `${this.caller}_${name}`;
this.counters[name] = this.meter.createCounter(full_name);
}
// Add to the count
if (finalParams) {
this.counters[name].add(value, finalParams);
} else {
this.counters[name].add(value);
}
}
observe(name, value, params) {
// If not initialized, just return
if (!this.meter) {
return;
}
// Create this ObservableGauge if we have not already
if (!(name in this.gauges)) {
this.gauges[name] = this.meter.createObservableGauge(`${this.caller}:${name}`);
this.observations[name] = [];
this.gauges[name].addCallback((observableResult)=>{
for (const [value, params] of this.observations[name]){
observableResult.observe(value, params);
}
this.observations[name] = [];
});
}
const finalParams = this.adjustParams(params);
// Record the observed value; it will be set in the callback when metrics are recorded
this.observations[name].push([
value,
finalParams
]);
}
record(name, value, params) {
// If not initialized, just return
if (!this.meter) {
return;
}
// Create this Histogram if we have not already
if (!(name in this.histograms)) {
this.histograms[name] = this.meter.createHistogram(`${this.caller}:${name}`);
}
const finalParams = this.adjustParams(params);
// Record the observed value
if (params) {
this.histograms[name].record(value, finalParams);
} else {
this.histograms[name].record(value);
}
}
recordAverage(name, arr) {
// if array is empty, just return
if (arr.length <= 0) {
return;
}
this.record(name, Utils.averageArray(arr));
}
recordObjectFields(prefix, obj) {
Object.entries(obj).forEach(([key, value])=>{
if (typeof value === "number") {
this.record(prefix + '_' + String(key), value);
}
});
}
recordRatio(name, numer, denom, digits = 2) {
if (denom == 0) {
this.log_warn(`Attempt to record ratio w zero denominator: ${name}`);
return;
}
this.record(name, numer / denom);
}
log_info(message) {
if (!this.logger) {
return;
}
try {
this.logger.info(message);
} catch {}
}
log_warn(message) {
if (!this.logger) {
return;
}
try {
this.logger.warn(message);
} catch {}
}
log_err(message) {
if (!this.logger) {
return;
}
try {
this.logger.err(message);
} catch {}
}
constructor(){
_define_property(this, "caller", void 0);
_define_property(this, "counters", void 0);
_define_property(this, "gauges", void 0);
_define_property(this, "histograms", void 0);
_define_property(this, "observations", void 0);
_define_property(this, "meter", void 0);
_define_property(this, "meterProvider", void 0);
_define_property(this, "tracer", void 0);
_define_property(this, "logger", void 0);
_define_property(this, "append_total_to_counters", void 0);
_define_property(this, "instanceId", void 0);
this.caller = '';
this.counters = {};
this.gauges = {};
this.observations = {};
this.histograms = {};
this.meter = null;
this.meterProvider = null;
this.tracer = null;
this.logger = null;
this.append_total_to_counters = true;
this.instanceId = '';
}
}
_define_property(_ServiceMetrics, "instance", void 0);
export const ServiceMetrics = _ServiceMetrics.getInstance();