From 6ece4996d4c4532f9cc8e51c7fedc9cc23fff44b Mon Sep 17 00:00:00 2001 From: Arya Date: Thu, 30 Jul 2026 17:58:32 +0530 Subject: [PATCH 1/2] chore(otlp-exporter): added missing required resource mapping (#2684) ref https://jsw.ibm.com/browse/INSTA-100980 --- .../core/src/otlpExporter/common/context.js | 2 - .../common/semconv/base/mappings.js | 3 +- .../common/transformers/resource.js | 49 +++- .../core/src/otlpExporter/metrics/util.js | 12 +- .../common/transformers/resource_test.js | 220 ++++++++++++++++++ .../otlpExporter/metrics/converter_test.js | 3 + .../fixtures/output/metrics-output.json | 1 + .../test/otlpExporter/metrics/util_test.js | 65 +----- 8 files changed, 269 insertions(+), 86 deletions(-) create mode 100644 packages/core/test/otlpExporter/common/transformers/resource_test.js diff --git a/packages/core/src/otlpExporter/common/context.js b/packages/core/src/otlpExporter/common/context.js index 46a243346a..76d3950f01 100644 --- a/packages/core/src/otlpExporter/common/context.js +++ b/packages/core/src/otlpExporter/common/context.js @@ -15,8 +15,6 @@ class OtlpConfigContext { /** @type {any} */ this._compiledSemConv = null; /** @type {string | null} */ - this._hostId = null; - /** @type {string | null} */ this._pid = null; /** @type {string | null} */ this._serviceName = null; diff --git a/packages/core/src/otlpExporter/common/semconv/base/mappings.js b/packages/core/src/otlpExporter/common/semconv/base/mappings.js index 09a6c1a2f5..15c6b96da6 100644 --- a/packages/core/src/otlpExporter/common/semconv/base/mappings.js +++ b/packages/core/src/otlpExporter/common/semconv/base/mappings.js @@ -14,7 +14,8 @@ const MAPPINGS = { SDK_VERSION: 'telemetry.sdk.version', HOST_NAME: 'host.name', HOST_ID: 'host.id', - PROCESS_PID: 'process.pid' + PROCESS_PID: 'process.pid', + OS_TYPE: 'os.type' }, metadata: { diff --git a/packages/core/src/otlpExporter/common/transformers/resource.js b/packages/core/src/otlpExporter/common/transformers/resource.js index 57e6a372b1..b8fc06d1db 100644 --- a/packages/core/src/otlpExporter/common/transformers/resource.js +++ b/packages/core/src/otlpExporter/common/transformers/resource.js @@ -7,6 +7,9 @@ const os = require('os'); const ctx = require('../context'); const { INSTRUMENTATION_SCOPE_NAME } = require('../constants'); +const { MAPPINGS } = require('../semconv/base/mappings'); + +const RESOURCE = MAPPINGS.resource; let SDK_VERSION = '1.0.0'; try { @@ -38,7 +41,7 @@ const resourceMapper = { */ serviceName(rawPayload) { const resource = rawPayload.data?.resource || rawPayload.resource || {}; - return resource['service.name'] || ctx.serviceName; + return resource[RESOURCE.SERVICE_NAME] || ctx.serviceName; }, /** @@ -47,7 +50,7 @@ const resourceMapper = { */ sdkLanguage(rawPayload) { const resource = rawPayload.data?.resource || rawPayload.resource || {}; - return resource['telemetry.sdk.language'] || SDK_LANGUAGE; + return resource[RESOURCE.SDK_LANGUAGE] || SDK_LANGUAGE; }, /** @@ -56,7 +59,7 @@ const resourceMapper = { */ sdkName(rawPayload) { const resource = rawPayload.data?.resource || rawPayload.resource || {}; - return resource['telemetry.sdk.name'] || SDK_NAME; + return resource[RESOURCE.SDK_NAME] || SDK_NAME; }, /** @@ -65,7 +68,7 @@ const resourceMapper = { */ sdkVersion(rawPayload) { const resource = rawPayload.data?.resource || rawPayload.resource || {}; - return resource['telemetry.sdk.version'] || SDK_VERSION; + return resource[RESOURCE.SDK_VERSION] || SDK_VERSION; }, /** @@ -76,7 +79,7 @@ const resourceMapper = { const resource = rawPayload.data?.resource || rawPayload.resource || {}; const metadata = rawPayload.f || {}; - const pid = resource['process.pid'] || metadata.e || ctx._pid; + const pid = resource[RESOURCE.PROCESS_PID] || metadata.e || ctx._pid; if (pid === null || pid === undefined) { return undefined; @@ -92,7 +95,7 @@ const resourceMapper = { */ hostName(rawPayload) { const resource = rawPayload.data?.resource || rawPayload.resource || {}; - let hostName = resource['host.name']; + let hostName = resource[RESOURCE.HOST_NAME]; if (!hostName) { try { @@ -114,9 +117,20 @@ const resourceMapper = { const resource = rawPayload.data?.resource || rawPayload.resource || {}; const metadata = rawPayload.f || {}; - const hostId = resource['host.id'] || metadata.h || ctx._hostId; + return resource[RESOURCE.HOST_ID] || metadata.h; + }, + /** + * @param {RawPayload} rawPayload + * @returns {string | undefined} + */ + osType(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + + if (resource[RESOURCE.OS_TYPE]) { + return resource[RESOURCE.OS_TYPE]; + } - return typeof hostId === 'string' ? hostId : undefined; + return normalizeOsType(os.platform()); } }; @@ -157,6 +171,11 @@ function extractResourceAttributes(rawPayload) { transform: resourceMapper.processId, valueType: 'int' }, + { + otlp: OTLP.resource.OS_TYPE, + transform: resourceMapper.osType, + valueType: 'string' + }, { otlp: OTLP.resource.HOST_NAME, transform: resourceMapper.hostName, @@ -182,6 +201,20 @@ function extractResourceAttributes(rawPayload) { return { attributes }; } +/** + * @param {string} nodePlatform + */ +function normalizeOsType(nodePlatform) { + switch (nodePlatform) { + case 'win32': + return 'windows'; + case 'sunos': + return 'solaris'; + default: + return nodePlatform; + } +} + module.exports = { extractResourceAttributes, INSTRUMENTATION_SCOPE diff --git a/packages/core/src/otlpExporter/metrics/util.js b/packages/core/src/otlpExporter/metrics/util.js index a30892b7b2..32d22b3c06 100644 --- a/packages/core/src/otlpExporter/metrics/util.js +++ b/packages/core/src/otlpExporter/metrics/util.js @@ -4,15 +4,6 @@ 'use strict'; -/** - * @param {Record} from - * @returns {string} - */ -function getResourceKey(from) { - if (!from) return 'h:empty|e:empty'; - return `h:${from.h || 'empty'}|e:${from.e || 'empty'}`; -} - /** * @param {Record} obj * @param {string} [prefix] @@ -90,6 +81,5 @@ function normalizeObject(metricsObj) { module.exports = { flattenObject, - normalizeMetrics, - getResourceKey + normalizeMetrics }; diff --git a/packages/core/test/otlpExporter/common/transformers/resource_test.js b/packages/core/test/otlpExporter/common/transformers/resource_test.js new file mode 100644 index 0000000000..dd63389019 --- /dev/null +++ b/packages/core/test/otlpExporter/common/transformers/resource_test.js @@ -0,0 +1,220 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; +const sinon = require('sinon'); +const os = require('node:os'); +const proxyquire = require('proxyquire'); + +const mockPackageJson = { version: '6.0.0' }; + +function loadResource() { + return proxyquire('../../../../src/otlpExporter/common/transformers/resource', { + '../../../../package.json': Object.assign({ '@noCallThru': true }, mockPackageJson) + }); +} + +const ctx = require('../../../../src/otlpExporter/common/context'); +const otlp = require('../../../../src/otlpExporter'); + +const INIT_CONFIG = { + serviceName: 'resource-test-service', + logger: console, + tracing: { otlp: { enabled: true, semConvVersion: '1.23' } } +}; + +const resource = loadResource(); + +function extract(span) { + return resource.extractResourceAttributes(span).attributes; +} + +function find(attrs, key) { + return attrs.find(a => a.key === key); +} + +function expectStr(attrs, key, value) { + expect(find(attrs, key), `Missing attribute "${key}"`).to.deep.equal({ + key, + value: { stringValue: value } + }); +} + +function expectInt(attrs, key, value) { + expect(find(attrs, key), `Missing attribute "${key}"`).to.deep.equal({ + key, + value: { intValue: value } + }); +} + +function expectAbsent(attrs, key) { + expect(find(attrs, key), `Attribute "${key}" should be absent`).to.be.undefined; +} + +function makeSpan(overrides = {}) { + return { + f: { e: '1234', h: 'default-host-id', ...(overrides.f || {}) }, + data: { ...(overrides.data || {}) } + }; +} + +describe('otlpExporter/common/transformers/resource', () => { + let hostnameStub; + + before(() => { + hostnameStub = sinon.stub(os, 'hostname').returns('stub.hostname.test'); + otlp.init(INIT_CONFIG); + }); + + after(() => { + hostnameStub.restore(); + ctx._config = null; + ctx._semConvVersion = null; + ctx._compiledSemConv = null; + ctx._pid = null; + ctx._serviceName = null; + ctx._serviceVersion = null; + }); + + describe('extractResourceAttributes', () => { + it('returns empty attributes for a null payload', () => { + expect(resource.extractResourceAttributes(null)).to.deep.equal({ attributes: [] }); + }); + + it('returns empty attributes for an undefined payload', () => { + expect(resource.extractResourceAttributes(undefined)).to.deep.equal({ attributes: [] }); + }); + }); + + describe('INSTRUMENTATION_SCOPE', () => { + it('exposes name and version', () => { + expect(resource.INSTRUMENTATION_SCOPE).to.deep.equal({ + name: '@instana/collector', + version: '6.0.0' + }); + }); + }); + + describe('service.name', () => { + it('uses resource["service.name"] from span data', () => { + const span = makeSpan({ data: { resource: { 'service.name': 'from-span-data' } } }); + expectStr(extract(span), 'service.name', 'from-span-data'); + }); + + it('uses ctx.serviceName set by otlp.init', () => { + expectStr(extract(makeSpan()), 'service.name', 'resource-test-service'); + }); + }); + + describe('telemetry.sdk.*', () => { + it('emits hardcoded sdk.language "nodejs"', () => { + expectStr(extract(makeSpan()), 'telemetry.sdk.language', 'nodejs'); + }); + + it('emits hardcoded sdk.name "instana"', () => { + expectStr(extract(makeSpan()), 'telemetry.sdk.name', 'instana'); + }); + + it('emits sdk.version from package.json (mocked to 6.0.0)', () => { + expectStr(extract(makeSpan()), 'telemetry.sdk.version', '6.0.0'); + }); + + it('allows span data to override sdk.language', () => { + const span = makeSpan({ data: { resource: { 'telemetry.sdk.language': 'python' } } }); + expectStr(extract(span), 'telemetry.sdk.language', 'python'); + }); + + it('allows span data to override sdk.name', () => { + const span = makeSpan({ data: { resource: { 'telemetry.sdk.name': 'opentelemetry' } } }); + expectStr(extract(span), 'telemetry.sdk.name', 'opentelemetry'); + }); + + it('allows span data to override sdk.version', () => { + const span = makeSpan({ data: { resource: { 'telemetry.sdk.version': '99.0.0' } } }); + expectStr(extract(span), 'telemetry.sdk.version', '99.0.0'); + }); + }); + + describe('process.pid', () => { + it('uses metadata f.e field as pid', () => { + const span = makeSpan({ f: { e: '9999', h: 'h' } }); + expectInt(extract(span), 'process.pid', 9999); + }); + + it('uses resource["process.pid"] from span data', () => { + const span = makeSpan({ data: { resource: { 'process.pid': 1234 } } }); + expectInt(extract(span), 'process.pid', 1234); + }); + + it('omits process.pid for non-numeric values', () => { + const span = { f: { e: 'not-a-number' }, data: {} }; + expectAbsent(extract(span), 'process.pid'); + }); + }); + + describe('os.type', () => { + let platformStub; + + beforeEach(() => { + platformStub = sinon.stub(process, 'platform').value('linux'); + }); + + afterEach(() => { + if (platformStub) { + platformStub.restore(); + platformStub = null; + } + }); + + function stubPlatform(value) { + platformStub.value(value); + } + + it('uses resource["os.type"] from span data, overriding process.platform', () => { + stubPlatform('linux'); + const span = makeSpan({ data: { resource: { 'os.type': 'windows' } } }); + expectStr(extract(span), 'os.type', 'windows'); + }); + + it('maps win32 → "windows"', () => { + stubPlatform('win32'); + expectStr(extract(makeSpan()), 'os.type', 'windows'); + }); + + it('passes unknown platforms through as-is', () => { + stubPlatform('freebsd'); + expectStr(extract(makeSpan()), 'os.type', 'freebsd'); + }); + }); + + describe('host.name', () => { + it('uses resource["host.name"] from span data', () => { + const span = makeSpan({ data: { resource: { 'host.name': 'custom-host.example' } } }); + expectStr(extract(span), 'host.name', 'custom-host.example'); + }); + + it('falls back to os.hostname() when not in span data', () => { + expectStr(extract(makeSpan()), 'host.name', 'stub.hostname.test'); + }); + }); + + describe('required attributes always present', () => { + it('emits all required attributes on every span', () => { + const attrs = extract(makeSpan()); + const required = [ + 'service.name', + 'telemetry.sdk.language', + 'telemetry.sdk.name', + 'telemetry.sdk.version', + 'process.pid', + 'os.type' + ]; + required.forEach(key => { + expect(find(attrs, key), `Required attribute "${key}" must be present`).to.exist; + }); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/metrics/converter_test.js b/packages/core/test/otlpExporter/metrics/converter_test.js index b26090f685..905c4fbc29 100644 --- a/packages/core/test/otlpExporter/metrics/converter_test.js +++ b/packages/core/test/otlpExporter/metrics/converter_test.js @@ -37,13 +37,16 @@ function loadOutputFixture(filename) { describe('metrics/converters/otlp', () => { let hostnameStub; + let platformStub; before(() => { hostnameStub = sinon.stub(os, 'hostname').returns('test-hostname'); + platformStub = sinon.stub(process, 'platform').value('linux'); }); after(() => { hostnameStub.restore(); + platformStub.restore(); }); describe('converter', () => { 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 3801767f1d..ef10be04e2 100644 --- a/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json +++ b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json @@ -7,6 +7,7 @@ { "key": "telemetry.sdk.language", "value": { "stringValue": "nodejs" } }, { "key": "telemetry.sdk.name", "value": { "stringValue": "instana" } }, { "key": "telemetry.sdk.version", "value": { "stringValue": "6.0.0" } }, + { "key": "os.type", "value": { "stringValue": "linux" } }, { "key": "host.name", "value": { "stringValue": "test-hostname" } } ] }, diff --git a/packages/core/test/otlpExporter/metrics/util_test.js b/packages/core/test/otlpExporter/metrics/util_test.js index 9225dfa2e2..d22b111786 100644 --- a/packages/core/test/otlpExporter/metrics/util_test.js +++ b/packages/core/test/otlpExporter/metrics/util_test.js @@ -6,72 +6,9 @@ const expect = require('chai').expect; -const { flattenObject, normalizeMetrics, getResourceKey } = require('../../../src/otlpExporter/metrics/util'); +const { flattenObject, normalizeMetrics } = require('../../../src/otlpExporter/metrics/util'); describe('otlpExporter/metrics/util', () => { - describe('getResourceKey', () => { - it('should generate key from host and entity', () => { - const from = { h: 'host123', e: 'entity456' }; - const key = getResourceKey(from); - - expect(key).to.equal('h:host123|e:entity456'); - }); - - it('should handle missing host', () => { - const from = { e: 'entity456' }; - const key = getResourceKey(from); - - expect(key).to.equal('h:empty|e:entity456'); - }); - - it('should handle missing entity', () => { - const from = { h: 'host123' }; - const key = getResourceKey(from); - - expect(key).to.equal('h:host123|e:empty'); - }); - - it('should handle both missing', () => { - const from = {}; - const key = getResourceKey(from); - - expect(key).to.equal('h:empty|e:empty'); - }); - - it('should handle null input', () => { - const key = getResourceKey(null); - - expect(key).to.equal('h:empty|e:empty'); - }); - - it('should handle undefined input', () => { - const key = getResourceKey(undefined); - - expect(key).to.equal('h:empty|e:empty'); - }); - - it('should handle numeric values', () => { - const from = { h: 123, e: 456 }; - const key = getResourceKey(from); - - expect(key).to.equal('h:123|e:456'); - }); - - it('should create unique keys for different resources', () => { - const key1 = getResourceKey({ h: 'host1', e: 'entity1' }); - const key2 = getResourceKey({ h: 'host2', e: 'entity2' }); - - expect(key1).to.not.equal(key2); - }); - - it('should create same key for identical resources', () => { - const key1 = getResourceKey({ h: 'host1', e: 'entity1' }); - const key2 = getResourceKey({ h: 'host1', e: 'entity1' }); - - expect(key1).to.equal(key2); - }); - }); - describe('flattenObject', () => { describe('basic flattening', () => { it('should flatten simple nested object', () => { From 59217816979252862a9d071ecb7a1caf54acd8a1 Mon Sep 17 00:00:00 2001 From: Arya Date: Fri, 31 Jul 2026 15:56:07 +0530 Subject: [PATCH 2/2] chore(otlp-exporter): added faas.name resource mapping (#2688) ref: https://jsw.ibm.com/browse/INSTA-100980 --- .../common/semconv/base/mappings.js | 3 +- .../common/transformers/resource.js | 28 +++++++++++++++++++ .../common/transformers/resource_test.js | 18 ++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/core/src/otlpExporter/common/semconv/base/mappings.js b/packages/core/src/otlpExporter/common/semconv/base/mappings.js index 15c6b96da6..93ef29869f 100644 --- a/packages/core/src/otlpExporter/common/semconv/base/mappings.js +++ b/packages/core/src/otlpExporter/common/semconv/base/mappings.js @@ -15,7 +15,8 @@ const MAPPINGS = { HOST_NAME: 'host.name', HOST_ID: 'host.id', PROCESS_PID: 'process.pid', - OS_TYPE: 'os.type' + OS_TYPE: 'os.type', + FAAS_NAME: 'faas.name' }, metadata: { diff --git a/packages/core/src/otlpExporter/common/transformers/resource.js b/packages/core/src/otlpExporter/common/transformers/resource.js index b8fc06d1db..da87337786 100644 --- a/packages/core/src/otlpExporter/common/transformers/resource.js +++ b/packages/core/src/otlpExporter/common/transformers/resource.js @@ -34,6 +34,19 @@ const INSTRUMENTATION_SCOPE = { * @property {Record} [f] */ +/** + * Extracts faas.name from provider-specific span data fields. + * + * @param {RawPayload} rawPayload + * @returns {string | undefined} + */ +function getFaasNameFromSpanData(rawPayload) { + return ( + // AWS Lambda + rawPayload.data?.lambda?.functionName + ); +} + const resourceMapper = { /** * @param {RawPayload} rawPayload @@ -119,6 +132,16 @@ const resourceMapper = { return resource[RESOURCE.HOST_ID] || metadata.h; }, + + /** + * @param {RawPayload} rawPayload + * @returns {string | undefined} + */ + faasName(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + return resource[RESOURCE.FAAS_NAME] || getFaasNameFromSpanData(rawPayload) || undefined; + }, + /** * @param {RawPayload} rawPayload * @returns {string | undefined} @@ -180,6 +203,11 @@ function extractResourceAttributes(rawPayload) { otlp: OTLP.resource.HOST_NAME, transform: resourceMapper.hostName, valueType: 'string' + }, + { + otlp: OTLP.resource.FAAS_NAME, + transform: resourceMapper.faasName, + valueType: 'string' } ]; diff --git a/packages/core/test/otlpExporter/common/transformers/resource_test.js b/packages/core/test/otlpExporter/common/transformers/resource_test.js index dd63389019..8a028956bf 100644 --- a/packages/core/test/otlpExporter/common/transformers/resource_test.js +++ b/packages/core/test/otlpExporter/common/transformers/resource_test.js @@ -201,6 +201,24 @@ describe('otlpExporter/common/transformers/resource', () => { }); }); + describe('faas.name', () => { + it('uses data.lambda.functionName from AWS Lambda span', () => { + const span = makeSpan({ data: { lambda: { functionName: 'my-lambda' } } }); + expectStr(extract(span), 'faas.name', 'my-lambda'); + }); + + it('prefers resource["faas.name"] override over span data', () => { + const span = makeSpan({ + data: { resource: { 'faas.name': 'override-fn' }, lambda: { functionName: 'my-lambda' } } + }); + expectStr(extract(span), 'faas.name', 'override-fn'); + }); + + it('omits faas.name when no FaaS span data is present', () => { + expectAbsent(extract(makeSpan()), 'faas.name'); + }); + }); + describe('required attributes always present', () => { it('emits all required attributes on every span', () => { const attrs = extract(makeSpan());