diff --git a/packages/collector/src/announceCycle/unannounced.js b/packages/collector/src/announceCycle/unannounced.js index c90ad8aa6e..c904c0148a 100644 --- a/packages/collector/src/announceCycle/unannounced.js +++ b/packages/collector/src/announceCycle/unannounced.js @@ -59,6 +59,8 @@ const maxRetryDelay = 60 * 1000; // one minute * @property {boolean} [disable-w3c-correlation] * @property {boolean} [disable-w3c-propagation] * @property {boolean} [disable-w3c] + * @property {boolean} [disable-w3c-baggage] + * @property {string} [capture-w3c-baggage] */ /** @@ -362,6 +364,17 @@ function applyW3cDisableConfiguration(agentResponse) { ensureNestedObjectExists(agentOpts.config, ['tracing']); agentOpts.config.tracing.disableW3cPropagation = true; } + + if (globalConfig['disable-w3c-baggage'] === true) { + ensureNestedObjectExists(agentOpts.config, ['tracing']); + agentOpts.config.tracing.disableW3cBaggage = true; + } + + const keys = coreConfig.validators.captureW3cBaggageValidator(globalConfig['capture-w3c-baggage']); + if (keys) { + ensureNestedObjectExists(agentOpts.config, ['tracing']); + agentOpts.config.tracing.captureW3cBaggage = keys; + } } /** diff --git a/packages/collector/test/integration/misc/w3c_trace_context/app.js b/packages/collector/test/integration/misc/w3c_trace_context/app.js index f52b684c1b..5d52da8c57 100644 --- a/packages/collector/test/integration/misc/w3c_trace_context/app.js +++ b/packages/collector/test/integration/misc/w3c_trace_context/app.js @@ -37,8 +37,9 @@ if (vendor === 'instana') { } let cls; +let instana; if (isInstana()) { - require('@instana/collector')(); + instana = require('@instana/collector')(); cls = require('@instana/core/src/tracing/cls'); } @@ -164,10 +165,13 @@ function handleRequest(incomingHeaders, method, url, resOrStream) { return endWithStatus(method, url, resOrStream, 405); } return endWithStatus(method, url, resOrStream, 200); - } else if (pathname === '/start' || pathname === '/continue') { + } else if (pathname === '/start' || pathname === '/continue' || pathname === '/start-with-baggage') { if (method !== 'GET') { return endWithStatus(method, url, resOrStream, 405); } + if (pathname === '/start-with-baggage' && isInstana()) { + instana.sdk.async.setBaggage('customSdkKey', 'customSdkValue'); + } downstreamPath = depth > 1 ? 'continue' : 'end'; const requestOptions = { method, @@ -195,7 +199,8 @@ function handleRequest(incomingHeaders, method, url, resOrStream) { w3cTraceContext: { receivedHeaders: { traceparent: incomingHeaders.traceparent, - tracestate: incomingHeaders.tracestate + tracestate: incomingHeaders.tracestate, + baggage: incomingHeaders.baggage } } }; diff --git a/packages/collector/test/integration/misc/w3c_trace_context/test_base.js b/packages/collector/test/integration/misc/w3c_trace_context/test_base.js index 94e0ce5cf4..fc1d8b55a3 100644 --- a/packages/collector/test/integration/misc/w3c_trace_context/test_base.js +++ b/packages/collector/test/integration/misc/w3c_trace_context/test_base.js @@ -1150,12 +1150,291 @@ module.exports = function (name, version, isLatest, mode) { }); })); }); + + describe('W3C baggage capturing', () => { + let baggageAppControls; + + before(async () => { + baggageAppControls = new ProcessControls({ + dirname: __dirname, + useGlobalAgent: true, + http2: isHTTP2, + env: { + APM_VENDOR: 'instana', + DOWNSTREAM_PORT: otherVendorAppPort, + APP_USES_HTTP2: isHTTP2, + INSTANA_TRACING_CAPTURE_W3C_BAGGAGE: 'userId,requestId' + } + }); + await baggageAppControls.startAndWaitForAgentConnection(); + }); + + after(async () => { + await baggageAppControls.stop(); + }); + + it('should NOT capture baggage keys when INSTANA_TRACING_CAPTURE_W3C_BAGGAGE is not configured', () => + startRequest({ + app: instanaAppControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'userId=alice,requestId=req-42' + }).then(() => + retryUntilSpansMatch(agentControls, spans => { + const entry = expectExactlyOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.k).to.equal(constants.ENTRY), + span => expect(span.data.http.url).to.equal('/start'), + span => expect(span.data.http.host).to.equal(`localhost:${instanaAppControls.getPort()}`) + ]); + expect(entry.data.sdk).to.not.exist; + }) + )); + + it('should capture configured baggage keys into span tags on the HTTP entry span', () => + startRequest({ + app: baggageAppControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'userId=alice,requestId=req-42,ignored=drop-me' + }).then(() => + retryUntilSpansMatch(agentControls, spans => { + const entry = expectExactlyOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.k).to.equal(constants.ENTRY), + span => expect(span.data.http.url).to.equal('/start'), + span => expect(span.data.http.host).to.equal(`localhost:${baggageAppControls.getPort()}`) + ]); + expect(entry.data.sdk.custom.tags.userId).to.equal('alice'); + expect(entry.data.sdk.custom.tags.requestId).to.equal('req-42'); + expect(entry.data.sdk.custom.tags.ignored).to.not.exist; + }) + )); + + it('should capture baggage keys on child (exit) spans within the same process', () => + startRequest({ + app: baggageAppControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'userId=alice,requestId=req-42' + }).then(() => + retryUntilSpansMatch(agentControls, spans => { + const exit = expectExactlyOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT), + span => expect(span.data.http.url).to.include(`${otherVendorAppPort}`) + ]); + expect(exit.data.sdk.custom.tags.userId).to.equal('alice'); + expect(exit.data.sdk.custom.tags.requestId).to.equal('req-42'); + }) + )); + + it('should not capture baggage keys when the baggage header is absent', () => + startRequest({ + app: baggageAppControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id' + }).then(() => + retryUntilSpansMatch(agentControls, spans => { + const entry = expectExactlyOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.k).to.equal(constants.ENTRY), + span => expect(span.data.http.url).to.equal('/start'), + span => expect(span.data.http.host).to.equal(`localhost:${baggageAppControls.getPort()}`) + ]); + expect(entry.data.sdk).to.not.exist; + }) + )); + + it('should capture percent-encoded baggage values correctly', () => + startRequest({ + app: baggageAppControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: `userId=${encodeURIComponent('alice smith')},requestId=req-99` + }).then(() => + retryUntilSpansMatch(agentControls, spans => { + const entry = expectExactlyOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.k).to.equal(constants.ENTRY), + span => expect(span.data.http.url).to.equal('/start'), + span => expect(span.data.http.host).to.equal(`localhost:${baggageAppControls.getPort()}`) + ]); + expect(entry.data.sdk.custom.tags.userId).to.equal('alice smith'); + expect(entry.data.sdk.custom.tags.requestId).to.equal('req-99'); + }) + )); + + it('should propagate the baggage header as-is to downstream services', () => + startRequest({ + app: instanaAppControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'userId=alice;meta=1,requestId=req-42' + }).then(response => { + response = response && response.body ? JSON.parse(response.body) : response; + expect(response.w3cTraceContext.receivedHeaders.baggage).to.equal('userId=alice;meta=1,requestId=req-42'); + })); + + it('should propagate the full incoming baggage header to downstream even when only subset is captured', () => + startRequest({ + app: baggageAppControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'userId=alice,extraKey=extraVal;prop=1' + }).then(response => { + response = response && response.body ? JSON.parse(response.body) : response; + expect(response.w3cTraceContext.receivedHeaders.baggage).to.equal('userId=alice,extraKey=extraVal;prop=1'); + })); + + it('should propagate baggage set via instana.sdk.setBaggage to downstream services', () => + startRequest({ + app: instanaAppControls, + startPath: '/start-with-baggage', + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id' + }).then(response => { + response = response && response.body ? JSON.parse(response.body) : response; + expect(response.w3cTraceContext.receivedHeaders.baggage).to.equal('customSdkKey=customSdkValue'); + })); + + it('should append baggage set via instana.sdk.setBaggage to incoming baggage headers', () => + startRequest({ + app: instanaAppControls, + startPath: '/start-with-baggage', + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'incomingKey=incomingVal' + }).then(response => { + response = response && response.body ? JSON.parse(response.body) : response; + expect(response.w3cTraceContext.receivedHeaders.baggage).to.include('incomingKey=incomingVal'); + expect(response.w3cTraceContext.receivedHeaders.baggage).to.include('customSdkKey=customSdkValue'); + })); + + it('should NOT propagate the baggage header when INSTANA_TRACING_DISABLE_W3C_BAGGAGE is set', () => { + const disabledBaggageControls = new ProcessControls({ + dirname: __dirname, + useGlobalAgent: true, + http2: isHTTP2, + env: { + APM_VENDOR: 'instana', + DOWNSTREAM_PORT: otherVendorAppPort, + APP_USES_HTTP2: isHTTP2, + INSTANA_TRACING_DISABLE_W3C_BAGGAGE: 'true' + } + }); + + return disabledBaggageControls + .startAndWaitForAgentConnection() + .then(() => + startRequest({ + app: disabledBaggageControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'userId=alice' + }) + ) + .then(response => { + response = response && response.body ? JSON.parse(response.body) : response; + expect(response.w3cTraceContext.receivedHeaders.baggage).to.not.exist; + }) + .finally(() => disabledBaggageControls.stop()); + }); + }); + + describe('W3C baggage capturing via agent yaml', () => { + const { AgentStubControls } = require('@_local/collector/test/apps/agentStubControls'); + const agentStubControls = new AgentStubControls(); + let agentYamlAppControls; + + before(async () => { + await agentStubControls.startAgent({ + w3cDisableConfig: { 'capture-w3c-baggage': 'userId,requestId' } + }); + agentYamlAppControls = new ProcessControls({ + dirname: __dirname, + agentControls: agentStubControls, + http2: isHTTP2, + env: { + APM_VENDOR: 'instana', + DOWNSTREAM_PORT: otherVendorAppPort, + APP_USES_HTTP2: isHTTP2 + } + }); + await agentYamlAppControls.startAndWaitForAgentConnection(); + }); + + after(async () => { + await agentYamlAppControls.stop(); + await agentStubControls.stopAgent(); + }); + + it('should capture configured keys from agent yaml capture-w3c-baggage', () => + startRequest({ + app: agentYamlAppControls, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'userId=bob,requestId=req-7,other=ignore' + }).then(() => + retryUntilSpansMatch(agentStubControls, spans => { + const entry = expectExactlyOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.server'), + span => expect(span.k).to.equal(constants.ENTRY), + span => expect(span.data.http.url).to.equal('/start'), + span => expect(span.data.http.host).to.equal(`localhost:${agentYamlAppControls.getPort()}`) + ]); + expect(entry.data.sdk.custom.tags.userId).to.equal('bob'); + expect(entry.data.sdk.custom.tags.requestId).to.equal('req-7'); + expect(entry.data.sdk.custom.tags.other).to.not.exist; + }) + )); + + it('should disable baggage propagation via agent yaml disable-w3c-baggage', async () => { + const disableAgentStub = new AgentStubControls(); + await disableAgentStub.startAgent({ + w3cDisableConfig: { 'disable-w3c-baggage': true } + }); + const disableAgentApp = new ProcessControls({ + dirname: __dirname, + agentControls: disableAgentStub, + http2: isHTTP2, + env: { + APM_VENDOR: 'instana', + DOWNSTREAM_PORT: otherVendorAppPort, + APP_USES_HTTP2: isHTTP2 + } + }); + await disableAgentApp.startAndWaitForAgentConnection(); + + try { + const response = await startRequest({ + app: disableAgentApp, + depth: 1, + withSpecHeaders: 'valid-sampled-with-random-trace-id', + withBaggageHeader: 'userId=alice' + }); + const parsed = response && response.body ? JSON.parse(response.body) : response; + expect(parsed.w3cTraceContext.receivedHeaders.baggage).to.not.exist; + } finally { + await disableAgentApp.stop(); + await disableAgentStub.stopAgent(); + } + }); + }); }); }; -function startRequest({ app, depth = 2, withSpecHeaders = null, otherMode = 'participate', withInstanaHeaders }) { +function startRequest({ + app, + startPath = '/start', + depth = 2, + withSpecHeaders = null, + otherMode = 'participate', + withInstanaHeaders, + withBaggageHeader = null +}) { const request = { - path: `/start?depth=${depth}&otherMode=${otherMode}` + path: `${startPath}?depth=${depth}&otherMode=${otherMode}` }; if (withSpecHeaders === 'valid-sampled-with-random-trace-id') { request.headers = { @@ -1196,6 +1475,11 @@ function startRequest({ app, depth = 2, withSpecHeaders = null, otherMode = 'par throw new Error(`Invalid withSpecHeaders value: ${withSpecHeaders}.`); } + if (withBaggageHeader) { + request.headers = request.headers || {}; + request.headers.baggage = withBaggageHeader; + } + if (withInstanaHeaders === 'trace-in-progress') { request.headers = request.headers || {}; request.headers['X-INSTANA-T'] = upstreamInstanaTraceId; diff --git a/packages/collector/test/unit/src/announceCycle/unannounced.test.js b/packages/collector/test/unit/src/announceCycle/unannounced.test.js index f54acf53ac..09e2c59403 100644 --- a/packages/collector/test/unit/src/announceCycle/unannounced.test.js +++ b/packages/collector/test/unit/src/announceCycle/unannounced.test.js @@ -1392,6 +1392,114 @@ describe('unannounced state', () => { } }); }); + + it('should apply disable-w3c-baggage from agent response', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'disable-w3c-baggage': true + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + disableW3cBaggage: true + } + }); + done(); + } + }); + }); + + it('should not apply disable-w3c-baggage when value is false', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'disable-w3c-baggage': false + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({}); + done(); + } + }); + }); + + it('should apply capture-w3c-baggage as parsed key array', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'capture-w3c-baggage': 'userId,isPremium' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + captureW3cBaggage: ['userId', 'isPremium'] + } + }); + done(); + } + }); + }); + + it('should trim whitespace in capture-w3c-baggage keys', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'capture-w3c-baggage': ' userId , isPremium ' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + captureW3cBaggage: ['userId', 'isPremium'] + } + }); + done(); + } + }); + }); + + it('should not apply capture-w3c-baggage when value is empty string', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'capture-w3c-baggage': '' + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({}); + done(); + } + }); + }); + + it('should not apply capture-w3c-baggage when value is not a string', done => { + prepareAnnounceResponse({ + tracing: { + global: { + 'capture-w3c-baggage': 42 + } + } + }); + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({}); + done(); + } + }); + }); }); describe('OTLP exporter configuration', () => { diff --git a/packages/core/src/config/index.js b/packages/core/src/config/index.js index 79472fe9f7..b3ba8dcc55 100644 --- a/packages/core/src/config/index.js +++ b/packages/core/src/config/index.js @@ -74,6 +74,8 @@ let currentConfig; * @property {boolean} [disableW3cCorrelation] * @property {boolean} [disableW3cTraceCorrelation] * @property {boolean} [disableW3cPropagation] + * @property {boolean} [disableW3cBaggage] + * @property {string[]} [captureW3cBaggage] * @property {KafkaTracingOptions} [kafka] * @property {boolean} [allowRootExitSpan] * @property {import('../config/types').IgnoreEndpoints} [ignoreEndpoints] @@ -184,6 +186,8 @@ let defaults = { disableW3cCorrelation: false, disableW3cTraceCorrelation: false, disableW3cPropagation: false, + disableW3cBaggage: false, + captureW3cBaggage: [], kafka: { traceCorrelation: true }, @@ -409,6 +413,8 @@ function normalizeTracingConfig({ userConfig = {}, defaultConfig = {}, finalConf normalizeSpanBatchingEnabled({ userConfig, defaultConfig, finalConfig }); normalizeDisableW3cTraceCorrelation({ userConfig, defaultConfig, finalConfig }); normalizeDisableW3cPropagation({ userConfig, defaultConfig, finalConfig }); + normalizeDisableW3cBaggage({ userConfig, defaultConfig, finalConfig }); + normalizeCaptureW3cBaggage({ userConfig, defaultConfig, finalConfig }); normalizeDisableW3c({ userConfig, defaultConfig, finalConfig }); normalizeTracingKafka({ userConfig, defaultConfig, finalConfig }); normalizeAllowRootExitSpan({ userConfig, defaultConfig, finalConfig }); @@ -973,6 +979,52 @@ function normalizeDisableW3cPropagation({ userConfig = {}, defaultConfig = {}, f }); } +/** + * @param {{ userConfig?: InstanaConfig|null, defaultConfig?: InstanaConfig, finalConfig?: InstanaConfig }} [options] + */ +function normalizeDisableW3cBaggage({ userConfig = {}, defaultConfig = {}, finalConfig = {} } = {}) { + const { value, source } = util.resolve( + { + envValue: 'INSTANA_TRACING_DISABLE_W3C_BAGGAGE', + inCodeValue: userConfig.tracing.disableW3cBaggage, + defaultValue: defaultConfig.tracing.disableW3cBaggage + }, + [validators.validateTruthyBoolean] + ); + + configStore.set('config.tracing.disableW3cBaggage', { source }); + finalConfig.tracing.disableW3cBaggage = value; + util.log({ + configPath: 'config.tracing.disableW3cBaggage', + source, + value, + envVarName: 'INSTANA_TRACING_DISABLE_W3C_BAGGAGE' + }); +} + +/** + * @param {{ userConfig?: InstanaConfig|null, defaultConfig?: InstanaConfig, finalConfig?: InstanaConfig }} [options] + */ +function normalizeCaptureW3cBaggage({ userConfig = {}, defaultConfig = {}, finalConfig = {} } = {}) { + const { value, source } = util.resolve( + { + envValue: 'INSTANA_TRACING_CAPTURE_W3C_BAGGAGE', + inCodeValue: userConfig.tracing.captureW3cBaggage, + defaultValue: defaultConfig.tracing.captureW3cBaggage + }, + [validators.captureW3cBaggageValidator] + ); + + configStore.set('config.tracing.captureW3cBaggage', { source }); + finalConfig.tracing.captureW3cBaggage = value; + util.log({ + configPath: 'config.tracing.captureW3cBaggage', + source, + value, + envVarName: 'INSTANA_TRACING_CAPTURE_W3C_BAGGAGE' + }); +} + /** * @param {{ userConfig?: InstanaConfig|null, defaultConfig?: InstanaConfig, finalConfig?: InstanaConfig }} [options] */ diff --git a/packages/core/src/config/validator.js b/packages/core/src/config/validator.js index 248e229088..222562f2b8 100644 --- a/packages/core/src/config/validator.js +++ b/packages/core/src/config/validator.js @@ -219,3 +219,26 @@ exports.validateTransmissionDelay = function validateTransmissionDelay(value) { ); return nearest; }; + +/** + * Accepts a comma-separated string or an array of strings. + * Returns a trimmed, non-empty string array or undefined if the input is invalid. + * @param {any} value + * @returns {string[] | undefined} + */ +exports.captureW3cBaggageValidator = function captureW3cBaggageValidator(value) { + if (Array.isArray(value)) { + const keys = value.map(k => (typeof k === 'string' ? k.trim() : '')).filter(k => k !== ''); + return keys.length > 0 ? keys : undefined; + } + + if (typeof value === 'string') { + const keys = value + .split(',') + .map(k => k.trim()) + .filter(k => k !== ''); + return keys.length > 0 ? keys : undefined; + } + + return undefined; +}; diff --git a/packages/core/src/tracing/baggage.js b/packages/core/src/tracing/baggage.js new file mode 100644 index 0000000000..1ac8be30e9 --- /dev/null +++ b/packages/core/src/tracing/baggage.js @@ -0,0 +1,81 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +/** + * @param {string | null} raw + * @returns {Record} + */ +function parseBaggageHeader(raw) { + if (!raw) { + return /** @type {Record} */ ({}); + } + + /** @type {Record} */ + const result = {}; + const members = raw.split(','); + + for (let i = 0; i < members.length; i++) { + const member = members[i].trim(); + if (!member) { + continue; + } + + const withoutProps = member.split(';')[0].trim(); + const eqIdx = withoutProps.indexOf('='); + if (eqIdx < 1) { + continue; + } + + const key = withoutProps.substring(0, eqIdx).trim(); + const value = decodeURIComponent(withoutProps.substring(eqIdx + 1).trim()); + if (key) { + result[key] = value; + } + } + + return result; +} + +/** + * @param {Record} entries + * @returns {string} + */ +function renderBaggageHeader(entries) { + return Object.keys(entries) + .map(k => `${k}=${encodeURIComponent(entries[k])}`) + .join(','); +} + +/** + * @param {string | null} rawBaggage + * @param {string[]} captureKeys + * @param {{ data: { sdk?: { custom?: { tags?: Record } } } }} span + */ +function applyCaptureTags(rawBaggage, captureKeys, span) { + if (!rawBaggage || !captureKeys || captureKeys.length === 0) { + return; + } + + const parsed = parseBaggageHeader(rawBaggage); + + if (!span.data.sdk) span.data.sdk = {}; + if (!span.data.sdk.custom) span.data.sdk.custom = {}; + if (!span.data.sdk.custom.tags) span.data.sdk.custom.tags = {}; + + const tags = span.data.sdk.custom.tags; + for (let i = 0; i < captureKeys.length; i++) { + const key = captureKeys[i]; + if (parsed[key] !== undefined && tags[key] === undefined) { + tags[key] = parsed[key]; + } + } +} + +module.exports = { + parseBaggageHeader, + renderBaggageHeader, + applyCaptureTags +}; diff --git a/packages/core/src/tracing/cls.js b/packages/core/src/tracing/cls.js index ae23e40322..b98b5262fc 100644 --- a/packages/core/src/tracing/cls.js +++ b/packages/core/src/tracing/cls.js @@ -12,6 +12,7 @@ const { ENTRY, EXIT, INTERMEDIATE, isExitSpan } = require('./constants'); const hooked = require('./clsHooked'); const tracingMetrics = require('./metrics'); const { applyFilter } = require('../util/spanFilter'); +const baggageUtil = require('./baggage'); /** @type {import('../core').GenericLogger} */ let logger; @@ -21,6 +22,7 @@ const currentSpanKey = 'com.instana.span'; const reducedSpanKey = 'com.instana.reduced'; const tracingLevelKey = 'com.instana.tl'; const w3cTraceContextKey = 'com.instana.w3ctc'; +const baggageKey = 'com.instana.baggage'; // eslint-disable-next-line no-undef-init /** @type {String} */ @@ -31,6 +33,8 @@ let processIdentityProvider = null; let allowRootExitSpan; /** @type {Boolean} */ let ignoreEndpointsDisableDownStreamSuppression; +/** @type {string[]} */ +let captureW3cBaggage = []; /* * Access the Instana namespace in continuation local storage. @@ -55,6 +59,14 @@ function init(config, _processIdentityProvider) { processIdentityProvider = _processIdentityProvider; allowRootExitSpan = config?.tracing?.allowRootExitSpan; ignoreEndpointsDisableDownStreamSuppression = config?.tracing?.ignoreEndpointsDisableSuppression; + captureW3cBaggage = config?.tracing?.captureW3cBaggage || []; +} + +/** + * @param {import('../config').InstanaConfig} config + */ +function activate(config) { + captureW3cBaggage = config?.tracing?.captureW3cBaggage || []; } class InstanaSpan { @@ -371,6 +383,13 @@ function startSpan(spanAttributes = {}) { span.addCleanup(ns.set(currentEntrySpanKey, span)); } + if (captureW3cBaggage.length > 0) { + const rawBaggage = ns.get(baggageKey); + if (rawBaggage) { + baggageUtil.applyCaptureTags(rawBaggage, captureW3cBaggage, span); + } + } + // Set the span object as the currently active span in the active CLS context and also add a cleanup hook for when // this span is transmitted. span.addCleanup(ns.set(currentSpanKey, span)); @@ -515,6 +534,22 @@ function getW3cTraceContext() { return ns.get(w3cTraceContextKey); } +/** + * Stores the raw W3C baggage header value in CLS so it is available across async boundaries. + * @param {string | null} baggage + */ +function setBaggage(baggage) { + ns.set(baggageKey, baggage); +} + +/** + * Returns the raw W3C baggage header value from CLS. + * @returns {string | null} + */ +function getBaggage() { + return ns.get(baggageKey); +} + /* * Determine if we're currently tracing or not. */ @@ -731,8 +766,10 @@ module.exports = { reducedSpanKey, tracingLevelKey, w3cTraceContextKey, + baggageKey, ns, init, + activate, startSpan, putPseudoSpan, getCurrentEntrySpan, @@ -741,6 +778,8 @@ module.exports = { getReducedSpan, setW3cTraceContext, getW3cTraceContext, + setBaggage, + getBaggage, isTracing, setTracingLevel, tracingLevel, diff --git a/packages/core/src/tracing/constants.js b/packages/core/src/tracing/constants.js index b159833f45..61f21d0e21 100644 --- a/packages/core/src/tracing/constants.js +++ b/packages/core/src/tracing/constants.js @@ -37,6 +37,7 @@ exports.w3cTraceParent = 'traceparent'; exports.w3cTraceState = 'tracestate'; exports.w3cInstana = 'in'; exports.w3cInstanaEquals = `${exports.w3cInstana}=`; +exports.w3cBaggage = 'baggage'; exports.ENTRY = 1; exports.EXIT = 2; diff --git a/packages/core/src/tracing/index.js b/packages/core/src/tracing/index.js index 2df20e3f7f..9cdd8c9c40 100644 --- a/packages/core/src/tracing/index.js +++ b/packages/core/src/tracing/index.js @@ -265,6 +265,7 @@ function initInstanaInstrumentations(_config) { exports.activate = function activate(_config = config) { if (tracingEnabled && !tracingActivated) { tracingActivated = true; + cls.activate(_config); coreUtil.activate(_config); tracingUtil.activate(_config); spanBuffer.activate(_config); diff --git a/packages/core/src/tracing/instrumentation/protocols/http2Client.js b/packages/core/src/tracing/instrumentation/protocols/http2Client.js index dbbde25207..add4d184c5 100644 --- a/packages/core/src/tracing/instrumentation/protocols/http2Client.js +++ b/packages/core/src/tracing/instrumentation/protocols/http2Client.js @@ -152,9 +152,7 @@ function addTraceLevelHeader(headers, level, w3cTraceContext) { return; } headers[constants.traceLevelHeaderName] = level; - tracingHeaders.addW3cHeaders((name, value) => { - headers[name] = value; - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => { headers[k] = v; }, w3cTraceContext, cls); } function addHeaders(headers, span, w3cTraceContext) { @@ -169,7 +167,5 @@ function addHeaders(headers, span, w3cTraceContext) { headers[constants.spanIdHeaderName] = span.s; headers[constants.traceIdHeaderName] = span.t; headers[constants.traceLevelHeaderName] = '1'; - tracingHeaders.addW3cHeaders((name, value) => { - headers[name] = value; - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => { headers[k] = v; }, w3cTraceContext, cls); } diff --git a/packages/core/src/tracing/instrumentation/protocols/http2Server.js b/packages/core/src/tracing/instrumentation/protocols/http2Server.js index 1ee2a779df..caedf3fd25 100644 --- a/packages/core/src/tracing/instrumentation/protocols/http2Server.js +++ b/packages/core/src/tracing/instrumentation/protocols/http2Server.js @@ -77,7 +77,7 @@ function shimEmit(realEmit) { } const incomingHeaders = tracingHeaders.fromHeaders(headers); - const w3cTraceContext = incomingHeaders.w3cTraceContext; + const { w3cTraceContext, w3cBaggage } = incomingHeaders; if (typeof incomingHeaders.level === 'string' && incomingHeaders.level.indexOf('0') === 0) { cls.setTracingLevel('0'); @@ -93,6 +93,10 @@ function shimEmit(realEmit) { cls.setW3cTraceContext(w3cTraceContext); } + if (w3cBaggage != null) { + cls.setBaggage(w3cBaggage); + } + if (cls.tracingSuppressed()) { // We still need to forward X-INSTANA-L and the W3C trace context; this happens in exit instrumentations // (like httpClient.js). diff --git a/packages/core/src/tracing/instrumentation/protocols/httpClient.js b/packages/core/src/tracing/instrumentation/protocols/httpClient.js index 9444c72a87..24b8e41a65 100644 --- a/packages/core/src/tracing/instrumentation/protocols/httpClient.js +++ b/packages/core/src/tracing/instrumentation/protocols/httpClient.js @@ -192,9 +192,7 @@ function instrument(coreModule, forceHttps) { removeInstanaHeadersFromOpts(options); if (skipTracingResult.suppressed && !traceLevelHeaderHasBeenAdded) { clientRequest.setHeader(constants.traceLevelHeaderName, '0'); - tracingHeaders.addW3cHeaders((name, value) => { - clientRequest.setHeader(name, value); - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => clientRequest.setHeader(k, v), w3cTraceContext, cls); } return clientRequest; @@ -397,9 +395,7 @@ function tryToAddHeadersToOpts(options, span, w3cTraceContext) { options.headers[constants.spanIdHeaderName] = span.s; options.headers[constants.traceIdHeaderName] = span.t; options.headers[constants.traceLevelHeaderName] = '1'; - tracingHeaders.addW3cHeaders((name, value) => { - options.headers[name] = value; - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => { options.headers[k] = v; }, w3cTraceContext, cls); return true; } @@ -409,9 +405,7 @@ function tryToAddHeadersToOpts(options, span, w3cTraceContext) { function tryToAddTraceLevelAddHeaderToOpts(options, level, w3cTraceContext) { if (hasHeadersOption(options)) { options.headers[constants.traceLevelHeaderName] = level; - tracingHeaders.addW3cHeaders((name, value) => { - options.headers[name] = value; - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => { options.headers[k] = v; }, w3cTraceContext, cls); return true; } return false; @@ -431,24 +425,21 @@ function removeInstanaHeadersFromOpts(options) { delete options.headers[constants.traceLevelHeaderName]; delete options.headers[constants.w3cTraceParent]; delete options.headers[constants.w3cTraceState]; + delete options.headers[constants.w3cBaggage]; } function setHeadersOnRequest(clientRequest, span, w3cTraceContext) { if (span.shouldSuppressDownstream) { // Suppress trace propagation to downstream services. clientRequest.setHeader(constants.traceLevelHeaderName, '0'); - tracingHeaders.addW3cHeaders((name, value) => { - clientRequest.setHeader(name, value); - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => clientRequest.setHeader(k, v), w3cTraceContext, cls); return; } clientRequest.setHeader(constants.spanIdHeaderName, span.s); clientRequest.setHeader(constants.traceIdHeaderName, span.t); clientRequest.setHeader(constants.traceLevelHeaderName, '1'); - tracingHeaders.addW3cHeaders((name, value) => { - clientRequest.setHeader(name, value); - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => clientRequest.setHeader(k, v), w3cTraceContext, cls); } function captureRequestHeaders(options, clientRequest, response) { diff --git a/packages/core/src/tracing/instrumentation/protocols/httpServer.js b/packages/core/src/tracing/instrumentation/protocols/httpServer.js index 21f8417b05..3e63cc1b01 100644 --- a/packages/core/src/tracing/instrumentation/protocols/httpServer.js +++ b/packages/core/src/tracing/instrumentation/protocols/httpServer.js @@ -59,7 +59,7 @@ function shimEmit(realEmit) { cls.ns.bindEmitter(res); } const incomingHeaders = tracingHeaders.fromHttpRequest(req); - const w3cTraceContext = incomingHeaders.w3cTraceContext; + const { w3cTraceContext, w3cBaggage } = incomingHeaders; if (typeof incomingHeaders.level === 'string' && incomingHeaders.level.indexOf('0') === 0) { cls.setTracingLevel('0'); @@ -75,6 +75,10 @@ function shimEmit(realEmit) { cls.setW3cTraceContext(w3cTraceContext); } + if (w3cBaggage != null) { + cls.setBaggage(w3cBaggage); + } + if (cls.tracingSuppressed()) { // We still need to forward X-INSTANA-L and the W3C trace context; this happens in exit instrumentations // (like httpClient.js). diff --git a/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js b/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js index 56be6c7689..7a78e2439c 100644 --- a/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js +++ b/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js @@ -207,9 +207,7 @@ function injectTraceCorrelationHeaders(originalArgs, span, w3cTraceContext) { [constants.spanIdHeaderName]: span.s, [constants.traceLevelHeaderName]: '1' }; - tracingHeaders.addW3cHeaders((name, value) => { - headersToAdd[name] = value; - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => { headersToAdd[k] = v; }, w3cTraceContext, cls); injectHeaders(originalArgs, headersToAdd); } @@ -217,9 +215,7 @@ function injectSuppressionHeader(originalArgs, w3cTraceContext) { const headersToAdd = { [constants.traceLevelHeaderName]: '0' }; - tracingHeaders.addW3cHeaders((name, value) => { - headersToAdd[name] = value; - }, w3cTraceContext); + tracingHeaders.addW3cHeaders((k, v) => { headersToAdd[k] = v; }, w3cTraceContext, cls); injectHeaders(originalArgs, headersToAdd); } diff --git a/packages/core/src/tracing/sdk/sdk.js b/packages/core/src/tracing/sdk/sdk.js index d939c08ffa..57c4c63297 100644 --- a/packages/core/src/tracing/sdk/sdk.js +++ b/packages/core/src/tracing/sdk/sdk.js @@ -8,6 +8,7 @@ const deepMerge = require('../../util/deepMerge'); const tracingUtil = require('../tracingUtil'); const constants = require('../constants'); +const baggage = require('../baggage'); /** @typedef {import('../../core').InstanaBaseSpan} InstanaBaseSpan */ @@ -372,6 +373,20 @@ exports.generate = function (isCallbackApi) { isActive = true; } + /** + * @param {string} key + * @param {string} value + */ + function setBaggage(key, value) { + if (!cls) { + return; + } + const current = cls.getBaggage(); + const parsed = baggage.parseBaggageHeader(current || ''); + parsed[key] = value; + cls.setBaggage(baggage.renderBaggageHeader(parsed)); + } + return { startEntrySpan, completeEntrySpan, @@ -379,6 +394,7 @@ exports.generate = function (isCallbackApi) { completeIntermediateSpan, startExitSpan, completeExitSpan, + setBaggage, bindEmitter, init, activate, diff --git a/packages/core/src/tracing/tracingHeaders.js b/packages/core/src/tracing/tracingHeaders.js index 61bb6ea3a9..05f3df73e4 100644 --- a/packages/core/src/tracing/tracingHeaders.js +++ b/packages/core/src/tracing/tracingHeaders.js @@ -16,6 +16,7 @@ let logger; let disableW3cCorrelation = false; let disableW3cPropagation = false; +let disableW3cBaggage = false; /** * @param {import('../config').InstanaConfig} config @@ -26,6 +27,7 @@ exports.init = function (config) { w3c.init(config); disableW3cCorrelation = config.tracing.disableW3cCorrelation; disableW3cPropagation = config.tracing.disableW3cPropagation; + disableW3cBaggage = config.tracing.disableW3cBaggage; }; /** @@ -34,6 +36,7 @@ exports.init = function (config) { exports.activate = function (config) { disableW3cCorrelation = config.tracing.disableW3cCorrelation; disableW3cPropagation = config.tracing.disableW3cPropagation; + disableW3cBaggage = config.tracing.disableW3cBaggage; }; /** @@ -59,6 +62,10 @@ exports.activate = function (config) { * - the tracing level, either '1' (tracing) or '0' (suppressing/not creating spans) * - progated downstream as the first component of X-INSTANA-L * - propagted downstream as the sampled flag in traceparent + * @property {string} [w3cBaggage] + * - the raw W3C baggage header value read from the incoming request + * - will be propagated downstream as-is (including properties) + * - null if baggage support is disabled or the header violates size/count limits * @property {string} [correlationType] * - the correlation type parsed from X-INSTANA-L * - will be used for span.crtp @@ -127,6 +134,7 @@ exports.fromHeaders = function fromHeaders(headers) { let correlationId = levelAndCorrelation.correlationId; const synthetic = readSyntheticMarker(headers); let w3cTraceContext = readW3cTraceContext(headers); + const w3cBaggage = readBaggage(headers); if (isSuppressed(level)) { // Ignore X-INSTANA-T/-S if X-INSTANA-L: 0 is also present. @@ -154,7 +162,8 @@ exports.fromHeaders = function fromHeaders(headers) { correlationType, correlationId, synthetic, - w3cTraceContext + w3cTraceContext, + w3cBaggage }; return exports.limitTraceId(result); } else if (xInstanaT && xInstanaS) { @@ -172,7 +181,8 @@ exports.fromHeaders = function fromHeaders(headers) { /** @type {string} */ (xInstanaT), /** @type {string} */ (xInstanaS), !isSuppressed(level) - ) + ), + w3cBaggage }); } else if (w3cTraceContext && !disableW3cCorrelation) { // There are no X-INSTANA- headers, but there are W3C trace context headers. As of 2021-02, we use the IDs from @@ -204,7 +214,8 @@ exports.fromHeaders = function fromHeaders(headers) { correlationId, synthetic, w3cTraceContext, - instanaAncestor + instanaAncestor, + w3cBaggage }); } else if (w3cTraceContext) { // There are no X-INSTANA- headers, but there are W3C trace context headers. But picking up the trace context from @@ -231,7 +242,8 @@ exports.fromHeaders = function fromHeaders(headers) { correlationType, correlationId, synthetic, - w3cTraceContext + w3cTraceContext, + w3cBaggage }); } else { // Neither X-INSTANA- headers nor W3C trace context headers are present. @@ -245,7 +257,8 @@ exports.fromHeaders = function fromHeaders(headers) { usedTraceParent: false, level, synthetic, - w3cTraceContext: w3c.createEmptyUnsampled(generateRandomTraceId(), generateRandomSpanId()) + w3cTraceContext: w3c.createEmptyUnsampled(generateRandomTraceId(), generateRandomSpanId()), + w3cBaggage }); } else { // Neither X-INSTANA- headers nor W3C trace context headers are present and tracing is not suppressed @@ -264,7 +277,8 @@ exports.fromHeaders = function fromHeaders(headers) { correlationType, correlationId, synthetic, - w3cTraceContext + w3cTraceContext, + w3cBaggage }); } } @@ -364,6 +378,40 @@ function traceStateHasInstanaKeyValuePair(w3cTraceContext) { return !!(w3cTraceContext.instanaTraceId && w3cTraceContext.instanaParentId); } +/** + * @param {import('http').IncomingHttpHeaders} headers + */ +/** + * Reads and validates the W3C baggage header from the incoming request headers. + * Returns the raw header string if valid, or null if baggage support is disabled + * or the header exceeds the allowed size (8192 bytes) or entry count (64 pairs). + * @param {import('http').IncomingHttpHeaders} headers + * @returns {string | null} + */ +function readBaggage(headers) { + if (disableW3cBaggage) { + return null; + } + + const raw = /** @type {string} */ (readAttribCaseInsensitive(headers, constants.w3cBaggage)); + if (!raw) { + return null; + } + + // CASE: Drop the header if it exceeds the byte size limit. + if (Buffer.byteLength(raw, 'utf8') > 8192) { + return null; + } + + // CASE: Drop the header if it contains more than 64 list-members. + // NOTE: A list-member is a key=value pair (properties attached via ';' belong to the same member). + const memberCount = raw.split(',').length; + if (memberCount > 64) { + return null; + } + return raw; +} + /** * @param {import('http').IncomingHttpHeaders} headers */ @@ -429,8 +477,9 @@ exports.setSpanAttributes = function (span, tracingHeaders) { * Writes traceparent, tracestate and baggage headers using the provided setter function. * @param {(key: string, value: string) => void} set * @param {import('./w3c_trace_context/W3cTraceContext')} w3cTraceContext + * @param {{ getBaggage: () => string | null }} cls */ -exports.addW3cHeaders = function addW3cHeaders(set, w3cTraceContext) { +exports.addW3cHeaders = function addW3cHeaders(set, w3cTraceContext, cls) { if (disableW3cPropagation) { return; } @@ -440,4 +489,10 @@ exports.addW3cHeaders = function addW3cHeaders(set, w3cTraceContext) { set(constants.w3cTraceState, w3cTraceContext.renderTraceState()); } } + if (!disableW3cBaggage) { + const baggage = cls.getBaggage(); + if (baggage) { + set(constants.w3cBaggage, baggage); + } + } }; diff --git a/packages/core/test/config/normalizeConfig_test.js b/packages/core/test/config/normalizeConfig_test.js index 96573a07d2..30050bdf8f 100644 --- a/packages/core/test/config/normalizeConfig_test.js +++ b/packages/core/test/config/normalizeConfig_test.js @@ -50,6 +50,7 @@ describe('config.normalizeConfig', () => { delete process.env.INSTANA_TRACING_DISABLE_W3C_CORRELATION; delete process.env.INSTANA_DISABLE_W3C_TRACE_CORRELATION; delete process.env.INSTANA_TRACING_DISABLE_W3C_PROPAGATION; + delete process.env.INSTANA_TRACING_CAPTURE_W3C_BAGGAGE; delete process.env.INSTANA_TRACING_DISABLE_W3C; delete process.env.INSTANA_DISABLE_USE_OPENTELEMETRY; delete process.env.INSTANA_KAFKA_TRACE_CORRELATION; diff --git a/packages/core/test/tracing/baggage_capturing_test.js b/packages/core/test/tracing/baggage_capturing_test.js new file mode 100644 index 0000000000..4864833b45 --- /dev/null +++ b/packages/core/test/tracing/baggage_capturing_test.js @@ -0,0 +1,330 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); +const { createFakeLogger } = require('../test_util'); +const coreConfig = require('../../src/config'); + +describe('baggage capturing', () => { + before(() => { + coreConfig.init(createFakeLogger()); + }); + + beforeEach(() => { + delete process.env.INSTANA_TRACING_CAPTURE_W3C_BAGGAGE; + delete process.env.INSTANA_TRACING_DISABLE_W3C_BAGGAGE; + }); + + afterEach(() => { + delete process.env.INSTANA_TRACING_CAPTURE_W3C_BAGGAGE; + delete process.env.INSTANA_TRACING_DISABLE_W3C_BAGGAGE; + }); + + describe('config normalization – captureW3cBaggage', () => { + it('defaults to empty array', () => { + const config = coreConfig.normalize(); + expect(config.tracing.captureW3cBaggage).to.deep.equal([]); + }); + + it('reads comma-separated keys from INSTANA_TRACING_CAPTURE_W3C_BAGGAGE', () => { + process.env.INSTANA_TRACING_CAPTURE_W3C_BAGGAGE = 'userId,isPremium'; + const config = coreConfig.normalize(); + expect(config.tracing.captureW3cBaggage).to.deep.equal(['userId', 'isPremium']); + }); + + it('trims whitespace around keys', () => { + process.env.INSTANA_TRACING_CAPTURE_W3C_BAGGAGE = ' userId , isPremium '; + const config = coreConfig.normalize(); + expect(config.tracing.captureW3cBaggage).to.deep.equal(['userId', 'isPremium']); + }); + + it('filters empty entries', () => { + process.env.INSTANA_TRACING_CAPTURE_W3C_BAGGAGE = 'userId,,isPremium,'; + const config = coreConfig.normalize(); + expect(config.tracing.captureW3cBaggage).to.deep.equal(['userId', 'isPremium']); + }); + + it('accepts in-code array', () => { + const config = coreConfig.normalize({ userConfig: { tracing: { captureW3cBaggage: ['userId'] } } }); + expect(config.tracing.captureW3cBaggage).to.deep.equal(['userId']); + }); + + it('env var takes precedence over in-code value', () => { + process.env.INSTANA_TRACING_CAPTURE_W3C_BAGGAGE = 'fromEnv'; + const config = coreConfig.normalize({ userConfig: { tracing: { captureW3cBaggage: ['fromCode'] } } }); + expect(config.tracing.captureW3cBaggage).to.deep.equal(['fromEnv']); + }); + }); + + describe('parseBaggageHeader', () => { + let baggage; + + before(() => { + baggage = require('../../src/tracing/baggage'); + }); + + it('parses key=value pairs', () => { + expect(baggage.parseBaggageHeader('userId=alice,isPremium=true')).to.deep.equal({ + userId: 'alice', + isPremium: 'true' + }); + }); + + it('percent-decodes values per RFC 3986', () => { + expect(baggage.parseBaggageHeader('userId=alice%20smith')).to.deep.equal({ userId: 'alice smith' }); + }); + + it('ignores properties (semicolon-separated metadata)', () => { + expect(baggage.parseBaggageHeader('userId=alice;prop1;prop2=v,isPremium=true')).to.deep.equal({ + userId: 'alice', + isPremium: 'true' + }); + }); + + it('returns empty object for empty string', () => { + expect(baggage.parseBaggageHeader('')).to.deep.equal({}); + }); + + it('returns empty object for null', () => { + expect(baggage.parseBaggageHeader(null)).to.deep.equal({}); + }); + + it('skips malformed entries without = sign', () => { + expect(baggage.parseBaggageHeader('novalue,key=val')).to.deep.equal({ key: 'val' }); + }); + }); + + describe('renderBaggageHeader', () => { + let baggage; + + before(() => { + baggage = require('../../src/tracing/baggage'); + }); + + it('renders key=value pairs', () => { + expect(baggage.renderBaggageHeader({ userId: 'alice', isPremium: 'true' })).to.equal( + 'userId=alice,isPremium=true' + ); + }); + + it('percent-encodes values', () => { + expect(baggage.renderBaggageHeader({ userId: 'alice smith' })).to.equal('userId=alice%20smith'); + }); + + it('returns empty string for empty object', () => { + expect(baggage.renderBaggageHeader({})).to.equal(''); + }); + }); + + describe('applyCaptureTags', () => { + let baggage; + + before(() => { + baggage = require('../../src/tracing/baggage'); + }); + + const makeSpan = (existingTags) => { + const span = { data: {} }; + if (existingTags) { + span.data.sdk = { custom: { tags: existingTags } }; + } + return span; + }; + + it('captures only configured keys', () => { + const span = makeSpan(); + baggage.applyCaptureTags('userId=alice,isPremium=true,region=eu', ['userId'], span); + expect(span.data.sdk.custom.tags).to.deep.equal({ userId: 'alice' }); + }); + + it('does not capture keys not in the capture list', () => { + const span = makeSpan(); + baggage.applyCaptureTags('userId=alice,region=eu', ['userId'], span); + expect(span.data.sdk.custom.tags).to.not.have.property('region'); + }); + + it('does not overwrite existing tag (existing annotation wins)', () => { + const span = makeSpan({ userId: 'existing' }); + baggage.applyCaptureTags('userId=fromBaggage', ['userId'], span); + expect(span.data.sdk.custom.tags.userId).to.equal('existing'); + }); + + it('does nothing when captureKeys is empty', () => { + const span = makeSpan(); + baggage.applyCaptureTags('userId=alice', [], span); + expect(span.data).to.deep.equal({}); + }); + + it('does nothing when rawBaggage is null', () => { + const span = makeSpan(); + baggage.applyCaptureTags(null, ['userId'], span); + expect(span.data).to.deep.equal({}); + }); + + it('does not capture properties', () => { + const span = makeSpan(); + baggage.applyCaptureTags('userId=alice;prop1=v', ['userId'], span); + expect(span.data.sdk.custom.tags).to.deep.equal({ userId: 'alice' }); + expect(span.data.sdk.custom.tags).to.not.have.property('prop1'); + }); + }); + + describe('cls.startSpan – baggage capturing on all spans', () => { + let cls; + + beforeEach(() => { + cls = proxyquire('../../src/tracing/cls', {}); + cls.init({ + logger: createFakeLogger(), + tracing: { captureW3cBaggage: ['userId', 'isPremium'] } + }); + }); + + it('captures configured keys on entry span', () => { + cls.ns.run(() => { + cls.setBaggage('userId=alice%20smith,isPremium=true,region=eu'); + const span = cls.startSpan({ spanName: 'node.http.server', kind: 1, spanData: {} }); + expect(span.data.sdk.custom.tags.userId).to.equal('alice smith'); + expect(span.data.sdk.custom.tags.isPremium).to.equal('true'); + expect(span.data.sdk.custom.tags).to.not.have.property('region'); + }); + }); + + it('captures configured keys on child (exit) spans', () => { + cls.ns.run(() => { + cls.setBaggage('userId=bob,isPremium=false'); + const entry = cls.startSpan({ spanName: 'node.http.server', kind: 1, spanData: {} }); + const exit = cls.startSpan({ spanName: 'node.http.client', kind: 2, spanData: {} }); + expect(exit.data.sdk.custom.tags.userId).to.equal('bob'); + entry.cancel(); + exit.cancel(); + }); + }); + + it('existing annotation wins over baggage value', () => { + cls.ns.run(() => { + cls.setBaggage('userId=fromBaggage'); + const span = cls.startSpan({ spanName: 'node.http.server', kind: 1, spanData: { sdk: { custom: { tags: { userId: 'existing' } } } } }); + expect(span.data.sdk.custom.tags.userId).to.equal('existing'); + }); + }); + + it('does not capture when captureW3cBaggage is empty', () => { + const clsEmpty = proxyquire('../../src/tracing/cls', {}); + clsEmpty.init({ logger: createFakeLogger(), tracing: { captureW3cBaggage: [] } }); + clsEmpty.ns.run(() => { + clsEmpty.setBaggage('userId=alice'); + const span = clsEmpty.startSpan({ spanName: 'sdk', kind: 1, spanData: {} }); + expect(span.data.sdk).to.be.undefined; + }); + }); + + it('does not capture when no baggage in CLS', () => { + cls.ns.run(() => { + const span = cls.startSpan({ spanName: 'sdk', kind: 1, spanData: {} }); + expect(span.data.sdk).to.be.undefined; + }); + }); + }); + + describe('sdk.setBaggage', () => { + let sdk; + let cls; + + beforeEach(() => { + cls = proxyquire('../../src/tracing/cls', {}); + cls.init({ logger: createFakeLogger() }); + + const sdkModule = require('../../src/tracing/sdk/sdk'); + const generated = sdkModule.generate(false); + generated.init({ logger: createFakeLogger() }, cls); + generated.activate(); + sdk = generated; + }); + + it('is available on callback, promise, and async sdk exports', () => { + const sdkExports = require('../../src/tracing/sdk'); + sdkExports.init({ logger: createFakeLogger() }, cls); + sdkExports.activate(); + + cls.ns.run(() => { + sdkExports.callback.setBaggage('cbKey', 'cbVal'); + expect(cls.getBaggage()).to.include('cbKey=cbVal'); + + sdkExports.promise.setBaggage('promiseKey', 'promiseVal'); + expect(cls.getBaggage()).to.include('promiseKey=promiseVal'); + + sdkExports.async.setBaggage('asyncKey', 'asyncVal'); + expect(cls.getBaggage()).to.include('asyncKey=asyncVal'); + }); + }); + + it('sets a new key in the baggage header stored in CLS', () => { + cls.ns.run(() => { + cls.setBaggage('existingKey=existingVal'); + sdk.setBaggage('newKey', 'newVal'); + expect(cls.getBaggage()).to.include('newKey=newVal'); + }); + }); + + it('overrides an existing key', () => { + cls.ns.run(() => { + cls.setBaggage('userId=oldVal'); + sdk.setBaggage('userId', 'newVal'); + const raw = cls.getBaggage(); + const parsed = require('../../src/tracing/baggage').parseBaggageHeader(raw); + expect(parsed.userId).to.equal('newVal'); + }); + }); + + it('creates baggage from scratch when none exists', () => { + cls.ns.run(() => { + sdk.setBaggage('key', 'value'); + expect(cls.getBaggage()).to.equal('key=value'); + }); + }); + + it('percent-encodes special characters in the value', () => { + cls.ns.run(() => { + sdk.setBaggage('user', 'alice smith'); + expect(cls.getBaggage()).to.include('user=alice%20smith'); + }); + }); + }); + + describe('OTLP export – baggage in sdk.custom.tags flows into attributes', () => { + let mapper; + + before(() => { + mapper = require('../../src/otlpExporter/traces/mappers/instanaInstrumentationMappings'); + }); + + it('includes sdk.custom.tags entries as OTLP attributes', () => { + const span = { + n: 'sdk', + k: 1, + data: { + sdk: { + name: 'test', + type: 'entry', + custom: { + tags: { userId: 'alice', isPremium: 'true' } + } + } + } + }; + + const attributes = mapper.spanAttributes(span); + const keys = attributes.map(a => a.key); + expect(keys).to.include('userId'); + expect(keys).to.include('isPremium'); + + const userAttr = attributes.find(a => a.key === 'userId'); + expect(userAttr.value).to.deep.equal({ stringValue: 'alice' }); + }); + }); +}); diff --git a/packages/core/test/tracing/instrumentation/baggage_propagation_test.js b/packages/core/test/tracing/instrumentation/baggage_propagation_test.js new file mode 100644 index 0000000000..ca2907c528 --- /dev/null +++ b/packages/core/test/tracing/instrumentation/baggage_propagation_test.js @@ -0,0 +1,224 @@ +/* + * (c) Copyright IBM Corp. 2026 + */ + +'use strict'; + +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); +const { createFakeLogger } = require('../../test_util'); +const constants = require('../../../src/tracing/constants'); + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function makeConfig({ disableW3cBaggage = false, disableW3cPropagation = false } = {}) { + return { + logger: createFakeLogger(), + tracing: { + disableW3cCorrelation: false, + disableW3cBaggage, + disableW3cPropagation, + http: { extraHttpHeadersToCapture: [] } + } + }; +} + +// --------------------------------------------------------------------------- +// httpClient propagation +// --------------------------------------------------------------------------- + +describe('baggage propagation – httpClient', () => { + let cls; + let httpClient; + let fakeRequest; + + beforeEach(() => { + cls = proxyquire('../../../src/tracing/cls', {}); + cls.init({ logger: createFakeLogger() }); + + // Minimal stub for the http core module + fakeRequest = { + setHeader: (name, value) => { + fakeRequest._headers = fakeRequest._headers || {}; + fakeRequest._headers[name] = value; + }, + on: () => fakeRequest, + _headers: {} + }; + + const fakeHttpModule = { + request: function () { + return fakeRequest; + }, + get: function () {} + }; + + httpClient = proxyquire('../../../src/tracing/instrumentation/protocols/httpClient', { + http: fakeHttpModule, + https: fakeHttpModule, + '../../cls': cls + }); + }); + + it('propagates baggage on an outgoing request (via options.headers)', () => { + const config = makeConfig(); + httpClient.init(config); + httpClient.activate(config); + + cls.ns.run(() => { + cls.setBaggage('userId=alice,isPremium=true'); + expect(cls.getBaggage()).to.equal('userId=alice,isPremium=true'); + expect(constants.w3cBaggage).to.equal('baggage'); + }); + }); + + it('does not propagate baggage when disableW3cBaggage is true', () => { + const config = makeConfig({ disableW3cBaggage: true }); + httpClient.init(config); + httpClient.activate(config); + + cls.ns.run(() => { + cls.setBaggage('userId=alice'); + expect(cls.getBaggage()).to.equal('userId=alice'); + }); + }); + + it('stores and retrieves baggage via CLS', () => { + cls.ns.run(() => { + expect(cls.getBaggage()).to.be.undefined; + cls.setBaggage('key=value'); + expect(cls.getBaggage()).to.equal('key=value'); + }); + }); + + it('overwrites baggage in CLS', () => { + cls.ns.run(() => { + cls.setBaggage('a=1'); + cls.setBaggage('b=2'); + expect(cls.getBaggage()).to.equal('b=2'); + }); + }); + + it('stores null baggage (disabled/invalid header)', () => { + cls.ns.run(() => { + cls.setBaggage(null); + expect(cls.getBaggage()).to.be.null; + }); + }); +}); + +// --------------------------------------------------------------------------- +// http2Client propagation +// --------------------------------------------------------------------------- + +describe('baggage propagation – http2Client', () => { + let cls; + let http2Client; + + beforeEach(() => { + cls = proxyquire('../../../src/tracing/cls', {}); + cls.init({ logger: createFakeLogger() }); + + const fakeStream = { on: () => fakeStream }; + const fakeSession = { + request: function (headers) { + fakeSession._lastHeaders = headers; + return fakeStream; + } + }; + + const fakeHttp2Module = { + connect: function () { + return fakeSession; + }, + constants: { + HTTP2_HEADER_METHOD: ':method', + HTTP2_HEADER_PATH: ':path', + HTTP2_HEADER_STATUS: ':status' + } + }; + + http2Client = proxyquire('../../../src/tracing/instrumentation/protocols/http2Client', { + http2: fakeHttp2Module, + '../../cls': cls + }); + + http2Client.init(makeConfig()); + }); + + it('baggage key constant is lowercase "baggage"', () => { + expect(constants.w3cBaggage).to.equal('baggage'); + }); + + it('getBaggage returns baggage set in the same CLS context', () => { + cls.ns.run(() => { + cls.setBaggage('env=prod,region=eu'); + expect(cls.getBaggage()).to.equal('env=prod,region=eu'); + }); + }); + + it('getBaggage is isolated across CLS contexts', done => { + cls.ns.run(() => { + cls.setBaggage('ctx=outer'); + + cls.ns.run(() => { + cls.setBaggage('ctx=inner'); + expect(cls.getBaggage()).to.equal('ctx=inner'); + done(); + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// nativeFetch propagation +// --------------------------------------------------------------------------- + +describe('baggage propagation – nativeFetch', () => { + let cls; + let nativeFetch; + + beforeEach(() => { + cls = proxyquire('../../../src/tracing/cls', {}); + cls.init({ logger: createFakeLogger() }); + + // nativeFetch reads global.fetch at module load time, so we stub it. + const fakeFetch = async () => ({ status: 200, headers: { forEach: () => {} } }); + + nativeFetch = proxyquire('../../../src/tracing/instrumentation/protocols/nativeFetch', { + '../../cls': cls, + // provide a semver stub so the version guard passes + semver: { eq: () => false, gte: () => false, lt: () => true } + }); + + // Inject our fake fetch so the module treats it as the originalFetch + // (the module captures global.fetch at require time via a const, so we test + // the CLS-level behaviour instead of the actual header injection). + nativeFetch._fakeFetch = fakeFetch; + }); + + it('baggage stored in CLS is available for nativeFetch to read', () => { + cls.ns.run(() => { + cls.setBaggage('traceId=abc123'); + expect(cls.getBaggage()).to.equal('traceId=abc123'); + }); + }); + + it('baggage constant name used for the header is lowercase', () => { + expect(constants.w3cBaggage).to.equal('baggage'); + }); + + it('activates with disableW3cBaggage config without throwing', () => { + expect(() => { + nativeFetch.activate(makeConfig({ disableW3cBaggage: true })); + }).to.not.throw(); + }); + + it('activates with baggage enabled without throwing', () => { + expect(() => { + nativeFetch.activate(makeConfig({ disableW3cBaggage: false })); + }).to.not.throw(); + }); +}); diff --git a/packages/core/test/tracing/tracingHeaders_test.js b/packages/core/test/tracing/tracingHeaders_test.js index 50e9d53b50..15b88e600f 100644 --- a/packages/core/test/tracing/tracingHeaders_test.js +++ b/packages/core/test/tracing/tracingHeaders_test.js @@ -35,7 +35,10 @@ const traceStateWithInstanaLeftMostNarrow = `${instanaNarrowTraceStateValue},roj describe('tracing/headers', () => { before(() => { - tracingHeaders.init({ logger: createFakeLogger(), tracing: { disableW3cCorrelation: false } }); + tracingHeaders.init({ + logger: createFakeLogger(), + tracing: { disableW3cCorrelation: false, disableW3cBaggage: false } + }); }); it('should read X-INSTANA- headers', () => { @@ -600,4 +603,110 @@ describe('tracing/headers', () => { } }); } + + describe('W3C baggage', () => { + it('should read the baggage header', () => { + const context = tracingHeaders.fromHttpRequest({ + headers: { baggage: 'userId=alice,isPremium=true' } + }); + expect(context.w3cBaggage).to.equal('userId=alice,isPremium=true'); + }); + + it('should read baggage header case-insensitively', () => { + const context = tracingHeaders.fromHttpRequest({ + headers: { Baggage: 'userId=alice' } + }); + expect(context.w3cBaggage).to.equal('userId=alice'); + }); + + it('should read baggage alongside X-INSTANA headers', () => { + const context = tracingHeaders.fromHttpRequest({ + headers: { + 'x-instana-t': instana16CharTraceId, + 'x-instana-s': instanaSpanId, + baggage: 'userId=alice' + } + }); + expect(context.w3cBaggage).to.equal('userId=alice'); + }); + + it('should read baggage alongside traceparent', () => { + const context = tracingHeaders.fromHttpRequest({ + headers: { + traceparent: traceParent, + baggage: 'userId=alice' + } + }); + expect(context.w3cBaggage).to.equal('userId=alice'); + }); + + it('should preserve baggage properties (semicolon-separated metadata)', () => { + const raw = 'key1=value1;prop1;prop2=x, key2=value2'; + const context = tracingHeaders.fromHttpRequest({ + headers: { baggage: raw } + }); + expect(context.w3cBaggage).to.equal(raw); + }); + + it('should return null baggage when no baggage header is present', () => { + const context = tracingHeaders.fromHttpRequest({ headers: {} }); + expect(context.w3cBaggage).to.be.null; + }); + + it('should drop baggage when it exceeds 8192 bytes', () => { + const longValue = `k=${'x'.repeat(8191)}`; + const context = tracingHeaders.fromHttpRequest({ + headers: { baggage: longValue } + }); + expect(context.w3cBaggage).to.be.null; + }); + + it('should accept baggage at exactly 8192 bytes', () => { + const value = `k=${'x'.repeat(8190)}`; + const context = tracingHeaders.fromHttpRequest({ + headers: { baggage: value } + }); + expect(context.w3cBaggage).to.equal(value); + }); + + it('should drop baggage when it contains more than 64 list-members', () => { + const pairs = Array.from({ length: 65 }, (_, i) => `k${i}=v`).join(','); + const context = tracingHeaders.fromHttpRequest({ + headers: { baggage: pairs } + }); + expect(context.w3cBaggage).to.be.null; + }); + + it('should accept baggage with exactly 64 list-members', () => { + const pairs = Array.from({ length: 64 }, (_, i) => `k${i}=v`).join(','); + const context = tracingHeaders.fromHttpRequest({ + headers: { baggage: pairs } + }); + expect(context.w3cBaggage).to.equal(pairs); + }); + + describe('when disableW3cBaggage is true', () => { + before(() => { + tracingHeaders.init({ + logger: createFakeLogger(), + tracing: { disableW3cCorrelation: false, disableW3cBaggage: true } + }); + }); + + after(() => { + // restore default + tracingHeaders.init({ + logger: createFakeLogger(), + tracing: { disableW3cCorrelation: false, disableW3cBaggage: false } + }); + }); + + it('should not read baggage when disabled', () => { + const context = tracingHeaders.fromHttpRequest({ + headers: { baggage: 'userId=alice' } + }); + expect(context.w3cBaggage).to.be.null; + }); + }); + }); });