UNPKG

elastic-apm-node

Version:

The official Elastic APM agent for Node.js

286 lines (265 loc) 10.2 kB
/* * Copyright Elasticsearch B.V. and other contributors where applicable. * Licensed under the BSD 2-Clause License; you may not use this file except in * compliance with the BSD 2-Clause License. */ const { ExportResultCode } = require('@opentelemetry/core'); const { AggregationTemporality, InstrumentType, ExplicitBucketHistogramAggregation, SumAggregation, LastValueAggregation, DropAggregation, DataPointType, } = require('@opentelemetry/sdk-metrics'); const { LRUCache } = require('lru-cache'); /** * The `timestamp` in a metricset for APM Server intake is "UTC based and * formatted as microseconds since Unix epoch". * * Dev note: We need to round because APM server intake requires an integer. * This means a loss of sub-ms precision, which for this use case is fine. */ function metricTimestampFromOTelHrTime(otelHrTime) { // OTel's HrTime is `[<seconds since unix epoch>, <nanoseconds>]` return Math.round(otelHrTime[0] * 1e6 + otelHrTime[1] / 1e3); } // From oteljs/packages/sdk-metrics/src/utils.ts#hashAttributes function hashAttributes(attributes) { let keys = Object.keys(attributes); if (keys.length === 0) return ''; // Return a string that is stable on key orders. keys = keys.sort(); return JSON.stringify(keys.map((key) => [key, attributes[key]])); } /** * Fill in an Intake V2 API "sample" object for a histogram from an OTel Metrics * histogram DataPoint. * * Algorithm is from the spec (`convertBucketBoundaries`) */ function fillIntakeHistogramSample(sample, otelDataPoint) { const otelCounts = otelDataPoint.value.buckets.counts; const otelBoundaries = otelDataPoint.value.buckets.boundaries; const bucketCount = otelCounts.length; if (bucketCount === 0) { return; } const intakeCounts = (sample.counts = []); const intakeValues = (sample.values = []); sample.type = 'histogram'; // otelBoundaries has a size of bucketCount-1 // the first bucket has the boundaries ( -inf, otelBoundaries[0] ] // the second bucket has the boundaries ( otelBoundaries[0], otelBoundaries[1] ] // .. // the last bucket has the boundaries (otelBoundaries[bucketCount-2], inf) for (let i = 0; i < bucketCount; i++) { if (otelCounts[i] !== 0) { // ignore empty buckets intakeCounts.push(otelCounts[i]); if (i === 0) { // first bucket let bound = otelBoundaries[i]; if (bound > 0) { bound /= 2; } intakeValues.push(bound); } else if (i === bucketCount - 1) { // last bucket intakeValues.push(otelBoundaries[bucketCount - 2]); } else { // in between const lower = otelBoundaries[i - 1]; const upper = otelBoundaries[i]; intakeValues.push(lower + (upper - lower) / 2); } } } } /** * A PushMetricExporter that exports to an Elastic APM server. It is meant to be * used with a PeriodicExportingMetricReader -- which defers to * `selectAggregation` and `selectAggregationTemporality` on this class. * * @implements {import('@opentelemetry/sdk-metrics').PushMetricExporter} */ class ElasticApmMetricExporter { constructor(agent) { this._agent = agent; this._histogramAggregation = new ExplicitBucketHistogramAggregation( this._agent._conf.customMetricsHistogramBoundaries, ); this._sumAggregation = new SumAggregation(); this._lastValueAggregation = new LastValueAggregation(); this._dropAggregation = new DropAggregation(); this._attrDropWarnCache = new LRUCache({ max: 1000 }); this._dataPointTypeDropWarnCache = new LRUCache({ max: 1000 }); } /** * Spec: https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#aggregation * * @param {import('@opentelemetry/sdk-metrics').InstrumentType} instrumentType * @returns {import('@opentelemetry/sdk-metrics').Aggregation} */ selectAggregation(instrumentType) { // The same behaviour as OTel's `DefaultAggregation`, except for changes // to the default Histogram bucket sizes and support for the // `custom_metrics_histogram_boundaries` config var. switch (instrumentType) { case InstrumentType.COUNTER: case InstrumentType.UP_DOWN_COUNTER: case InstrumentType.OBSERVABLE_COUNTER: case InstrumentType.OBSERVABLE_UP_DOWN_COUNTER: return this._sumAggregation; case InstrumentType.OBSERVABLE_GAUGE: return this._lastValueAggregation; case InstrumentType.HISTOGRAM: return this._histogramAggregation; default: this._agent.logger.warn( `cannot selectAggregation: unknown OTel Metric instrumentType: ${instrumentType}`, ); return this._dropAggregation; } } // Spec: https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#aggregation-temporality // // Note: This differs from the OTel SDK default. // `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=Cumulative` // https://opentelemetry.io/docs/reference/specification/metrics/sdk_exporters/otlp/ selectAggregationTemporality(instrumentType) { switch (instrumentType) { case InstrumentType.COUNTER: case InstrumentType.OBSERVABLE_COUNTER: case InstrumentType.HISTOGRAM: case InstrumentType.OBSERVABLE_GAUGE: return AggregationTemporality.DELTA; case InstrumentType.UP_DOWN_COUNTER: case InstrumentType.OBSERVABLE_UP_DOWN_COUNTER: return AggregationTemporality.CUMULATIVE; } } async forceFlush() { return this._agent.flush(); } async shutdown() { return this._agent.flush(); } /** * Export an OTel `ResourceMetrics` to Elastic APM intake `metricset`s. * * Dev notes: * - Explicitly *not* including `metricData.descriptor.unit` because the APM * spec doesn't include it. It isn't clear there is value. */ export(resourceMetrics, resultCallback) { // console.log('resourceMetrics:'); console.dir(resourceMetrics, { depth: 10 }) for (const scopeMetrics of resourceMetrics.scopeMetrics) { // Metrics from separate instrumentation scopes must be in separate // `metricset` objects. In the future, the APM spec may dictate that we // add labels for the instrumentation scope -- perhaps `otel.scope.*`. // Discussion: https://github.com/elastic/apm/pull/742#discussion_r1061444699 const metricsetFromAttrHash = {}; for (const metricData of scopeMetrics.metrics) { const metricName = metricData.descriptor.name; if (this._agent._isMetricNameDisabled(metricName)) { continue; } if ( !( metricData.dataPointType === DataPointType.GAUGE || metricData.dataPointType === DataPointType.SUM || metricData.dataPointType === DataPointType.HISTOGRAM ) ) { if (!this._dataPointTypeDropWarnCache.has(metricName)) { this._agent.logger.warn( `dropping metric "${metricName}": cannot export metrics with dataPointType=${metricData.dataPointType}`, ); this._dataPointTypeDropWarnCache.set(metricName, true); } } for (const dataPoint of metricData.dataPoints) { const labels = this._labelsFromOTelMetricAttributes( dataPoint.attributes, metricData.descriptor.name, ); const attrHash = hashAttributes(labels); let metricset = metricsetFromAttrHash[attrHash]; if (!metricset) { metricset = { samples: {}, // Assumption: `endTime` is the same for all `dataPoint`s in // this `metricData`. timestamp: metricTimestampFromOTelHrTime(dataPoint.endTime), tags: labels, }; metricsetFromAttrHash[attrHash] = metricset; } const sample = {}; switch (metricData.dataPointType) { case DataPointType.GAUGE: sample.type = 'gauge'; sample.value = dataPoint.value; break; case DataPointType.SUM: if (metricData.isMonotonic) { sample.type = 'counter'; } else { sample.type = 'gauge'; } sample.value = dataPoint.value; break; case DataPointType.HISTOGRAM: fillIntakeHistogramSample(sample, dataPoint); break; } if (sample.type) { metricset.samples[metricData.descriptor.name] = sample; } } } // Importantly, if a metric has no `dataPoints` then we send nothing. This // satisfies the following from the APM agents spec: // // > For all instrument types with delta temporality, agents MUST filter out // > zero values before exporting. Object.values(metricsetFromAttrHash).forEach((metricset) => { this._agent._apmClient.sendMetricSet(metricset); }); } return resultCallback({ code: ExportResultCode.SUCCESS }); } /** * Convert from `dataPoint.attributes` to a set of labels (a.k.a. tags) for * Elastic APM intake. Attributes with an *array* value are not supported -- * they are dropped with a log.warn that mentions the metric and attribute * names. * * This makes *in-place* changes to the given `attrs` argument. It returns * the same object. * * https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#labels */ _labelsFromOTelMetricAttributes(attrs, metricName) { const keys = Object.keys(attrs); for (var i = 0; i < keys.length; i++) { const k = keys[i]; const v = attrs[k]; if (Array.isArray(v)) { delete attrs[k]; const cacheKey = metricName + '/' + k; if (!this._attrDropWarnCache.has(cacheKey)) { this._agent.logger.warn( { metricName, attrName: k }, 'dropping array-valued metric attribute: array attribute values are not supported', ); this._attrDropWarnCache.set(cacheKey, true); } } } return attrs; } } module.exports = ElasticApmMetricExporter;