diff --git a/packages/core/src/otlpExporter/common/semconv/base/mappings.js b/packages/core/src/otlpExporter/common/semconv/base/mappings.js index 93ef29869f..c43ffd20da 100644 --- a/packages/core/src/otlpExporter/common/semconv/base/mappings.js +++ b/packages/core/src/otlpExporter/common/semconv/base/mappings.js @@ -122,6 +122,28 @@ const MAPPINGS = { error: { TYPE: 'error.type' + }, + + metrics: { + v8js: { + GC_DURATION: 'v8js.gc.duration', + HEAP_SPACE_AVAILABLE_SIZE: 'v8js.memory.heap.space.available_size', + HEAP_SPACE_PHYSICAL_SIZE: 'v8js.memory.heap.space.physical_size', + HEAP_SPACE_SIZE: 'v8js.memory.heap.space.size', + HEAP_USED: 'v8js.memory.heap.used', + RESOURCE_ACTIVE: 'v8js.resource.active', + + attributes: { + GC_TYPE: 'v8js.gc.type', + HEAP_SPACE_NAME: 'v8js.heap.space.name', + RESOURCE_TYPE: 'v8js.resource.type' + } + }, + nodejs: { + EVENTLOOP_DELAY_MIN: 'nodejs.eventloop.delay.min', + EVENTLOOP_DELAY_MAX: 'nodejs.eventloop.delay.max', + EVENTLOOP_DELAY_MEAN: 'nodejs.eventloop.delay.mean' + } } }; diff --git a/packages/core/src/otlpExporter/metrics/converter.js b/packages/core/src/otlpExporter/metrics/converter.js index 2be367ab49..7a12489ad8 100644 --- a/packages/core/src/otlpExporter/metrics/converter.js +++ b/packages/core/src/otlpExporter/metrics/converter.js @@ -7,6 +7,7 @@ const otlpCtx = require('../common/context'); const { normalizeMetrics } = require('./util'); const transformers = require('./transformers'); +const mappers = require('./mappers'); const { INSTRUMENTATION_SCOPE } = transformers.resource; @@ -54,8 +55,7 @@ function convert(metrics) { scopeMetrics: [ { scope: INSTRUMENTATION_SCOPE, - // TODO: implement metrics transformation later in phase2 - metrics: [] + metrics: transformers.extractMetrics(metrics, mappers.allMappings) } ] } diff --git a/packages/core/src/otlpExporter/metrics/mappers/constants.js b/packages/core/src/otlpExporter/metrics/mappers/constants.js new file mode 100644 index 0000000000..59fbe10f26 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/mappers/constants.js @@ -0,0 +1,17 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +exports.METRIC_TYPES = { + GAUGE: 'gauge', + UPDOWNCOUNTER: 'updowncounter', + HISTOGRAM: 'histogram' +}; + +exports.METRIC_UNITS = { + SECONDS: 's', + BYTES: 'By', + RESOURCES: '{resource}' +}; diff --git a/packages/core/src/otlpExporter/metrics/mappers/index.js b/packages/core/src/otlpExporter/metrics/mappers/index.js new file mode 100644 index 0000000000..8f5b7cd63f --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/mappers/index.js @@ -0,0 +1,16 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const runtimeMetricsMappings = require('./runtimeMetricsMappings'); + +module.exports = { + get allMappings() { + return [ + runtimeMetricsMappings + // future: httpMetricsMappings, + ]; + } +}; diff --git a/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js b/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js new file mode 100644 index 0000000000..991c8011e9 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/mappers/runtimeMetricsMappings.js @@ -0,0 +1,157 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const ctx = require('../../common/context'); +const { METRIC_TYPES, METRIC_UNITS } = require('./constants'); +const { msToSeconds, computeMean } = require('./util'); + +const OTLP = /** @type {any} */ (ctx.semConv); + +/** + * A single-value metric — maps one Instana payload field to one OTLP data point. + * + * @typedef {Object} SinglePointMapping + * @property {'single'} pointType + * @property {string} name + * @property {string} unit + * @property {string} type + * @property {string} instana + * @property {Record} [attributes] + * @property {(value: any, payload: Record) => any} [transform] + */ + +/** + * A histogram metric — maps one Instana payload field to an OTLP histogram data point + * (produces `{ count, sum }` instead of a plain number). + * + * @typedef {Object} HistogramMapping + * @property {'histogram'} pointType + * @property {string} name + * @property {string} unit + * @property {string} type + * @property {string} instana + * @property {Record} [attributes] + * @property {(value: any, payload: Record) => any} [transform] + */ + +/** + * A fan-out metric — iterates over a map of heap spaces and emits one data point per space. + * + * @typedef {Object} HeapSpaceMapping + * @property {'heapSpace'} pointType + * @property {string} name + * @property {string} unit + * @property {string} type + * @property {string} instana + * @property {string} field + * @property {string} attributeKey + */ + +/** + * @typedef {SinglePointMapping | HistogramMapping | HeapSpaceMapping} MetricMapping + */ + +const OTLP_V8 = OTLP.metrics.v8js; +const OTLP_NODEJS = OTLP.metrics.nodejs; + +/** @type {MetricMapping[]} */ +const v8Mappings = [ + { + pointType: 'histogram', + name: OTLP_V8.GC_DURATION, + unit: METRIC_UNITS.SECONDS, + type: METRIC_TYPES.HISTOGRAM, + instana: 'gc.gcPause', + attributes: { [OTLP_V8.attributes.GC_TYPE]: 'all' }, + transform: msToSeconds + }, + + { + pointType: 'heapSpace', + name: OTLP_V8.HEAP_SPACE_AVAILABLE_SIZE, + unit: METRIC_UNITS.BYTES, + type: METRIC_TYPES.UPDOWNCOUNTER, + instana: 'heapSpaces', + field: 'available', + attributeKey: OTLP_V8.attributes.HEAP_SPACE_NAME + }, + + { + pointType: 'heapSpace', + name: OTLP_V8.HEAP_SPACE_PHYSICAL_SIZE, + unit: METRIC_UNITS.BYTES, + type: METRIC_TYPES.UPDOWNCOUNTER, + instana: 'heapSpaces', + field: 'physical', + attributeKey: OTLP_V8.attributes.HEAP_SPACE_NAME + }, + + { + pointType: 'heapSpace', + name: OTLP_V8.HEAP_SPACE_SIZE, + unit: METRIC_UNITS.BYTES, + type: METRIC_TYPES.UPDOWNCOUNTER, + instana: 'heapSpaces', + field: 'current', + attributeKey: OTLP_V8.attributes.HEAP_SPACE_NAME + }, + + { + pointType: 'heapSpace', + name: OTLP_V8.HEAP_USED, + unit: METRIC_UNITS.BYTES, + type: METRIC_TYPES.UPDOWNCOUNTER, + instana: 'heapSpaces', + field: 'used', + attributeKey: OTLP_V8.attributes.HEAP_SPACE_NAME + }, + + { + pointType: 'single', + name: OTLP_V8.RESOURCE_ACTIVE, + unit: METRIC_UNITS.RESOURCES, + type: METRIC_TYPES.GAUGE, + instana: 'activeResources.count', + attributes: { [OTLP_V8.attributes.RESOURCE_TYPE]: 'all' } + } +]; + +/** @type {MetricMapping[]} */ +const nodejsMappings = [ + { + pointType: 'single', + name: OTLP_NODEJS.EVENTLOOP_DELAY_MIN, + unit: METRIC_UNITS.SECONDS, + type: METRIC_TYPES.GAUGE, + instana: 'libuv.min', + attributes: {}, + transform: msToSeconds + }, + + { + pointType: 'single', + name: OTLP_NODEJS.EVENTLOOP_DELAY_MAX, + unit: METRIC_UNITS.SECONDS, + type: METRIC_TYPES.GAUGE, + instana: 'libuv.max', + attributes: {}, + transform: msToSeconds + }, + + { + pointType: 'single', + name: OTLP_NODEJS.EVENTLOOP_DELAY_MEAN, + unit: METRIC_UNITS.SECONDS, + type: METRIC_TYPES.GAUGE, + instana: 'libuv', + attributes: {}, + transform: computeMean + } +]; + +module.exports = { + metricMappings: [...v8Mappings, ...nodejsMappings] +}; diff --git a/packages/core/src/otlpExporter/metrics/mappers/util.js b/packages/core/src/otlpExporter/metrics/mappers/util.js new file mode 100644 index 0000000000..79313014c3 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/mappers/util.js @@ -0,0 +1,40 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * @param {any} ms + * @returns {number | undefined} + */ +function msToSeconds(ms) { + if (typeof ms !== 'number') return undefined; + return ms / 1000; +} + +/** + * @param {any} libuv - The `libuv` sub-object from the metrics payload + * @returns {number | undefined} + */ +function computeMean(libuv) { + if (!libuv || typeof libuv.sum !== 'number' || typeof libuv.num !== 'number' || libuv.num === 0) { + return undefined; + } + return msToSeconds(libuv.sum / libuv.num); +} + +/** + * @param {Record} payload + * @param {string} path + * @returns {any} + */ +function resolvePath(payload, path) { + return path.split('.').reduce((obj, key) => (obj != null ? obj[key] : undefined), payload); +} + +module.exports = { + msToSeconds, + computeMean, + resolvePath +}; diff --git a/packages/core/src/otlpExporter/metrics/transformers/index.js b/packages/core/src/otlpExporter/metrics/transformers/index.js index ea8239c3e9..1be44f816c 100644 --- a/packages/core/src/otlpExporter/metrics/transformers/index.js +++ b/packages/core/src/otlpExporter/metrics/transformers/index.js @@ -5,7 +5,19 @@ 'use strict'; const resource = require('../../common/transformers/resource'); +const runtimeMetrics = require('./runtimeMetrics'); + +/** + * @param {Record} metricsPayload + * @param {Array<{ metricMappings: any[] }>} allMappings + * @returns {Array>} OTLP metric objects + */ +function extractMetrics(metricsPayload, allMappings) { + return allMappings.flatMap(mapper => runtimeMetrics.extractMetrics(metricsPayload, mapper)); +} module.exports = { - resource + resource, + runtimeMetrics, + extractMetrics }; diff --git a/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js b/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js new file mode 100644 index 0000000000..c661e1c186 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/transformers/runtimeMetrics.js @@ -0,0 +1,21 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { extractMappedMetrics } = require('./util'); + +/** + * @param {Record} metricsPayload + * @param {{ metricMappings: import('../mappers/runtimeMetricsMappings').MetricMapping[] }} mapper + * @returns {Array>} OTLP metric objects + */ +function extractMetrics(metricsPayload, mapper) { + const timeUnixNano = (metricsPayload?.timestamp ?? Date.now()) * 1e6; + return extractMappedMetrics(metricsPayload, mapper, timeUnixNano); +} + +module.exports = { + extractMetrics +}; diff --git a/packages/core/src/otlpExporter/metrics/transformers/util.js b/packages/core/src/otlpExporter/metrics/transformers/util.js new file mode 100644 index 0000000000..110c78f196 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/transformers/util.js @@ -0,0 +1,170 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { METRIC_TYPES } = require('../mappers/constants'); +const { resolvePath } = require('../mappers/util'); + +/** + * @typedef {import('../mappers/runtimeMetricsMappings').MetricMapping} MetricMapping + */ + +/** + * @param {Record} attributes + * @returns {Array<{ key: string, value: Record }>} + */ +function formatAttributes(attributes) { + return Object.keys(attributes).map(key => { + const val = attributes[key]; + const type = typeof val; + let value; + + if (type === 'number') { + value = Number.isInteger(val) ? { intValue: val } : { doubleValue: val }; + } else if (type === 'boolean') { + value = { boolValue: val }; + } else { + value = { stringValue: String(val) }; + } + + return { key, value }; + }); +} + +/** + * @param {Array<{ attributes: Record, value: any }>} rawPoints + * @param {number} timeUnixNano + * @returns {Array>} + */ +function buildDataPoints(rawPoints, timeUnixNano) { + return rawPoints.map(point => { + const val = point.value; + const type = typeof val; + let numericField; + + if (type === 'number') { + numericField = Number.isInteger(val) ? { asInt: val } : { asDouble: val }; + } else if (val !== null && type === 'object' && ('count' in val || 'sum' in val)) { + numericField = { count: String(val.count), sum: val.sum }; + } else { + numericField = { asDouble: Number(val) }; + } + + return { + ...numericField, + timeUnixNano: String(timeUnixNano), + attributes: formatAttributes(point.attributes) + }; + }); +} + +const OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE = 2; + +/** + * @param {string} type - One of METRIC_TYPES + * @param {Array>} dataPoints + * @returns {Record} + */ +function buildMetricEnvelope(type, dataPoints) { + switch (type) { + case METRIC_TYPES.UPDOWNCOUNTER: + return { + sum: { + aggregationTemporality: OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE, + isMonotonic: false, + dataPoints + } + }; + + case METRIC_TYPES.HISTOGRAM: + return { + histogram: { + aggregationTemporality: OTLP_AGGREGATION_TEMPORALITY_CUMULATIVE, + dataPoints + } + }; + + case METRIC_TYPES.GAUGE: + default: + return { gauge: { dataPoints } }; + } +} + +/** + * Supported `pointType` values: + * 'single' — scalar field → one data point + * 'histogram' — scalar field → one histogram data point ({ count, sum }) + * 'heapSpace' — object map → one data point per entry + * + * @param {MetricMapping} mapping + * @param {Record} payload + * @returns {Array<{ attributes: Record, value: any }> | null} + */ +function resolveDataPoints(mapping, payload) { + if (mapping.pointType === 'heapSpace') { + const heapSpaces = resolvePath(payload, mapping.instana); + if (!heapSpaces || typeof heapSpaces !== 'object') return null; + + const points = Object.entries(heapSpaces) + .filter(([, space]) => space && typeof space[mapping.field] === 'number') + .map(([name, space]) => ({ + attributes: { [mapping.attributeKey]: name }, + value: space[mapping.field] + })); + + return points.length ? points : null; + } + + const raw = resolvePath(payload, mapping.instana); + + if (!mapping.transform && typeof raw !== 'number') return null; + + const value = mapping.transform ? mapping.transform(raw, payload) : raw; + + if (value === undefined || value === null) return null; + if (typeof value === 'number' && isNaN(value)) return null; + + const attributes = mapping.attributes ?? {}; + + if (mapping.pointType === 'histogram') { + return [{ attributes, value: { count: 1, sum: value } }]; + } + + return [{ attributes, value }]; +} + +/** + * @param {Record} metricsPayload + * @param {{ metricMappings: MetricMapping[] }} mapper + * @param {number} timeUnixNano + * @returns {Array>} OTLP metric objects + */ +function extractMappedMetrics(metricsPayload, mapper, timeUnixNano) { + if (!metricsPayload || !Array.isArray(mapper?.metricMappings)) { + return []; + } + + return mapper.metricMappings.reduce((/** @type {any[]} */ acc, mapping) => { + const rawDataPoints = resolveDataPoints(mapping, metricsPayload); + + if (rawDataPoints) { + acc.push({ + name: mapping.name, + unit: mapping.unit, + ...buildMetricEnvelope(mapping.type, buildDataPoints(rawDataPoints, timeUnixNano)) + }); + } + + return acc; + }, []); +} + +module.exports = { + formatAttributes, + buildDataPoints, + buildMetricEnvelope, + resolveDataPoints, + extractMappedMetrics +}; diff --git a/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json b/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json index 1ba936ae13..f19ab2ff6c 100644 --- a/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json +++ b/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json @@ -1,4 +1,5 @@ { + "timestamp": 1544712660300, "activeResources": { "count": 3 }, @@ -37,6 +38,7 @@ }, "keywords": ["opentelemetry", "instana", "tracing"], "libuv": { + "min": 0, "max": 496, "num": 241, "sum": 1003 diff --git a/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json index ef10be04e2..71614f6d4c 100644 --- a/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json +++ b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json @@ -11,7 +11,170 @@ { "key": "host.name", "value": { "stringValue": "test-hostname" } } ] }, - "scopeMetrics": [{ "scope": { "name": "@instana/collector", "version": "6.0.0" }, "metrics": [] }] + "scopeMetrics": [ + { + "scope": { "name": "@instana/collector", "version": "6.0.0" }, + "metrics": [ + { + "name": "v8js.memory.heap.space.available_size", + "unit": "By", + "sum": { + "aggregationTemporality": 2, + "isMonotonic": false, + "dataPoints": [ + { + "asInt": 6153600, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "new_space" } }] + }, + { + "asInt": 522192, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "old_space" } }] + }, + { + "asInt": 345536, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "code_space" } }] + }, + { + "asInt": 370768, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "trusted_space" } }] + } + ] + } + }, + { + "name": "v8js.memory.heap.space.physical_size", + "unit": "By", + "sum": { + "aggregationTemporality": 2, + "isMonotonic": false, + "dataPoints": [ + { + "asInt": 27901952, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "new_space" } }] + }, + { + "asInt": 17039360, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "old_space" } }] + }, + { + "asInt": 1572864, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "code_space" } }] + } + ] + } + }, + { + "name": "v8js.memory.heap.space.size", + "unit": "By", + "sum": { + "aggregationTemporality": 2, + "isMonotonic": false, + "dataPoints": [ + { + "asInt": 16859136, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "old_space" } }] + }, + { + "asInt": 1572864, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "code_space" } }] + } + ] + } + }, + { + "name": "v8js.memory.heap.used", + "unit": "By", + "sum": { + "aggregationTemporality": 2, + "isMonotonic": false, + "dataPoints": [ + { + "asInt": 10622592, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "new_space" } }] + }, + { + "asInt": 16309064, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "old_space" } }] + }, + { + "asInt": 1227136, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "code_space" } }] + }, + { + "asInt": 2659888, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.heap.space.name", "value": { "stringValue": "trusted_space" } }] + } + ] + } + }, + { + "name": "v8js.resource.active", + "unit": "{resource}", + "gauge": { + "dataPoints": [ + { + "asInt": 3, + "timeUnixNano": "1544712660300000000", + "attributes": [{ "key": "v8js.resource.type", "value": { "stringValue": "all" } }] + } + ] + } + }, + { + "name": "nodejs.eventloop.delay.min", + "unit": "s", + "gauge": { + "dataPoints": [ + { + "asInt": 0, + "timeUnixNano": "1544712660300000000", + "attributes": [] + } + ] + } + }, + { + "name": "nodejs.eventloop.delay.max", + "unit": "s", + "gauge": { + "dataPoints": [ + { + "asDouble": 0.496, + "timeUnixNano": "1544712660300000000", + "attributes": [] + } + ] + } + }, + { + "name": "nodejs.eventloop.delay.mean", + "unit": "s", + "gauge": { + "dataPoints": [ + { + "asDouble": 0.004161825726141079, + "timeUnixNano": "1544712660300000000", + "attributes": [] + } + ] + } + } + ] + } + ] } ] } diff --git a/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js b/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js new file mode 100644 index 0000000000..ed0bfa9f37 --- /dev/null +++ b/packages/core/test/otlpExporter/metrics/mappers/runtimeMetricsMappings_test.js @@ -0,0 +1,334 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; + +const { MAPPINGS } = require('../../../../src/otlpExporter/common/semconv/base/mappings'); +const V8 = MAPPINGS.metrics.v8js; +const NODEJS = MAPPINGS.metrics.nodejs; + +const mapper = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetricsMappings'); +const { resolvePath, msToSeconds, computeMean } = require('../../../../src/otlpExporter/metrics/mappers/util'); +const { resolveDataPoints } = require('../../../../src/otlpExporter/metrics/transformers/util'); + +const FULL_PAYLOAD = { + gc: { gcPause: 414 }, + heapSpaces: { + new_space: { available: 5972864, used: 10622592, physical: 27901952, current: 6291456 }, + old_space: { available: 25165824, used: 16309064, physical: 17039360, current: 16859136 } + }, + activeResources: { count: 18 }, + libuv: { min: 0, max: 582, sum: 4820, num: 42 } +}; + +/** + * @param {string} name + */ +function findMapping(name) { + return mapper.metricMappings.find(m => m.name === name); +} + +describe('otlpExporter/metrics/mappers/runtimeMetrics', () => { + describe('metricMappings', () => { + it('exports 9 mapping entries', () => { + expect(mapper.metricMappings).to.have.length(9); + }); + + it('every mapping declares pointType, name, unit, type and instana', () => { + mapper.metricMappings.forEach(m => { + expect(m).to.have.property('pointType').that.is.a('string'); + expect(m).to.have.property('name').that.is.a('string'); + expect(m).to.have.property('unit').that.is.a('string'); + expect(m).to.have.property('type').that.is.a('string'); + expect(m).to.have.property('instana').that.is.a('string'); + }); + }); + }); + + describe('v8js.gc.duration', () => { + let mapping; + before(() => { + mapping = findMapping(V8.GC_DURATION); + }); + + it('has correct descriptor metadata', () => { + expect(mapping.unit).to.equal('s'); + expect(mapping.type).to.equal('histogram'); + expect(mapping.pointType).to.equal('histogram'); + }); + + it('declares instana path gc.gcPause', () => { + expect(mapping.instana).to.equal('gc.gcPause'); + }); + + it('declares gc.type = "all" attribute', () => { + expect(mapping.attributes).to.deep.include({ [V8.attributes.GC_TYPE]: 'all' }); + }); + + it('transform converts ms → seconds', () => { + expect(mapping.transform(414)).to.equal(0.414); + }); + + it('resolves to a histogram data point from a full payload', () => { + const points = resolveDataPoints(mapping, { gc: { gcPause: 414 } }); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.GC_TYPE]: 'all' }, value: { count: 1, sum: 0.414 } } + ]); + }); + + it('returns null when gc is missing', () => { + expect(resolveDataPoints(mapping, {})).to.be.null; + }); + + it('returns null when gcPause is not a number', () => { + expect(resolveDataPoints(mapping, { gc: { gcPause: null } })).to.be.null; + }); + }); + + describe('v8js.memory.heap.space.available_size', () => { + let mapping; + before(() => { + mapping = findMapping(V8.HEAP_SPACE_AVAILABLE_SIZE); + }); + + it('has correct descriptor metadata', () => { + expect(mapping.unit).to.equal('By'); + expect(mapping.type).to.equal('updowncounter'); + expect(mapping.pointType).to.equal('heapSpace'); + }); + + it('declares field = "available" and correct attributeKey', () => { + expect(mapping.field).to.equal('available'); + expect(mapping.attributeKey).to.equal(V8.attributes.HEAP_SPACE_NAME); + }); + + it('emits one data-point per space that has available', () => { + const points = resolveDataPoints(mapping, FULL_PAYLOAD); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 5972864 }, + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 25165824 } + ]); + }); + + it('returns null when heapSpaces is missing', () => { + expect(resolveDataPoints(mapping, {})).to.be.null; + }); + + it('returns null when no space has available', () => { + expect(resolveDataPoints(mapping, { heapSpaces: { x: { current: 1 } } })).to.be.null; + }); + }); + + describe('v8js.memory.heap.space.physical_size', () => { + let mapping; + before(() => { + mapping = findMapping(V8.HEAP_SPACE_PHYSICAL_SIZE); + }); + + it('declares field = "physical"', () => { + expect(mapping.field).to.equal('physical'); + }); + + it('emits one data-point per space that has physical', () => { + const points = resolveDataPoints(mapping, FULL_PAYLOAD); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 27901952 }, + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 17039360 } + ]); + }); + + it('returns null when heapSpaces is missing', () => { + expect(resolveDataPoints(mapping, {})).to.be.null; + }); + }); + + describe('v8js.memory.heap.space.size', () => { + let mapping; + before(() => { + mapping = findMapping(V8.HEAP_SPACE_SIZE); + }); + + it('declares field = "current"', () => { + expect(mapping.field).to.equal('current'); + }); + + it('emits one data-point per space that has current', () => { + const points = resolveDataPoints(mapping, FULL_PAYLOAD); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 6291456 }, + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 16859136 } + ]); + }); + + it('returns null when heapSpaces is missing', () => { + expect(resolveDataPoints(mapping, {})).to.be.null; + }); + }); + + describe('v8js.memory.heap.used', () => { + let mapping; + before(() => { + mapping = findMapping(V8.HEAP_USED); + }); + + it('declares field = "used"', () => { + expect(mapping.field).to.equal('used'); + }); + + it('emits one data-point per space that has used', () => { + const points = resolveDataPoints(mapping, FULL_PAYLOAD); + expect(points).to.deep.equal([ + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'new_space' }, value: 10622592 }, + { attributes: { [V8.attributes.HEAP_SPACE_NAME]: 'old_space' }, value: 16309064 } + ]); + }); + + it('returns null when heapSpaces is missing', () => { + expect(resolveDataPoints(mapping, {})).to.be.null; + }); + }); + + describe('v8js.resource.active', () => { + let mapping; + before(() => { + mapping = findMapping(V8.RESOURCE_ACTIVE); + }); + + it('has correct descriptor metadata', () => { + expect(mapping.unit).to.equal('{resource}'); + expect(mapping.type).to.equal('gauge'); + expect(mapping.pointType).to.equal('single'); + }); + + it('declares instana path activeResources.count', () => { + expect(mapping.instana).to.equal('activeResources.count'); + }); + + it('declares resource.type = "all" attribute', () => { + expect(mapping.attributes).to.deep.include({ [V8.attributes.RESOURCE_TYPE]: 'all' }); + }); + + it('maps activeResources.count with resource.type = "all"', () => { + const points = resolveDataPoints(mapping, { activeResources: { count: 18 } }); + expect(points).to.deep.equal([{ attributes: { [V8.attributes.RESOURCE_TYPE]: 'all' }, value: 18 }]); + }); + + it('returns null when activeResources is missing', () => { + expect(resolveDataPoints(mapping, {})).to.be.null; + }); + + it('returns null when count is not a number', () => { + expect(resolveDataPoints(mapping, { activeResources: { count: null } })).to.be.null; + }); + }); + + describe('nodejs.eventloop.delay.min', () => { + let mapping; + before(() => { + mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MIN); + }); + + it('declares instana path libuv.min and transform = msToSeconds', () => { + expect(mapping.instana).to.equal('libuv.min'); + expect(mapping.transform).to.equal(msToSeconds); + }); + + it('converts libuv.min ms → seconds', () => { + const points = resolveDataPoints(mapping, { libuv: { min: 0 } }); + expect(points).to.deep.equal([{ attributes: {}, value: 0 }]); + }); + + it('converts non-zero min', () => { + const points = resolveDataPoints(mapping, { libuv: { min: 5000 } }); + expect(points[0].value).to.equal(5); + }); + + it('returns null when libuv is missing', () => { + expect(resolveDataPoints(mapping, {})).to.be.null; + }); + + it('returns null when min is not a number', () => { + expect(resolveDataPoints(mapping, { libuv: { min: null } })).to.be.null; + }); + }); + + describe('nodejs.eventloop.delay.max', () => { + let mapping; + before(() => { + mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MAX); + }); + + it('declares instana path libuv.max and transform = msToSeconds', () => { + expect(mapping.instana).to.equal('libuv.max'); + expect(mapping.transform).to.equal(msToSeconds); + }); + + it('converts libuv.max ms → seconds', () => { + const points = resolveDataPoints(mapping, { libuv: { max: 582 } }); + expect(points).to.deep.equal([{ attributes: {}, value: 0.582 }]); + }); + + it('returns null when max is absent', () => { + expect(resolveDataPoints(mapping, { libuv: {} })).to.be.null; + }); + }); + + describe('nodejs.eventloop.delay.mean', () => { + let mapping; + before(() => { + mapping = findMapping(NODEJS.EVENTLOOP_DELAY_MEAN); + }); + + it('declares instana path libuv and transform = computeMean', () => { + expect(mapping.instana).to.equal('libuv'); + expect(mapping.transform).to.equal(computeMean); + }); + + it('derives mean from sum / num and converts ms → seconds', () => { + const points = resolveDataPoints(mapping, { libuv: { sum: 4200, num: 42 } }); + expect(points).to.deep.equal([{ attributes: {}, value: 0.1 }]); + }); + + it('returns null when num is 0 (avoids division by zero)', () => { + expect(resolveDataPoints(mapping, { libuv: { sum: 100, num: 0 } })).to.be.null; + }); + + it('returns null when sum or num is missing', () => { + expect(resolveDataPoints(mapping, { libuv: { sum: 100 } })).to.be.null; + expect(resolveDataPoints(mapping, { libuv: { num: 5 } })).to.be.null; + }); + }); + + describe('mappers/util helpers', () => { + describe('resolvePath', () => { + it('resolves a nested dot path', () => { + expect(resolvePath({ a: { b: 42 } }, 'a.b')).to.equal(42); + }); + + it('returns undefined for missing segments', () => { + expect(resolvePath({}, 'a.b')).to.be.undefined; + }); + + it('returns undefined when an intermediate segment is null', () => { + expect(resolvePath({ a: null }, 'a.b')).to.be.undefined; + }); + }); + + describe('computeMean', () => { + it('returns mean in seconds', () => { + expect(computeMean({ sum: 4200, num: 42 })).to.equal(0.1); + }); + + it('returns undefined when num is 0', () => { + expect(computeMean({ sum: 100, num: 0 })).to.be.undefined; + }); + + it('returns undefined when libuv is null', () => { + expect(computeMean(null)).to.be.undefined; + }); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js b/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js new file mode 100644 index 0000000000..838c2b9db5 --- /dev/null +++ b/packages/core/test/otlpExporter/metrics/transformers/runtimeMetrics_test.js @@ -0,0 +1,107 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; + +const { extractMetrics } = require('../../../../src/otlpExporter/metrics/transformers/runtimeMetrics'); +const runtimeMetricsMappings = require('../../../../src/otlpExporter/metrics/mappers/runtimeMetricsMappings'); + +const FULL_PAYLOAD = { + gc: { gcPause: 414 }, + heapSpaces: { + new_space: { available: 5972864, used: 10622592, physical: 27901952, current: 6291456 }, + old_space: { available: 25165824, used: 16309064, physical: 17039360, current: 16859136 } + }, + activeResources: { count: 18 }, + libuv: { min: 0, max: 582, sum: 4820, num: 42 }, + timestamp: 1544712660300 +}; + +describe('otlpExporter/metrics/transformers/runtimeMetrics', () => { + describe('extractMetrics', () => { + it('produces all 9 metrics from a full payload', () => { + const result = extractMetrics(FULL_PAYLOAD, runtimeMetricsMappings); + const names = result.map(m => m.name); + expect(names).to.deep.equal([ + 'v8js.gc.duration', + 'v8js.memory.heap.space.available_size', + 'v8js.memory.heap.space.physical_size', + 'v8js.memory.heap.space.size', + 'v8js.memory.heap.used', + 'v8js.resource.active', + 'nodejs.eventloop.delay.min', + 'nodejs.eventloop.delay.max', + 'nodejs.eventloop.delay.mean' + ]); + }); + + it('each metric has name, unit and the correct OTLP type envelope', () => { + const result = extractMetrics(FULL_PAYLOAD, runtimeMetricsMappings); + result.forEach(m => { + expect(m).to.have.property('name').that.is.a('string'); + expect(m).to.have.property('unit').that.is.a('string'); + const hasEnvelope = 'gauge' in m || 'sum' in m || 'histogram' in m; + expect(hasEnvelope).to.equal(true); + }); + }); + + it('returns an empty array for an empty payload', () => { + expect(extractMetrics({}, runtimeMetricsMappings)).to.deep.equal([]); + }); + + it('returns an empty array for null payload', () => { + expect(extractMetrics(null, runtimeMetricsMappings)).to.deep.equal([]); + }); + + it('only emits metrics whose source fields are present', () => { + const result = extractMetrics({ libuv: { min: 10, max: 200, sum: 500, num: 5 } }, runtimeMetricsMappings); + const names = result.map(m => m.name); + expect(names).to.deep.equal([ + 'nodejs.eventloop.delay.min', + 'nodejs.eventloop.delay.max', + 'nodejs.eventloop.delay.mean' + ]); + }); + + it('gc.duration uses histogram envelope with count and sum', () => { + const result = extractMetrics({ gc: { gcPause: 1000 } }, runtimeMetricsMappings); + const gcMetric = result.find(m => m.name === 'v8js.gc.duration'); + expect(gcMetric).to.have.property('histogram'); + expect(gcMetric.histogram).to.have.property('dataPoints').with.length(1); + expect(gcMetric.histogram.dataPoints[0]).to.include({ count: '1', sum: 1 }); + }); + + it('gauge metrics use gauge envelope', () => { + const result = extractMetrics({ activeResources: { count: 18 } }, runtimeMetricsMappings); + const metric = result.find(m => m.name === 'v8js.resource.active'); + expect(metric).to.have.property('gauge'); + expect(metric.gauge.dataPoints[0]).to.have.property('asInt', 18); + }); + + it('updowncounter metrics use sum envelope with isMonotonic false', () => { + const result = extractMetrics(FULL_PAYLOAD, runtimeMetricsMappings); + const metric = result.find(m => m.name === 'v8js.memory.heap.space.available_size'); + expect(metric).to.have.property('sum'); + expect(metric.sum.isMonotonic).to.equal(false); + expect(metric.sum.aggregationTemporality).to.equal(2); + }); + + it('data-points have timeUnixNano derived from payload timestamp', () => { + const result = extractMetrics({ activeResources: { count: 5 }, timestamp: 1544712660300 }, runtimeMetricsMappings); + const metric = result.find(m => m.name === 'v8js.resource.active'); + expect(metric.gauge.dataPoints[0].timeUnixNano).to.equal(String(1544712660300 * 1e6)); + }); + + it('data-point attributes are formatted as OTLP key-value array', () => { + const result = extractMetrics({ activeResources: { count: 5 } }, runtimeMetricsMappings); + const metric = result.find(m => m.name === 'v8js.resource.active'); + const attrs = metric.gauge.dataPoints[0].attributes; + expect(attrs).to.be.an('array').with.length(1); + expect(attrs[0]).to.deep.include({ key: 'v8js.resource.type' }); + expect(attrs[0].value).to.deep.equal({ stringValue: 'all' }); + }); + }); +});