UNPKG

@ceramicnetwork/observability

Version:

Typescript library for instrumenting ceramic networks

387 lines (386 loc) 14.6 kB
/* 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();