diff --git a/packages/collector/src/announceCycle/unannounced.js b/packages/collector/src/announceCycle/unannounced.js index dc22d6850a..b7cd75a1ff 100644 --- a/packages/collector/src/announceCycle/unannounced.js +++ b/packages/collector/src/announceCycle/unannounced.js @@ -43,6 +43,7 @@ const maxRetryDelay = 60 * 1000; // one minute * @typedef {Object} TracingConfig * @property {Array.} [extra-http-headers] * @property {KafkaTracingConfig} [kafka] + * @property {Object.} [ignore-endpoints] * @property {boolean} [span-batching-enabled] */ @@ -126,6 +127,7 @@ function applyAgentConfiguration(agentResponse) { applyExtraHttpHeaderConfiguration(agentResponse); applyKafkaTracingConfiguration(agentResponse); applySpanBatchingConfiguration(agentResponse); + applyIgnoreEndpointsConfiguration(agentResponse); } /** @@ -220,3 +222,34 @@ function applySpanBatchingConfiguration(agentResponse) { agentOpts.config.tracing.spanBatchingEnabled = true; } } + +/** + * - The agent configuration currently uses a pipe ('|') as a separator for endpoints. + * - This function supports both ('|') and comma (',') to ensure future compatibility. + * - Additionally, it supports the `string[]` format for backward compatibility, + * as this was the previously used standard. The final design decision is not yet completed. + * https://github.ibm.com/instana/requests-for-discussion/pull/84 + * + * @param {AgentAnnounceResponse} agentResponse + */ +function applyIgnoreEndpointsConfiguration(agentResponse) { + if (agentResponse?.tracing?.['ignore-endpoints']) { + const endpointTracingConfigFromAgent = agentResponse.tracing['ignore-endpoints']; + + const endpointTracingConfig = Object.fromEntries( + Object.entries(endpointTracingConfigFromAgent).map(([service, endpoints]) => { + let normalizedEndpoints = null; + if (typeof endpoints === 'string') { + normalizedEndpoints = endpoints.split(/[|,]/).map(endpoint => endpoint?.trim()?.toLowerCase()); + } else if (Array.isArray(endpoints)) { + normalizedEndpoints = endpoints.map(endpoint => endpoint?.toLowerCase()); + } + + return [service.toLowerCase(), normalizedEndpoints]; + }) + ); + + ensureNestedObjectExists(agentOpts.config, ['tracing', 'ignoreEndpoints']); + agentOpts.config.tracing.ignoreEndpoints = endpointTracingConfig; + } +} diff --git a/packages/collector/test/announceCycle/unannounced_test.js b/packages/collector/test/announceCycle/unannounced_test.js index 37fbf9e790..1ab1a26158 100644 --- a/packages/collector/test/announceCycle/unannounced_test.js +++ b/packages/collector/test/announceCycle/unannounced_test.js @@ -213,6 +213,97 @@ describe('unannounced state', () => { } }); }); + it('should apply the configuration to ignore a single endpoint for a package', done => { + prepareAnnounceResponse({ + tracing: { + 'ignore-endpoints': { + redis: 'get' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + ignoreEndpoints: { + redis: ['get'] + } + } + }); + done(); + } + }); + }); + + it('should apply the configuration to ignore multiple endpoints for a package', done => { + prepareAnnounceResponse({ + tracing: { + 'ignore-endpoints': { + redis: 'SET|GET' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + ignoreEndpoints: { + redis: ['set', 'get'] + } + } + }); + done(); + } + }); + }); + + it('should apply tracing configuration to ignore specified endpoints across different packages', done => { + prepareAnnounceResponse({ + tracing: { + 'ignore-endpoints': { + REDIS: 'get|set', + dynamodb: 'query' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + ignoreEndpoints: { + redis: ['get', 'set'], + dynamodb: ['query'] + } + } + }); + done(); + } + }); + }); + + it('should apply tracing configuration to ignore endpoints when specified using array format', done => { + prepareAnnounceResponse({ + tracing: { + 'ignore-endpoints': { + REDIS: ['get', 'type'], + dynamodb: 'query' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + ignoreEndpoints: { + redis: ['get', 'type'], + dynamodb: ['query'] + } + } + }); + done(); + } + }); + }); function prepareAnnounceResponse(announceResponse) { agentConnectionStub.announceNodeCollector.callsArgWithAsync(0, null, JSON.stringify(announceResponse)); diff --git a/packages/collector/test/apps/agentStub.js b/packages/collector/test/apps/agentStub.js index dd0269484c..ebf2f62cd0 100644 --- a/packages/collector/test/apps/agentStub.js +++ b/packages/collector/test/apps/agentStub.js @@ -38,6 +38,7 @@ const enableSpanBatching = process.env.ENABLE_SPANBATCHING === 'true'; const kafkaTraceCorrelation = process.env.KAFKA_TRACE_CORRELATION ? process.env.KAFKA_TRACE_CORRELATION === 'true' : null; +const ignoreEndpoints = process.env.IGNORE_ENDPOINTS && JSON.parse(process.env.IGNORE_ENDPOINTS); let discoveries = {}; let rejectAnnounceAttempts = 0; @@ -86,7 +87,7 @@ app.put('/com.instana.plugin.nodejs.discovery', (req, res) => { } }; - if (kafkaTraceCorrelation != null || extraHeaders.length > 0 || enableSpanBatching) { + if (kafkaTraceCorrelation != null || extraHeaders.length > 0 || enableSpanBatching || ignoreEndpoints) { response.tracing = {}; if (extraHeaders.length > 0) { @@ -103,8 +104,10 @@ app.put('/com.instana.plugin.nodejs.discovery', (req, res) => { if (enableSpanBatching) { response.tracing['span-batching-enabled'] = true; } + if (ignoreEndpoints) { + response.tracing['ignore-endpoints'] = ignoreEndpoints; + } } - res.send(response); }); diff --git a/packages/collector/test/apps/agentStubControls.js b/packages/collector/test/apps/agentStubControls.js index eee7a6c027..755d78af16 100644 --- a/packages/collector/test/apps/agentStubControls.js +++ b/packages/collector/test/apps/agentStubControls.js @@ -43,6 +43,11 @@ class AgentStubControls { env.KAFKA_TRACE_CORRELATION = opts.kafkaConfig.traceCorrelation.toString(); } } + // This is not the INSTANA_IGNORE_ENDPOINTS env. We use this "IGNORE_ENDPOINTS" env for the fake agent to + // serve the ignore endpoints config to our tracer. + if (opts.ignoreEndpoints) { + env.IGNORE_ENDPOINTS = JSON.stringify(opts.ignoreEndpoints); + } this.agentStub = spawn('node', [path.join(__dirname, 'agentStub.js')], { stdio: config.getAppStdio(), diff --git a/packages/collector/test/tracing/database/ioredis/test.js b/packages/collector/test/tracing/database/ioredis/test.js index 463eacece1..7e1c303b84 100644 --- a/packages/collector/test/tracing/database/ioredis/test.js +++ b/packages/collector/test/tracing/database/ioredis/test.js @@ -1281,4 +1281,142 @@ function checkConnection(span, setupType) { }); } }); + mochaSuiteFn('ignore-endpoints:', function () { + describe('when ignore-endpoints is enabled via agent configuration', () => { + const { AgentStubControls } = require('../../../apps/agentStubControls'); + const customAgentControls = new AgentStubControls(); + let controls; + + before(async () => { + await customAgentControls.startAgent({ + ignoreEndpoints: { redis: 'get|set' } + }); + + controls = new ProcessControls({ + agentControls: customAgentControls, + dirname: __dirname + }); + await controls.startAndWaitForAgentConnection(); + }); + + beforeEach(async () => { + await customAgentControls.clearReceivedTraceData(); + }); + + after(async () => { + await customAgentControls.stopAgent(); + await controls.stop(); + }); + + it('should ignore redis spans for ignored endpoints (get, set)', async () => { + await controls + .sendRequest({ + method: 'POST', + path: '/values', + qs: { + key: 'discount', + value: 50 + } + }) + .then(async () => { + return retry(async () => { + const spans = await customAgentControls.getSpans(); + // 1 x http entry span + expect(spans.length).to.equal(1); + spans.forEach(span => { + expect(span.n).not.to.equal('redis'); + }); + expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.data.http.method).to.equal('POST') + ]); + }); + }); + }); + }); + describe('when ignore-endpoints is enabled via tracing configuration', async () => { + globalAgent.setUpCleanUpHooks(); + const agentControls = globalAgent.instance; + let controls; + + before(async () => { + controls = new ProcessControls({ + useGlobalAgent: true, + dirname: __dirname, + env: { + INSTANA_IGNORE_ENDPOINTS: '{"redis": ["get"]}' + } + }); + await controls.start(); + }); + + beforeEach(async () => { + await agentControls.clearReceivedTraceData(); + }); + + after(async () => { + await controls.stop(); + }); + + afterEach(async () => { + await controls.clearIpcMessages(); + }); + it('should ignore spans for ignored endpoint (get)', async function () { + await controls + .sendRequest({ + method: 'GET', + path: '/values', + qs: { + key: 'discount', + value: 50 + } + }) + + .then(async () => { + return retry(async () => { + const spans = await agentControls.getSpans(); + // 1 x http entry span + expect(spans.length).to.equal(1); + spans.forEach(span => { + expect(span.n).not.to.equal('redis'); + }); + + expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.data.http.method).to.equal('GET') + ]); + }); + }); + }); + it('should not ignore spans for endpoints that are not in the ignore list', async () => { + await controls + .sendRequest({ + method: 'POST', + path: '/values', + qs: { + key: 'discount', + value: 50 + } + }) + .then(async () => { + return retry(async () => { + const spans = await agentControls.getSpans(); + expect(spans.length).to.equal(2); + + const entrySpan = expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.data.http.method).to.equal('POST') + ]); + + expectExactlyOneMatching(spans, [ + span => expect(span.t).to.equal(entrySpan.t), + span => expect(span.p).to.equal(entrySpan.s), + span => expect(span.n).to.equal('redis'), + span => expect(span.data.redis.command).to.equal('set') + ]); + }); + }); + }); + }); + }); }); diff --git a/packages/collector/test/tracing/database/redis/test.js b/packages/collector/test/tracing/database/redis/test.js index 70b497cde1..9217d56e6c 100644 --- a/packages/collector/test/tracing/database/redis/test.js +++ b/packages/collector/test/tracing/database/redis/test.js @@ -852,6 +852,160 @@ const globalAgent = require('../../../globalAgent'); }); } }); + mochaSuiteFn('ignore-endpoints:', function () { + describe('when ignore-endpoints is enabled via agent configuration', () => { + const { AgentStubControls } = require('../../../apps/agentStubControls'); + const customAgentControls = new AgentStubControls(); + let controls; + before(async () => { + await customAgentControls.startAgent({ + ignoreEndpoints: { redis: 'get|set' } + }); + + controls = new ProcessControls({ + agentControls: customAgentControls, + appPath: + redisVersion === 'latest' ? path.join(__dirname, 'app.js') : path.join(__dirname, 'legacyApp.js'), + env: { + REDIS_VERSION: redisVersion, + REDIS_PKG: redisPkg + } + }); + await controls.startAndWaitForAgentConnection(5000, Date.now() + 1000 * 60 * 5); + }); + + beforeEach(async () => { + await customAgentControls.clearReceivedTraceData(); + }); + + after(async () => { + await customAgentControls.stopAgent(); + await controls.stop(); + }); + + it('should ignore redis spans for ignored endpoints (get, set)', async () => { + await controls + .sendRequest({ + method: 'POST', + path: '/values', + qs: { + key: 'discount', + value: 50 + } + }) + .then(async () => { + return retry(async () => { + const spans = await customAgentControls.getSpans(); + // 1 x http entry span + // 1 x http client span + expect(spans.length).to.equal(2); + spans.forEach(span => { + expect(span.n).not.to.equal('redis'); + }); + }); + }); + }); + }); + describe('ignore-endpoints enabled via tracing config', async () => { + globalAgent.setUpCleanUpHooks(); + let controls; + + before(async () => { + controls = new ProcessControls({ + useGlobalAgent: true, + appPath: + redisVersion === 'latest' ? path.join(__dirname, 'app.js') : path.join(__dirname, 'legacyApp.js'), + env: { + REDIS_VERSION: redisVersion, + REDIS_PKG: redisPkg, + INSTANA_IGNORE_ENDPOINTS: '{"redis": ["get","set"]}' + } + }); + await controls.start(); + }); + + beforeEach(async () => { + await agentControls.clearReceivedTraceData(); + }); + + before(async () => { + await controls.sendRequest({ + method: 'POST', + path: '/clearkeys' + }); + }); + + after(async () => { + await controls.stop(); + }); + + afterEach(async () => { + await controls.clearIpcMessages(); + }); + it('should ignore spans for configured ignore endpoints(get,set)', async function () { + await controls + .sendRequest({ + method: 'POST', + path: '/values', + qs: { + key: 'price', + value: 42 + } + }) + .then(() => + controls.sendRequest({ + method: 'GET', + path: '/values', + qs: { + key: 'price' + } + }) + ) + .then(async response => { + expect(String(response)).to.equal('42'); + + return retry(async () => { + const spans = await agentControls.getSpans(); + // 2 x http entry span + // 2 x http client span + expect(spans.length).to.equal(4); + + spans.forEach(span => { + expect(span.n).not.to.equal('redis'); + }); + + expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.data.http.method).to.equal('POST') + ]); + + expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.data.http.method).to.equal('GET') + ]); + }); + }); + }); + it('should not ignore spans for endpoints that are not in the ignore list', async () => { + await controls + .sendRequest({ + method: 'GET', + path: '/hset-hget' + }) + .then(async () => { + return retry(async () => { + const spans = await agentControls.getSpans(); + // 1 x http entry span + // 1 x http client span + // 1 x redis hSet span + // 1 x redis hGetAll span + expect(spans.length).to.equal(4); + expect(spans.some(span => span.n === 'redis')).to.be.true; + }); + }); + }); + }); + }); }); function verifyHttpExit(controls, spans, parent) { diff --git a/packages/collector/test/tracing/messaging/bull/test.js b/packages/collector/test/tracing/messaging/bull/test.js index aa5fb56b7b..bfd573e863 100644 --- a/packages/collector/test/tracing/messaging/bull/test.js +++ b/packages/collector/test/tracing/messaging/bull/test.js @@ -35,8 +35,7 @@ if (process.env.BULL_QUEUE_NAME) { queueName = `${process.env.BULL_QUEUE_NAME}${semver.major(process.versions.node)}`; } -const mochaSuiteFn = - supportedVersion(process.versions.node) ? describe : describe.skip; +const mochaSuiteFn = supportedVersion(process.versions.node) ? describe : describe.skip; const retryTime = 1000; diff --git a/packages/core/src/tracing/backend_mappers/index.js b/packages/core/src/tracing/backend_mappers/index.js new file mode 100644 index 0000000000..831d0036dd --- /dev/null +++ b/packages/core/src/tracing/backend_mappers/index.js @@ -0,0 +1,17 @@ +/* + * (c) Copyright IBM Corp. 2024 + */ + +'use strict'; + +module.exports = { + get transform() { + return (/** @type {import('../../core').InstanaBaseSpan} */ span) => { + try { + return require(`./${span.n}_mapper`).transform(span); + } catch { + return span; + } + }; + } +}; diff --git a/packages/core/src/tracing/backend_mappers/redis_mapper.js b/packages/core/src/tracing/backend_mappers/redis_mapper.js new file mode 100644 index 0000000000..e470b011c1 --- /dev/null +++ b/packages/core/src/tracing/backend_mappers/redis_mapper.js @@ -0,0 +1,29 @@ +/* + * (c) Copyright IBM Corp. 2024 + */ + +'use strict'; + +const fieldMappings = { + // internal-format: backend-format + operation: 'command' +}; + +/** + * Transforms Redis-related span data fields to match the backend format. + * + * @param {import('../../core').InstanaBaseSpan} span + * @returns {import('../../core').InstanaBaseSpan} The transformed span. + */ +module.exports.transform = span => { + if (span.data?.redis) { + Object.entries(fieldMappings).forEach(([internalField, backendField]) => { + if (span.data.redis[internalField]) { + span.data.redis[backendField] = span.data.redis[internalField]; + delete span.data.redis[internalField]; + } + }); + } + + return span; +}; diff --git a/packages/core/src/tracing/index.js b/packages/core/src/tracing/index.js index 8056e5265e..2560cabdd4 100644 --- a/packages/core/src/tracing/index.js +++ b/packages/core/src/tracing/index.js @@ -119,6 +119,10 @@ if (customInstrumentations.length > 0) { * @property {boolean} [traceCorrelation] */ +/** + * @typedef {Object.} IgnoreEndpoints + */ + /** @type {Array.} */ let additionalInstrumentationModules = []; /** @type {Object.} */ diff --git a/packages/core/src/tracing/instrumentation/database/ioredis.js b/packages/core/src/tracing/instrumentation/database/ioredis.js index 204849d5d7..bc83cbe082 100644 --- a/packages/core/src/tracing/instrumentation/database/ioredis.js +++ b/packages/core/src/tracing/instrumentation/database/ioredis.js @@ -61,7 +61,7 @@ function instrumentSendCommand(original) { if ( parentSpan && parentSpan.n === exports.spanName && - (parentSpan.data.redis.command === 'multi' || parentSpan.data.redis.command === 'pipeline') && + (parentSpan.data.redis.operation === 'multi' || parentSpan.data.redis.operation === 'pipeline') && command.name !== 'multi' ) { const parentSpanSubCommands = (parentSpan.data.redis.subCommands = parentSpan.data.redis.subCommands || []); @@ -88,7 +88,7 @@ function instrumentSendCommand(original) { span.data.redis = { connection, - command: command.name.toLowerCase() + operation: command.name.toLowerCase() }; callback = cls.ns.bind(onResult); @@ -157,7 +157,7 @@ function instrumentMultiOrPipelineCommand(commandName, original) { span.stack = tracingUtil.getStackTrace(wrappedInternalMultiOrPipelineCommand); span.data.redis = { connection, - command: commandName + operation: commandName }; const multiOrPipeline = original.apply(this, arguments); diff --git a/packages/core/src/tracing/instrumentation/database/redis.js b/packages/core/src/tracing/instrumentation/database/redis.js index a7476b9fb2..36fe34c0f9 100644 --- a/packages/core/src/tracing/instrumentation/database/redis.js +++ b/packages/core/src/tracing/instrumentation/database/redis.js @@ -241,7 +241,7 @@ function instrumentCommand(original, command, address, cbStyle) { span.data.redis = { connection: address || origCtx.address, - command + operation: command }; let userProvidedCallback; @@ -323,7 +323,7 @@ function instrumentMultiExec(origCtx, origArgs, original, address, isAtomic, cbS span.data.redis = { connection: address, // pipeline = batch - command: isAtomic ? 'multi' : 'pipeline' + operation: isAtomic ? 'multi' : 'pipeline' }; const subCommands = (span.data.redis.subCommands = []); diff --git a/packages/core/src/tracing/spanBuffer.js b/packages/core/src/tracing/spanBuffer.js index 6a2e860542..51b584cbb2 100644 --- a/packages/core/src/tracing/spanBuffer.js +++ b/packages/core/src/tracing/spanBuffer.js @@ -6,6 +6,8 @@ 'use strict'; const tracingMetrics = require('./metrics'); +const { applyFilter } = require('../util/spanFilter'); +const { transform } = require('./backend_mappers'); /** @type {import('../core').GenericLogger} */ let logger; @@ -87,6 +89,10 @@ const batchBucketWidth = 18; /** @type {BatchingBucketMap} */ const batchingBuckets = new Map(); +/** + * @type {import('../tracing').IgnoreEndpoints} + */ +let ignoreEndpoints; /** * @param {import('../util/normalizeConfig').InstanaConfig} config @@ -98,6 +104,7 @@ exports.init = function init(config, _downstreamConnection) { forceTransmissionStartingAt = config.tracing.forceTransmissionStartingAt; transmissionDelay = config.tracing.transmissionDelay; batchingEnabled = config.tracing.spanBatchingEnabled; + ignoreEndpoints = config.tracing.ignoreEndpoints; initialDelayBeforeSendingSpans = Math.max(transmissionDelay, minDelayBeforeSendingSpans); isFaaS = false; transmitImmediate = false; @@ -127,8 +134,13 @@ exports.activate = function activate(extraConfig) { return; } - if (extraConfig && extraConfig.tracing && extraConfig.tracing.spanBatchingEnabled) { - batchingEnabled = true; + if (extraConfig?.tracing) { + if (extraConfig.tracing.spanBatchingEnabled) { + batchingEnabled = true; + } + if (extraConfig.tracing.ignoreEndpoints) { + ignoreEndpoints = extraConfig.tracing.ignoreEndpoints; + } } isActive = true; @@ -179,6 +191,13 @@ exports.addSpan = function (span) { return; } + // Process the span, apply any transformations, and implement filtering if necessary. + const processedSpan = processSpan(span); + if (!processedSpan) { + return; + } + span = processedSpan; + if (span.t == null) { logger.warn('Span of type %s has no trace ID. Not transmitting this span', span.n); return; @@ -483,3 +502,17 @@ function removeSpansIfNecessary() { spans = spans.slice(-maxBufferedSpans); } } +/** + * @param {import('../core').InstanaBaseSpan} span + * @returns {import('../core').InstanaBaseSpan} span + */ +function processSpan(span) { + if (ignoreEndpoints) { + span = applyFilter({ span, ignoreEndpoints }); + + if (!span) { + return null; + } + } + return transform(span); +} diff --git a/packages/core/src/util/normalizeConfig.js b/packages/core/src/util/normalizeConfig.js index 44f74a65ac..cad667baec 100644 --- a/packages/core/src/util/normalizeConfig.js +++ b/packages/core/src/util/normalizeConfig.js @@ -27,6 +27,7 @@ const constants = require('../tracing/constants'); * @property {boolean} [disableW3cTraceCorrelation] * @property {KafkaTracingOptions} [kafka] * @property {boolean} [allowRootExitSpan] + * @property {import('../tracing').IgnoreEndpoints} [ignoreEndpoints] */ /** @@ -71,6 +72,7 @@ const constants = require('../tracing/constants'); * @property {AgentTracingHttpConfig} [http] * @property {AgentTracingKafkaConfig} [kafka] * @property {boolean|string} [spanBatchingEnabled] + * @property {import('../tracing').IgnoreEndpoints|{}} [ignoreEndpoints] */ /** @@ -117,7 +119,8 @@ const defaults = { disableW3cTraceCorrelation: false, kafka: { traceCorrelation: true - } + }, + ignoreEndpoints: {} }, secrets: { matcherMode: 'contains-ignore-case', @@ -218,6 +221,7 @@ function normalizeTracingConfig(config) { normalizeDisableW3cTraceCorrelation(config); normalizeTracingKafka(config); normalizeAllowRootExitSpan(config); + normalizeIgnoreEndpoints(config); } /** @@ -674,3 +678,58 @@ function normalizeSingleValue(configValue, defaultValue, configPath, envVarKey) } return configValue; } +/** + * @param {InstanaConfig} config + */ +function normalizeIgnoreEndpoints(config) { + if (!config.tracing.ignoreEndpoints) { + config.tracing.ignoreEndpoints = {}; + } + + const ignoreEndpoints = config.tracing.ignoreEndpoints; + + if (typeof ignoreEndpoints !== 'object' || Array.isArray(ignoreEndpoints)) { + logger.warn( + `Invalid tracing.ignoreEndpoints configuration. Expected an object, but received: ${JSON.stringify( + ignoreEndpoints + )}` + ); + config.tracing.ignoreEndpoints = {}; + return; + } + + if (Object.keys(ignoreEndpoints).length) { + Object.entries(ignoreEndpoints).forEach(([service, endpoints]) => { + const normalizedService = service.toLowerCase(); + + if (Array.isArray(endpoints)) { + config.tracing.ignoreEndpoints[normalizedService] = endpoints.map(endpoint => + typeof endpoint === 'string' ? endpoint.toLowerCase() : endpoint + ); + } else if (typeof endpoints === 'string') { + config.tracing.ignoreEndpoints[normalizedService] = [endpoints?.toLowerCase()]; + } else { + logger.warn( + `Invalid configuration for ${normalizedService}: tracing.ignoreEndpoints.${normalizedService} is neither a string nor an array. Value will be ignored: ${JSON.stringify( + endpoints + )}` + ); + config.tracing.ignoreEndpoints[normalizedService] = null; + } + }); + } else if (process.env.INSTANA_IGNORE_ENDPOINTS) { + // The environment variable name and its format are still under discussion. + // It is currently private and will not be documented or publicly shared. + try { + config.tracing.ignoreEndpoints = JSON.parse(process.env.INSTANA_IGNORE_ENDPOINTS); + } catch (error) { + logger.warn( + `Failed to parse INSTANA_IGNORE_ENDPOINTS: ${process.env.INSTANA_IGNORE_ENDPOINTS}. Error: ${error.message}` + ); + } + } else { + return; + } + + logger.debug(`Ignore endpoints have been configured: ${JSON.stringify(config.tracing.ignoreEndpoints)}`); +} diff --git a/packages/core/src/util/spanFilter.js b/packages/core/src/util/spanFilter.js new file mode 100644 index 0000000000..b75d205c03 --- /dev/null +++ b/packages/core/src/util/spanFilter.js @@ -0,0 +1,45 @@ +/* + * (c) Copyright IBM Corp. 2024 + */ + +'use strict'; + +// List of span types to allowed to ignore +const IGNORABLE_SPAN_TYPES = ['redis']; + +/** + * @param {import('../core').InstanaBaseSpan} span + * @param {import('../tracing').IgnoreEndpoints} endpoints + * @returns {boolean} + */ +function shouldIgnore(span, endpoints) { + // Skip if the span type is not in the ignored list + if (!IGNORABLE_SPAN_TYPES.includes(span.n)) { + return false; + } + const operation = span.data?.[span.n]?.operation; + + if (operation && endpoints[span.n]) { + const endpoint = endpoints[span.n]; + if (Array.isArray(endpoint)) { + return endpoint.some(op => op === operation); + } else if (typeof endpoint === 'string') { + return endpoint === operation; + } + } + + return false; +} + +/** + * @param {{ span: import('../core').InstanaBaseSpan, ignoreEndpoints: import('../tracing').IgnoreEndpoints}} params + * @returns {import('../core').InstanaBaseSpan | null} + */ +function applyFilter({ span, ignoreEndpoints }) { + if (ignoreEndpoints && shouldIgnore(span, ignoreEndpoints)) { + return null; + } + return span; +} + +module.exports = { applyFilter }; diff --git a/packages/core/test/tracing/backend_mappers/mapper_test.js b/packages/core/test/tracing/backend_mappers/mapper_test.js new file mode 100644 index 0000000000..94105c2c96 --- /dev/null +++ b/packages/core/test/tracing/backend_mappers/mapper_test.js @@ -0,0 +1,43 @@ +/* + * (c) Copyright IBM Corp. 2024 + */ + +'use strict'; + +const expect = require('chai').expect; +const { transform } = require('../../../src/tracing/backend_mappers'); + +describe('tracing/backend_mappers', () => { + let span; + + beforeEach(() => { + span = { n: 'redis', t: '1234567803', s: '1234567892', p: '1234567891', data: { redis: { operation: 'GET' } } }; + }); + + describe('should invoke transform function', () => { + it('should transform redis span using the redis mapper', () => { + const result = transform(span); + expect(result.data.redis.command).equal('GET'); + expect(result.data.redis).to.not.have.property('operation'); + }); + it('should not modify fields that need not be transformed in the redis span', () => { + span = { data: { redis: { command: 'SET' }, otherField: 'value' } }; + + const result = transform(span); + expect(result.data.redis).to.not.have.property('operation'); + expect(result.data.redis.command).to.equal('SET'); + expect(result.data.otherField).to.equal('value'); + }); + + it('should return the span unmodified if no mapper is found', () => { + span.n = 'http'; + const result = transform(span); + expect(result).to.equal(span); + }); + + it('should cache the mapper after the first load', () => { + transform(span); + expect(transform(span)).to.deep.equal(span); + }); + }); +}); diff --git a/packages/core/test/tracing/spanBuffer_test.js b/packages/core/test/tracing/spanBuffer_test.js index f31bbb6ce0..9787bcbe79 100644 --- a/packages/core/test/tracing/spanBuffer_test.js +++ b/packages/core/test/tracing/spanBuffer_test.js @@ -566,6 +566,59 @@ describe('tracing/spanBuffer', () => { verifyNoBatching(span1, span2); }); }); + describe('when ignoreEndpoints is configured', () => { + before(() => { + spanBuffer.init( + { + tracing: { + ignoreEndpoints: { redis: ['get'] } + } + }, + { + /* downstreamConnection */ + sendSpans: function () {} + } + ); + }); + + beforeEach(() => spanBuffer.activate()); + + afterEach(() => spanBuffer.deactivate()); + const span = { + t: '1234567803', + s: '1234567892', + p: '1234567891', + n: 'redis', + k: 2, + data: { + redis: { + operation: 'get' + } + } + }; + + it('should ignore the redis span when the operation is listed in the ignoreEndpoints config', () => { + spanBuffer.addSpan(span); + const spans = spanBuffer.getAndResetSpans(); + expect(spans).to.have.lengthOf(0); + }); + + it('should transform the redis span if the operation is not specified in the ignoreEndpoints config', () => { + 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'); + }); + it('should return the span unmodified for unsupported ignore endpoints', () => { + span.n = 'http'; + spanBuffer.addSpan(span); + const spans = spanBuffer.getAndResetSpans(); + expect(spans).to.have.lengthOf(1); + expect(span).to.deep.equal(span); + }); + }); }); function timestamp(offset) { diff --git a/packages/core/test/util/normalizeConfig_test.js b/packages/core/test/util/normalizeConfig_test.js index 3cbc6402b0..4b1be86dc4 100644 --- a/packages/core/test/util/normalizeConfig_test.js +++ b/packages/core/test/util/normalizeConfig_test.js @@ -471,6 +471,46 @@ describe('util.normalizeConfig', () => { const config = normalizeConfig(); expect(config.tracing.allowRootExitSpan).to.equal(true); }); + it('should not set ignore endpoints tracers by default', () => { + const config = normalizeConfig(); + expect(config.tracing.ignoreEndpoints).to.deep.equal({}); + }); + + it('should apply ignore endpoints if the INSTANA_IGNORE_ENDPOINTS is set and valid', () => { + process.env.INSTANA_IGNORE_ENDPOINTS = '{"redis": ["get", "set"]}'; + const config = normalizeConfig(); + expect(config.tracing.ignoreEndpoints).to.deep.equal({ redis: ['get', 'set'] }); + }); + + it('should fallback to default if INSTANA_IGNORE_ENDPOINTS is set but has an invalid format', () => { + process.env.INSTANA_IGNORE_ENDPOINTS = '"redis": ["get", "set"]'; + const config = normalizeConfig(); + expect(config.tracing.ignoreEndpoints).to.deep.equal({}); + }); + it('should apply ignore endpoints via config', () => { + const config = normalizeConfig({ + tracing: { + ignoreEndpoints: { redis: ['get'] } + } + }); + expect(config.tracing.ignoreEndpoints).to.deep.equal({ redis: ['get'] }); + }); + it('should apply multiple ignore endpoints via config', () => { + const config = normalizeConfig({ + tracing: { + ignoreEndpoints: { redis: ['GET', 'TYPE'] } + } + }); + expect(config.tracing.ignoreEndpoints).to.deep.equal({ redis: ['get', 'type'] }); + }); + it('should apply ignore endpoints via config for multiple packages', () => { + const config = normalizeConfig({ + tracing: { + ignoreEndpoints: { redis: ['get'], dynamodb: ['querey'] } + } + }); + expect(config.tracing.ignoreEndpoints).to.deep.equal({ redis: ['get'], dynamodb: ['querey'] }); + }); function checkDefaults(config) { expect(config).to.be.an('object'); diff --git a/packages/core/test/util/spanFilter_test.js b/packages/core/test/util/spanFilter_test.js new file mode 100644 index 0000000000..4bec4e157f --- /dev/null +++ b/packages/core/test/util/spanFilter_test.js @@ -0,0 +1,46 @@ +/* + * (c) Copyright IBM Corp. 2024 + */ + +'use strict'; + +const expect = require('chai').expect; +const { applyFilter } = require('../../src/util/spanFilter'); + +const span = { + t: '1234567803', + s: '1234567892', + p: '1234567891', + n: 'redis', + k: 2, + data: { + redis: { + operation: '' + } + } +}; +let ignoreEndpoints = { + redis: ['GET', 'TYPE'] +}; + +describe('util.spanFilter', () => { + it('should return null when the span should be ignored', () => { + span.data.redis.operation = 'GET'; + expect(applyFilter({ span, ignoreEndpoints })).equal(null); + }); + + it('should return the span when command is not in the ignore list', () => { + span.data.redis.operation = 'HGET'; + expect(applyFilter({ span, ignoreEndpoints })).equal(span); + }); + + it('should return the span when span.n does not match any endpoint in config', () => { + span.n = 'node.http.client'; + expect(applyFilter({ span, ignoreEndpoints })).equal(span); + }); + it('should return span when no ignoreconfiguration', () => { + ignoreEndpoints = {}; + span.data.redis.operation = 'GET'; + expect(applyFilter({ span, ignoreEndpoints })).equal(span); + }); +});