diff --git a/example-apps/collector/package.json b/example-apps/collector/package.json index 666f404608..da940d241d 100644 --- a/example-apps/collector/package.json +++ b/example-apps/collector/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "main": "src/index.js", "scripts": { - "start": "node src/index.js" + "start": "node src/index.js", + "start:otlp": "INSTANA_TRACING_OTLP_ENABLED=true node src/index.js" }, "dependencies": { "@instana/collector": "latest", diff --git a/packages/collector/src/agentConnection.js b/packages/collector/src/agentConnection.js index 7ca47d69c5..96b2eb4f64 100644 --- a/packages/collector/src/agentConnection.js +++ b/packages/collector/src/agentConnection.js @@ -17,6 +17,9 @@ const cmdline = require('./cmdline'); let logger; /** @type {{ pid: number }} */ let pidStore; +/** @type {import('@instana/core/src/config').InstanaConfig} */ +let config; +let isOtlpExporterEnabled = false; // How many extra characters are to be reserved for the inode and // file descriptor fields in the collector announce cycle. @@ -31,19 +34,35 @@ let maxContentErrorHasBeenLogged = false; const http = uninstrumentedHttp.http; let isConnected = false; +/** + * @type {number | undefined} + */ +let otlpPort; + /** @type {string | null} */ let cpuSetFileContent = null; /** - * @param {import('@instana/core/src/config').InstanaConfig} config + * @param {import('@instana/core/src/config').InstanaConfig} _config * @param {any} _pidStore */ -exports.init = function init(config, _pidStore) { +exports.init = function init(_config, _pidStore) { + config = _config; logger = config.logger; pidStore = _pidStore; cmdline.init(config); cpuSetFileContent = getCpuSetFileContent(); + isOtlpExporterEnabled = config.tracing.otlp.enabled; + otlpPort = config.tracing.otlp.port; +}; + +/** + * @param {import('@instana/core/src/config').InstanaConfig} _config + */ +exports.activate = function activate(_config) { + config = _config; + isOtlpExporterEnabled = config.tracing.otlp.enabled; }; exports.AgentEventSeverity = { @@ -86,6 +105,37 @@ exports.AgentEventSeverity = { * @property {string} [cpuSetFileContent] */ +/** @type {Record string }>} */ +const EXPORT_ENDPOINTS = { + traces: { + otlpPath: '/v1/traces', + instanaPath: () => `/com.instana.plugin.nodejs/traces.${pidStore.pid}` + }, + metrics: { + otlpPath: '/v1/metrics', + instanaPath: () => `/com.instana.plugin.nodejs.${pidStore.pid}` + } +}; + +/** + * @param {string} type + * @returns { {path: string, port: number} } + */ +function resolveExportEndpoint(type) { + const endpoint = EXPORT_ENDPOINTS[type]; + + if (isOtlpExporterEnabled) { + return { + path: endpoint.otlpPath, + port: otlpPort + }; + } + + return { + path: endpoint.instanaPath(), + port: agentOpts.port + }; +} /** * @param {(err: Error, rawResponse?: string) => void} callback */ @@ -307,15 +357,28 @@ function checkWhetherResponseForPathIsOkay(path, cb) { exports.sendMetrics = function sendMetrics(data, cb) { cb = util.atMostOnce('callback for sendMetrics', cb); - sendData(`/com.instana.plugin.nodejs.${pidStore.pid}`, data, (err, body) => { - if (err) { - cb(err, null); - } else { + const exportTarget = resolveExportEndpoint('metrics'); + sendData({ + ...exportTarget, + data, + cb: (err, body) => { + if (err) { + cb(err, null); + return; + } + try { // 2016-09-11 - // Older sensor versions will not repond with a JSON + // Older sensor versions will not respond with a JSON // structure. Support a smooth update path. body = JSON.parse(body); + // Ensure body is always an array for requestHandler.handleRequests() + // - Instana agent endpoint returns arrays [] + // - OTLP endpoints return acknowledgments (empty object {}), not request arrays + // Convert non-array responses to empty array to prevent error in requestHandler + if (!Array.isArray(body)) { + body = []; + } } catch (e) { body = []; } @@ -327,24 +390,33 @@ exports.sendMetrics = function sendMetrics(data, cb) { /** * - * @param {Array.} spans + * @param {Array.|Object} spans * @param {(...args: *) => *} cb */ exports.sendSpans = function sendSpans(spans, cb) { const callback = util.atMostOnce('callback for sendSpans', err => { if (err && !maxContentErrorHasBeenLogged && err instanceof PayloadTooLargeError) { - logLargeSpans(spans); + if (Array.isArray(spans)) { + logLargeSpans(spans); + } } else if (err) { - const spanInfo = getSpanLengthInfo(spans); - logger.debug(`Failed to send: ${JSON.stringify(spanInfo)}`); - } else { + if (Array.isArray(spans)) { + const spanInfo = getSpanLengthInfo(spans); + logger.debug(`Failed to send: ${JSON.stringify(spanInfo)}`); + } + } else if (Array.isArray(spans)) { const spanInfo = getSpanLengthInfo(spans); logger.debug(`Successfully sent: ${JSON.stringify(spanInfo)}`); } cb(err); }); - - sendData(`/com.instana.plugin.nodejs/traces.${pidStore.pid}`, spans, callback, true); + const exportTarget = resolveExportEndpoint('traces'); + sendData({ + ...exportTarget, + data: spans, + cb: callback, + ignore404: true + }); }; /** @@ -364,7 +436,12 @@ exports.sendProfiles = function sendProfiles(profiles, cb) { cb(err); }); - sendData(`/com.instana.plugin.nodejs/profiles.${pidStore.pid}`, profiles, callback); + sendData({ + path: `/com.instana.plugin.nodejs/profiles.${pidStore.pid}`, + port: agentOpts.port, + data: profiles, + cb: callback + }); }; /** @@ -375,8 +452,12 @@ exports.sendEvent = function sendEvent(eventData, cb) { const callback = util.atMostOnce('callback for sendEvent', (err, responseBody) => { cb(err, responseBody); }); - - sendData('/com.instana.plugin.generic.event', eventData, callback); + sendData({ + path: '/com.instana.plugin.generic.event', + port: agentOpts.port, + data: eventData, + cb: callback + }); }; /** @@ -398,7 +479,12 @@ exports.sendAgentMonitoringEvent = function sendAgentMonitoringEvent(code, categ cb(err, responseBody); }); - sendData('/com.instana.plugin.generic.agent-monitoring-event', event, callback); + sendData({ + path: '/com.instana.plugin.generic.agent-monitoring-event', + port: agentOpts.port, + data: event, + cb: callback + }); }; /** @@ -409,11 +495,12 @@ exports.sendAgentMonitoringEvent = function sendAgentMonitoringEvent(code, categ exports.sendAgentResponseToAgent = function sendAgentResponseToAgent(messageId, response, cb) { cb = util.atMostOnce('callback for sendAgentResponseToAgent', cb); - sendData( - `/com.instana.plugin.nodejs/response.${pidStore.pid}?messageId=${encodeURIComponent(messageId)}`, - response, + sendData({ + path: `/com.instana.plugin.nodejs/response.${pidStore.pid}?messageId=${encodeURIComponent(messageId)}`, + port: agentOpts.port, + data: response, cb - ); + }); }; /** @@ -425,17 +512,24 @@ exports.sendTracingMetricsToAgent = function sendTracingMetricsToAgent(tracingMe cb(err); }); - sendData('/tracermetrics', tracingMetrics, callback); + sendData({ + path: '/tracermetrics', + port: agentOpts.port, + data: tracingMetrics, + cb: callback + }); }; /** - * @param {string} path - * @param {*} data - * @param {(...args: *) => *} cb - * @param {boolean} [ignore404] - * @returns + * @param {Object} params + * @param {string} params.path + * @param {*} params.data + * @param {(...args: *) => *} params.cb + * @param {boolean} [params.ignore404] + * @param {number} params.port + * @returns {*} */ -function sendData(path, data, cb, ignore404 = false) { +function sendData({ path, data, cb, ignore404 = false, port }) { cb = util.atMostOnce(`callback for sendData: ${path}`, cb); const payloadAsString = JSON.stringify(data, circularReferenceRemover()); @@ -451,11 +545,10 @@ function sendData(path, data, cb, ignore404 = false) { const error = new PayloadTooLargeError(`Request payload is too large. Will not send data to agent. (POST ${path})`); return setImmediate(cb.bind(null, error)); } - const req = http.request( { host: agentOpts.host, - port: agentOpts.port, + port, path, method: 'POST', agent: http.agent, diff --git a/packages/collector/src/announceCycle/agentready.js b/packages/collector/src/announceCycle/agentready.js index f091e6341a..8205ef5d28 100644 --- a/packages/collector/src/announceCycle/agentready.js +++ b/packages/collector/src/announceCycle/agentready.js @@ -107,6 +107,13 @@ function enter(_ctx) { logger.debug(`isMainThread: ${isMainThread}`); + const updatedConfig = coreConfig.update({ + externalConfig: agentOpts.config, + source: util.constants.CONFIG_SOURCES.AGENT + }); + + agentConnection.activate(updatedConfig); + if (isMainThread) { uncaught.activate(); metrics.activate(); @@ -122,7 +129,8 @@ function enter(_ctx) { }, function onError() { ctx.transitionTo('unannounced'); - } + }, + updatedConfig ); scheduleTracingMetrics(); if (!disableEOLEvents) { @@ -130,10 +138,6 @@ function enter(_ctx) { } } - const updatedConfig = coreConfig.update({ - externalConfig: agentOpts.config, - source: util.constants.CONFIG_SOURCES.AGENT - }); tracing.activate(updatedConfig); if (agentOpts.autoProfile && autoprofile) { diff --git a/packages/collector/src/announceCycle/unannounced.js b/packages/collector/src/announceCycle/unannounced.js index 230c83d389..33628b65d1 100644 --- a/packages/collector/src/announceCycle/unannounced.js +++ b/packages/collector/src/announceCycle/unannounced.js @@ -45,6 +45,7 @@ const maxRetryDelay = 60 * 1000; // one minute * @typedef {Object} TracingConfig * @property {Array.} [extra-http-headers] * @property {KafkaTracingConfig} [kafka] + * @property {OtlpConfig} [otlp] * @property {import('@instana/core/src/config/types').IgnoreEndpoints} [ignore-endpoints] * @property {boolean} [span-batching-enabled] * @property {import('@instana/core/src/config/types').Disable} [disable] @@ -62,6 +63,12 @@ const maxRetryDelay = 60 * 1000; // one minute * @property {boolean} [trace-correlation] */ +/** + * @typedef {Object} OtlpConfig + * @property {boolean} [enabled] + * @property {number} [port] + */ + /** * @param {import('@instana/core/src/config').InstanaConfig} config * @param {any} _pidStore @@ -128,6 +135,7 @@ function applyAgentConfiguration(agentResponse) { applySecretsConfiguration(agentResponse); applyExtraHttpHeaderConfiguration(agentResponse); applyKafkaTracingConfiguration(agentResponse); + applyOtlpExporterConfiguration(agentResponse); applySpanBatchingConfiguration(agentResponse); applyIgnoreEndpointsConfiguration(agentResponse); applyStackTraceConfiguration(agentResponse); @@ -208,6 +216,23 @@ function applyKafkaTracingConfiguration(agentResponse) { // were only introduced with the Node.js discovery version 1.2.18. } +/** + * @param {AgentAnnounceResponse} agentResponse + */ +function applyOtlpExporterConfiguration(agentResponse) { + if (agentResponse.tracing && agentResponse.tracing.otlp) { + const otlpConfigFromAgent = /** @type {Record} */ (agentResponse.tracing.otlp); + ensureNestedObjectExists(agentOpts.config, ['tracing', 'otlp']); + + Object.keys(otlpConfigFromAgent).forEach(key => { + const value = otlpConfigFromAgent[key]; + if (value != null) { + /** @type {Record} */ (agentOpts.config.tracing.otlp)[key] = value; + } + }); + } +} + /** * @param {AgentAnnounceResponse} agentResponse */ diff --git a/packages/collector/src/metrics/transmissionCycle.js b/packages/collector/src/metrics/transmissionCycle.js index 41b88d7c13..028fe65028 100644 --- a/packages/collector/src/metrics/transmissionCycle.js +++ b/packages/collector/src/metrics/transmissionCycle.js @@ -6,6 +6,7 @@ 'use strict'; const core = require('@instana/core'); +const otlpExporter = require('@instana/core/src/otlpExporter'); /** @type {import('@instana/core/src/core').GenericLogger} */ let logger; @@ -31,6 +32,7 @@ let previousTransmittedValue; let transmissionTimeoutHandle; let transmissionDelay = 1000; let isActive = false; +let useOtlpExporter = false; /** * @param {import('@instana/core/src/metrics').InstanaConfig} config @@ -38,21 +40,26 @@ let isActive = false; exports.init = function init(config) { logger = config.logger; transmissionDelay = config.metrics.transmissionDelay; + useOtlpExporter = config.tracing.otlp.enabled; }; /** + * TODO: Consider using an options object * @param {import('./')} _metrics * @param {import('../agentConnection')} _downstreamConnection * @param {(requests: Array.) => void} _onSuccess * @param {() => void} _onError + * @param {import('@instana/core/src/metrics').InstanaConfig} _config * @returns */ -exports.activate = function activate(_metrics, _downstreamConnection, _onSuccess, _onError) { +exports.activate = function activate(_metrics, _downstreamConnection, _onSuccess, _onError, _config) { metrics = _metrics; downstreamConnection = _downstreamConnection; onSuccess = _onSuccess; onError = _onError; + useOtlpExporter = _config.tracing.otlp.enabled; + if (!metrics) { logger.error('No metrics have been set.'); return; @@ -101,6 +108,10 @@ function sendMetrics() { payload = core.util.compression(previousTransmittedValue, newValueToTransmit); } + if (useOtlpExporter) { + payload = otlpExporter.metrics.transform(payload); + } + downstreamConnection.sendMetrics(payload, onMetricsHaveBeenSent.bind(null, isFullTransmission, newValueToTransmit)); } diff --git a/packages/collector/src/types/collector.d.ts b/packages/collector/src/types/collector.d.ts index 008b263e59..18c03e1e76 100644 --- a/packages/collector/src/types/collector.d.ts +++ b/packages/collector/src/types/collector.d.ts @@ -17,6 +17,9 @@ export interface AgentConfig { stackTrace?: string; stackTraceLength?: number; }; + otlp?: { + enabled?: boolean; + }; }; [key: string]: any; } diff --git a/packages/collector/test/apps/agentStub.js b/packages/collector/test/apps/agentStub.js index 46c12fc7db..f8be8c4b96 100644 --- a/packages/collector/test/apps/agentStub.js +++ b/packages/collector/test/apps/agentStub.js @@ -46,6 +46,7 @@ const kafkaTraceCorrelation = process.env.KAFKA_TRACE_CORRELATION const ignoreEndpoints = process.env.IGNORE_ENDPOINTS && JSON.parse(process.env.IGNORE_ENDPOINTS); const disable = process.env.AGENT_DISABLE_TRACING && JSON.parse(process.env.AGENT_DISABLE_TRACING); const stackTraceConfig = process.env.STACK_TRACE_CONFIG && JSON.parse(process.env.STACK_TRACE_CONFIG); +const otlpExporter = process.env.OTLP_EXPORTER && JSON.parse(process.env.OTLP_EXPORTER); const uuids = {}; const agentLogs = []; @@ -124,7 +125,8 @@ app.put('/com.instana.plugin.nodejs.discovery', (req, res) => { enableSpanBatching || ignoreEndpoints || disable || - stackTraceConfig + stackTraceConfig || + otlpExporter ) { response.tracing = {}; @@ -152,6 +154,9 @@ app.put('/com.instana.plugin.nodejs.discovery', (req, res) => { response.tracing.global = response.tracing.global || {}; deepMerge(response.tracing.global, stackTraceConfig); } + if (otlpExporter) { + response.tracing.otlp = otlpExporter; + } } res.send(response); @@ -407,6 +412,29 @@ app process.exit(1); }); +app.post('/v1/traces', function handleOtlpTraces(req, res) { + if (rejectTraces) { + return res.sendStatus(400); + } + if (!dropAllData) { + receivedData.traces.push({ + time: Date.now(), + data: req.body + }); + } + res.send('OK'); +}); + +app.post('/v1/metrics', function handleOtlpMetrics(req, res) { + if (!dropAllData) { + receivedData.metrics.push({ + time: Date.now(), + data: req.body + }); + } + res.send('OK'); +}); + function aggregateMetrics(entityId, snapshotUpdate) { if (!receivedData.aggregatedMetrics[entityId]) { receivedData.aggregatedMetrics[entityId] = _.cloneDeep(snapshotUpdate); diff --git a/packages/collector/test/apps/agentStubControls.js b/packages/collector/test/apps/agentStubControls.js index 63da76a756..20d9af445b 100644 --- a/packages/collector/test/apps/agentStubControls.js +++ b/packages/collector/test/apps/agentStubControls.js @@ -68,6 +68,10 @@ class AgentStubControls { env.STACK_TRACE_CONFIG = JSON.stringify(opts.stackTraceConfig); } + if (opts.otlpExporter) { + env.OTLP_EXPORTER = JSON.stringify(opts.otlpExporter); + } + this.agentStub = spawn('node', [path.join(__dirname, 'agentStub.js')], { stdio: config.getAppStdio(), env diff --git a/packages/collector/test/integration/misc/agent_connection/test_base.js b/packages/collector/test/integration/misc/agent_connection/test_base.js index 4df7477fa4..9dc34d7a0e 100644 --- a/packages/collector/test/integration/misc/agent_connection/test_base.js +++ b/packages/collector/test/integration/misc/agent_connection/test_base.js @@ -62,7 +62,7 @@ module.exports = function () { const agentOpts = require('@_local/collector/src/agent/opts'); const originalPort = agentOpts.port; - const config = { logger: testUtils.createFakeLogger() }; + const config = { logger: testUtils.createFakeLogger(), tracing: { otlp: { enabled: false } } }; const pidStore = require('@_local/collector/src/pidStore'); let agentConnection; diff --git a/packages/collector/test/integration/misc/otlp-exporter/app.js b/packages/collector/test/integration/misc/otlp-exporter/app.js new file mode 100644 index 0000000000..2b706f9181 --- /dev/null +++ b/packages/collector/test/integration/misc/otlp-exporter/app.js @@ -0,0 +1,49 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +// NOTE: c8 bug https://github.com/bcoe/c8/issues/166 +process.on('SIGTERM', () => { + process.disconnect(); + process.exit(0); +}); +const instana = require('@instana/collector'); +// When OTLP is enabled, we need to set the OTLP port to match the agent port +// so that the AgentStub can receive the OTLP data +const agentPort = process.env.INSTANA_AGENT_PORT ? parseInt(process.env.INSTANA_AGENT_PORT, 10) : 42699; +if (process.env.OTLP_ENABLED_IN_CODE === 'true') { + instana({ + tracing: { + otlp: { + enabled: true, + port: agentPort + } + } + }); +} else { + instana({ + tracing: { + otlp: { + port: agentPort + } + } + }); +} + +const express = require('express'); +const port = require('@_local/collector/test/test_util/app-port')(); +const app = express(); + +app.get('/', (req, res) => { + res.send('OK'); +}); + +app.get('/otlp-format', (req, res) => { + res.send('OK'); +}); + +app.listen(port, () => { + console.log(`Listening on port: ${port}`); +}); diff --git a/packages/collector/test/integration/misc/otlp-exporter/package.json.template b/packages/collector/test/integration/misc/otlp-exporter/package.json.template new file mode 100644 index 0000000000..b5bc951f20 --- /dev/null +++ b/packages/collector/test/integration/misc/otlp-exporter/package.json.template @@ -0,0 +1,11 @@ +{ + "name": "instana-collector-test-otlp", + "version": "1.0.0", + "private": true, + "main": "app.js", + "dependencies": { + "@instana/collector": "{{collectorVersion}}", + "@instana/core": "{{coreVersion}}", + "@instana/shared-metrics": "{{sharedMetricsVersion}}" + } +} \ No newline at end of file diff --git a/packages/collector/test/integration/misc/otlp-exporter/test_base.js b/packages/collector/test/integration/misc/otlp-exporter/test_base.js new file mode 100644 index 0000000000..0462254714 --- /dev/null +++ b/packages/collector/test/integration/misc/otlp-exporter/test_base.js @@ -0,0 +1,169 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { expect } = require('chai'); + +const testConfig = require('@_local/core/test/config'); +const { retry } = require('@_local/core/test/test_util'); +const ProcessControls = require('@_local/collector/test/test_util/ProcessControls'); +const globalAgent = require('@_local/collector/test/globalAgent'); + +const mochaSuiteFn = testConfig.getTestTimeout() > 0 ? describe : describe.skip; + +module.exports = function () { + mochaSuiteFn('OTLP format', function () { + this.timeout(testConfig.getTestTimeout()); + + globalAgent.setUpCleanUpHooks(); + const agentControls = globalAgent.instance; + + let controls; + + const startApp = async (extra = {}) => { + controls = new ProcessControls({ + dirname: __dirname, + useGlobalAgent: true, + ...extra + }); + + await controls.startAndWaitForAgentConnection(); + }; + + const getSpans = () => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.be.an('array'); + expect(spans.length).to.be.at.least(1); + return spans; + }) + ); + + before(async () => { + await startApp({ env: { OTLP_ENABLED_IN_CODE: 'true' } }); + }); + + beforeEach(async () => { + await agentControls.clearReceivedTraceData(); + }); + + after(async () => { + await controls.stop(); + }); + + it('should send spans in OTLP format when enabled via code configuration', () => + controls.sendRequest({ method: 'GET', path: '/otlp-format' }).then(async () => { + const spans = await getSpans(); + + const otlp = spans[0]; + const resourceSpan = otlp.resourceSpans[0]; + + expect(resourceSpan.resource.attributes.find(a => a.key === 'service.name')?.value.stringValue).to.equal( + 'instana-collector-test-otlp' + ); + + const span = resourceSpan.scopeSpans[0].spans[0]; + + expect(span.name).to.equal('GET /otlp-format'); + expect(span.kind).to.equal(2); + + const httpMethod = span.attributes.find(a => a.key === 'http.method'); + + expect(httpMethod.value.stringValue).to.equal('GET'); + })); + + it('should send spans in Instana format when OTLP is disabled', async () => { + const disabled = new ProcessControls({ + dirname: __dirname, + useGlobalAgent: true + }); + + await disabled.startAndWaitForAgentConnection(); + await agentControls.clearReceivedTraceData(); + + try { + await disabled.sendRequest({ method: 'GET', path: '/otlp-format' }); + + const spans = await getSpans(); + + const httpSpan = spans.find(s => s.n === 'node.http.server'); + + expect(httpSpan).to.exist; + expect(httpSpan.data.http.method).to.equal('GET'); + expect(httpSpan.data.http.url).to.equal('/otlp-format'); + + expect(spans[0].resourceSpans).to.not.exist; + } finally { + await disabled.stop(); + } + }); + + it('should send spans in OTLP format when enabled via environment variable', async () => { + const env = new ProcessControls({ + dirname: __dirname, + useGlobalAgent: true, + env: { INSTANA_TRACING_OTLP_ENABLED: 'true' } + }); + + await env.startAndWaitForAgentConnection(); + await agentControls.clearReceivedTraceData(); + + try { + await env.sendRequest({ method: 'GET', path: '/otlp-format' }); + + const spans = await getSpans(); + expect(spans[0].resourceSpans).to.be.an('array'); + } finally { + await env.stop(); + } + }); + + it('should send spans in OTLP format when enabled by agent configuration', async () => { + const { AgentStubControls } = require('@_local/collector/test/apps/agentStubControls'); + + const customAgentControls = new AgentStubControls(); + + await customAgentControls.startAgent({ + otlpExporter: { enabled: true } + }); + const cfg = new ProcessControls({ + agentControls: customAgentControls, + dirname: __dirname + }); + + await cfg.startAndWaitForAgentConnection(); + + await new Promise(resolve => setTimeout(resolve, 500)); + + await customAgentControls.clearReceivedTraceData(); + + try { + await cfg.sendRequest({ method: 'GET', path: '/otlp-format' }); + + await retry(async () => { + const spans = await customAgentControls.getSpans(); + + expect(spans).to.be.an('array'); + expect(spans.length).to.be.at.least(1); + + const otlpPayload = spans[0]; + + expect(otlpPayload.resourceSpans).to.be.an('array'); + expect(otlpPayload.resourceSpans.length).to.be.at.least(1); + + const resourceSpan = otlpPayload.resourceSpans[0]; + + expect(resourceSpan.resource).to.exist; + expect(resourceSpan.scopeSpans).to.be.an('array'); + expect(resourceSpan.scopeSpans[0].spans).to.be.an('array'); + expect(resourceSpan.scopeSpans[0].spans.length).to.be.at.least(1); + }); + } finally { + await cfg.stop(); + await customAgentControls.stopAgent(); + } + }); + }); +}; diff --git a/packages/collector/test/unit/src/agent_connection.test.js b/packages/collector/test/unit/src/agent_connection.test.js index b71a030246..f4bb7b1624 100644 --- a/packages/collector/test/unit/src/agent_connection.test.js +++ b/packages/collector/test/unit/src/agent_connection.test.js @@ -4,11 +4,16 @@ 'use strict'; -const { expect } = require('chai'); +const chai = require('chai'); +const { expect } = chai; const proxyquire = require('proxyquire'); const EventEmitter = require('events'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); const testUtils = require('@_local/core/test/test_util'); +chai.use(sinonChai); + class MockRequestEmitter extends EventEmitter { setTimeout() {} @@ -34,7 +39,10 @@ describe('agent connection/bazel', function () { '@instana/core': mockInstanaCoreHttp() }); - agentConnection.init({ logger: testUtils.createFakeLogger() }, { pid: 1234 }); + agentConnection.init( + { logger: testUtils.createFakeLogger(), tracing: { otlp: { enabled: false } } }, + { pid: 1234 } + ); }); it('should remove the leading path segmentes which node-patches prepends', done => { @@ -59,7 +67,10 @@ describe('agent connection/bazel', function () { '@instana/core': mockInstanaCoreHttp() }); - agentConnection.init({ logger: testUtils.createFakeLogger() }, { pid: 1234 }); + agentConnection.init( + { logger: testUtils.createFakeLogger(), tracing: { otlp: { enabled: false } } }, + { pid: 1234 } + ); }); it('should not modify the readlinkSync result', done => { @@ -113,3 +124,417 @@ describe('agent connection/bazel', function () { }; } }); + +describe('agent connection/export endpoints', function () { + let agentConnection; + let httpRequestStub; + let logger; + let requestTimeout; + let pidStore; + let agentOptsStub; + let requests; + let responseQueue; + + class ExportRequestEmitter extends EventEmitter { + constructor() { + super(); + this.destroyed = false; + } + + setTimeout(timeout, handler) { + this.timeout = timeout; + this.timeoutHandler = handler; + } + + write(payload) { + this.payload = payload; + } + + end() { + if (this.autoError) { + setImmediate(() => this.emit('error', this.autoError)); + return; + } + + const nextResponse = responseQueue.shift() || { statusCode: 200, body: '' }; + setImmediate(() => { + const res = new MockResponseEmitter(); + res.statusCode = nextResponse.statusCode; + this.response = res; + this.responseCallback(res); + + setImmediate(() => { + if (nextResponse.body) { + res.emit('data', nextResponse.body); + } + res.emit('end'); + }); + }); + } + + destroy() { + this.destroyed = true; + } + } + + beforeEach(() => { + requests = []; + responseQueue = []; + logger = testUtils.createFakeLogger(); + sinon.spy(logger, 'error'); + sinon.spy(logger, 'debug'); + sinon.spy(logger, 'trace'); + + requestTimeout = 5000; + pidStore = { pid: 4711 }; + agentOptsStub = { + host: '127.0.0.1', + port: 42699, + requestTimeout + }; + + httpRequestStub = sinon.stub().callsFake((options, responseCallback) => { + const req = new ExportRequestEmitter(); + req.options = options; + req.responseCallback = responseCallback; + requests.push(req); + return req; + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + function initAgentConnection(customConfig = {}) { + const config = { + logger, + tracing: { + otlp: { + enabled: false, + port: 4318 + } + }, + ...customConfig + }; + + if (!config.tracing) { + config.tracing = { + otlp: { + enabled: false, + port: 4318 + } + }; + } + if (!config.tracing.otlp) { + config.tracing.otlp = { + enabled: false, + port: 4318 + }; + } + + agentConnection = proxyquire('@_local/collector/src/agentConnection', { + '@instana/core': { + util: { + atMostOnce: (_name, fn) => fn, + propertySizes: () => [] + }, + uninstrumentedHttp: { + http: { + request: httpRequestStub, + agent: {} + } + }, + uninstrumentedFs: { + readFileSync: () => null + } + }, + './agent/opts': agentOptsStub, + './cmdline': { + init: sinon.stub(), + getCmdline: sinon.stub().returns({}) + } + }); + + agentConnection.init(config, pidStore); + return agentConnection; + } + + describe('sendSpans', () => { + it('should use the legacy Instana traces endpoint when OTLP export is disabled', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: false, + port: 4318 + } + } + }); + responseQueue.push({ statusCode: 200, body: '' }); + + agentConnection.sendSpans([{ n: 'span', k: 1 }], err => { + expect(err).to.not.exist; + expect(requests).to.have.lengthOf(1); + expect(requests[0].options).to.include({ + host: '127.0.0.1', + port: 42699, + path: '/com.instana.plugin.nodejs/traces.4711', + method: 'POST' + }); + expect(requests[0].options.headers['Content-Type']).to.equal('application/json; charset=UTF-8'); + expect(JSON.parse(requests[0].payload.toString())).to.deep.equal([{ n: 'span', k: 1 }]); + done(); + }); + }); + + it('should use the OTLP traces endpoint when OTLP export is enabled', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + responseQueue.push({ statusCode: 200, body: '' }); + + agentConnection.sendSpans([{ n: 'span', k: 1 }], err => { + expect(err).to.not.exist; + expect(requests).to.have.lengthOf(1); + expect(requests[0].options).to.include({ + host: '127.0.0.1', + port: 4318, + path: '/v1/traces', + method: 'POST' + }); + expect(requests[0].options.headers['Content-Type']).to.equal('application/json; charset=UTF-8'); + expect(JSON.parse(requests[0].payload.toString())).to.deep.equal([{ n: 'span', k: 1 }]); + done(); + }); + }); + + it('should switch endpoint selection after activate enables OTLP export', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: false, + port: 4318 + } + } + }); + agentConnection.activate({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + responseQueue.push({ statusCode: 200, body: '' }); + + agentConnection.sendSpans([{ n: 'span', k: 1 }], err => { + expect(err).to.not.exist; + expect(requests[0].options.port).to.equal(4318); + expect(requests[0].options.path).to.equal('/v1/traces'); + done(); + }); + }); + + it('should report OTLP traces request failures with the OTLP endpoint in the error message', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + + const requestError = new Error('connection refused'); + httpRequestStub.callsFake((options, responseCallback) => { + const req = new ExportRequestEmitter(); + req.options = options; + req.responseCallback = responseCallback; + req.autoError = requestError; + requests.push(req); + return req; + }); + + agentConnection.sendSpans([{ n: 'span', k: 1 }], err => { + expect(err).to.exist; + expect(err.message).to.equal('Send data to agent via POST /v1/traces. Request failed: connection refused'); + done(); + }); + }); + + it('should ignore 404 responses for traces on the OTLP endpoint', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + responseQueue.push({ statusCode: 404, body: '' }); + + agentConnection.sendSpans([{ n: 'span', k: 1 }], err => { + expect(err).to.not.exist; + done(); + }); + }); + }); + + describe('sendMetrics', () => { + it('should use the legacy Instana metrics endpoint when OTLP export is disabled', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: false, + port: 4318 + } + } + }); + responseQueue.push({ statusCode: 200, body: '["ok"]' }); + + agentConnection.sendMetrics({ m: 1 }, (err, body) => { + expect(err).to.not.exist; + expect(body).to.deep.equal(['ok']); + expect(requests).to.have.lengthOf(1); + expect(requests[0].options).to.include({ + host: '127.0.0.1', + port: 42699, + path: '/com.instana.plugin.nodejs.4711', + method: 'POST' + }); + expect(JSON.parse(requests[0].payload.toString())).to.deep.equal({ m: 1 }); + done(); + }); + }); + + it('should use the OTLP metrics endpoint when OTLP export is enabled', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + responseQueue.push({ statusCode: 200, body: '[]' }); + + agentConnection.sendMetrics({ resourceMetrics: [] }, (err, body) => { + expect(err).to.not.exist; + expect(body).to.deep.equal([]); + expect(requests).to.have.lengthOf(1); + expect(requests[0].options).to.include({ + host: '127.0.0.1', + port: 4318, + path: '/v1/metrics', + method: 'POST' + }); + expect(requests[0].options.headers['Content-Type']).to.equal('application/json; charset=UTF-8'); + expect(JSON.parse(requests[0].payload.toString())).to.deep.equal({ resourceMetrics: [] }); + done(); + }); + }); + + it('should forward OTLP metrics errors to the callback', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + + const requestError = new Error('socket hang up'); + httpRequestStub.callsFake((options, responseCallback) => { + const req = new ExportRequestEmitter(); + req.options = options; + req.responseCallback = responseCallback; + req.autoError = requestError; + requests.push(req); + return req; + }); + + agentConnection.sendMetrics({ resourceMetrics: [] }, (err, body) => { + expect(err).to.exist; + expect(body).to.equal(null); + expect(err.message).to.equal('Send data to agent via POST /v1/metrics. Request failed: socket hang up'); + expect(logger.error).to.not.have.been.called; + done(); + }); + }); + + it('should not log legacy metrics errors when OTLP export is disabled', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: false, + port: 4318 + } + } + }); + + const requestError = new Error('socket hang up'); + httpRequestStub.callsFake((options, responseCallback) => { + const req = new ExportRequestEmitter(); + req.options = options; + req.responseCallback = responseCallback; + req.autoError = requestError; + requests.push(req); + return req; + }); + + agentConnection.sendMetrics({ m: 1 }, err => { + expect(err).to.exist; + expect(err.message).to.equal( + 'Send data to agent via POST /com.instana.plugin.nodejs.4711. Request failed: socket hang up' + ); + expect(logger.error).to.not.have.been.called; + done(); + }); + }); + + it('should forward non-2xx OTLP metrics responses as errors', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + responseQueue.push({ statusCode: 500, body: 'boom' }); + + agentConnection.sendMetrics({ resourceMetrics: [] }, (err, body) => { + expect(err).to.exist; + expect(body).to.equal(null); + expect(err.message).to.equal('Failed to send data to agent via POST /v1/metrics. Got status code 500.'); + expect(logger.error).to.not.have.been.called; + done(); + }); + }); + + it('should use the configured OTLP port for metrics requests', done => { + initAgentConnection({ + tracing: { + otlp: { + enabled: true, + port: 9999 + } + } + }); + responseQueue.push({ statusCode: 200, body: '' }); + + agentConnection.sendMetrics({ resourceMetrics: [] }, err => { + expect(err).to.not.exist; + expect(requests[0].options.port).to.equal(9999); + expect(requests[0].options.path).to.equal('/v1/metrics'); + done(); + }); + }); + }); +}); diff --git a/packages/collector/test/unit/src/announceCycle/unannounced.test.js b/packages/collector/test/unit/src/announceCycle/unannounced.test.js index 1c0bce962b..ebf518041d 100644 --- a/packages/collector/test/unit/src/announceCycle/unannounced.test.js +++ b/packages/collector/test/unit/src/announceCycle/unannounced.test.js @@ -1062,6 +1062,220 @@ describe('unannounced state', () => { }); }); + describe('OTLP exporter configuration', () => { + it('should apply OTLP exporter configuration when enabled is true', done => { + prepareAnnounceResponse({ + tracing: { + otlp: { + enabled: true + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + otlp: { + enabled: true + } + } + }); + done(); + } + }); + }); + + it('should apply OTLP exporter configuration when enabled is false', done => { + prepareAnnounceResponse({ + tracing: { + otlp: { + enabled: false + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + otlp: { + enabled: false + } + } + }); + done(); + } + }); + }); + + it('should preserve existing OTLP configuration when tracing.otlp is missing', done => { + agentOptsStub.config = { + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }; + + prepareAnnounceResponse({ + tracing: {} + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + done(); + } + }); + }); + + it('should create tracing.otlp object but not set enabled when enabled is missing', done => { + prepareAnnounceResponse({ + tracing: { + otlp: {} + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + otlp: {} + } + }); + done(); + } + }); + }); + + it('should preserve existing OTLP configuration when enabled is null', done => { + agentOptsStub.config = { + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }; + + prepareAnnounceResponse({ + tracing: { + otlp: { + enabled: null + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + otlp: { + enabled: true, + port: 4318 + } + } + }); + done(); + } + }); + }); + + it('should preserve existing OTLP configuration when enabled is undefined', done => { + agentOptsStub.config = { + tracing: { + otlp: { + enabled: false, + port: 4318 + } + } + }; + + prepareAnnounceResponse({ + tracing: { + otlp: {} + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + otlp: { + enabled: false, + port: 4318 + } + } + }); + done(); + } + }); + }); + + it('should apply non-boolean enabled values as provided by the agent', done => { + prepareAnnounceResponse({ + tracing: { + otlp: { + enabled: 'true' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + otlp: { + enabled: 'true' + } + } + }); + done(); + } + }); + }); + + it('should override only tracing.otlp.enabled and preserve existing sibling properties', done => { + agentOptsStub.config = { + tracing: { + otlp: { + enabled: false, + port: 9999 + }, + kafka: { + traceCorrelation: false + } + } + }; + + prepareAnnounceResponse({ + tracing: { + otlp: { + enabled: true + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + otlp: { + enabled: true, + port: 9999 + }, + kafka: { + traceCorrelation: false + } + } + }); + done(); + } + }); + }); + }); + function prepareAnnounceResponse(announceResponse) { agentConnectionStub.announceNodeCollector.callsArgWithAsync(0, null, JSON.stringify(announceResponse)); } diff --git a/packages/core/src/config/index.js b/packages/core/src/config/index.js index 3b862be2ef..71c9ee037c 100644 --- a/packages/core/src/config/index.js +++ b/packages/core/src/config/index.js @@ -73,6 +73,7 @@ let currentConfig; * @property {boolean} [ignoreEndpointsDisableSuppression] * @property {boolean} [disableEOLEvents] * @property {globalStackTraceConfig} [global] + * @property {otlpExporterOptions} [otlp] */ /** @@ -85,6 +86,13 @@ let currentConfig; * @property {boolean} [traceCorrelation] */ +/** + * @typedef {Object} otlpExporterOptions + * @property {boolean} [enabled] + * @property {number} [port] + * @property {string} [semConvVersion] + */ + /** * @typedef {Object} globalStackTraceConfig * @property {string} [stackTrace] @@ -160,7 +168,14 @@ let defaults = { }, ignoreEndpoints: {}, ignoreEndpointsDisableSuppression: false, - disableEOLEvents: false + disableEOLEvents: false, + otlp: { + enabled: false, + // Currently, we only have http protocol support and default to 4318 + // This option is internal and not exposed + port: 4318, + semConvVersion: '1.23' + } }, preloadOpentelemetry: false, secrets: { @@ -341,6 +356,7 @@ function normalizeTracingConfig({ userConfig = {}, defaultConfig = {}, finalConf normalizeIgnoreEndpoints({ userConfig, defaultConfig, finalConfig }); normalizeIgnoreEndpointsDisableSuppression({ userConfig, defaultConfig, finalConfig }); normalizeDisableEOLEvents({ userConfig, defaultConfig, finalConfig }); + normalizeOtlpExporter({ userConfig, defaultConfig, finalConfig }); } /** @@ -1095,6 +1111,33 @@ function normalizePreloadOpentelemetry({ userConfig = {}, defaultConfig = {}, fi }); } +/** + * @param {{ userConfig?: InstanaConfig|null, defaultConfig?: InstanaConfig, finalConfig?: InstanaConfig }} [options] + */ +function normalizeOtlpExporter({ userConfig = {}, defaultConfig = {}, finalConfig = {} } = {}) { + // TODO: This needs to be extended for the rest of the otlp configurations + const userOtlp = userConfig.tracing?.otlp || {}; + + finalConfig.tracing.otlp = Object.assign({}, defaultConfig.tracing?.otlp, finalConfig.tracing.otlp); + const { value, source } = util.resolve( + { + envValue: 'INSTANA_TRACING_OTLP_ENABLED', + inCodeValue: userOtlp.enabled, + defaultValue: defaultConfig.tracing?.otlp?.enabled + }, + [validate.booleanValidator] + ); + + configStore.set('config.tracing.otlp.enabled', { source }); + finalConfig.tracing.otlp.enabled = value; + util.log({ + configPath: 'config.tracing.otlp.enabled', + source, + value, + envVarName: 'INSTANA_TRACING_OTLP_ENABLED' + }); +} + /** * Updates configuration values dynamically from external sources (e.g., agent) * diff --git a/packages/core/src/index.js b/packages/core/src/index.js index b774b855f0..f717075447 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -14,6 +14,7 @@ const secrets = require('./secrets'); const tracing = require('./tracing'); const util = require('./util'); const coreConfig = require('./config'); +const otlpExporter = require('./otlpExporter'); /** * @typedef {{ @@ -80,6 +81,7 @@ function init(config, downstreamConnection, processIdentityProvider) { util.init(config); util.hasThePackageBeenInitializedTooLate.activate(); secrets.init(config); + otlpExporter.init(config); tracing.init(config, downstreamConnection, processIdentityProvider); } @@ -98,5 +100,6 @@ module.exports = { util, init, preInit, - registerAdditionalInstrumentations + registerAdditionalInstrumentations, + otlpExporter }; diff --git a/packages/core/src/otlpExporter/common/constants.js b/packages/core/src/otlpExporter/common/constants.js new file mode 100644 index 0000000000..70b825195c --- /dev/null +++ b/packages/core/src/otlpExporter/common/constants.js @@ -0,0 +1,7 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +exports.INSTRUMENTATION_SCOPE_NAME = '@instana/collector'; diff --git a/packages/core/src/otlpExporter/common/context.js b/packages/core/src/otlpExporter/common/context.js new file mode 100644 index 0000000000..46a243346a --- /dev/null +++ b/packages/core/src/otlpExporter/common/context.js @@ -0,0 +1,60 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { getLookupConfig } = require('./semconv'); + +class OtlpConfigContext { + constructor() { + /** @type {Record | null} */ + this._config = null; + /** @type {string | null} */ + this._semConvVersion = null; + /** @type {any} */ + this._compiledSemConv = null; + /** @type {string | null} */ + this._hostId = null; + /** @type {string | null} */ + this._pid = null; + /** @type {string | null} */ + this._serviceName = null; + } + + /** + * @param {Record} config + */ + init(config = {}) { + this._config = config; + this._semConvVersion = config.tracing.otlp.semConvVersion || this._semConvVersion; + this._compiledSemConv = getLookupConfig(this._semConvVersion); + this._pid = String(process.pid); + this._serviceName = config.serviceName || null; + } + + get semConv() { + // eslint-disable-next-line no-return-assign + return this._compiledSemConv || (this._compiledSemConv = getLookupConfig(this._semConvVersion)); + } + + get semConvVersion() { + return this._semConvVersion; + } + + /** + * @param {string} serviceName + */ + setServiceName(serviceName) { + if (!serviceName || this._serviceName === serviceName) { + return; + } + this._serviceName = serviceName; + } + + get serviceName() { + return this._serviceName; + } +} + +module.exports = new OtlpConfigContext(); diff --git a/packages/core/src/otlpExporter/common/index.js b/packages/core/src/otlpExporter/common/index.js new file mode 100644 index 0000000000..e02bc32e9c --- /dev/null +++ b/packages/core/src/otlpExporter/common/index.js @@ -0,0 +1,23 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const context = require('./context'); +const transformers = require('./transformers'); +const semconv = require('./semconv'); + +/** + * @param {Object} config + */ +function init(config) { + context.init(config); +} + +module.exports = { + context, + transformers, + semconv, + init +}; diff --git a/packages/core/src/otlpExporter/common/semconv/base/mappings.js b/packages/core/src/otlpExporter/common/semconv/base/mappings.js new file mode 100644 index 0000000000..de5a914a6d --- /dev/null +++ b/packages/core/src/otlpExporter/common/semconv/base/mappings.js @@ -0,0 +1,148 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +// Base mappings - common across all semantic convention versions +// Version-specific attribute names should be defined in their respective version directories +const MAPPINGS = { + resource: { + SERVICE_NAME: 'service.name', + SDK_LANGUAGE: 'telemetry.sdk.language', + SDK_NAME: 'telemetry.sdk.name', + SDK_VERSION: 'telemetry.sdk.version', + HOST_NAME: 'host.name', + HOST_ID: 'host.id', + PROCESS_PID: 'process.pid' + }, + + metadata: { + TRACE_ID: 'traceId', + SPAN_ID: 'spanId', + PARENT_ID: 'parentSpanId', + SPAN_KIND: 'kind', + NAME: 'name', + STATUS: 'status', + RESOURCE: 'resource', + INSTRUMENTATION_SCOPE: 'instrumentationScope', + EVENTS: 'events', + LINKS: 'links', + START_TIME_UNIX_NANO: 'startTimeUnixNano', + END_TIME_UNIX_NANO: 'endTimeUnixNano' + }, + + http: { + ROUTE: 'http.route', + STATUS_TEXT: 'http.status_text', + URL_TEMPLATE: 'http.url.template', + NETWORK_PROTOCOL: 'network.protocol.name', + REQUEST_HEADER: 'http.request.header', + RESPONSE_HEADER: 'http.response.header' + }, + + messaging: { + SYSTEM: 'messaging.system', + OPERATION_TYPE: 'messaging.operation.type', + OPERATION_NAME: 'messaging.operation.name', + CONSUMER_GROUP: 'messaging.consumer.group.name', + MESSAGE_ID: 'messaging.message.id', + MESSAGE_BODY_SIZE: 'messaging.message.body.size', + DESTINATION_NAME: 'messaging.destination.name', + DESTINATION_TEMPLATE: 'messaging.destination.template', + DESTINATION_PARTITION_ID: 'messaging.destination.partition.id', + kafka: { + OFFSET: 'messaging.kafka.message.offset', + MESSAGE_KEY: 'messaging.kafka.message.key' + }, + rabbitmq: { + ROUTING_KEY: 'messaging.rabbitmq.destination.routing_key', + MESSAGE_ROUTING_KEY: 'messaging.rabbitmq.message.routing_key' + }, + gcp: { PROJECT_ID: 'gcp.project_id' } + }, + + database: { + OPERATION: 'db.operation.name', + NAMESPACE: 'db.namespace', + QUERY_TEXT: 'db.query.text', + NAME: 'db.name', + USER: 'db.user', + COLLECTION: 'db.collection.name', + TABLE: 'db.sql.table', + CONNECTION: 'db.connection' + }, + + rpc: { + SYSTEM: 'rpc.system', + SYSTEM_NAME: 'rpc.system.name', + METHOD: 'rpc.method', + METHOD_ORIGINAL: 'rpc.method_original', + SERVICE: 'rpc.service', + GRPC_STATUS: 'rpc.grpc.status_code', + GRPC_ERROR: 'rpc.grpc.status_message' + }, + + graphql: { + OPERATION_NAME: 'graphql.operation.name', + OPERATION_TYPE: 'graphql.operation.type' + }, + + log: { + BODY: 'log.body', + SEVERITY: 'log.severity', + FUNCTION: 'code.function' + }, + + cloud: { + REGION: 'cloud.region', + PROVIDER: 'cloud.provider', + ACCOUNT_ID: 'cloud.account.id', + gcp: { + PROJECT_ID: 'gcp.project_id', + STORAGE_BUCKET: 'gcp.storage.bucket', + STORAGE_OBJECT: 'gcp.storage.object', + STORAGE_SOURCE_BUCKET: 'gcp.storage.source.bucket', + STORAGE_DESTINATION_BUCKET: 'gcp.storage.destination.bucket', + STORAGE_SOURCE_OBJECT: 'gcp.storage.source.object', + STORAGE_DESTINATION_OBJECT: 'gcp.storage.destination.object' + }, + aws: { + S3_BUCKET: 'aws.s3.bucket', + S3_KEY: 'aws.s3.key', + KINESIS_STREAM: 'aws.kinesis.stream_name', + KINESIS_SHARD: 'aws.kinesis.shard_id', + KINESIS_SHARD_ITERATOR_TYPE: 'aws.kinesis.shard_iterator_type', + KINESIS_STARTING_SEQUENCE_NUMBER: 'aws.kinesis.starting_sequence_number', + KINESIS_EXPLICIT_HASH_KEY: 'aws.kinesis.explicit_hash_key' + }, + azure: { + STORAGE_ACCOUNT: 'az.storage.account.name', + CONTAINER: 'az.storage.container.name', + BLOB: 'az.storage.blob.name' + } + }, + + faas: { + NAME: 'faas.name', + INVOCATION_TYPE: 'faas.invocation_type', + TRIGGER: 'faas.trigger' + }, + + exception: { + MESSAGE: 'exception.message', + STACKTRACE: 'exception.stacktrace', + TYPE: 'exception.type' + }, + + server: { + ADDRESS: 'server.address', + PORT: 'server.port' + }, + + error: { + TYPE: 'error.type' + } +}; + +module.exports = { MAPPINGS }; diff --git a/packages/core/src/otlpExporter/common/semconv/index.js b/packages/core/src/otlpExporter/common/semconv/index.js new file mode 100644 index 0000000000..50857d5f38 --- /dev/null +++ b/packages/core/src/otlpExporter/common/semconv/index.js @@ -0,0 +1,31 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** @type {Record} */ +const VERSIONS = { + 1.23: require('./v1.23'), + 1.41: require('./v1.41') +}; + +/** + * Get the semantic convention lookup configuration for a specific version. + * + * @param {string} [version] - The semantic convention version (e.g., '1.23', '1.41') + * @returns {any} The compiled semantic convention mappings + */ +function getLookupConfig(version) { + const targetVersion = version || '1.23'; + + if (!VERSIONS[targetVersion]) { + throw new Error(`Unknown semantic convention version: ${targetVersion}`); + } + + return VERSIONS[targetVersion]; +} + +module.exports = { + getLookupConfig +}; diff --git a/packages/core/src/otlpExporter/common/semconv/merge.js b/packages/core/src/otlpExporter/common/semconv/merge.js new file mode 100644 index 0000000000..39c44955eb --- /dev/null +++ b/packages/core/src/otlpExporter/common/semconv/merge.js @@ -0,0 +1,34 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * @param {Record} base - The base mapping configuration + * @param {Record} overrides - The version-specific overrides + * @returns {Record} + */ +function merge(base, overrides) { + if (!overrides || Object.keys(overrides).length === 0) { + return Object.freeze({ ...base }); + } + + /** @type {Record} */ + const merged = { ...base }; + + Object.keys(overrides).forEach(key => { + const overrideValue = overrides[key]; + + // If the override value is a nested object, merge recursively + if (overrideValue && typeof overrideValue === 'object' && !Array.isArray(overrideValue)) { + merged[key] = merge(base[key] || {}, overrideValue); + } else { + merged[key] = overrideValue; + } + }); + + return Object.freeze(merged); +} + +module.exports = { merge }; diff --git a/packages/core/src/otlpExporter/common/semconv/v1.23/index.js b/packages/core/src/otlpExporter/common/semconv/v1.23/index.js new file mode 100644 index 0000000000..52b2874e39 --- /dev/null +++ b/packages/core/src/otlpExporter/common/semconv/v1.23/index.js @@ -0,0 +1,12 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { merge } = require('../merge'); +const base = require('../base/mappings').MAPPINGS; +const { MAPPINGS } = require('./mappings'); + +// v1.23 semantic conventions - merge with base (which is now empty) +module.exports = merge(base, MAPPINGS); diff --git a/packages/core/src/otlpExporter/common/semconv/v1.23/mappings.js b/packages/core/src/otlpExporter/common/semconv/v1.23/mappings.js new file mode 100644 index 0000000000..5a95a5e03b --- /dev/null +++ b/packages/core/src/otlpExporter/common/semconv/v1.23/mappings.js @@ -0,0 +1,39 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +// v1.23 semantic conventions - specific attribute names for v1.23 +const MAPPINGS = { + http: { + REQUEST_METHOD: 'http.method', + RESPONSE_STATUS: 'http.status_code', + URL_FULL: 'http.url', + URL_PATH: 'http.target', + URL_QUERY: 'http.url.query' + }, + + messaging: { + DESTINATION_NAME: 'messaging.destination', + kafka: { + PARTITION: 'messaging.kafka.partition' + }, + sqs: { + SYSTEM: 'aws.sqs' + } + }, + + database: { + SYSTEM: 'db.system', + PEER_NAME: 'net.peer.name', + PEER_PORT: 'net.peer.port' + }, + + network: { + PEER_NAME: 'net.peer.name', + PEER_PORT: 'net.peer.port' + } +}; + +module.exports = { MAPPINGS }; diff --git a/packages/core/src/otlpExporter/common/semconv/v1.41/index.js b/packages/core/src/otlpExporter/common/semconv/v1.41/index.js new file mode 100644 index 0000000000..48a823125b --- /dev/null +++ b/packages/core/src/otlpExporter/common/semconv/v1.41/index.js @@ -0,0 +1,11 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { merge } = require('../merge'); +const base = require('../base/mappings').MAPPINGS; +const { MAPPINGS } = require('./mappings'); + +module.exports = merge(base, MAPPINGS); diff --git a/packages/core/src/otlpExporter/common/semconv/v1.41/mappings.js b/packages/core/src/otlpExporter/common/semconv/v1.41/mappings.js new file mode 100644 index 0000000000..90ddb7732b --- /dev/null +++ b/packages/core/src/otlpExporter/common/semconv/v1.41/mappings.js @@ -0,0 +1,39 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +// v1.41 semantic conventions - overrides to base +const MAPPINGS = { + http: { + REQUEST_METHOD: 'http.request.method', + RESPONSE_STATUS: 'http.response.status_code', + URL_FULL: 'url.full', + URL_PATH: 'url.path', + URL_QUERY: 'url.query' + }, + messaging: { + DESTINATION_NAME: 'messaging.destination.name', + kafka: { + PARTITION: 'messaging.kafka.destination.partition' + }, + sqs: { + SYSTEM: 'aws_sqs' + }, + gcp: { + SYSTEM: 'gcp_pubsub' + } + }, + database: { + SYSTEM: 'db.system.name', + PEER_NAME: 'server.address', + PEER_PORT: 'server.port' + }, + network: { + PEER_NAME: 'server.address', + PEER_PORT: 'server.port' + } +}; + +module.exports = { MAPPINGS }; diff --git a/packages/core/src/otlpExporter/common/transformers/index.js b/packages/core/src/otlpExporter/common/transformers/index.js new file mode 100644 index 0000000000..11b660c39d --- /dev/null +++ b/packages/core/src/otlpExporter/common/transformers/index.js @@ -0,0 +1,11 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const resource = require('./resource'); + +module.exports = { + resource +}; diff --git a/packages/core/src/otlpExporter/common/transformers/resource.js b/packages/core/src/otlpExporter/common/transformers/resource.js new file mode 100644 index 0000000000..478d694fd8 --- /dev/null +++ b/packages/core/src/otlpExporter/common/transformers/resource.js @@ -0,0 +1,193 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const os = require('os'); +const ctx = require('../context'); +const { INSTRUMENTATION_SCOPE_NAME } = require('../constants'); + +let SDK_VERSION = '1.0.0'; +try { + // @ts-ignore + SDK_VERSION = require('../../../../package.json').version; +} catch (_) { + // ignore the error +} + +const SDK_LANGUAGE = 'nodejs'; +const SDK_NAME = 'instana'; + +const INSTRUMENTATION_SCOPE = { + name: INSTRUMENTATION_SCOPE_NAME, + version: SDK_VERSION +}; + +/** + * @typedef {Object} RawPayload + * @property {Record} [data] + * @property {Record} [resource] + * @property {Record} [f] + */ + +const resourceMapper = { + /** + * @param {RawPayload} rawPayload + * @returns {string | undefined} + */ + serviceName(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + return resource['service.name'] || ctx.serviceName; + }, + + /** + * @param {RawPayload} rawPayload + * @returns {string} + */ + sdkLanguage(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + return resource['telemetry.sdk.language'] || SDK_LANGUAGE; + }, + + /** + * @param {RawPayload} rawPayload + * @returns {string} + */ + sdkName(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + return resource['telemetry.sdk.name'] || SDK_NAME; + }, + + /** + * @param {RawPayload} rawPayload + * @returns {string} + */ + sdkVersion(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + return resource['telemetry.sdk.version'] || SDK_VERSION; + }, + + /** + * @param {RawPayload} rawPayload + * @returns {number | undefined} + */ + processId(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + const metadata = rawPayload.f || {}; + + const pid = resource['process.pid'] || metadata.e || ctx._pid; + + if (pid === null || pid === undefined) { + return undefined; + } + + const value = Number(pid); + return Number.isInteger(value) && value > 0 ? value : undefined; + }, + + /** + * @param {RawPayload} rawPayload + * @returns {string | undefined} + */ + hostName(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + let hostName = resource['host.name']; + + if (!hostName) { + try { + hostName = os.hostname(); + } catch (err) { + // If os.hostname() fails, return undefined + hostName = undefined; + } + } + + return typeof hostName === 'string' ? hostName : undefined; + }, + + /** + * @param {RawPayload} rawPayload + * @returns {string | undefined} + */ + hostId(rawPayload) { + const resource = rawPayload.data?.resource || rawPayload.resource || {}; + const metadata = rawPayload.f || {}; + + const hostId = resource['host.id'] || metadata.h || ctx._hostId; + + return typeof hostId === 'string' ? hostId : undefined; + } +}; + +/** + * @param {RawPayload} rawPayload + * @returns {{ attributes: Array<{ key: string, value: { intValue?: number, stringValue?: string } }> }} + */ +function extractResourceAttributes(rawPayload) { + if (!rawPayload) { + return { attributes: [] }; + } + + const OTLP = /** @type {any} */ (ctx.semConv); + + const resourceMappings = [ + { + otlp: OTLP.resource.SERVICE_NAME, + transform: resourceMapper.serviceName, + valueType: 'string' + }, + { + otlp: OTLP.resource.SDK_LANGUAGE, + transform: resourceMapper.sdkLanguage, + valueType: 'string' + }, + { + otlp: OTLP.resource.SDK_NAME, + transform: resourceMapper.sdkName, + valueType: 'string' + }, + { + otlp: OTLP.resource.SDK_VERSION, + transform: resourceMapper.sdkVersion, + valueType: 'string' + }, + { + otlp: OTLP.resource.PROCESS_PID, + transform: resourceMapper.processId, + valueType: 'int' + }, + { + otlp: OTLP.resource.HOST_NAME, + transform: resourceMapper.hostName, + valueType: 'string' + }, + { + otlp: OTLP.resource.HOST_ID, + transform: resourceMapper.hostId, + valueType: 'string' + } + ]; + + /** @type {Array<{ key: string, value: { intValue?: number, stringValue?: string } }>} */ + const attributes = resourceMappings.reduce((result, mapping) => { + const value = mapping.transform(rawPayload); + + if (value !== undefined && value !== null) { + result.push({ + key: mapping.otlp, + value: + mapping.valueType === 'int' ? { intValue: /** @type {number} */ (value) } : { stringValue: String(value) } + }); + } + + return result; + }, /** @type {Array<{ key: string, value: { intValue?: number, stringValue?: string } }>} */ ([])); + + return { attributes }; +} + +module.exports = { + extractResourceAttributes, + INSTRUMENTATION_SCOPE +}; diff --git a/packages/core/src/otlpExporter/index.js b/packages/core/src/otlpExporter/index.js new file mode 100644 index 0000000000..52dfbd44ec --- /dev/null +++ b/packages/core/src/otlpExporter/index.js @@ -0,0 +1,25 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const common = require('./common'); +const traces = require('./traces'); +const metrics = require('./metrics'); + +/** + * @param {import('../config').InstanaConfig} config + */ +function init(config) { + common.init(config); + traces.init(config); + metrics.init(config); +} + +module.exports = { + init, + common, + traces, + metrics +}; diff --git a/packages/core/src/otlpExporter/metrics/converter.js b/packages/core/src/otlpExporter/metrics/converter.js new file mode 100644 index 0000000000..2be367ab49 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/converter.js @@ -0,0 +1,73 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const otlpCtx = require('../common/context'); +const { normalizeMetrics } = require('./util'); +const transformers = require('./transformers'); + +const { INSTRUMENTATION_SCOPE } = transformers.resource; + +/** @type {import('@instana/core/src/core').GenericLogger} */ +let logger; + +/** + * @param {import('../../config').InstanaConfig} config + */ +function init(config) { + logger = config?.logger; +} + +/** + * @param {any} metrics + */ +function resolveServiceName(metrics) { + if (metrics?.name && typeof metrics.name === 'string' && !otlpCtx.serviceName) { + otlpCtx.setServiceName(metrics.name); + } +} + +/** + * @param {any} metrics + * @returns {Object} + */ +function convert(metrics) { + try { + const metricsArray = normalizeMetrics(metrics); + + if (metricsArray.length === 0) { + return { resourceMetrics: [] }; + } + + // Service name resolution, it not come from first metric once it set it will be used for all metrics + resolveServiceName(metrics); + + // All metrics share the same resource, so we can extract the attributes from the first one + const resource = transformers.resource.extractResourceAttributes(/** @type {any} */ (metricsArray[0])); + + return { + resourceMetrics: [ + { + resource, + scopeMetrics: [ + { + scope: INSTRUMENTATION_SCOPE, + // TODO: implement metrics transformation later in phase2 + metrics: [] + } + ] + } + ] + }; + } catch (error) { + logger?.debug('Failed to convert metrics to OTLP format.', error); + return { resourceMetrics: [] }; + } +} + +module.exports = { + init, + convert +}; diff --git a/packages/core/src/otlpExporter/metrics/index.js b/packages/core/src/otlpExporter/metrics/index.js new file mode 100644 index 0000000000..38e5d137ba --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/index.js @@ -0,0 +1,26 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const converter = require('./converter'); + +/** + * @param {Object} config + */ +function init(config) { + converter.init(config); +} + +/** + * @param {any} metrics + */ +function transform(metrics) { + return converter.convert(metrics); +} + +module.exports = { + init, + transform +}; diff --git a/packages/core/src/otlpExporter/metrics/transformers/index.js b/packages/core/src/otlpExporter/metrics/transformers/index.js new file mode 100644 index 0000000000..ea8239c3e9 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/transformers/index.js @@ -0,0 +1,11 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const resource = require('../../common/transformers/resource'); + +module.exports = { + resource +}; diff --git a/packages/core/src/otlpExporter/metrics/util.js b/packages/core/src/otlpExporter/metrics/util.js new file mode 100644 index 0000000000..a30892b7b2 --- /dev/null +++ b/packages/core/src/otlpExporter/metrics/util.js @@ -0,0 +1,95 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'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] + * @returns {Record} + */ +function flattenObject(obj, prefix = '') { + if (!obj || typeof obj !== 'object') return {}; + + return Object.keys(obj).reduce( + /** + * @param {Record} flattened + * @param {string} key + */ + (flattened, key) => { + const value = obj[key]; + if (value === null || value === undefined) return flattened; + + const newKey = prefix ? `${prefix}.${key}` : key; + + if (typeof value === 'object' && !Array.isArray(value)) { + Object.assign(flattened, flattenObject(value, newKey)); + } else if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { + flattened[newKey] = value; + } + + return flattened; + }, + /** @type {Record} */ ({}) + ); +} + +/** + * Normalized to internal format for easier mapping + * @param {any} metrics + */ +function normalizeMetrics(metrics) { + if (Array.isArray(metrics)) { + return normalizeArray(metrics); + } else if (typeof metrics === 'object' && metrics !== null) { + return normalizeObject(metrics); + } + return []; +} + +/** + * @param {any[]} metricsList + */ +function normalizeArray(metricsList) { + return metricsList.filter(Boolean).map(item => ({ + name: item.name, + value: item.value, + timestamp: item.timestamp || 0, + unit: item.unit || '', + from: item.from + })); +} + +/** + * Normalized to internal format for easier mapping + * @param {{ [x: string]: any; timestamp: any; from?: any; }} metricsObj + */ +function normalizeObject(metricsObj) { + const { from, timestamp, ...metricsData } = metricsObj; + const flattened = flattenObject(metricsData); + const fallbackTimestamp = timestamp || metricsObj.timestamp || 0; + + return Object.keys(flattened).map(key => ({ + name: key, + value: flattened[key], + timestamp: fallbackTimestamp, + unit: '', // we don't have any unit + from + })); +} + +module.exports = { + flattenObject, + normalizeMetrics, + getResourceKey +}; diff --git a/packages/core/src/otlpExporter/traces/converter.js b/packages/core/src/otlpExporter/traces/converter.js new file mode 100644 index 0000000000..68b06650fd --- /dev/null +++ b/packages/core/src/otlpExporter/traces/converter.js @@ -0,0 +1,84 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const transformers = require('./transformers'); +const mappers = require('./mappers'); +const { isLogSpan } = require('./util'); + +const { INSTRUMENTATION_SCOPE } = transformers.resource; + +/** + * @type {import("../../core").GenericLogger | undefined} + */ +let logger; + +/** + * @param {import('../../config').InstanaConfig} config + */ +function init(config) { + logger = config?.logger; +} + +/** + * @param {import('../../core').InstanaBaseSpan[]} spans + * @returns {Object} Payload matching { resourceSpans: [...] } + */ +function convert(spans) { + const otlpSpans = []; + let sampleResourceSpan = null; + + // TODO: consider parallel processing of spans in later phase + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + if (isLogSpan(span)) { + // TODO: Add log span converter + continue; + } + + try { + const mapper = mappers.get(span); + const otlpSpan = { + ...transformers.spanMetaData.extractSpanMetadata(span, mapper), + attributes: transformers.spanAttributes.extractSpanAttributes(span, mapper) + }; + + // All spans in the same process share the same resource + // Extract resource once from the first valid span + if (sampleResourceSpan === null) { + sampleResourceSpan = span; + } + + otlpSpans.push(otlpSpan); + } catch (error) { + logger?.debug('Failed to convert span to OTLP format.', error); + } + } + + if (otlpSpans.length === 0) { + return { resourceSpans: [] }; + } + + const resource = transformers.resource.extractResourceAttributes(sampleResourceSpan); + + return { + resourceSpans: [ + { + resource, + scopeSpans: [ + { + scope: INSTRUMENTATION_SCOPE, + spans: otlpSpans + } + ] + } + ] + }; +} + +module.exports = { + init, + convert +}; diff --git a/packages/core/src/otlpExporter/traces/index.js b/packages/core/src/otlpExporter/traces/index.js new file mode 100644 index 0000000000..f98374a6a9 --- /dev/null +++ b/packages/core/src/otlpExporter/traces/index.js @@ -0,0 +1,26 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const converter = require('./converter'); + +/** + * @param {import('../../config').InstanaConfig} config + */ +function init(config) { + converter.init(config); +} + +/** + * @param {import('../../core').InstanaBaseSpan[]} spans + */ +function transform(spans) { + return converter.convert(spans); +} + +module.exports = { + init, + transform +}; diff --git a/packages/core/src/otlpExporter/traces/mappers/constants.js b/packages/core/src/otlpExporter/traces/mappers/constants.js new file mode 100644 index 0000000000..fc7d661580 --- /dev/null +++ b/packages/core/src/otlpExporter/traces/mappers/constants.js @@ -0,0 +1,66 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { INSTRUMENTATION_SCOPE_NAME } = require('../../common/constants'); + +exports.INSTRUMENTATION_SCOPE_NAME = INSTRUMENTATION_SCOPE_NAME; + +exports.INSTRUMENTATION_TYPES = { + HTTP: 'http', + KAFKA: 'kafka', + RABBITMQ: 'rabbitmq', + NATS: 'nats', + BULL: 'bull', + SQS: 'sqs', + SNS: 'sns', + GCPS: 'gcps', + PG: 'pg', + MYSQL: 'mysql', + MSSQL: 'mssql', + MONGO: 'mongo', + PEER: 'peer', + REDIS: 'redis', + COUCHBASE: 'couchbase', + ELASTICSEARCH: 'elasticsearch', + DYNAMODB: 'dynamodb', + DB2: 'db2', + MEMCACHED: 'memcached', + PRISMA: 'prisma', + RPC: 'rpc', + GRAPHQL: 'graphql', + GCS: 'gcs', + S3: 's3', + KINESIS: 'kinesis', + AZSTORAGE: 'azstorage', + AWS_LAMBDA_INVOKE: 'aws.lambda.invoke' +}; + +exports.OTLP_STATUS_CODES = { + UNSET: 0, + OK: 1, + ERROR: 2 +}; + +exports.OTLP_SPAN_KINDS = { + UNSPECIFIED: 0, + INTERNAL: 1, + SERVER: 2, + CLIENT: 3, + PRODUCER: 4, + CONSUMER: 5 +}; + +exports.SPECIAL_SPAN_DATA_TYPES = { + RESOURCE: 'resource', + TAGS: 'tags', + OTEL: 'otel' +}; + +exports.INSTANA_SPAN_KINDS = { + ENTRY: 1, + EXIT: 2, + INTERMEDIATE: 3 +}; diff --git a/packages/core/src/otlpExporter/traces/mappers/index.js b/packages/core/src/otlpExporter/traces/mappers/index.js new file mode 100644 index 0000000000..83b31e2faf --- /dev/null +++ b/packages/core/src/otlpExporter/traces/mappers/index.js @@ -0,0 +1,19 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const instanaMappings = require('./instanaInstrumentationMappings'); +const otelMappings = require('./otelInstrumentationMappings'); + +/** + * @param {import('../../../core').InstanaBaseSpan} span + */ +function get(span) { + return otelMappings.isOtelSpan(span) ? otelMappings : instanaMappings; +} + +module.exports = { + get +}; diff --git a/packages/core/src/otlpExporter/traces/mappers/instanaInstrumentationMappings.js b/packages/core/src/otlpExporter/traces/mappers/instanaInstrumentationMappings.js new file mode 100644 index 0000000000..20ca6e1d61 --- /dev/null +++ b/packages/core/src/otlpExporter/traces/mappers/instanaInstrumentationMappings.js @@ -0,0 +1,510 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { toUpperCase, firstDefined, formatOTLPValue, combineFields, extractHost, extractPort } = require('./util'); + +const ctx = require('../../common/context'); +const { INSTRUMENTATION_TYPES, OTLP_STATUS_CODES, SPECIAL_SPAN_DATA_TYPES } = require('./constants'); + +const OTLP = /** @type {any} */ (ctx.semConv); + +/** + * @typedef {Object} OTLPFormattedValue + * @property {string} [stringValue] + * @property {number} [intValue] + * @property {number} [doubleValue] + * @property {boolean} [boolValue] + */ + +/** + * @typedef {Object} SpanAttribute + * @property {string} key + * @property {OTLPFormattedValue} value + */ + +/** + * @typedef {(values: any, spanData?: Record) => any} TransformFunction + */ + +/** + * @typedef {Object} AttributeMapping + * @property {string} otlp + * @property {string | string[]} [instana] + * @property {any} [value] + * @property {TransformFunction} [transform] + */ + +/** + * @typedef {(data: Record) => string} SpanNameFunction + */ + +/** + * @typedef {Object} InstrumentationMapping + * @property {SpanNameFunction} [spanName] + * @property {AttributeMapping[]} [spanAttributes] + */ + +/** + * @typedef {Record} InstrumentationMappings + */ + +/** @type {InstrumentationMappings} */ +const instrumentationMappings = { + [INSTRUMENTATION_TYPES.HTTP]: { + spanName: data => { + const method = data.operation.toUpperCase(); + return `${method} ${data.path_tpl || data.path || '/'}`; + }, + spanAttributes: [ + { otlp: OTLP.http.REQUEST_METHOD, instana: 'operation', transform: toUpperCase }, + { otlp: OTLP.http.URL_FULL, instana: 'endpoints' }, + { otlp: OTLP.http.URL_PATH, instana: 'path' }, + { otlp: OTLP.http.URL_QUERY, instana: 'params' }, + { otlp: OTLP.http.RESPONSE_STATUS, instana: 'status' }, + // TODO: Instana stores both request and response headers in the same `header` field. + // We need an internal mechanism to distinguish between request and response headers. + { otlp: OTLP.http.REQUEST_HEADER, instana: 'header' }, + { otlp: OTLP.http.URL_TEMPLATE, instana: 'path_tpl' }, + { otlp: OTLP.http.ROUTE, instana: 'route' }, + { otlp: OTLP.server.ADDRESS, instana: 'connection', transform: extractHost }, + { otlp: OTLP.server.PORT, instana: 'connection', transform: extractPort }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.KAFKA]: { + spanName: data => `${data.operation} ${data.endpoints}`, + spanAttributes: [ + { otlp: OTLP.messaging.SYSTEM, value: 'kafka' }, + { otlp: OTLP.messaging.DESTINATION_NAME, instana: 'endpoints' }, + { otlp: OTLP.messaging.OPERATION_NAME, instana: 'operation' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.RABBITMQ]: { + spanName: data => `${data.sort || 'process'} ${data.exchange || data.key || data.queue || 'unknown'}`, + spanAttributes: [ + { otlp: OTLP.messaging.SYSTEM, value: 'rabbitmq' }, + { otlp: OTLP.messaging.OPERATION_NAME, instana: 'sort' }, + { otlp: OTLP.messaging.DESTINATION_NAME, instana: ['exchange', 'key', 'queue'] }, + { otlp: OTLP.messaging.rabbitmq.ROUTING_KEY, instana: 'exchange' }, + { otlp: OTLP.messaging.rabbitmq.MESSAGE_ROUTING_KEY, instana: 'key' }, + { otlp: OTLP.server.ADDRESS, instana: 'address', transform: extractHost }, + { otlp: OTLP.server.PORT, instana: 'address', transform: extractPort }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.NATS]: { + spanName: data => `${data.sort || 'process'} ${data.subject || 'unknown'}`, + spanAttributes: [ + { otlp: OTLP.messaging.SYSTEM, value: 'nats' }, + { otlp: OTLP.messaging.DESTINATION_NAME, instana: 'subject' }, + { otlp: OTLP.messaging.OPERATION_NAME, instana: 'sort' }, + { otlp: OTLP.server.ADDRESS, instana: 'address', transform: extractHost }, + { otlp: OTLP.server.PORT, instana: 'address', transform: extractPort }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.BULL]: { + spanName: data => `${data.sort || 'process'} ${data.queue || 'unknown'}`, + spanAttributes: [ + { otlp: OTLP.messaging.SYSTEM, value: 'bull' }, + { otlp: OTLP.messaging.OPERATION_NAME, instana: 'sort' }, + { otlp: OTLP.messaging.DESTINATION_NAME, instana: 'queue' }, + { otlp: OTLP.messaging.MESSAGE_ID, instana: 'messageId' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.SQS]: { + spanName: data => `${data.type || data.sort || 'process'} ${data.queue || 'unknown'}`, + spanAttributes: [ + { otlp: OTLP.messaging.SYSTEM, value: OTLP.messaging.sqs?.SYSTEM || 'aws.sqs' }, + { otlp: OTLP.messaging.OPERATION_NAME, instana: 'type' }, + { otlp: OTLP.messaging.CONSUMER_GROUP, instana: 'group' }, + { otlp: OTLP.messaging.DESTINATION_NAME, instana: 'queue' }, + { otlp: OTLP.messaging.MESSAGE_BODY_SIZE, instana: 'size' }, + { otlp: OTLP.messaging.MESSAGE_ID, instana: 'messageId' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.SNS]: { + spanName: data => `publish ${data.topic || data.subject || 'unknown'}`, + spanAttributes: [ + { otlp: OTLP.messaging.SYSTEM, value: 'aws.sns' }, + { otlp: OTLP.messaging.DESTINATION_NAME, instana: 'topic' }, + { otlp: OTLP.messaging.OPERATION_NAME, value: 'send' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + // Entry and Exit + [INSTRUMENTATION_TYPES.GCPS]: { + spanName: data => `${data.op || 'process'} ${data.top || data.sub || 'unknown'}`, + spanAttributes: [ + { otlp: OTLP.messaging.SYSTEM, value: OTLP.messaging.gcp?.SYSTEM || 'gcp.pubsub' }, + { otlp: OTLP.messaging.OPERATION_NAME, instana: 'op' }, + { + otlp: OTLP.messaging.DESTINATION_NAME, + instana: ['top', 'sub'], + transform: firstDefined + }, + { otlp: OTLP.messaging.gcp.PROJECT_ID, instana: 'projid' }, + { otlp: OTLP.messaging.MESSAGE_ID, instana: 'messageId' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.PG]: { + spanName: data => data.stmt?.split(/\s+/)[0]?.toUpperCase() || 'POSTGRESQL', + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'postgresql' }, + { otlp: OTLP.database.QUERY_TEXT, instana: 'stmt' }, + { otlp: OTLP.database.USER, instana: 'user' }, + { otlp: OTLP.database.NAME, instana: 'db' }, + { otlp: OTLP.server.ADDRESS, instana: 'host' }, + { otlp: OTLP.server.PORT, instana: 'port' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.MYSQL]: { + spanName: data => data.stmt?.split(/\s+/)[0]?.toUpperCase() || 'MYSQL', + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'mysql' }, + { otlp: OTLP.database.QUERY_TEXT, instana: 'stmt' }, + { otlp: OTLP.database.USER, instana: 'user' }, + { otlp: OTLP.database.NAME, instana: 'db' }, + { otlp: OTLP.server.ADDRESS, instana: 'host' }, + { otlp: OTLP.server.PORT, instana: 'port' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.MSSQL]: { + spanName: data => data.stmt?.split(/\s+/)[0]?.toUpperCase() || 'MSSQL', + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'mssql' }, + { otlp: OTLP.database.QUERY_TEXT, instana: 'stmt' }, + { otlp: OTLP.database.USER, instana: 'user' }, + { otlp: OTLP.database.NAME, instana: 'db' }, + { otlp: OTLP.server.ADDRESS, instana: 'host' }, + { otlp: OTLP.server.PORT, instana: 'port' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.DB2]: { + spanName: data => data.stmt?.split(/\s+/)[0]?.toUpperCase() || 'DB2', + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'db2' }, + { otlp: OTLP.database.QUERY_TEXT, instana: 'stmt' }, + { otlp: OTLP.database.USER, instana: 'user' }, + { otlp: OTLP.database.NAME, instana: 'db' }, + { otlp: OTLP.server.ADDRESS, instana: 'host' }, + { otlp: OTLP.server.PORT, instana: 'port' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.MONGO]: { + spanName: data => `mongo.${data.command}`, + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'mongodb' }, + { otlp: OTLP.database.NAMESPACE, instana: 'namespace' }, + { otlp: OTLP.database.COLLECTION, instana: 'collection' }, + { otlp: OTLP.database.OPERATION, instana: 'command', transform: toUpperCase }, + { otlp: OTLP.database.QUERY_TEXT, instana: ['json', 'filter'], transform: firstDefined }, + { otlp: OTLP.server.ADDRESS, instana: 'service', transform: extractHost }, + { otlp: OTLP.server.PORT, instana: 'service', transform: extractPort }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.REDIS]: { + spanName: data => `redis.${data.operation || 'command'}`, + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'redis' }, + { otlp: OTLP.database.OPERATION, instana: 'operation' }, + { otlp: OTLP.database.CONNECTION, instana: 'connection' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.COUCHBASE]: { + spanName: data => `couchbase.${data.bucket || 'operation'}`, + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'other_nosql' }, + { otlp: OTLP.database.NAME, instana: 'bucket' }, + { otlp: OTLP.database.QUERY_TEXT, instana: 'sql' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.ELASTICSEARCH]: { + spanName: data => `elasticsearch.${data.action || 'request'}`, + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'elasticsearch' }, + { otlp: OTLP.database.OPERATION, instana: 'action' }, + { otlp: OTLP.database.NAME, instana: 'cluster' }, + { otlp: OTLP.database.COLLECTION, instana: 'index' }, + { otlp: OTLP.database.QUERY_TEXT, instana: 'query' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.DYNAMODB]: { + spanName: data => `dynamodb.${data.operation || 'request'}`, + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'dynamodb' }, + { otlp: OTLP.database.OPERATION, instana: 'operation' }, + { otlp: OTLP.cloud.REGION, instana: 'region' }, + { otlp: OTLP.database.COLLECTION, instana: 'table' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.MEMCACHED]: { + spanName: data => `memcached.${data.operation || 'command'}`, + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, value: 'memcached' }, + { otlp: OTLP.database.CONNECTION, instana: 'connection' }, + { otlp: OTLP.database.OPERATION, instana: 'operation' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + // Note: There is no official OpenTelemetry semantic convention for Prisma, and it is not covered in our RFD either. + // We can therefore adopt a generic database convention, using the Prisma provider as the database system identifier. + [INSTRUMENTATION_TYPES.PRISMA]: { + spanName: data => `prisma.${data.action || 'query'}`, + spanAttributes: [ + { otlp: OTLP.database.SYSTEM, instana: 'provider' }, + { otlp: OTLP.database.COLLECTION, instana: 'model' }, + { otlp: OTLP.database.OPERATION, instana: 'action' }, + { otlp: OTLP.database.CONNECTION, instana: 'url' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.RPC]: { + spanName: data => data.call || 'rpc.call', + spanAttributes: [ + { otlp: OTLP.rpc.METHOD, instana: 'call' }, + { otlp: OTLP.rpc.SYSTEM, instana: 'flavor' }, + { otlp: OTLP.network.PEER_NAME, instana: 'host' }, + { otlp: OTLP.network.PEER_PORT, instana: 'port' }, + { otlp: OTLP.rpc.GRPC_ERROR, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.GRAPHQL]: { + spanName: data => + data.operationName ? `${data.operationType || 'query'} ${data.operationName}` : data.operationType || 'graphql', + spanAttributes: [ + { otlp: OTLP.graphql.OPERATION_NAME, instana: 'operationName' }, + { otlp: OTLP.graphql.OPERATION_TYPE, instana: 'operationType' } + ] + }, + + [INSTRUMENTATION_TYPES.GCS]: { + spanName: data => `gcs.${data.op || 'operation'}`, + spanAttributes: [ + { otlp: OTLP.database.OPERATION, instana: 'op' }, + { otlp: OTLP.cloud.gcp.STORAGE_BUCKET, instana: 'bucket' }, + { otlp: OTLP.cloud.gcp.STORAGE_OBJECT, instana: 'object' }, + { otlp: OTLP.cloud.gcp.PROJECT_ID, instana: 'projectId' }, + { otlp: OTLP.cloud.gcp.STORAGE_SOURCE_BUCKET, instana: 'sourceBucket' }, + { otlp: OTLP.cloud.gcp.STORAGE_SOURCE_OBJECT, instana: 'sourceObject' }, + { otlp: OTLP.cloud.gcp.STORAGE_DESTINATION_BUCKET, instana: 'destinationBucket' }, + { otlp: OTLP.cloud.gcp.STORAGE_DESTINATION_OBJECT, instana: 'destinationObject' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.S3]: { + spanName: data => `s3.${data.op || 'operation'}`, + spanAttributes: [ + { otlp: OTLP.database.OPERATION, instana: 'op' }, + { otlp: OTLP.cloud.aws.S3_BUCKET, instana: 'bucket' }, + { otlp: OTLP.cloud.aws.S3_KEY, instana: 'key' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.KINESIS]: { + spanName: data => `kinesis.${data.op || 'operation'}`, + spanAttributes: [ + { otlp: OTLP.messaging.SYSTEM, value: 'aws.kinesis' }, + { otlp: OTLP.database.OPERATION, instana: 'op' }, + { otlp: OTLP.cloud.aws.KINESIS_STREAM, instana: 'stream' }, + { otlp: OTLP.messaging.DESTINATION_NAME, instana: 'stream' }, + { otlp: OTLP.messaging.DESTINATION_PARTITION_ID, instana: 'shard' }, + { otlp: OTLP.cloud.aws.KINESIS_EXPLICIT_HASH_KEY, instana: 'record' }, + { otlp: OTLP.cloud.aws.KINESIS_SHARD_ITERATOR_TYPE, instana: 'shardType' }, + { otlp: OTLP.cloud.aws.KINESIS_STARTING_SEQUENCE_NUMBER, instana: 'startSequenceNumber' }, + { otlp: OTLP.cloud.aws.KINESIS_SHARD, instana: 'shard' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.AZSTORAGE]: { + spanName: data => `azure.storage.${data.op || 'operation'}`, + spanAttributes: [ + { otlp: OTLP.cloud.PROVIDER, value: 'azure' }, + { otlp: OTLP.database.OPERATION, instana: 'op' }, + { otlp: OTLP.cloud.azure.STORAGE_ACCOUNT, instana: 'accountName' }, + { otlp: OTLP.cloud.azure.CONTAINER, instana: 'containerName' }, + { otlp: OTLP.cloud.azure.BLOB, instana: 'blobName' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + [INSTRUMENTATION_TYPES.AWS_LAMBDA_INVOKE]: { + spanName: data => (data.function ? `Invoke ${data.function}` : 'Lambda Invoke'), + spanAttributes: [ + { otlp: OTLP.faas.NAME, instana: 'function' }, + { otlp: OTLP.faas.INVOCATION_TYPE, instana: 'type' }, + { otlp: OTLP.error.TYPE, instana: 'error' } + ] + }, + + // PEER is special - only has attributes, no span name + [INSTRUMENTATION_TYPES.PEER]: { + spanAttributes: [ + { otlp: OTLP.network.PEER_NAME, instana: 'hostname' }, + { otlp: OTLP.network.PEER_PORT, instana: 'port' } + ] + } +}; + +/** + * @param {import('../../../core').InstanaBaseSpan} span + * @returns {string|null} + */ +function getSpanType(span) { + if (!span || !span.data) { + return null; + } + + const key = Object.keys(span.data).find( + k => k !== INSTRUMENTATION_TYPES.PEER && k !== SPECIAL_SPAN_DATA_TYPES.RESOURCE + ); + + return key || null; +} + +/** + * + * @param {AttributeMapping} mapping + * @param {Record} spanData + * @returns {SpanAttribute|null} + */ +function applyMapping(mapping, spanData) { + if (!mapping) return null; + + let value; + + if (mapping.value !== undefined && !mapping.instana) { + value = mapping.value; + } else if (Array.isArray(mapping.instana)) { + const values = mapping.instana.map(k => spanData?.[k]); + + value = mapping.transform ? mapping.transform(values, spanData) : combineFields(spanData, mapping.instana); + } else if (typeof mapping.instana === 'string') { + const rawValue = spanData?.[mapping.instana]; + + if (rawValue === null || rawValue === undefined) { + return null; + } + + value = mapping.transform ? mapping.transform(rawValue, spanData) : rawValue; + } else { + return null; + } + + if (value === null || value === undefined) { + return null; + } + + return { + key: mapping.otlp, + value: formatOTLPValue(value) + }; +} + +module.exports = { + /** + * @param {import('../../../core').InstanaBaseSpan} span + */ + spanName(span) { + const type = getSpanType(span); + const handler = instrumentationMappings[type]?.spanName; + const spanData = type ? span.data?.[type] : null; + + if (typeof handler === 'function' && spanData) { + return handler(spanData); + } + + return span?.n || type || 'unknown'; + }, + + /** @param {import('../../../core').InstanaBaseSpan} span */ + spanAttributes(span) { + const attributes = []; + const spanTypes = Object.keys(span.data || {}); + + for (let i = 0; i < spanTypes.length; i++) { + const spanType = spanTypes[i]; + + if (spanType === SPECIAL_SPAN_DATA_TYPES.RESOURCE) { + continue; + } + + const handler = instrumentationMappings[spanType]?.spanAttributes; + const spanData = span.data[spanType]; + + if (!Array.isArray(handler) || !spanData) { + continue; + } + + for (let j = 0; j < handler.length; j++) { + const attribute = applyMapping(handler[j], spanData); + + if (attribute) { + attributes.push(attribute); + } + } + } + + return attributes; + }, + + /** + * @param {import('../../../core').InstanaBaseSpan} span + */ + spanStatus(span) { + const type = getSpanType(span); + const data = type ? span.data?.[type] : null; + + // Special case: HTTP client 4xx responses are reported as errors according to OTel semantic conventions, even + // though Instana leaves span.ec unset for these responses. We handled it here. + // Remove this handling once INSTA-98209 is implemented. + const shouldReportHttpClientAsError = span.n === 'node.http.client' && data?.status >= 400 && data?.status < 500; + + if (!span?.ec && !shouldReportHttpClientAsError) { + return { code: OTLP_STATUS_CODES.UNSET }; + } + + return { + code: OTLP_STATUS_CODES.ERROR, + message: String(data?.error || `${type || span.n || 'operation'} failed`) + }; + } +}; diff --git a/packages/core/src/otlpExporter/traces/mappers/otelInstrumentationMappings.js b/packages/core/src/otlpExporter/traces/mappers/otelInstrumentationMappings.js new file mode 100644 index 0000000000..2da3fe473f --- /dev/null +++ b/packages/core/src/otlpExporter/traces/mappers/otelInstrumentationMappings.js @@ -0,0 +1,133 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { formatOTLPValue } = require('./util'); +const { OTLP_STATUS_CODES, SPECIAL_SPAN_DATA_TYPES } = require('./constants'); + +const OTEL_SPAN_NAME = SPECIAL_SPAN_DATA_TYPES.OTEL; + +/** + * @typedef {Object} OtelMapping + * @property {string} otlp + * @property {any} [value] + * @property {(data: any) => any} [transform] + */ + +/** + * @typedef {Object} OtelInstrumentationMapping + * @property {OtelMapping[]} [spanAttributes] + */ + +/** @type {Record} */ +const instrumentationMappings = { + operation: { + spanAttributes: [{ otlp: 'operation' }] + } +}; + +/** + * @param {import('../../../core').InstanaBaseSpan} span + * @returns {boolean} + */ +function isOtelSpan(span) { + return span.n === OTEL_SPAN_NAME; +} + +/** + * @param {OtelMapping} mapping + * @param {any} spanData + */ +function applyMapping(mapping, spanData) { + if (!mapping) { + return null; + } + + let value = mapping.value; + + if (value === undefined) { + if (spanData === null || spanData === undefined) { + return null; + } + + value = mapping.transform ? mapping.transform(spanData) : spanData; + } + + if (value === null || value === undefined) { + return null; + } + + return { + key: mapping.otlp, + value: formatOTLPValue(value) + }; +} + +module.exports = { + OTEL_SPAN_NAME, + isOtelSpan, + /** @param {import('../../../core').InstanaBaseSpan} span */ + spanName(span) { + return span.n || 'unknown'; + }, + /** @param {import('../../../core').InstanaBaseSpan} span */ + spanAttributes(span) { + const attributes = []; + const spanTypes = Object.keys(span.data || {}); + + for (let i = 0; i < spanTypes.length; i++) { + const spanType = spanTypes[i]; + + if (spanType === SPECIAL_SPAN_DATA_TYPES.RESOURCE) { + continue; + } + + const spanData = span.data[spanType]; + + if (spanType === SPECIAL_SPAN_DATA_TYPES.TAGS && spanData) { + const tagKeys = Object.keys(spanData); + + for (let j = 0; j < tagKeys.length; j++) { + const key = tagKeys[j]; + + attributes.push({ + key, + value: formatOTLPValue(spanData[key]) + }); + } + + continue; + } + + const handler = instrumentationMappings[spanType]?.spanAttributes; + + if (!Array.isArray(handler) || spanData === null || spanData === undefined) { + continue; + } + + for (let j = 0; j < handler.length; j++) { + const attribute = applyMapping(handler[j], spanData); + + if (attribute) { + attributes.push(attribute); + } + } + } + + return attributes; + }, + + /** @param {import('../../../core').InstanaBaseSpan} span */ + spanStatus(span) { + if (!span?.ec) { + return { code: OTLP_STATUS_CODES.UNSET }; + } + + return { + code: OTLP_STATUS_CODES.ERROR, + message: String(span.data?.tags?.error || `${span?.n || 'operation'} failed`) + }; + } +}; diff --git a/packages/core/src/otlpExporter/traces/mappers/util.js b/packages/core/src/otlpExporter/traces/mappers/util.js new file mode 100644 index 0000000000..57613e89e9 --- /dev/null +++ b/packages/core/src/otlpExporter/traces/mappers/util.js @@ -0,0 +1,108 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * @param {any} str + * @returns {string} + */ +function toUpperCase(str) { + return typeof str === 'string' ? str.toUpperCase() : ''; +} + +/** + * @param {any[] | any} values + * @returns {any} + */ +function firstDefined(values) { + if (!Array.isArray(values)) { + return values != null ? values : undefined; + } + return values.find(v => v != null); +} + +/** + * @param {any[]} values + * @returns {string} + */ +function joinWith(values) { + return values.filter(v => v != null).join(':'); +} + +/** + * @param {Record} data + * @param {string[]} keys + * @returns {string} + */ +function combineFields(data, keys) { + if (!data || !Array.isArray(keys)) return ''; + /** @type {any[]} */ + const parts = []; + keys.forEach(key => { + if (data[key] !== undefined && data[key] !== null) { + parts.push(data[key]); + } + }); + return parts.join('.'); +} + +/** + * @param {any} value + * @returns {{ stringValue?: string, intValue?: number, doubleValue?: number, boolValue?: boolean }} + */ +function formatOTLPValue(value) { + const type = typeof value; + if (type === 'string') return { stringValue: value }; + if (type === 'number') { + return Number.isInteger(value) ? { intValue: value } : { doubleValue: value }; + } + if (type === 'boolean') return { boolValue: value }; + if (type === 'object' && value !== null) return { stringValue: JSON.stringify(value) }; + return { stringValue: String(value) }; +} + +/** + * @param {string | URL} connection + * @returns {{ host?: string, port?: number }} + */ +function parseConnection(connection) { + if (!connection) { + return {}; + } + + try { + const connectionStr = typeof connection === 'string' ? connection : connection.toString(); + const url = new URL(connectionStr.includes('://') ? connectionStr : `http://${connectionStr}`); + + return { + host: url.hostname, + port: url.port ? Number(url.port) : undefined + }; + } catch { + return {}; + } +} + +/** + * @param {string | URL} connection + * @returns {string | undefined} + */ +const extractHost = connection => parseConnection(connection).host; + +/** + * @param {string | URL} connection + * @returns {number | undefined} + */ +const extractPort = connection => parseConnection(connection).port; + +module.exports = { + toUpperCase, + firstDefined, + joinWith, + combineFields, + formatOTLPValue, + extractHost, + extractPort +}; diff --git a/packages/core/src/otlpExporter/traces/transformers/index.js b/packages/core/src/otlpExporter/traces/transformers/index.js new file mode 100644 index 0000000000..40a1c355a8 --- /dev/null +++ b/packages/core/src/otlpExporter/traces/transformers/index.js @@ -0,0 +1,15 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const resource = require('../../common/transformers/resource'); +const spanMetaData = require('./spanMetaData'); +const spanAttributes = require('./spanAttributes'); + +module.exports = { + resource, + spanMetaData, + spanAttributes +}; diff --git a/packages/core/src/otlpExporter/traces/transformers/spanAttributes.js b/packages/core/src/otlpExporter/traces/transformers/spanAttributes.js new file mode 100644 index 0000000000..e30603ca0d --- /dev/null +++ b/packages/core/src/otlpExporter/traces/transformers/spanAttributes.js @@ -0,0 +1,27 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * @typedef {Object} AttributeMapper + * @property {function(import('../../../core').InstanaBaseSpan): Array<{ key: string, value: any }>} spanAttributes + */ + +/** + * @param {import('../../../core').InstanaBaseSpan} span + * @param {AttributeMapper} mapper + * @returns {Array<{ key: string, value: any }>} + */ +function extractSpanAttributes(span, mapper) { + if (!span?.data) { + return []; + } + + return mapper.spanAttributes(span); +} + +module.exports = { + extractSpanAttributes +}; diff --git a/packages/core/src/otlpExporter/traces/transformers/spanMetaData.js b/packages/core/src/otlpExporter/traces/transformers/spanMetaData.js new file mode 100644 index 0000000000..9badd10b37 --- /dev/null +++ b/packages/core/src/otlpExporter/traces/transformers/spanMetaData.js @@ -0,0 +1,131 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const ctx = require('../../common/context'); +const { OTLP_SPAN_KINDS, INSTANA_SPAN_KINDS } = require('../mappers/constants'); + +const metaMapper = { + /** + * @param {import('../../../core').InstanaBaseSpan} span + * @returns {string | undefined} + */ + convertTraceId(span) { + if (span.t === undefined) return undefined; + if (!span.t) return ''; + return String(span.t).padStart(32, '0'); + }, + + /** + * @param {import('../../../core').InstanaBaseSpan} span + * @returns {string | undefined} + */ + convertSpanId(span) { + if (span.s === undefined) return undefined; + if (!span.s) return ''; + return String(span.s).padStart(16, '0'); + }, + + /** + * @param {import('../../../core').InstanaBaseSpan} span + * @returns {string | undefined} + */ + convertParentId(span) { + if (span.p === undefined) return undefined; + if (!span.p) return ''; + return String(span.p).padStart(16, '0'); + }, + + /** + * @param {import('../../../core').InstanaBaseSpan} span + * @returns {number | undefined} + */ + convertSpanKind(span) { + if (span.k === INSTANA_SPAN_KINDS.ENTRY) { + return OTLP_SPAN_KINDS.SERVER; + } + if (span.k === INSTANA_SPAN_KINDS.EXIT) { + return OTLP_SPAN_KINDS.CLIENT; + } + + if (span.k === INSTANA_SPAN_KINDS.INTERMEDIATE) { + return OTLP_SPAN_KINDS.INTERNAL; + } + + return OTLP_SPAN_KINDS.UNSPECIFIED; + }, + + /** + * @param {import('../../../core').InstanaBaseSpan} span + * @returns {string | undefined} + */ + convertStartTime(span) { + if (span.ts === undefined) return undefined; + return String(Number(span.ts) * 1000000); + }, + + /** + * @param {import('../../../core').InstanaBaseSpan} span + * @returns {string} + */ + generateEndTime(span) { + const startMs = span.ts !== undefined ? Number(span.ts) : 0; + const deltaMs = span.d !== undefined ? Number(span.d) : 0; + return String((startMs + deltaMs) * 1000000); + }, + + /** + * TODO: currently not supported in Instana and not added in the payload + * @returns {any[]} + */ + events() { + return []; + }, + + /** + * TODO: currently not supported in Instana and not added in the payload + * @returns {any[]} + */ + links() { + return []; + } +}; + +/** + * @typedef {Object} SpanMapper + * @property {function(import('../../../core').InstanaBaseSpan): string} spanName + * @property {function(import('../../../core').InstanaBaseSpan): { code: number, message?: string }} spanStatus + */ + +/** + * @param {import('../../../core').InstanaBaseSpan} span + * @param {SpanMapper} mapper + * @returns {Record} + */ +function extractSpanMetadata(span, mapper) { + const OTLP = /** @type {any} */ (ctx.semConv); + + const metadataMappings = [ + { otlp: OTLP.metadata.TRACE_ID, value: metaMapper.convertTraceId(span) }, + { otlp: OTLP.metadata.SPAN_ID, value: metaMapper.convertSpanId(span) }, + { otlp: OTLP.metadata.PARENT_ID, value: metaMapper.convertParentId(span) }, + { otlp: OTLP.metadata.SPAN_KIND, value: metaMapper.convertSpanKind(span) }, + { otlp: OTLP.metadata.START_TIME_UNIX_NANO, value: metaMapper.convertStartTime(span) }, + { otlp: OTLP.metadata.END_TIME_UNIX_NANO, value: metaMapper.generateEndTime(span) }, + { otlp: OTLP.metadata.NAME, value: mapper.spanName(span) }, + { otlp: OTLP.metadata.STATUS, value: mapper.spanStatus(span) } + ]; + + return metadataMappings.reduce((/** @type {Record} */ acc, current) => { + if (current.value !== undefined) { + acc[current.otlp] = current.value; + } + return acc; + }, {}); +} + +module.exports = { + extractSpanMetadata +}; diff --git a/packages/core/src/otlpExporter/traces/util.js b/packages/core/src/otlpExporter/traces/util.js new file mode 100644 index 0000000000..3fb0e8db6b --- /dev/null +++ b/packages/core/src/otlpExporter/traces/util.js @@ -0,0 +1,21 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * Fast interceptor to identify log frames on the hot path + * @param {import('../../core').InstanaBaseSpan} span + * @returns {boolean} + */ +function isLogSpan(span) { + if (!span) return false; + if (span.data && span.data.log) return true; + if (span.n && typeof span.n === 'string' && span.n.startsWith('log.')) return true; + return false; +} + +module.exports = { + isLogSpan +}; diff --git a/packages/core/src/tracing/spanBuffer.js b/packages/core/src/tracing/spanBuffer.js index 8f4f6f2002..e703aca302 100644 --- a/packages/core/src/tracing/spanBuffer.js +++ b/packages/core/src/tracing/spanBuffer.js @@ -7,6 +7,7 @@ const tracingMetrics = require('./metrics'); const instanaBackendMapper = require('./backend_mappers'); +const otlpExporter = require('../otlpExporter'); /** @type {import('../core').GenericLogger} */ let logger; @@ -35,6 +36,8 @@ let preActivationCleanupIntervalHandle; let isFaaS; /** @type {boolean} */ let transmitImmediate; +/** @type {boolean} */ +let useOtlpExporter = false; /** @type {Array.} */ let spans = []; @@ -92,6 +95,7 @@ exports.init = function init(config, _downstreamConnection) { batchingEnabled = config.tracing.spanBatchingEnabled; isFaaS = false; transmitImmediate = false; + useOtlpExporter = config.tracing.otlp.enabled; if (config.tracing.activateImmediately) { preActivationCleanupIntervalHandle = setInterval(() => { @@ -120,6 +124,7 @@ exports.activate = function activate(_config) { } batchingEnabled = _config.tracing.spanBatchingEnabled; + useOtlpExporter = _config.tracing.otlp?.enabled; isActive = true; if (activatedAt == null) { @@ -502,10 +507,18 @@ function removeSpansIfNecessary() { /** * Transforms internal spans into the format required by the configured exporter. + * Depending on configuration, spans are transformed to either: + * - Instana backend format + * - OTLP format + * * @param {import('../core').InstanaBaseSpan[]} spansToSend * @returns {any} */ function applySpanTransformation(spansToSend) { + if (useOtlpExporter) { + return otlpExporter.traces.transform(spansToSend); + } + return ( spansToSend // Transform internal span data format into external (backend) readable format. diff --git a/packages/core/test/config/normalizeConfig_test.js b/packages/core/test/config/normalizeConfig_test.js index 5e27e2805a..26174385ac 100644 --- a/packages/core/test/config/normalizeConfig_test.js +++ b/packages/core/test/config/normalizeConfig_test.js @@ -45,6 +45,7 @@ describe('config.normalizeConfig', () => { delete process.env.INSTANA_IGNORE_ENDPOINTS; delete process.env.INSTANA_IGNORE_ENDPOINTS_PATH; delete process.env.INSTANA_IGNORE_ENDPOINTS_DISABLE_SUPPRESSION; + delete process.env.INSTANA_TRACING_OTLP_ENABLED; } describe('default configuration', () => { @@ -1529,6 +1530,293 @@ describe('config.normalizeConfig', () => { }); }); + describe('OTLP exporter configuration', () => { + it('should use default OTLP configuration when neither env nor config is set', () => { + const config = coreConfig.normalize({}); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should enable OTLP exporter via config', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: true, + port: 7908 + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: true, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should disable OTLP exporter via config', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: false + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should normalize string true from config', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: 'true' + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: true, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should normalize string false from config', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: 'false' + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should normalize numeric string 1 from config', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: '1' + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: true, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should normalize numeric string 0 from config', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: '0' + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should give precedence to INSTANA_TRACING_OTLP_ENABLED env var set to true over config set to false', () => { + process.env.INSTANA_TRACING_OTLP_ENABLED = 'true'; + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: false + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: true, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should give precedence to INSTANA_TRACING_OTLP_ENABLED env var set to false over config set to true', () => { + process.env.INSTANA_TRACING_OTLP_ENABLED = 'false'; + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: true + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should normalize string 1 from env var', () => { + process.env.INSTANA_TRACING_OTLP_ENABLED = '1'; + const config = coreConfig.normalize(); + expect(config.tracing.otlp).to.deep.equal({ + enabled: true, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should normalize string 0 from env var', () => { + process.env.INSTANA_TRACING_OTLP_ENABLED = '0'; + const config = coreConfig.normalize(); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should fall back to config when INSTANA_TRACING_OTLP_ENABLED is invalid', () => { + process.env.INSTANA_TRACING_OTLP_ENABLED = 'invalid'; + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: true + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: true, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should fall back to default when both env and config are invalid', () => { + process.env.INSTANA_TRACING_OTLP_ENABLED = 'invalid'; + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: 'invalid' + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should use default when config tracing.otlp is undefined', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: undefined + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should use default when config tracing.otlp is null', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: null + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should use default when config tracing.otlp.enabled is undefined', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: undefined + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should use default when config tracing.otlp.enabled is null', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: null + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + + it('should ignore unsupported object values for config tracing.otlp.enabled', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + otlp: { + enabled: {} + } + } + } + }); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); + }); + }); + describe('preloadOpentelemetry', () => { it('preloadOpentelemetry should default to false', () => { const config = coreConfig.normalize({}); @@ -2125,6 +2413,11 @@ describe('config.normalizeConfig', () => { expect(config.tracing.useOpentelemetry).to.equal(true); expect(config.tracing.allowRootExitSpan).to.equal(false); + expect(config.tracing.otlp).to.deep.equal({ + enabled: false, + port: 4318, + semConvVersion: '1.23' + }); expect(config.preloadOpentelemetry).to.equal(false); expect(config.secrets).to.be.an('object'); diff --git a/packages/core/test/otlpExporter/common/semconv_test.js b/packages/core/test/otlpExporter/common/semconv_test.js new file mode 100644 index 0000000000..eca35f1628 --- /dev/null +++ b/packages/core/test/otlpExporter/common/semconv_test.js @@ -0,0 +1,555 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; + +const { merge } = require('../../../src/otlpExporter/common/semconv/merge'); + +describe('otlpExporter/common/semconv', () => { + describe('merge function', () => { + describe('basic merging', () => { + it('should merge two simple objects without conflicts', () => { + const base = { a: 1, b: 2 }; + const overrides = { c: 3 }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ a: 1, b: 2, c: 3 }); + }); + + it('should override base property values when conflicts exist', () => { + const base = { a: 1, b: 2 }; + const overrides = { b: 99 }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ a: 1, b: 99 }); + }); + + it('should correctly apply multiple property overrides simultaneously', () => { + const base = { a: 1, b: 2, c: 3 }; + const overrides = { b: 20, c: 30 }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ a: 1, b: 20, c: 30 }); + }); + }); + + describe('Nested object merging', () => { + it('should recursively merge nested objects while preserving structure', () => { + const base = { + level1: { + a: 1, + b: 2 + } + }; + const overrides = { + level1: { + c: 3 + } + }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ + level1: { + a: 1, + b: 2, + c: 3 + } + }); + }); + + it('should override values within nested objects correctly', () => { + const base = { + level1: { + a: 1, + b: 2 + } + }; + const overrides = { + level1: { + b: 99 + } + }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ + level1: { + a: 1, + b: 99 + } + }); + }); + + it('should handle deeply nested object hierarchies (3+ levels)', () => { + const base = { + level1: { + level2: { + level3: { + a: 1 + } + } + } + }; + const overrides = { + level1: { + level2: { + level3: { + b: 2 + } + } + } + }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ + level1: { + level2: { + level3: { + a: 1, + b: 2 + } + } + } + }); + }); + + it('should merge multiple nested object branches simultaneously', () => { + const base = { + http: { method: 'GET', status: 200 }, + db: { system: 'postgresql' } + }; + const overrides = { + http: { url: '/api/users' }, + messaging: { system: 'kafka' } + }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ + http: { method: 'GET', status: 200, url: '/api/users' }, + db: { system: 'postgresql' }, + messaging: { system: 'kafka' } + }); + }); + }); + + describe('Array handling', () => { + it('should replace arrays completely instead of merging elements', () => { + const base = { arr: [1, 2, 3] }; + const overrides = { arr: [4, 5] }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ arr: [4, 5] }); + }); + + it('should replace arrays within nested object structures', () => { + const base = { + data: { + items: [1, 2, 3] + } + }; + const overrides = { + data: { + items: [4, 5] + } + }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ + data: { + items: [4, 5] + } + }); + }); + + describe('getLookupConfig', () => { + const { getLookupConfig } = require('../../../src/otlpExporter/common/semconv'); + + describe('version switching', () => { + it('should return version 1.23 configuration when no version is specified', () => { + const config = getLookupConfig(); + + expect(config).to.be.an('object'); + expect(config).to.have.property('http'); + expect(config).to.have.property('metadata'); + expect(config).to.have.property('resource'); + }); + + it('should return version 1.23 configuration when explicitly requested', () => { + const config = getLookupConfig('1.23'); + + expect(config).to.be.an('object'); + expect(config).to.have.property('http'); + expect(config).to.have.property('metadata'); + expect(config).to.have.property('resource'); + }); + + it('should return version 1.41 configuration when explicitly requested', () => { + const config = getLookupConfig('1.41'); + + expect(config).to.be.an('object'); + expect(config).to.have.property('http'); + expect(config).to.have.property('metadata'); + expect(config).to.have.property('resource'); + }); + + it('should throw error for unknown semantic convention version', () => { + expect(() => getLookupConfig('2.0')).to.throw('Unknown semantic convention version: 2.0'); + }); + + it('should throw error for invalid version format', () => { + expect(() => getLookupConfig('invalid')).to.throw('Unknown semantic convention version: invalid'); + }); + + it('should return different configurations for different versions', () => { + const config123 = getLookupConfig('1.23'); + const config141 = getLookupConfig('1.41'); + + // Configurations should be different objects + expect(config123).to.not.equal(config141); + }); + + it('should have consistent metadata structure across versions', () => { + const config123 = getLookupConfig('1.23'); + const config141 = getLookupConfig('1.41'); + + // Both should have metadata with core fields + expect(config123.metadata).to.have.property('TRACE_ID'); + expect(config123.metadata).to.have.property('SPAN_ID'); + expect(config123.metadata).to.have.property('PARENT_ID'); + + expect(config141.metadata).to.have.property('TRACE_ID'); + expect(config141.metadata).to.have.property('SPAN_ID'); + expect(config141.metadata).to.have.property('PARENT_ID'); + }); + + it('should have consistent resource structure across versions', () => { + const config123 = getLookupConfig('1.23'); + const config141 = getLookupConfig('1.41'); + + expect(config123.resource).to.have.property('SERVICE_NAME'); + expect(config123.resource).to.have.property('SDK_LANGUAGE'); + expect(config123.resource).to.have.property('SDK_NAME'); + + expect(config141.resource).to.have.property('SERVICE_NAME'); + expect(config141.resource).to.have.property('SDK_LANGUAGE'); + expect(config141.resource).to.have.property('SDK_NAME'); + }); + + it('should return frozen/immutable configuration objects', () => { + const config123 = getLookupConfig('1.23'); + const config141 = getLookupConfig('1.41'); + + expect(Object.isFrozen(config123)).to.be.true; + expect(Object.isFrozen(config141)).to.be.true; + }); + + it('should cache and return same instance for repeated calls with same version', () => { + const config1 = getLookupConfig('1.23'); + const config2 = getLookupConfig('1.23'); + + expect(config1).to.equal(config2); + }); + + it('should have http semantic conventions in both versions', () => { + const config123 = getLookupConfig('1.23'); + const config141 = getLookupConfig('1.41'); + + expect(config123.http).to.be.an('object'); + expect(config141.http).to.be.an('object'); + + expect(config123.http).to.have.property('REQUEST_METHOD'); + expect(config141.http).to.have.property('REQUEST_METHOD'); + }); + + it('should have database semantic conventions in both versions', () => { + const config123 = getLookupConfig('1.23'); + const config141 = getLookupConfig('1.41'); + + expect(config123.database).to.be.an('object'); + expect(config141.database).to.be.an('object'); + + expect(config123.database).to.have.property('SYSTEM'); + expect(config141.database).to.have.property('SYSTEM'); + }); + + it('should have messaging semantic conventions in both versions', () => { + const config123 = getLookupConfig('1.23'); + const config141 = getLookupConfig('1.41'); + + expect(config123.messaging).to.be.an('object'); + expect(config141.messaging).to.be.an('object'); + + expect(config123.messaging).to.have.property('SYSTEM'); + expect(config141.messaging).to.have.property('SYSTEM'); + }); + + it('should handle version as string with proper type checking', () => { + const config = getLookupConfig('1.23'); + expect(config).to.be.an('object'); + + expect(() => getLookupConfig('1.41')).to.not.throw(); + }); + + it('should maintain attribute naming consistency for metadata fields', () => { + const config123 = getLookupConfig('1.23'); + const config141 = getLookupConfig('1.41'); + + expect(config123.metadata.TRACE_ID).to.equal('traceId'); + expect(config141.metadata.TRACE_ID).to.equal('traceId'); + + expect(config123.metadata.SPAN_ID).to.equal('spanId'); + expect(config141.metadata.SPAN_ID).to.equal('spanId'); + + expect(config123.metadata.PARENT_ID).to.equal('parentSpanId'); + expect(config141.metadata.PARENT_ID).to.equal('parentSpanId'); + }); + + it('should support switching between versions multiple times', () => { + const config123First = getLookupConfig('1.23'); + const config141First = getLookupConfig('1.41'); + const config123Second = getLookupConfig('1.23'); + const config141Second = getLookupConfig('1.41'); + expect(config123First).to.equal(config123Second); + expect(config141First).to.equal(config141Second); + + expect(config123First).to.not.equal(config141First); + }); + }); + }); + }); + + describe('Edge cases and boundary conditions', () => { + it('should return a frozen copy of base when overrides is null', () => { + const base = { a: 1, b: 2 }; + const result = merge(base, null); + + expect(result).to.deep.equal({ a: 1, b: 2 }); + expect(Object.isFrozen(result)).to.be.true; + }); + + it('should return a frozen copy of base when overrides is undefined', () => { + const base = { a: 1, b: 2 }; + const result = merge(base, undefined); + + expect(result).to.deep.equal({ a: 1, b: 2 }); + expect(Object.isFrozen(result)).to.be.true; + }); + + it('should return a frozen copy of base when overrides is an empty object', () => { + const base = { a: 1, b: 2 }; + const result = merge(base, {}); + + expect(result).to.deep.equal({ a: 1, b: 2 }); + expect(Object.isFrozen(result)).to.be.true; + }); + + it('should successfully merge when base object is empty', () => { + const base = {}; + const overrides = { a: 1 }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ a: 1 }); + }); + + it('should correctly handle null values in override properties', () => { + const base = { a: 1, b: 2 }; + const overrides = { b: null }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ a: 1, b: null }); + }); + + it('should correctly handle undefined values in override properties', () => { + const base = { a: 1, b: 2 }; + const overrides = { b: undefined }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ a: 1, b: undefined }); + }); + + it('should correctly merge boolean values', () => { + const base = { flag: false }; + const overrides = { flag: true }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ flag: true }); + }); + + it('should correctly handle numeric values including zero', () => { + const base = { count: 10 }; + const overrides = { count: 0 }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ count: 0 }); + }); + + it('should correctly handle string values including empty strings', () => { + const base = { name: 'test' }; + const overrides = { name: '' }; + const result = merge(base, overrides); + + expect(result).to.deep.equal({ name: '' }); + }); + }); + + describe('Immutability guarantees', () => { + it('should return a deeply frozen immutable object', () => { + const base = { a: 1 }; + const overrides = { b: 2 }; + const result = merge(base, overrides); + + expect(Object.isFrozen(result)).to.be.true; + }); + + it('should not mutate the original base object', () => { + const base = { a: 1, b: 2 }; + const baseCopy = { ...base }; + const overrides = { c: 3 }; + + merge(base, overrides); + + expect(base).to.deep.equal(baseCopy); + }); + + it('should not mutate the original overrides object', () => { + const base = { a: 1 }; + const overrides = { b: 2 }; + const overridesCopy = { ...overrides }; + + merge(base, overrides); + + expect(overrides).to.deep.equal(overridesCopy); + }); + + it('should recursively freeze all nested objects', () => { + const base = { nested: { a: 1 } }; + const overrides = { nested: { b: 2 } }; + const result = merge(base, overrides); + + expect(Object.isFrozen(result.nested)).to.be.true; + }); + + it('should throw error when attempting to modify the returned object', () => { + const base = { a: 1 }; + const overrides = { b: 2 }; + const result = merge(base, overrides); + + expect(() => { + result.c = 3; + }).to.throw(); + }); + }); + + describe('Complex real-world scenarios', () => { + it('should merge OpenTelemetry semantic convention attribute mappings', () => { + const base = { + http: { + method: 'http.method', + status_code: 'http.status_code', + url: 'http.url' + }, + db: { + system: 'db.system', + statement: 'db.statement' + } + }; + + const overrides = { + http: { + route: 'http.route', + status_code: 'http.response.status_code' // Override for newer version + }, + messaging: { + system: 'messaging.system' + } + }; + + const result = merge(base, overrides); + + expect(result).to.deep.equal({ + http: { + method: 'http.method', + status_code: 'http.response.status_code', + url: 'http.url', + route: 'http.route' + }, + db: { + system: 'db.system', + statement: 'db.statement' + }, + messaging: { + system: 'messaging.system' + } + }); + }); + + it('should handle configuration objects with mixed data types', () => { + const base = { + config: { + enabled: true, + timeout: 5000, + endpoints: ['http://localhost:8080'] + } + }; + + const overrides = { + config: { + timeout: 10000, + retries: 3 + } + }; + + const result = merge(base, overrides); + + expect(result).to.deep.equal({ + config: { + enabled: true, + timeout: 10000, + endpoints: ['http://localhost:8080'], + retries: 3 + } + }); + }); + }); + + describe('Type preservation', () => { + it('should preserve string data types during merge', () => { + const base = { str: 'hello' }; + const overrides = { str: 'world' }; + const result = merge(base, overrides); + + expect(result.str).to.be.a('string'); + expect(result.str).to.equal('world'); + }); + + it('should preserve number data types during merge', () => { + const base = { num: 42 }; + const overrides = { num: 100 }; + const result = merge(base, overrides); + + expect(result.num).to.be.a('number'); + expect(result.num).to.equal(100); + }); + + it('should preserve boolean data types during merge', () => { + const base = { bool: false }; + const overrides = { bool: true }; + const result = merge(base, overrides); + + expect(result.bool).to.be.a('boolean'); + expect(result.bool).to.equal(true); + }); + + it('should preserve array data types during merge', () => { + const base = { arr: [1, 2] }; + const overrides = { arr: [3, 4] }; + const result = merge(base, overrides); + + expect(result.arr).to.be.an('array'); + expect(result.arr).to.deep.equal([3, 4]); + }); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/metrics/converter_test.js b/packages/core/test/otlpExporter/metrics/converter_test.js new file mode 100644 index 0000000000..b26090f685 --- /dev/null +++ b/packages/core/test/otlpExporter/metrics/converter_test.js @@ -0,0 +1,76 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; +const fs = require('node:fs'); +const path = require('node:path'); +const sinon = require('sinon'); +const os = require('os'); +const proxyquire = require('proxyquire'); + +const mockPackageJson = { version: '6.0.0' }; + +const resourceTransformer = proxyquire('../../../src/otlpExporter/common/transformers/resource', { + '../../../../package.json': mockPackageJson +}); + +const converter = proxyquire('../../../src/otlpExporter/metrics', { + './converter': proxyquire('../../../src/otlpExporter/metrics/converter', { + './transformers': { + resource: resourceTransformer + } + }) +}); + +function loadInputFixture(filename) { + const fixturePath = path.join(__dirname, 'fixtures/input', filename); + return JSON.parse(fs.readFileSync(fixturePath, 'utf8')); +} + +function loadOutputFixture(filename) { + const fixturePath = path.join(__dirname, 'fixtures/output', filename); + return JSON.parse(fs.readFileSync(fixturePath, 'utf8')); +} + +describe('metrics/converters/otlp', () => { + let hostnameStub; + + before(() => { + hostnameStub = sinon.stub(os, 'hostname').returns('test-hostname'); + }); + + after(() => { + hostnameStub.restore(); + }); + + describe('converter', () => { + describe('basic conversion', () => { + it('should convert array of metrics to OTLP format', () => { + const input = loadInputFixture('metrics.json'); + const expectedOutput = loadOutputFixture('metrics-output.json'); + + const result = converter.transform(input); + + expect(result).to.deep.equal(expectedOutput); + }); + + it('should return empty resourceMetrics for empty input', () => { + const result = converter.transform([]); + expect(result).to.deep.equal({ resourceMetrics: [] }); + }); + + it('should return empty resourceMetrics for null input', () => { + const result = converter.transform(null); + expect(result).to.deep.equal({ resourceMetrics: [] }); + }); + + it('should return empty resourceMetrics for undefined input', () => { + const result = converter.transform(undefined); + expect(result).to.deep.equal({ resourceMetrics: [] }); + }); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json b/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json new file mode 100644 index 0000000000..1ba936ae13 --- /dev/null +++ b/packages/core/test/otlpExporter/metrics/fixtures/input/metrics.json @@ -0,0 +1,53 @@ +{ + "activeResources": { + "count": 3 + }, + "dependencies": { + "abc": "1.3.8" + }, + "directDependencies": { + "dependencies": { + "xyz": "5.0.0" + }, + "otel-exporter-test": "1.0.0" + }, + "description": "Sample app for testing OpenTelemetry exporter with Instana (HTTP, PostgreSQL, Kafka)", + "heapSpaces": { + "new_space": { + "available": 6153600, + "used": 10622592, + "physical": 27901952 + }, + "old_space": { + "current": 16859136, + "available": 522192, + "used": 16309064, + "physical": 17039360 + }, + "code_space": { + "current": 1572864, + "available": 345536, + "used": 1227136, + "physical": 1572864 + }, + "trusted_space": { + "available": 370768, + "used": 2659888 + } + }, + "keywords": ["opentelemetry", "instana", "tracing"], + "libuv": { + "max": 496, + "num": 241, + "sum": 1003 + }, + "memory": { + "rss": 116752384, + "heapTotal": 58900480, + "heapUsed": 34640048, + "external": 4548842, + "arrayBuffers": 1731836 + }, + "name": "otel-exporter-test", + "version": "1.0.0" +} diff --git a/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json new file mode 100644 index 0000000000..3801767f1d --- /dev/null +++ b/packages/core/test/otlpExporter/metrics/fixtures/output/metrics-output.json @@ -0,0 +1,16 @@ +{ + "resourceMetrics": [ + { + "resource": { + "attributes": [ + { "key": "service.name", "value": { "stringValue": "otel-exporter-test" } }, + { "key": "telemetry.sdk.language", "value": { "stringValue": "nodejs" } }, + { "key": "telemetry.sdk.name", "value": { "stringValue": "instana" } }, + { "key": "telemetry.sdk.version", "value": { "stringValue": "6.0.0" } }, + { "key": "host.name", "value": { "stringValue": "test-hostname" } } + ] + }, + "scopeMetrics": [{ "scope": { "name": "@instana/collector", "version": "6.0.0" }, "metrics": [] }] + } + ] +} diff --git a/packages/core/test/otlpExporter/metrics/util_test.js b/packages/core/test/otlpExporter/metrics/util_test.js new file mode 100644 index 0000000000..9225dfa2e2 --- /dev/null +++ b/packages/core/test/otlpExporter/metrics/util_test.js @@ -0,0 +1,702 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; + +const { flattenObject, normalizeMetrics, getResourceKey } = 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', () => { + const obj = { + level1: { + level2: 'value' + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + 'level1.level2': 'value' + }); + }); + + it('should flatten multiple properties at same level', () => { + const obj = { + a: 1, + b: 2, + c: 3 + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + a: 1, + b: 2, + c: 3 + }); + }); + + it('should flatten deeply nested object', () => { + const obj = { + level1: { + level2: { + level3: { + value: 42 + } + } + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + 'level1.level2.level3.value': 42 + }); + }); + + it('should flatten mixed depth properties', () => { + const obj = { + shallow: 'value1', + deep: { + nested: { + value: 'value2' + } + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + shallow: 'value1', + 'deep.nested.value': 'value2' + }); + }); + }); + + describe('data type handling', () => { + it('should include number values', () => { + const obj = { + count: 42, + nested: { + value: 100 + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + count: 42, + 'nested.value': 100 + }); + }); + + it('should include string values', () => { + const obj = { + name: 'test', + nested: { + description: 'nested test' + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + name: 'test', + 'nested.description': 'nested test' + }); + }); + + it('should include boolean values', () => { + const obj = { + enabled: true, + nested: { + active: false + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + enabled: true, + 'nested.active': false + }); + }); + + it('should exclude null values', () => { + const obj = { + valid: 'value', + invalid: null + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + valid: 'value' + }); + }); + + it('should exclude undefined values', () => { + const obj = { + valid: 'value', + invalid: undefined + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + valid: 'value' + }); + }); + + it('should exclude array values', () => { + const obj = { + valid: 'value', + array: [1, 2, 3] + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + valid: 'value' + }); + }); + + it('should handle zero as valid number', () => { + const obj = { + count: 0 + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + count: 0 + }); + }); + + it('should handle empty string as valid value', () => { + const obj = { + name: '' + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + name: '' + }); + }); + }); + + describe('edge cases', () => { + it('should return empty object for null input', () => { + const result = flattenObject(null); + + expect(result).to.deep.equal({}); + }); + + it('should return empty object for undefined input', () => { + const result = flattenObject(undefined); + + expect(result).to.deep.equal({}); + }); + + it('should return empty object for non-object input', () => { + const result = flattenObject('string'); + + expect(result).to.deep.equal({}); + }); + + it('should return empty object for empty object', () => { + const result = flattenObject({}); + + expect(result).to.deep.equal({}); + }); + + it('should handle object with only null values', () => { + const obj = { + a: null, + b: null + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({}); + }); + + it('should handle nested empty objects', () => { + const obj = { + level1: { + level2: {} + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({}); + }); + }); + + describe('prefix parameter', () => { + it('should use prefix when provided', () => { + const obj = { + value: 42 + }; + + const result = flattenObject(obj, 'prefix'); + + expect(result).to.deep.equal({ + 'prefix.value': 42 + }); + }); + + it('should chain prefixes for nested objects', () => { + const obj = { + nested: { + value: 42 + } + }; + + const result = flattenObject(obj, 'prefix'); + + expect(result).to.deep.equal({ + 'prefix.nested.value': 42 + }); + }); + }); + + describe('real-world metrics scenarios', () => { + it('should flatten CPU metrics', () => { + const obj = { + cpu: { + user: 45.5, + system: 12.3, + idle: 42.2 + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + 'cpu.user': 45.5, + 'cpu.system': 12.3, + 'cpu.idle': 42.2 + }); + }); + + it('should flatten memory metrics', () => { + const obj = { + memory: { + used: 1024000, + free: 512000, + total: 1536000 + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + 'memory.used': 1024000, + 'memory.free': 512000, + 'memory.total': 1536000 + }); + }); + + it('should flatten nested service metrics', () => { + const obj = { + service: { + http: { + requests: 1000, + errors: 5 + }, + db: { + queries: 500, + slow: 10 + } + } + }; + + const result = flattenObject(obj); + + expect(result).to.deep.equal({ + 'service.http.requests': 1000, + 'service.http.errors': 5, + 'service.db.queries': 500, + 'service.db.slow': 10 + }); + }); + }); + }); + + describe('normalizeMetrics', () => { + describe('array input', () => { + it('should normalize array of metric objects', () => { + const metrics = [ + { name: 'cpu.usage', value: 45.5, timestamp: 1000 }, + { name: 'memory.used', value: 1024, timestamp: 1000 } + ]; + + const result = normalizeMetrics(metrics); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + expect(result[0]).to.deep.equal({ + name: 'cpu.usage', + value: 45.5, + timestamp: 1000, + unit: '', + from: undefined + }); + }); + + it('should add default timestamp when missing', () => { + const metrics = [{ name: 'cpu.usage', value: 45.5 }]; + + const result = normalizeMetrics(metrics); + + expect(result[0].timestamp).to.equal(0); + }); + + it('should add default unit when missing', () => { + const metrics = [{ name: 'cpu.usage', value: 45.5, timestamp: 1000 }]; + + const result = normalizeMetrics(metrics); + + expect(result[0].unit).to.equal(''); + }); + + it('should preserve unit when provided', () => { + const metrics = [{ name: 'cpu.usage', value: 45.5, timestamp: 1000, unit: 'percent' }]; + + const result = normalizeMetrics(metrics); + + expect(result[0].unit).to.equal('percent'); + }); + + it('should preserve from property', () => { + const metrics = [{ name: 'cpu.usage', value: 45.5, timestamp: 1000, from: { h: 'host1', e: 'entity1' } }]; + + const result = normalizeMetrics(metrics); + + expect(result[0].from).to.deep.equal({ h: 'host1', e: 'entity1' }); + }); + + it('should filter out null entries', () => { + const metrics = [{ name: 'cpu.usage', value: 45.5 }, null, { name: 'memory.used', value: 1024 }]; + + const result = normalizeMetrics(metrics); + + expect(result).to.have.lengthOf(2); + expect(result[0].name).to.equal('cpu.usage'); + expect(result[1].name).to.equal('memory.used'); + }); + + it('should filter out undefined entries', () => { + const metrics = [{ name: 'cpu.usage', value: 45.5 }, undefined, { name: 'memory.used', value: 1024 }]; + + const result = normalizeMetrics(metrics); + + expect(result).to.have.lengthOf(2); + }); + + it('should handle empty array', () => { + const result = normalizeMetrics([]); + + expect(result).to.deep.equal([]); + }); + + it('should handle array with all null values', () => { + const metrics = [null, null, null]; + + const result = normalizeMetrics(metrics); + + expect(result).to.deep.equal([]); + }); + }); + + describe('object input', () => { + it('should normalize simple object to array of metrics', () => { + const metrics = { + cpu: 45.5, + memory: 1024, + timestamp: 1000 + }; + + const result = normalizeMetrics(metrics); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + const cpuMetric = result.find(m => m.name === 'cpu'); + expect(cpuMetric).to.exist; + expect(cpuMetric.value).to.equal(45.5); + expect(cpuMetric.timestamp).to.equal(1000); + }); + + it('should flatten nested object properties', () => { + const metrics = { + cpu: { + user: 45.5, + system: 12.3 + }, + timestamp: 1000 + }; + + const result = normalizeMetrics(metrics); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(2); + + const userMetric = result.find(m => m.name === 'cpu.user'); + expect(userMetric).to.exist; + expect(userMetric.value).to.equal(45.5); + }); + + it('should use fallback timestamp from object', () => { + const metrics = { + cpu: 45.5, + timestamp: 1000 + }; + + const result = normalizeMetrics(metrics); + + expect(result[0].timestamp).to.equal(1000); + }); + + it('should use default timestamp when not provided', () => { + const metrics = { + cpu: 45.5 + }; + + const result = normalizeMetrics(metrics); + + expect(result[0].timestamp).to.equal(0); + }); + + it('should preserve from property', () => { + const metrics = { + cpu: 45.5, + from: { h: 'host1', e: 'entity1' }, + timestamp: 1000 + }; + + const result = normalizeMetrics(metrics); + + expect(result[0].from).to.deep.equal({ h: 'host1', e: 'entity1' }); + }); + + it('should exclude timestamp and from from metric names', () => { + const metrics = { + cpu: 45.5, + memory: 1024, + timestamp: 1000, + from: { h: 'host1' } + }; + + const result = normalizeMetrics(metrics); + + const names = result.map(m => m.name); + expect(names).to.not.include('timestamp'); + expect(names).to.not.include('from'); + }); + + it('should handle deeply nested metrics', () => { + const metrics = { + service: { + http: { + requests: 1000 + } + }, + timestamp: 1000 + }; + + const result = normalizeMetrics(metrics); + + const metric = result.find(m => m.name === 'service.http.requests'); + expect(metric).to.exist; + expect(metric.value).to.equal(1000); + }); + + it('should set empty unit for all metrics', () => { + const metrics = { + cpu: 45.5, + memory: 1024 + }; + + const result = normalizeMetrics(metrics); + + result.forEach(metric => { + expect(metric.unit).to.equal(''); + }); + }); + }); + + describe('edge cases', () => { + it('should return empty array for null input', () => { + const result = normalizeMetrics(null); + + expect(result).to.deep.equal([]); + }); + + it('should return empty array for undefined input', () => { + const result = normalizeMetrics(undefined); + + expect(result).to.deep.equal([]); + }); + + it('should return empty array for non-object, non-array input', () => { + const result = normalizeMetrics('string'); + + expect(result).to.deep.equal([]); + }); + + it('should return empty array for number input', () => { + const result = normalizeMetrics(42); + + expect(result).to.deep.equal([]); + }); + + it('should return empty array for boolean input', () => { + const result = normalizeMetrics(true); + + expect(result).to.deep.equal([]); + }); + + it('should handle object with only metadata (no metrics)', () => { + const metrics = { + timestamp: 1000, + from: { h: 'host1' } + }; + + const result = normalizeMetrics(metrics); + + expect(result).to.deep.equal([]); + }); + }); + + describe('real-world scenarios', () => { + it('should normalize collector metrics payload', () => { + const metrics = { + name: 'my-service', + cpu: { + user: 45.5, + system: 12.3 + }, + memory: { + used: 1024000, + free: 512000 + }, + timestamp: 1234567890, + from: { h: 'host123', e: 'pid456' } + }; + + const result = normalizeMetrics(metrics); + + expect(result).to.be.an('array'); + expect(result.length).to.be.greaterThan(0); + + result.forEach(metric => { + expect(metric).to.have.property('name'); + expect(metric).to.have.property('value'); + expect(metric).to.have.property('timestamp'); + expect(metric).to.have.property('unit'); + expect(metric).to.have.property('from'); + expect(metric.timestamp).to.equal(1234567890); + expect(metric.from).to.deep.equal({ h: 'host123', e: 'pid456' }); + }); + }); + + it('should normalize array-based metrics from agent', () => { + const metrics = [ + { + name: 'nodejs.heap.used', + value: 50000000, + timestamp: 1234567890, + unit: 'bytes', + from: { h: 'host123', e: 'pid456' } + }, + { + name: 'nodejs.heap.total', + value: 100000000, + timestamp: 1234567890, + unit: 'bytes', + from: { h: 'host123', e: 'pid456' } + } + ]; + + const result = normalizeMetrics(metrics); + + expect(result).to.have.lengthOf(2); + expect(result[0].name).to.equal('nodejs.heap.used'); + expect(result[0].unit).to.equal('bytes'); + expect(result[1].name).to.equal('nodejs.heap.total'); + }); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/traces/converter_test.js b/packages/core/test/otlpExporter/traces/converter_test.js new file mode 100644 index 0000000000..8df5f18889 --- /dev/null +++ b/packages/core/test/otlpExporter/traces/converter_test.js @@ -0,0 +1,494 @@ +/* + * (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' }; + +const resourceTransformer = proxyquire('../../../src/otlpExporter/common/transformers/resource', { + '../../../../package.json': mockPackageJson +}); +const { convert } = proxyquire('../../../src/otlpExporter/traces/converter', { + './transformers': { + ...require('../../../src/otlpExporter/traces/transformers'), + resource: resourceTransformer + } +}); + +const otlp = require('../../../src/otlpExporter'); +const { extractSpanMetadata } = require('../../../src/otlpExporter/traces/transformers/spanMetaData'); +const { extractSpanAttributes } = require('../../../src/otlpExporter/traces/transformers/spanAttributes'); +const mappers = require('../../../src/otlpExporter/traces/mappers'); + +function createSpan(overrides = {}) { + return { + t: 'trace-1', + s: 'span-1', + ts: 1710000000000, + d: 25, + n: 'custom.span', + k: 1, + ...overrides, + f: { + e: '321', + h: 'host-id-1', + ...(overrides.f || {}) + }, + data: { + ...(overrides.data || {}) + } + }; +} + +function createHttpSpan(overrides = {}) { + const http = { + operation: 'get', + path: '/users/42', + path_tpl: '/users/:id', + route: '/users/:id', + endpoints: 'https://example.test/users/42', + params: 'active=true', + status: 200, + connection: 'api.example.test:443', + ...(overrides.data?.http || {}) + }; + + return createSpan({ + n: 'node.http.server', + k: 1, + ...overrides, + data: { + http, + ...(overrides.data || {}) + } + }); +} + +function createPgSpan(overrides = {}) { + const pg = { + stmt: 'SELECT * FROM users WHERE id = $1', + host: 'db.example.test', + port: 5432, + db: 'users', + user: 'instana', + ...(overrides.data?.pg || {}) + }; + + return createSpan({ + n: 'postgres', + k: 2, + ...overrides, + data: { + pg, + ...(overrides.data || {}) + } + }); +} + +function createKafkaSpan(overrides = {}) { + const kafka = { + operation: 'send', + endpoints: 'orders', + error: undefined, + ...(overrides.data?.kafka || {}) + }; + + return createSpan({ + n: 'kafka', + k: 2, + ...overrides, + data: { + kafka, + ...(overrides.data || {}) + } + }); +} + +function createInternalSpan(overrides = {}) { + return createSpan({ + n: 'custom.internal', + k: 3, + ...overrides + }); +} + +function createAzureBlobSpan(overrides = {}) { + const azstorage = { + op: 'put', + accountName: 'storage-account', + containerName: 'uploads', + blobName: 'invoice.pdf', + ...(overrides.data?.azstorage || {}) + }; + + return createSpan({ + n: 'azure.blob', + k: 2, + ...overrides, + data: { + azstorage, + ...(overrides.data || {}) + } + }); +} + +function createOtelSpan(overrides = {}) { + return createSpan({ + n: 'otel', + k: 2, + data: { + operation: 'publish', + tags: { + 'http.method': 'POST', + 'messaging.system': 'custom-bus', + success: true + }, + ...(overrides.data || {}) + }, + ...overrides + }); +} + +function getConvertedSpans(result) { + return result.resourceSpans[0].scopeSpans[0].spans; +} + +function getResourceAttributes(result) { + return result.resourceSpans[0].resource.attributes; +} + +function findAttribute(attributes, key) { + return attributes.find(attribute => attribute.key === key); +} + +function expectAttribute(attributes, key, expectedValue) { + expect(findAttribute(attributes, key), `Missing attribute ${key}`).to.deep.equal({ + key, + value: expectedValue + }); +} + +describe('tracing/converters/otlp', () => { + let hostnameStub; + + before(() => { + otlp.init({ + serviceName: 'otel-exporter-test', + logger: console, + tracing: { + otlp: { + enabled: true, + semConvVersion: '1.23' + } + } + }); + + hostnameStub = sinon.stub(os, 'hostname').returns('test.local.server'); + }); + + after(() => { + hostnameStub.restore(); + }); + + describe('converter', () => { + it('converts a representative batch of spans with focused assertions', () => { + const spans = [ + createHttpSpan(), + createPgSpan({ + t: 'trace-1', + s: 'span-2', + p: 'span-1', + ts: 1710000000100, + d: 40 + }), + createKafkaSpan({ + t: 'trace-1', + s: 'span-3', + p: 'span-2', + ts: 1710000000200, + d: 15 + }), + createInternalSpan({ + t: 'trace-1', + s: 'span-4', + p: 'span-3', + ts: 1710000000300, + d: 5 + }), + createAzureBlobSpan({ + t: 'trace-1', + s: 'span-5', + p: 'span-4', + ts: 1710000000400, + d: 12 + }), + createOtelSpan({ + t: 'trace-1', + s: 'span-6', + p: 'span-5', + ts: 1710000000500, + d: 8 + }) + ]; + + const result = convert(spans); + + expect(result.resourceSpans).to.have.lengthOf(1); + expect(result.resourceSpans[0].scopeSpans).to.have.lengthOf(1); + expect(result.resourceSpans[0].scopeSpans[0].scope).to.deep.equal({ + name: '@instana/collector', + version: '6.0.0' + }); + + const resourceAttributes = getResourceAttributes(result); + expectAttribute(resourceAttributes, 'service.name', { stringValue: 'otel-exporter-test' }); + expectAttribute(resourceAttributes, 'telemetry.sdk.language', { stringValue: 'nodejs' }); + expectAttribute(resourceAttributes, 'telemetry.sdk.name', { stringValue: 'instana' }); + expectAttribute(resourceAttributes, 'telemetry.sdk.version', { stringValue: '6.0.0' }); + expectAttribute(resourceAttributes, 'process.pid', { intValue: 321 }); + expectAttribute(resourceAttributes, 'host.name', { stringValue: 'test.local.server' }); + expectAttribute(resourceAttributes, 'host.id', { stringValue: 'host-id-1' }); + + const convertedSpans = getConvertedSpans(result); + expect(convertedSpans).to.have.lengthOf(6); + + const httpSpan = convertedSpans[0]; + expect(httpSpan.name).to.equal('GET /users/:id'); + expect(httpSpan.kind).to.equal(2); + expect(httpSpan.traceId).to.equal('0000000000000000000000000trace-1'); + expect(httpSpan.spanId).to.equal('0000000000span-1'); + expect(httpSpan.startTimeUnixNano).to.equal('1710000000000000000'); + expect(httpSpan.endTimeUnixNano).to.equal('1710000000025000000'); + expect(httpSpan.status).to.deep.equal({ code: 0 }); + expectAttribute(httpSpan.attributes, 'http.method', { stringValue: 'GET' }); + expectAttribute(httpSpan.attributes, 'http.url', { stringValue: 'https://example.test/users/42' }); + expectAttribute(httpSpan.attributes, 'http.target', { stringValue: '/users/42' }); + expectAttribute(httpSpan.attributes, 'http.route', { stringValue: '/users/:id' }); + expectAttribute(httpSpan.attributes, 'server.address', { stringValue: 'api.example.test' }); + expectAttribute(httpSpan.attributes, 'server.port', { intValue: 443 }); + + const pgSpan = convertedSpans[1]; + expect(pgSpan.name).to.equal('SELECT'); + expect(pgSpan.kind).to.equal(3); + expect(pgSpan.parentSpanId).to.equal('0000000000span-1'); + expectAttribute(pgSpan.attributes, 'db.system', { stringValue: 'postgresql' }); + expectAttribute(pgSpan.attributes, 'db.query.text', { stringValue: 'SELECT * FROM users WHERE id = $1' }); + expectAttribute(pgSpan.attributes, 'db.user', { stringValue: 'instana' }); + expectAttribute(pgSpan.attributes, 'db.name', { stringValue: 'users' }); + + const kafkaSpan = convertedSpans[2]; + expect(kafkaSpan.name).to.equal('send orders'); + expect(kafkaSpan.kind).to.equal(3); + expectAttribute(kafkaSpan.attributes, 'messaging.system', { stringValue: 'kafka' }); + expectAttribute(kafkaSpan.attributes, 'messaging.destination', { stringValue: 'orders' }); + expectAttribute(kafkaSpan.attributes, 'messaging.operation.name', { stringValue: 'send' }); + + const internalSpan = convertedSpans[3]; + expect(internalSpan.name).to.equal('custom.internal'); + expect(internalSpan.kind).to.equal(1); + expect(internalSpan.attributes).to.deep.equal([]); + + const azureBlobSpan = convertedSpans[4]; + expect(azureBlobSpan.name).to.equal('azure.storage.put'); + expect(azureBlobSpan.kind).to.equal(3); + expectAttribute(azureBlobSpan.attributes, 'cloud.provider', { stringValue: 'azure' }); + expectAttribute(azureBlobSpan.attributes, 'db.operation.name', { stringValue: 'put' }); + expect(azureBlobSpan.attributes.some(attribute => attribute.value.stringValue === 'storage-account')).to.be.true; + expect(azureBlobSpan.attributes.some(attribute => attribute.value.stringValue === 'uploads')).to.be.true; + expect(azureBlobSpan.attributes.some(attribute => attribute.value.stringValue === 'invoice.pdf')).to.be.true; + + const otelSpan = convertedSpans[5]; + expect(otelSpan.name).to.equal('otel'); + expect(otelSpan.kind).to.equal(3); + expectAttribute(otelSpan.attributes, 'operation', { stringValue: 'publish' }); + expectAttribute(otelSpan.attributes, 'http.method', { stringValue: 'POST' }); + expectAttribute(otelSpan.attributes, 'messaging.system', { stringValue: 'custom-bus' }); + expectAttribute(otelSpan.attributes, 'success', { boolValue: true }); + }); + + it('returns empty resourceSpans for empty input', () => { + expect(convert([])).to.deep.equal({ resourceSpans: [] }); + }); + + it('skips invalid, null and log spans while keeping valid spans', () => { + const validSpan = createHttpSpan(); + const invalidSpan = null; + const logSpan = { + t: 'trace-2', + s: 'log-1', + n: 'log.console', + data: { log: { message: 'test' } } + }; + + const result = convert([null, invalidSpan, logSpan, validSpan, null]); + + expect(result.resourceSpans).to.have.lengthOf(1); + expect(getConvertedSpans(result)).to.have.lengthOf(1); + expect(getConvertedSpans(result)[0].name).to.equal('GET /users/:id'); + }); + }); + + describe('transformers', () => { + describe('spanMetaData', () => { + it('extracts metadata for server, client, internal and error spans', () => { + const httpSpan = createHttpSpan(); + const kafkaSpan = createKafkaSpan({ + ec: 1, + data: { + kafka: { + operation: 'send', + endpoints: 'orders', + error: 'broker unavailable' + } + } + }); + const internalSpan = createInternalSpan(); + const otelSpan = createOtelSpan({ + ec: 1, + data: { + operation: 'publish', + tags: { + error: 'otel failed' + } + } + }); + + expect(extractSpanMetadata(httpSpan, mappers.get(httpSpan))).to.include({ + traceId: '0000000000000000000000000trace-1', + spanId: '0000000000span-1', + kind: 2, + name: 'GET /users/:id' + }); + expect(extractSpanMetadata(httpSpan, mappers.get(httpSpan)).status).to.deep.equal({ code: 0 }); + + expect(extractSpanMetadata(kafkaSpan, mappers.get(kafkaSpan))).to.include({ + kind: 3, + name: 'send orders' + }); + expect(extractSpanMetadata(kafkaSpan, mappers.get(kafkaSpan)).status).to.deep.equal({ + code: 2, + message: 'broker unavailable' + }); + + expect(extractSpanMetadata(internalSpan, mappers.get(internalSpan))).to.include({ + kind: 1, + name: 'custom.internal' + }); + + expect(extractSpanMetadata(otelSpan, mappers.get(otelSpan)).status).to.deep.equal({ + code: 2, + message: 'otel failed' + }); + }); + + it('marks HTTP client 4xx spans as errors', () => { + const httpClientSpan = createHttpSpan({ + n: 'node.http.client', + k: 2, + ec: 0, + data: { + http: { + operation: 'get', + path: '/missing', + status: 404 + } + } + }); + + const result = extractSpanMetadata(httpClientSpan, mappers.get(httpClientSpan)); + + expect(result.kind).to.equal(3); + expect(result.status).to.deep.equal({ + code: 2, + message: 'http failed' + }); + }); + }); + + describe('spanAttributes', () => { + it('extracts representative attributes for http, database, messaging, cloud and otel spans', () => { + const httpSpan = createHttpSpan({ + data: { + http: { + operation: 'post', + path: '/orders', + endpoints: 'https://example.test/orders', + status: 201, + error: 'validation failed' + } + } + }); + const pgSpan = createPgSpan(); + const mongoSpan = createSpan({ + n: 'mongo', + data: { + mongo: { + command: 'find', + service: 'mongodb://mongo.example.test:27017', + namespace: 'users.accounts', + collection: 'accounts', + filter: '{"active":true}' + } + } + }); + const rabbitmqSpan = createSpan({ + n: 'rabbitmq', + data: { + rabbitmq: { + sort: 'publish', + exchange: 'orders', + key: 'orders.created', + address: 'mq.example.test:5672' + } + } + }); + const azureBlobSpan = createAzureBlobSpan(); + const otelSpan = createOtelSpan(); + + const httpAttributes = extractSpanAttributes(httpSpan, mappers.get(httpSpan)); + expectAttribute(httpAttributes, 'http.method', { stringValue: 'POST' }); + expectAttribute(httpAttributes, 'http.status_code', { intValue: 201 }); + expectAttribute(httpAttributes, 'error.type', { stringValue: 'validation failed' }); + + const pgAttributes = extractSpanAttributes(pgSpan, mappers.get(pgSpan)); + expectAttribute(pgAttributes, 'db.system', { stringValue: 'postgresql' }); + expectAttribute(pgAttributes, 'server.address', { stringValue: 'db.example.test' }); + expectAttribute(pgAttributes, 'server.port', { intValue: 5432 }); + + const mongoAttributes = extractSpanAttributes(mongoSpan, mappers.get(mongoSpan)); + expectAttribute(mongoAttributes, 'db.system', { stringValue: 'mongodb' }); + expectAttribute(mongoAttributes, 'db.operation.name', { stringValue: 'FIND' }); + expectAttribute(mongoAttributes, 'db.collection.name', { stringValue: 'accounts' }); + expectAttribute(mongoAttributes, 'server.address', { stringValue: 'mongo.example.test' }); + expectAttribute(mongoAttributes, 'server.port', { intValue: 27017 }); + + const rabbitmqAttributes = extractSpanAttributes(rabbitmqSpan, mappers.get(rabbitmqSpan)); + expectAttribute(rabbitmqAttributes, 'messaging.system', { stringValue: 'rabbitmq' }); + expectAttribute(rabbitmqAttributes, 'messaging.operation.name', { stringValue: 'publish' }); + expectAttribute(rabbitmqAttributes, 'messaging.destination', { stringValue: 'orders.orders.created' }); + expectAttribute(rabbitmqAttributes, 'server.address', { stringValue: 'mq.example.test' }); + expectAttribute(rabbitmqAttributes, 'server.port', { intValue: 5672 }); + + const azureBlobAttributes = extractSpanAttributes(azureBlobSpan, mappers.get(azureBlobSpan)); + expectAttribute(azureBlobAttributes, 'cloud.provider', { stringValue: 'azure' }); + expect(azureBlobAttributes.some(attribute => attribute.value.stringValue === 'invoice.pdf')).to.be.true; + + const otelAttributes = extractSpanAttributes(otelSpan, mappers.get(otelSpan)); + expectAttribute(otelAttributes, 'operation', { stringValue: 'publish' }); + expectAttribute(otelAttributes, 'http.method', { stringValue: 'POST' }); + expectAttribute(otelAttributes, 'success', { boolValue: true }); + }); + + it('returns empty array for spans without data', () => { + expect(extractSpanAttributes({ t: '123', s: '456' }, mappers.get({}))).to.deep.equal([]); + expect(extractSpanAttributes(null, mappers.get({}))).to.deep.equal([]); + }); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/traces/mappers/instanaInstrumentationMappings_test.js b/packages/core/test/otlpExporter/traces/mappers/instanaInstrumentationMappings_test.js new file mode 100644 index 0000000000..400a25c447 --- /dev/null +++ b/packages/core/test/otlpExporter/traces/mappers/instanaInstrumentationMappings_test.js @@ -0,0 +1,466 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; +const { + spanName, + spanAttributes, + spanStatus +} = require('../../../../src/otlpExporter/traces/mappers/instanaInstrumentationMappings'); +const { OTLP_STATUS_CODES } = require('../../../../src/otlpExporter/traces/mappers/constants'); + +describe('otlpExporter/traces/mappers/instanaInstrumentationMappings', () => { + describe('spanName', () => { + it('should generate HTTP span name with method and path', () => { + const span = { + n: 'node.http.server', + data: { + http: { + operation: 'GET', + path: '/api/users' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('GET /api/users'); + }); + + it('should generate HTTP span name with operation and path_tpl', () => { + const span = { + n: 'node.http.server', + data: { + http: { + operation: 'POST', + path_tpl: '/api/users/:id' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('POST /api/users/:id'); + }); + + it('should generate Kafka span name', () => { + const span = { + n: 'kafka', + data: { + kafka: { + operation: 'publish', + endpoints: 'my-topic' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('publish my-topic'); + }); + + it('should generate RabbitMQ span name', () => { + const span = { + n: 'rabbitmq', + data: { + rabbitmq: { + sort: 'publish', + exchange: 'my-exchange' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('publish my-exchange'); + }); + + it('should generate PostgreSQL span name from statement', () => { + const span = { + n: 'postgres', + data: { + pg: { + stmt: 'SELECT * FROM users WHERE id = $1' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('SELECT'); + }); + + it('should generate MySQL span name from statement', () => { + const span = { + n: 'mysql', + data: { + mysql: { + stmt: 'INSERT INTO users (name, email) VALUES (?, ?)' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('INSERT'); + }); + + it('should generate MongoDB span name', () => { + const span = { + n: 'mongo', + data: { + mongo: { + command: 'find' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('mongo.find'); + }); + + it('should generate Redis span name', () => { + const span = { + n: 'redis', + data: { + redis: { + operation: 'get' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('redis.get'); + }); + + it('should generate DynamoDB span name', () => { + const span = { + n: 'dynamodb', + data: { + dynamodb: { + operation: 'GetItem' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('dynamodb.GetItem'); + }); + + it('should generate S3 span name', () => { + const span = { + n: 's3', + data: { + s3: { + op: 'putObject' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('s3.putObject'); + }); + + it('should generate Lambda invoke span name', () => { + const span = { + n: 'aws.lambda.invoke', + data: { + 'aws.lambda.invoke': { + function: 'my-function' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('Invoke my-function'); + }); + + it('should generate GraphQL span name with operation name', () => { + const span = { + n: 'graphql', + data: { + graphql: { + operationType: 'query', + operationName: 'GetUser' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('query GetUser'); + }); + + it('should generate GraphQL span name without operation name', () => { + const span = { + n: 'graphql', + data: { + graphql: { + operationType: 'mutation' + } + } + }; + + const result = spanName(span); + expect(result).to.equal('mutation'); + }); + + it('should fallback to span.n when no handler exists', () => { + const span = { + n: 'custom.span', + data: { + custom: {} + } + }; + + const result = spanName(span); + expect(result).to.equal('custom.span'); + }); + + it('should return "unknown" when span has no name or type', () => { + const span = { + data: {} + }; + + const result = spanName(span); + expect(result).to.equal('unknown'); + }); + }); + + describe('spanAttributes', () => { + it('should extract HTTP attributes', () => { + const span = { + data: { + http: { + operation: 'POST', + path: '/api/users', + status: 201, + host: 'example.com:8080' + } + } + }; + + const result = spanAttributes(span); + console.log(result); + expect(result).to.be.an('array'); + expect(result).to.deep.include({ + key: 'http.method', + value: { stringValue: 'POST' } + }); + expect(result).to.deep.include({ + key: 'http.target', + value: { stringValue: '/api/users' } + }); + expect(result).to.deep.include({ + key: 'http.status_code', + value: { intValue: 201 } + }); + }); + }); + + describe('spanStatus', () => { + it('should return UNSET status when span has no error', () => { + const span = { + n: 'node.http.server', + data: { + http: { + operation: 'GET', + path: '/api/users', + status: 200 + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should return ERROR status when span.ec is set', () => { + const span = { + n: 'node.http.server', + ec: 1, + data: { + http: { + operation: 'GET', + path: '/api/users', + status: 500, + error: 'Internal Server Error' + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'Internal Server Error' + }); + }); + + it('should return ERROR status for HTTP client 4xx responses', () => { + const span = { + n: 'node.http.client', + data: { + http: { + operation: 'GET', + path: '/api/users', + status: 404 + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'http failed' + }); + }); + + it('should return ERROR status for HTTP client 400 response', () => { + const span = { + n: 'node.http.client', + data: { + http: { + operation: 'POST', + path: '/api/users', + status: 400 + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'http failed' + }); + }); + + it('should return ERROR status for HTTP client 499 response', () => { + const span = { + n: 'node.http.client', + data: { + http: { + operation: 'GET', + path: '/api/users', + status: 499 + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'http failed' + }); + }); + + it('should NOT return ERROR status for HTTP client 5xx responses without ec', () => { + const span = { + n: 'node.http.client', + data: { + http: { + operation: 'GET', + path: '/api/users', + status: 500 + } + } + }; + + const result = spanStatus(span); + + // 5xx without ec should be UNSET (not in 4xx range) + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should NOT return ERROR status for HTTP client 3xx responses', () => { + const span = { + n: 'node.http.client', + data: { + http: { + operation: 'GET', + path: '/api/users', + status: 301 + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should use error message from span data when available', () => { + const span = { + n: 'postgres', + ec: 1, + data: { + pg: { + stmt: 'SELECT * FROM users', + error: 'Connection refused' + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'Connection refused' + }); + }); + + it('should fallback to span type in error message', () => { + const span = { + n: 'redis', + ec: 1, + data: { + redis: { + operation: 'get' + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'redis failed' + }); + }); + + it('should fallback to span name in error message when no type', () => { + const span = { + n: 'custom.operation', + ec: 1, + data: {} + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'custom.operation failed' + }); + }); + + it('should fallback to "operation" in error message when no type or name', () => { + const span = { + ec: 1, + data: {} + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'operation failed' + }); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/traces/mappers/otelInstrumentationMappings_test.js b/packages/core/test/otlpExporter/traces/mappers/otelInstrumentationMappings_test.js new file mode 100644 index 0000000000..a1b30602b0 --- /dev/null +++ b/packages/core/test/otlpExporter/traces/mappers/otelInstrumentationMappings_test.js @@ -0,0 +1,584 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; +const { + OTEL_SPAN_NAME, + isOtelSpan, + spanName, + spanAttributes, + spanStatus +} = require('../../../../src/otlpExporter/traces/mappers/otelInstrumentationMappings'); +const { OTLP_STATUS_CODES } = require('../../../../src/otlpExporter/traces/mappers/constants'); + +describe('otlpExporter/traces/mappers/otelInstrumentationMappings', () => { + describe('OTEL_SPAN_NAME', () => { + it('should export the correct constant', () => { + expect(OTEL_SPAN_NAME).to.equal('otel'); + }); + }); + + describe('isOtelSpan', () => { + it('should return true for otel spans', () => { + const span = { + n: 'otel' + }; + + expect(isOtelSpan(span)).to.be.true; + }); + + it('should return false for non-otel spans', () => { + const span = { + n: 'node.http.server' + }; + + expect(isOtelSpan(span)).to.be.false; + }); + + it('should return false for spans without name', () => { + const span = { + data: {} + }; + + expect(isOtelSpan(span)).to.be.false; + }); + }); + + describe('spanName', () => { + it('should return span name when present', () => { + const span = { + n: 'otel' + }; + + const result = spanName(span); + expect(result).to.equal('otel'); + }); + + it('should return custom span name', () => { + const span = { + n: 'custom.operation' + }; + + const result = spanName(span); + expect(result).to.equal('custom.operation'); + }); + + it('should return "unknown" when span has no name', () => { + const span = { + data: {} + }; + + const result = spanName(span); + expect(result).to.equal('unknown'); + }); + }); + + describe('spanAttributes', () => { + it('should extract tags as attributes', () => { + const span = { + n: 'otel', + data: { + tags: { + 'http.method': 'GET', + 'http.url': 'https://example.com/api', + 'http.status_code': 200 + } + } + }; + + const result = spanAttributes(span); + + expect(result).to.be.an('array'); + expect(result).to.have.lengthOf(3); + expect(result).to.deep.include({ + key: 'http.method', + value: { stringValue: 'GET' } + }); + expect(result).to.deep.include({ + key: 'http.url', + value: { stringValue: 'https://example.com/api' } + }); + expect(result).to.deep.include({ + key: 'http.status_code', + value: { intValue: 200 } + }); + }); + + it('should handle tags with different value types', () => { + const span = { + n: 'otel', + data: { + tags: { + stringTag: 'value', + intTag: 42, + floatTag: 3.14, + boolTag: true, + objectTag: { nested: 'value' } + } + } + }; + + const result = spanAttributes(span); + + expect(result).to.deep.include({ + key: 'stringTag', + value: { stringValue: 'value' } + }); + expect(result).to.deep.include({ + key: 'intTag', + value: { intValue: 42 } + }); + expect(result).to.deep.include({ + key: 'floatTag', + value: { doubleValue: 3.14 } + }); + expect(result).to.deep.include({ + key: 'boolTag', + value: { boolValue: true } + }); + expect(result).to.deep.include({ + key: 'objectTag', + value: { stringValue: '{"nested":"value"}' } + }); + }); + + it('should extract operation attributes', () => { + const span = { + n: 'otel', + data: { + operation: 'custom.operation' + } + }; + + const result = spanAttributes(span); + + expect(result).to.deep.include({ + key: 'operation', + value: { stringValue: 'custom.operation' } + }); + }); + + it('should handle both tags and operation', () => { + const span = { + n: 'otel', + data: { + tags: { + 'custom.tag': 'value' + }, + operation: 'my.operation' + } + }; + + const result = spanAttributes(span); + + expect(result).to.have.lengthOf(2); + expect(result).to.deep.include({ + key: 'custom.tag', + value: { stringValue: 'value' } + }); + expect(result).to.deep.include({ + key: 'operation', + value: { stringValue: 'my.operation' } + }); + }); + + it('should skip resource span type', () => { + const span = { + n: 'otel', + data: { + tags: { + 'custom.tag': 'value' + }, + resource: { + someData: 'should be ignored' + } + } + }; + + const result = spanAttributes(span); + + expect(result).to.have.lengthOf(1); + expect(result).to.deep.include({ + key: 'custom.tag', + value: { stringValue: 'value' } + }); + // Should not have any resource attributes + expect(result.every(attr => !attr.key.includes('resource'))).to.be.true; + }); + + it('should return empty array for span with no data', () => { + const span = { + n: 'otel', + data: {} + }; + + const result = spanAttributes(span); + expect(result).to.deep.equal([]); + }); + + it('should return empty array for span with null data', () => { + const span = { + n: 'otel', + data: null + }; + + const result = spanAttributes(span); + expect(result).to.deep.equal([]); + }); + + it('should return empty array for span with undefined data', () => { + const span = { + n: 'otel' + }; + + const result = spanAttributes(span); + expect(result).to.deep.equal([]); + }); + + it('should handle empty tags object', () => { + const span = { + n: 'otel', + data: { + tags: {} + } + }; + + const result = spanAttributes(span); + expect(result).to.deep.equal([]); + }); + + it('should handle null tags', () => { + const span = { + n: 'otel', + data: { + tags: null + } + }; + + const result = spanAttributes(span); + expect(result).to.deep.equal([]); + }); + + it('should handle null operation', () => { + const span = { + n: 'otel', + data: { + operation: null + } + }; + + const result = spanAttributes(span); + expect(result).to.deep.equal([]); + }); + + it('should handle undefined operation', () => { + const span = { + n: 'otel', + data: { + operation: undefined + } + }; + + const result = spanAttributes(span); + expect(result).to.deep.equal([]); + }); + + it('should handle tags with null values', () => { + const span = { + n: 'otel', + data: { + tags: { + 'valid.tag': 'value', + 'null.tag': null, + 'undefined.tag': undefined + } + } + }; + + const result = spanAttributes(span); + + // Should include all tags, even with null/undefined values + // formatOTLPValue converts them to strings + expect(result).to.have.lengthOf(3); + expect(result).to.deep.include({ + key: 'valid.tag', + value: { stringValue: 'value' } + }); + }); + + it('should handle multiple span types excluding resource', () => { + const span = { + n: 'otel', + data: { + tags: { + tag1: 'value1' + }, + operation: 'op1', + resource: { + ignored: 'data' + }, + unknownType: { + data: 'value' + } + } + }; + + const result = spanAttributes(span); + + // Should have tags and operation, but not resource or unknownType (no handler) + expect(result.some(attr => attr.key === 'tag1')).to.be.true; + expect(result.some(attr => attr.key === 'operation')).to.be.true; + expect(result.every(attr => !attr.key.includes('resource'))).to.be.true; + expect(result.every(attr => !attr.key.includes('unknownType'))).to.be.true; + }); + + it('should handle tags with special characters in keys', () => { + const span = { + n: 'otel', + data: { + tags: { + 'http.request.method': 'GET', + 'db.system': 'postgresql', + 'messaging.destination.name': 'my-queue' + } + } + }; + + const result = spanAttributes(span); + + expect(result).to.have.lengthOf(3); + expect(result).to.deep.include({ + key: 'http.request.method', + value: { stringValue: 'GET' } + }); + expect(result).to.deep.include({ + key: 'db.system', + value: { stringValue: 'postgresql' } + }); + expect(result).to.deep.include({ + key: 'messaging.destination.name', + value: { stringValue: 'my-queue' } + }); + }); + }); + + describe('spanStatus', () => { + it('should return UNSET status when span has no error', () => { + const span = { + n: 'otel', + data: { + tags: { + 'http.status_code': 200 + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should return ERROR status when span.ec is set', () => { + const span = { + n: 'otel', + ec: 1, + data: { + tags: { + error: 'Something went wrong' + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'Something went wrong' + }); + }); + + it('should return ERROR status with span name when no error tag', () => { + const span = { + n: 'otel', + ec: 1, + data: { + tags: {} + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'otel failed' + }); + }); + + it('should return ERROR status with "operation failed" when no span name', () => { + const span = { + ec: 1, + data: { + tags: {} + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'operation failed' + }); + }); + + it('should handle ec as truthy value', () => { + const span = { + n: 'otel', + ec: 5, + data: { + tags: { + error: 'Multiple errors' + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'Multiple errors' + }); + }); + + it('should return UNSET when ec is 0', () => { + const span = { + n: 'otel', + ec: 0, + data: { + tags: {} + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should return UNSET when ec is false', () => { + const span = { + n: 'otel', + ec: false, + data: { + tags: {} + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should return UNSET when ec is null', () => { + const span = { + n: 'otel', + ec: null, + data: { + tags: {} + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should return UNSET when ec is undefined', () => { + const span = { + n: 'otel', + data: { + tags: {} + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should handle missing data object', () => { + const span = { + n: 'otel', + ec: 1 + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'otel failed' + }); + }); + + it('should handle missing tags in data', () => { + const span = { + n: 'otel', + ec: 1, + data: {} + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: 'otel failed' + }); + }); + + it('should handle null span', () => { + const result = spanStatus(null); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should handle undefined span', () => { + const result = spanStatus(undefined); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.UNSET + }); + }); + + it('should convert error tag to string', () => { + const span = { + n: 'otel', + ec: 1, + data: { + tags: { + error: 12345 + } + } + }; + + const result = spanStatus(span); + + expect(result).to.deep.equal({ + code: OTLP_STATUS_CODES.ERROR, + message: '12345' + }); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/traces/transformers/spanMetaData_test.js b/packages/core/test/otlpExporter/traces/transformers/spanMetaData_test.js new file mode 100644 index 0000000000..b79b1f1ea7 --- /dev/null +++ b/packages/core/test/otlpExporter/traces/transformers/spanMetaData_test.js @@ -0,0 +1,535 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +const { OTLP_SPAN_KINDS, OTLP_STATUS_CODES } = require('../../../../src/otlpExporter/traces/mappers/constants'); + +describe('otlpExporter/traces/transformers/spanMetaData', () => { + let extractSpanMetadata; + let mockContext; + let mockMapper; + + beforeEach(() => { + mockContext = { + semConv: { + metadata: { + TRACE_ID: 'traceId', + SPAN_ID: 'spanId', + PARENT_ID: 'parentSpanId', + SPAN_KIND: 'kind', + START_TIME_UNIX_NANO: 'startTimeUnixNano', + END_TIME_UNIX_NANO: 'endTimeUnixNano', + NAME: 'name', + STATUS: 'status' + } + } + }; + + mockMapper = { + spanName: sinon.stub().returns('test.span'), + spanStatus: sinon.stub().returns({ code: OTLP_STATUS_CODES.UNSET }) + }; + + const spanMetaDataModule = proxyquire('../../../../src/otlpExporter/traces/transformers/spanMetaData', { + '../../common/context': mockContext + }); + + extractSpanMetadata = spanMetaDataModule.extractSpanMetadata; + }); + + describe('extractSpanMetadata', () => { + it('should extract all metadata fields from a complete span', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + p: '1111222233334444', + k: 1, + ts: 1609459200000, + d: 100 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result).to.deep.equal({ + traceId: '00000000000000001234567890abcdef', + spanId: 'fedcba0987654321', + parentSpanId: '1111222233334444', + kind: OTLP_SPAN_KINDS.SERVER, + startTimeUnixNano: '1609459200000000000', + endTimeUnixNano: '1609459200100000000', + name: 'test.span', + status: { code: OTLP_STATUS_CODES.UNSET } + }); + + expect(mockMapper.spanName.calledOnceWith(span)).to.be.true; + expect(mockMapper.spanStatus.calledOnceWith(span)).to.be.true; + }); + + it('should pad trace ID to 32 characters', () => { + const span = { + t: 'abc123', + s: '123', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.traceId).to.equal('00000000000000000000000000abc123'); + expect(result.traceId).to.have.lengthOf(32); + }); + + it('should pad span ID to 16 characters', () => { + const span = { + t: '1234567890abcdef', + s: 'abc', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.spanId).to.equal('0000000000000abc'); + expect(result.spanId).to.have.lengthOf(16); + }); + + it('should pad parent ID to 16 characters', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + p: '123', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.parentSpanId).to.equal('0000000000000123'); + expect(result.parentSpanId).to.have.lengthOf(16); + }); + + it('should convert span kind 1 to SERVER', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + k: 1, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.kind).to.equal(OTLP_SPAN_KINDS.SERVER); + }); + + it('should convert span kind 2 to CLIENT', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + k: 2, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.kind).to.equal(OTLP_SPAN_KINDS.CLIENT); + }); + + it('should convert span kind 3 to INTERNAL', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + k: 3, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.kind).to.equal(OTLP_SPAN_KINDS.INTERNAL); + }); + + it('should convert unknown span kind to UNSPECIFIED', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + k: 99, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.kind).to.equal(OTLP_SPAN_KINDS.UNSPECIFIED); + }); + + it('should convert span kind 0 to UNSPECIFIED', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + k: 0, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.kind).to.equal(OTLP_SPAN_KINDS.UNSPECIFIED); + }); + + it('should convert timestamps to nanoseconds', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1609459200000, // milliseconds + d: 150 // milliseconds + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.startTimeUnixNano).to.equal('1609459200000000000'); + expect(result.endTimeUnixNano).to.equal('1609459200150000000'); + }); + + it('should calculate end time from start time and duration', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1000, + d: 500 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.startTimeUnixNano).to.equal('1000000000'); + expect(result.endTimeUnixNano).to.equal('1500000000'); + }); + + it('should handle zero duration', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1000, + d: 0 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.startTimeUnixNano).to.equal('1000000000'); + expect(result.endTimeUnixNano).to.equal('1000000000'); + }); + + it('should handle missing duration (defaults to 0)', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1000 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.startTimeUnixNano).to.equal('1000000000'); + expect(result.endTimeUnixNano).to.equal('1000000000'); + }); + + it('should handle missing start time (defaults to 0)', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + d: 100 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.endTimeUnixNano).to.equal('100000000'); + }); + + it('should exclude undefined trace ID', () => { + const span = { + s: 'fedcba0987654321', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result).to.not.have.property('traceId'); + }); + + it('should exclude undefined span ID', () => { + const span = { + t: '1234567890abcdef', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result).to.not.have.property('spanId'); + }); + + it('should exclude undefined parent ID', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result).to.not.have.property('parentSpanId'); + }); + + it('should include undefined span kind as unspecified', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result).to.have.property('kind'); + }); + + it('should exclude undefined start time', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result).to.not.have.property('startTimeUnixNano'); + }); + + it('should handle empty string trace ID', () => { + const span = { + t: '', + s: 'fedcba0987654321', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.traceId).to.equal(''); + }); + + it('should handle empty string span ID', () => { + const span = { + t: '1234567890abcdef', + s: '', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.spanId).to.equal(''); + }); + + it('should handle empty string parent ID', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + p: '', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.parentSpanId).to.equal(''); + }); + + it('should handle zero values for IDs', () => { + const span = { + t: 0, + s: 0, + p: 0, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.traceId).to.equal(''); + expect(result.spanId).to.equal(''); + expect(result.parentSpanId).to.equal(''); + }); + + it('should call mapper.spanName with the span', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1000, + d: 10 + }; + + mockMapper.spanName.returns('custom.span.name'); + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.name).to.equal('custom.span.name'); + expect(mockMapper.spanName.calledOnceWith(span)).to.be.true; + }); + + it('should call mapper.spanStatus with the span', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1000, + d: 10, + ec: 1 + }; + + const expectedStatus = { + code: OTLP_STATUS_CODES.ERROR, + message: 'Test error' + }; + mockMapper.spanStatus.returns(expectedStatus); + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.status).to.deep.equal(expectedStatus); + expect(mockMapper.spanStatus.calledOnceWith(span)).to.be.true; + }); + + it('should handle numeric trace ID', () => { + const span = { + t: 123456789, + s: 'fedcba0987654321', + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.traceId).to.equal('00000000000000000000000123456789'); + }); + + it('should handle numeric span ID', () => { + const span = { + t: '1234567890abcdef', + s: 987654321, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.spanId).to.equal('0000000987654321'); + expect(result.spanId).to.have.lengthOf(16); + }); + + it('should handle numeric parent ID', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + p: 111222333, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.parentSpanId).to.equal('0000000111222333'); + expect(result.parentSpanId).to.have.lengthOf(16); + }); + + it('should handle large timestamp values', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 9999999999999, + d: 1000 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.startTimeUnixNano).to.equal('9999999999999000000'); + expect(result.endTimeUnixNano).to.equal('10000000000999000000'); + }); + + it('should handle fractional timestamp values', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + ts: 1000.5, + d: 10.5 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.startTimeUnixNano).to.equal('1000500000'); + expect(result.endTimeUnixNano).to.equal('1011000000'); + }); + + it('should handle all fields being undefined except required ones', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321' + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result).to.have.property('traceId'); + expect(result).to.have.property('spanId'); + expect(result).to.have.property('name'); + expect(result).to.have.property('status'); + expect(result).to.have.property('kind'); + expect(result).to.have.property('endTimeUnixNano'); + expect(result).to.not.have.property('parentSpanId'); + expect(result).to.not.have.property('startTimeUnixNano'); + }); + + it('should handle root span (no parent)', () => { + const span = { + t: '1234567890abcdef', + s: 'fedcba0987654321', + k: 1, + ts: 1000, + d: 10 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result).to.not.have.property('parentSpanId'); + expect(result).to.have.property('traceId'); + expect(result).to.have.property('spanId'); + }); + + it('should preserve all metadata fields in correct format', () => { + const span = { + t: 'abc123def456', + s: '123456789abc', + p: 'parent123456', + k: 2, + ts: 1234567890, + d: 999 + }; + + const result = extractSpanMetadata(span, mockMapper); + + expect(result.traceId).to.be.a('string'); + expect(result.spanId).to.be.a('string'); + expect(result.parentSpanId).to.be.a('string'); + expect(result.kind).to.be.a('number'); + expect(result.startTimeUnixNano).to.be.a('string'); + expect(result.endTimeUnixNano).to.be.a('string'); + expect(result.name).to.be.a('string'); + expect(result.status).to.be.an('object'); + }); + }); +}); diff --git a/packages/core/test/otlpExporter/traces/util_test.js b/packages/core/test/otlpExporter/traces/util_test.js new file mode 100644 index 0000000000..1af2df30cb --- /dev/null +++ b/packages/core/test/otlpExporter/traces/util_test.js @@ -0,0 +1,132 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const expect = require('chai').expect; + +const { isLogSpan } = require('../../../src/otlpExporter/traces/util'); + +describe('otlpExporter/traces/util', () => { + describe('isLogSpan', () => { + describe('log span detection via data.log', () => { + it('should return true for span with data.log property', () => { + const span = { + t: '123', + s: '456', + data: { + log: { + message: 'test log message' + } + } + }; + + expect(isLogSpan(span)).to.be.true; + }); + + it('should return true for span with empty data.log object', () => { + const span = { + t: '123', + s: '456', + data: { + log: {} + } + }; + + expect(isLogSpan(span)).to.be.true; + }); + + it('should return true for span with data.log containing multiple properties', () => { + const span = { + t: '123', + s: '456', + data: { + log: { + message: 'error occurred', + level: 'error', + timestamp: 1234567890 + } + } + }; + + expect(isLogSpan(span)).to.be.true; + }); + }); + + describe('log span detection via span name prefix', () => { + it('should return true for span with name starting with "log."', () => { + const span = { + t: '123', + s: '456', + n: 'log.console' + }; + + expect(isLogSpan(span)).to.be.true; + }); + + it('should return true for span with name "log.winston"', () => { + const span = { + t: '123', + s: '456', + n: 'log.winston' + }; + + expect(isLogSpan(span)).to.be.true; + }); + + it('should return true for any span name starting with "log."', () => { + const span = { + t: '123', + s: '456', + n: 'log.custom' + }; + + expect(isLogSpan(span)).to.be.true; + }); + + it('should return true for span with just "log." as name', () => { + const span = { + t: '123', + s: '456', + n: 'log.' + }; + + expect(isLogSpan(span)).to.be.true; + }); + }); + + describe('non-log span detection', () => { + it('should return false for HTTP span', () => { + const span = { + t: '123', + s: '456', + n: 'node.http.server', + data: { + http: { + method: 'GET', + url: '/api/users' + } + } + }; + + expect(isLogSpan(span)).to.be.false; + }); + + it('should return false for database span', () => { + const span = { + t: '123', + s: '456', + n: 'postgres', + data: { + pg: { + stmt: 'SELECT * FROM users' + } + } + }; + + expect(isLogSpan(span)).to.be.false; + }); + }); + }); +}); diff --git a/packages/core/test/tracing/spanBuffer_test.js b/packages/core/test/tracing/spanBuffer_test.js index 695cb77424..30031a5b64 100644 --- a/packages/core/test/tracing/spanBuffer_test.js +++ b/packages/core/test/tracing/spanBuffer_test.js @@ -35,7 +35,10 @@ describe('tracing/spanBuffer', () => { tracing: { maxBufferedSpans: 1000, forceTransmissionStartingAt: 500, - transmissionDelay: 1000 + transmissionDelay: 1000, + otlp: { + enabled: false + } } }, downstreamConnectionStub @@ -120,7 +123,10 @@ describe('tracing/spanBuffer', () => { maxBufferedSpans: 1000, forceTransmissionStartingAt: 2, initialTransmissionDelay: 200, - transmissionDelay: 200 + transmissionDelay: 200, + otlp: { + enabled: false + } } }, downstreamConnectionStub @@ -218,7 +224,10 @@ describe('tracing/spanBuffer', () => { maxBufferedSpans: 1000, forceTransmissionStartingAt: 500, transmissionDelay: 1000, - spanBatchingEnabled: true + spanBatchingEnabled: true, + otlp: { + enabled: false + } } }, { @@ -570,7 +579,10 @@ describe('tracing/spanBuffer', () => { maxBufferedSpans: 1000, forceTransmissionStartingAt: 500, transmissionDelay: 1000, - spanBatchingEnabled: false + spanBatchingEnabled: false, + otlp: { + enabled: false + } } }, { @@ -599,44 +611,353 @@ describe('tracing/spanBuffer', () => { }); }); - describe('when applying span transformations', () => { - beforeEach(() => { - spanBuffer.setTransmitImmediate(false); - spanBuffer.activate({ - tracing: { - spanBatchingEnabled: false - } + describe('OTLP transformation', () => { + describe('when OTLP is disabled', () => { + before(() => { + downstreamConnectionStub = { + sendSpans: sinon.stub() + }; + + spanBuffer.init( + { + logger: testUtils.createFakeLogger(), + tracing: { + maxBufferedSpans: 1000, + forceTransmissionStartingAt: 500, + transmissionDelay: 1000, + spanBatchingEnabled: false, + otlp: { + enabled: false + } + } + }, + downstreamConnectionStub + ); }); - }); - afterEach(() => spanBuffer.deactivate()); - const span = { - t: '1234567803', - s: '1234567892', - p: '1234567891', - n: 'redis', - k: 2, - data: { - redis: { - operation: 'get' - } - } - }; + beforeEach(() => { + spanBuffer.activate({ + tracing: { + spanBatchingEnabled: false, + otlp: { + enabled: false + } + } + }); + downstreamConnectionStub.sendSpans.resetHistory(); + }); - it('should correctly transform the Redis span by renaming the operation property', () => { - span.data.redis.operation = 'set'; - spanBuffer.addSpan(span); - const spans = spanBuffer.getAndResetSpans(); - expect(spans).to.have.lengthOf(1); - expect(span.data.redis.command).to.equal('set'); - expect(span.data.redis).to.not.have.property('operation'); + afterEach(() => spanBuffer.deactivate()); + + it('should keep spans in Instana internal format', () => { + spanBuffer.setTransmitImmediate(true); + + const span = { + t: '1234567803', + s: '1234567892', + p: '1234567891', + n: 'redis', + k: 2, + ts: timestamp(Date.now()), + d: 10, + ec: 0, + data: { + redis: { + command: 'get', + connection: 'localhost:6379' + } + } + }; + + spanBuffer.addSpan(span); + + expect(downstreamConnectionStub.sendSpans.calledOnce).to.be.true; + const sentPayload = downstreamConnectionStub.sendSpans.getCall(0).args[0]; + expect(Array.isArray(sentPayload)).to.be.true; + expect(sentPayload).to.have.lengthOf(1); + expect(sentPayload[0]).to.have.property('t'); + expect(sentPayload[0]).to.have.property('s'); + expect(sentPayload[0]).to.have.property('n'); + expect(sentPayload[0].n).to.equal('redis'); + }); }); - it('should return the span unchanged for non-mapped types', () => { - span.n = 'http'; - spanBuffer.addSpan(span); - const spans = spanBuffer.getAndResetSpans(); - expect(spans).to.have.lengthOf(1); - expect(span).to.deep.equal(span); + + describe('when OTLP is enabled', () => { + before(() => { + downstreamConnectionStub = { + sendSpans: sinon.stub() + }; + + spanBuffer.init( + { + logger: testUtils.createFakeLogger(), + tracing: { + maxBufferedSpans: 1000, + forceTransmissionStartingAt: 500, + transmissionDelay: 1000, + spanBatchingEnabled: false, + otlp: { + enabled: true + } + } + }, + downstreamConnectionStub + ); + }); + + beforeEach(() => { + spanBuffer.activate({ + tracing: { + spanBatchingEnabled: false, + otlp: { + enabled: true + } + } + }); + downstreamConnectionStub.sendSpans.resetHistory(); + }); + + afterEach(() => spanBuffer.deactivate()); + + it('should transform HTTP server spans to OTLP format', () => { + spanBuffer.setTransmitImmediate(true); + + const httpSpan = { + t: '1234567803', + s: '1234567892', + p: '1234567891', + n: 'node.http.server', + k: 1, + f: { + e: '45543', + h: 'localhost' + }, + ts: timestamp(Date.now()), + d: 25, + ec: 0, + data: { + http: { + operation: 'GET', + url: '/orders', + host: 'localhost', + status: 200 + } + } + }; + + spanBuffer.addSpan(httpSpan); + + expect(downstreamConnectionStub.sendSpans.calledOnce).to.be.true; + const sentPayload = downstreamConnectionStub.sendSpans.getCall(0).args[0]; + expect(sentPayload).to.have.property('resourceSpans'); + expect(sentPayload.resourceSpans).to.be.an('array'); + expect(sentPayload.resourceSpans).to.have.lengthOf(1); + + const resourceSpan = sentPayload.resourceSpans[0]; + expect(resourceSpan).to.have.property('resource'); + expect(resourceSpan).to.have.property('scopeSpans'); + expect(resourceSpan.scopeSpans).to.have.lengthOf(1); + + const scopeSpan = resourceSpan.scopeSpans[0]; + expect(scopeSpan).to.have.property('scope'); + expect(scopeSpan).to.have.property('spans'); + expect(scopeSpan.spans).to.have.lengthOf(1); + + const sentSpan = scopeSpan.spans[0]; + expect(sentSpan).to.have.property('traceId'); + expect(sentSpan).to.have.property('spanId'); + expect(sentSpan).to.have.property('parentSpanId'); + expect(sentSpan).to.have.property('name'); + expect(sentSpan).to.have.property('kind'); + expect(sentSpan).to.have.property('startTimeUnixNano'); + expect(sentSpan).to.have.property('endTimeUnixNano'); + expect(sentSpan).to.have.property('attributes'); + + expect(sentSpan.traceId).to.have.lengthOf(32); + expect(sentSpan.traceId).to.match(/^[0-9a-f]{32}$/); + + expect(sentSpan.kind).to.equal(2); + }); + + it('should transform HTTP client spans to OTLP format', () => { + spanBuffer.setTransmitImmediate(true); + + const httpClientSpan = { + t: '1234567803', + s: '1234567893', + p: '1234567892', + n: 'node.http.client', + k: 2, + f: { + e: '45543', + h: 'localhost' + }, + ts: timestamp(Date.now()), + d: 15, + ec: 0, + data: { + http: { + operation: 'POST', + url: 'https://api.example.com/users', + status: 201 + } + } + }; + + spanBuffer.addSpan(httpClientSpan); + + expect(downstreamConnectionStub.sendSpans.calledOnce).to.be.true; + const sentPayload = downstreamConnectionStub.sendSpans.getCall(0).args[0]; + const sentSpan = sentPayload.resourceSpans[0].scopeSpans[0].spans[0]; + expect(sentSpan.kind).to.equal(3); + expect(sentSpan.traceId).to.have.lengthOf(32); + }); + + it('should transform database spans to OTLP format', () => { + spanBuffer.setTransmitImmediate(true); + + const dbSpan = { + t: '1234567803', + s: '1234567894', + p: '1234567892', + n: 'postgres', + k: 2, + f: { + e: '45543', + h: 'localhost' + }, + ts: timestamp(Date.now()), + d: 8, + ec: 0, + data: { + pg: { + stmt: 'SELECT * FROM users WHERE id = $1', + host: 'localhost', + port: 5432, + db: 'myapp' + } + } + }; + + spanBuffer.addSpan(dbSpan); + + expect(downstreamConnectionStub.sendSpans.calledOnce).to.be.true; + const sentPayload = downstreamConnectionStub.sendSpans.getCall(0).args[0]; + const sentSpan = sentPayload.resourceSpans[0].scopeSpans[0].spans[0]; + + expect(sentSpan).to.have.property('traceId'); + expect(sentSpan.traceId).to.have.lengthOf(32); + expect(sentSpan).to.have.property('attributes'); + }); + + it('should transform multiple spans in a batch to OTLP format', () => { + spanBuffer.setTransmitImmediate(true); + + const span1 = { + t: '1234567803', + s: '1234567892', + p: '1234567891', + n: 'node.http.server', + k: 1, + f: { e: '45543', h: 'localhost' }, + ts: timestamp(Date.now()), + d: 25, + ec: 0, + data: { http: { operation: 'GET', url: '/api/users', status: 200 } } + }; + + const span2 = { + t: '1234567803', + s: '1234567893', + p: '1234567892', + n: 'postgres', + k: 2, + f: { e: '45543', h: 'localhost' }, + ts: timestamp(Date.now() + 5), + d: 10, + ec: 0, + data: { pg: { stmt: 'SELECT * FROM users', db: 'myapp' } } + }; + + spanBuffer.addSpan(span1); + spanBuffer.addSpan(span2); + + expect(downstreamConnectionStub.sendSpans.callCount).to.equal(2); + + const payload1 = downstreamConnectionStub.sendSpans.getCall(0).args[0]; + const payload2 = downstreamConnectionStub.sendSpans.getCall(1).args[0]; + + expect(payload1.resourceSpans[0].scopeSpans[0].spans).to.have.lengthOf(1); + expect(payload2.resourceSpans[0].scopeSpans[0].spans).to.have.lengthOf(1); + }); + + it('should handle spans with error count in OTLP format', () => { + spanBuffer.setTransmitImmediate(true); + + const errorSpan = { + t: '1234567803', + s: '1234567892', + p: '1234567891', + n: 'node.http.server', + k: 1, + f: { e: '45543', h: 'localhost' }, + ts: timestamp(Date.now()), + d: 25, + ec: 1, + data: { + http: { + operation: 'GET', + url: '/error', + status: 500, + error: 'Internal Server Error' + } + } + }; + + spanBuffer.addSpan(errorSpan); + + expect(downstreamConnectionStub.sendSpans.calledOnce).to.be.true; + const sentPayload = downstreamConnectionStub.sendSpans.getCall(0).args[0]; + const sentSpan = sentPayload.resourceSpans[0].scopeSpans[0].spans[0]; + + expect(sentSpan).to.have.property('traceId'); + expect(sentSpan.traceId).to.have.lengthOf(32); + }); + + it('should include resource attributes in OTLP payload', () => { + spanBuffer.setTransmitImmediate(true); + + const span = { + t: '1234567803', + s: '1234567892', + p: '1234567891', + n: 'node.http.server', + k: 1, + f: { + e: '45543', + h: 'localhost' + }, + ts: timestamp(Date.now()), + d: 25, + ec: 0, + data: { + http: { + operation: 'GET', + url: '/test', + status: 200 + } + } + }; + + spanBuffer.addSpan(span); + + expect(downstreamConnectionStub.sendSpans.calledOnce).to.be.true; + const sentPayload = downstreamConnectionStub.sendSpans.getCall(0).args[0]; + const resource = sentPayload.resourceSpans[0].resource; + + expect(resource).to.have.property('attributes'); + expect(resource.attributes).to.be.an('array'); + }); }); }); });