From 4ff7e144bd2c6bdc59fdaa8a39586c366643df06 Mon Sep 17 00:00:00 2001 From: Eric Petzel Date: Fri, 13 Mar 2026 17:24:49 -0400 Subject: [PATCH] feat: tag trace and current span with feature flag metadata --- index.d.ts | 10 + .../openfeature-exposure-events.spec.js | 73 ++++++++ packages/dd-trace/src/config/index.js | 5 + .../src/config/supported-configurations.json | 10 + .../src/openfeature/constants/constants.js | 6 + .../src/openfeature/flagging_provider.js | 72 ++++++++ .../dd-trace/src/openfeature/span_tagger.js | 76 ++++++++ .../openfeature/flagging_provider.spec.js | 88 ++++++++- .../test/openfeature/span_tagger.spec.js | 172 ++++++++++++++++++ 9 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 packages/dd-trace/src/openfeature/span_tagger.js create mode 100644 packages/dd-trace/test/openfeature/span_tagger.spec.js diff --git a/index.d.ts b/index.d.ts index b046117a36a..1084db3cfad 100644 --- a/index.d.ts +++ b/index.d.ts @@ -802,6 +802,16 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ initializationTimeoutMs?: number + /** + * Maximum number of feature flag tags to attach to a single trace. + * Limits bandwidth when many flags are evaluated in one trace. Capped at 1000. + * Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS environment variable. + * + * @default 300 + * @env DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS + * Programmatic configuration takes precedence over the environment variables listed above. + */ + maxFlagTags?: number } }; diff --git a/integration-tests/openfeature/openfeature-exposure-events.spec.js b/integration-tests/openfeature/openfeature-exposure-events.spec.js index 1b255dc0ce3..ef77b3f6c4c 100644 --- a/integration-tests/openfeature/openfeature-exposure-events.spec.js +++ b/integration-tests/openfeature/openfeature-exposure-events.spec.js @@ -326,4 +326,77 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { }) }) }) + + describe('Feature flag span tagging', () => { + let agent, proc + + beforeEach(async () => { + agent = await new FakeAgent().start() + proc = await spawnProc(appFile, { + cwd, + env: { + DD_TRACE_AGENT_PORT: agent.port, + DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1', + DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + }, + }) + }) + + afterEach(async () => { + await stopProc(proc) + await agent.stop() + }) + + it('should tag root span with feature flag metadata', (done) => { + const configId = 'org-42-env-test' + + agent.on('remote-config-ack-update', async (id, _version, state) => { + if (state === UNACKNOWLEDGED) return + if (id !== configId) return + + try { + assert.strictEqual(state, ACKNOWLEDGED) + + const response = await fetch(`${proc.url}/evaluate-flags`) + assert.strictEqual(response.status, 200) + } catch (error) { + done(error) + } + }) + + agent.assertMessageReceived(({ payload }) => { + const trace = payload.flat() + const webSpan = trace.find(s => s.resource === 'GET /evaluate-flags') + if (!webSpan) throw new Error('Web span not found in this payload') + + // Verify no child spans are created for feature flag evaluations + const ffSpans = trace.filter(s => s.name === 'dd-trace.feature_flag.evaluate') + assert.strictEqual(ffSpans.length, 0, 'Should not create child spans for flag evaluations') + + // Verify root span has feature flag metadata tags + assert.ok( + webSpan.meta['feature_flags.test-boolean-flag.variant.key'], + 'Root span should have boolean flag variant tag' + ) + assert.strictEqual( + webSpan.meta['feature_flags.test-boolean-flag.subject.id'], + 'test-user-123' + ) + assert.ok( + webSpan.meta['feature_flags.test-string-flag.variant.key'], + 'Root span should have string flag variant tag' + ) + assert.strictEqual( + webSpan.meta['feature_flags.test-string-flag.subject.id'], + 'test-user-456' + ) + }, 30000, 10, true).then(done, done) + + agent.addRemoteConfig({ + product: RC_PRODUCT, + id: configId, + config: ufcPayloads.testBooleanAndStringFlags, + }) + }) + }) }) diff --git a/packages/dd-trace/src/config/index.js b/packages/dd-trace/src/config/index.js index f5fe3703418..dd433821cc8 100644 --- a/packages/dd-trace/src/config/index.js +++ b/packages/dd-trace/src/config/index.js @@ -415,6 +415,7 @@ class Config { OTEL_TRACES_SAMPLER_ARG, DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS, + DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS, OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_HEADERS, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL, @@ -603,6 +604,10 @@ class Config { target['experimental.flaggingProvider.initializationTimeoutMs'] = maybeInt(DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS) } + if (DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS != null) { + target['experimental.flaggingProvider.maxFlagTags'] = + Math.min(maybeInt(DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS), 1000) + } setBoolean(target, 'traceEnabled', DD_TRACE_ENABLED) setBoolean(target, 'experimental.aiguard.enabled', DD_AI_GUARD_ENABLED) setString(target, 'experimental.aiguard.endpoint', DD_AI_GUARD_ENDPOINT) diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index dffd439c88a..3c24961259f 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -742,6 +742,16 @@ "default": "false" } ], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS": [ + { + "implementation": "A", + "type": "int", + "configurationNames": [ + "experimental.flaggingProvider.maxFlagTags" + ], + "default": "300" + } + ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ { "implementation": "A", diff --git a/packages/dd-trace/src/openfeature/constants/constants.js b/packages/dd-trace/src/openfeature/constants/constants.js index 886a2eb4ec9..3dc0b635f5d 100644 --- a/packages/dd-trace/src/openfeature/constants/constants.js +++ b/packages/dd-trace/src/openfeature/constants/constants.js @@ -48,4 +48,10 @@ module.exports = { * @type {string} Reason code for noop provider evaluations */ NOOP_REASON: 'STATIC', + + /** + * @constant + * @type {string} Tag prefix for feature flag metadata on spans + */ + FF_TAG_PREFIX: 'feature_flags', } diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index 7bb88e09d4b..1a07cd25312 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -4,6 +4,7 @@ const { DatadogNodeServerProvider } = require('@datadog/openfeature-node-server' const { channel } = require('dc-polyfill') const log = require('../log') const { EXPOSURE_CHANNEL } = require('./constants/constants') +const { tagSpansForEvaluation } = require('./span_tagger') /** * OpenFeature provider that integrates with Datadog's feature flagging system. @@ -28,6 +29,77 @@ class FlaggingProvider extends DatadogNodeServerProvider { config.experimental.flaggingProvider.initializationTimeoutMs) } + /** + * Tags the active and root spans with feature flag evaluation metadata. + * + * @param {string} flagKey - The feature flag key + * @param {import('@openfeature/core').EvaluationContext} context - The evaluation context + * @param {import('@openfeature/core').ResolutionDetails< + * boolean | string | number | import('@openfeature/core').JsonValue + * >} result - The evaluation result + * @returns {import('@openfeature/core').ResolutionDetails< + * boolean | string | number | import('@openfeature/core').JsonValue + * >} The evaluation result (passthrough) + */ + _tagSpans (flagKey, context, result) { + tagSpansForEvaluation(this._tracer, { + flagKey, + variantKey: result.variant ?? String(result.value), + subjectId: context?.targetingKey, + maxFlagTags: this._config.experimental.flaggingProvider.maxFlagTags, + }) + + return result + } + + /** + * @param {string} flagKey + * @param {boolean} defaultValue + * @param {import('@openfeature/core').EvaluationContext} context + * @param {import('@openfeature/core').Logger} logger + * @returns {Promise>} + */ + resolveBooleanEvaluation (flagKey, defaultValue, context, logger) { + return super.resolveBooleanEvaluation(flagKey, defaultValue, context, logger) + .then(result => this._tagSpans(flagKey, context, result)) + } + + /** + * @param {string} flagKey + * @param {string} defaultValue + * @param {import('@openfeature/core').EvaluationContext} context + * @param {import('@openfeature/core').Logger} logger + * @returns {Promise>} + */ + resolveStringEvaluation (flagKey, defaultValue, context, logger) { + return super.resolveStringEvaluation(flagKey, defaultValue, context, logger) + .then(result => this._tagSpans(flagKey, context, result)) + } + + /** + * @param {string} flagKey + * @param {number} defaultValue + * @param {import('@openfeature/core').EvaluationContext} context + * @param {import('@openfeature/core').Logger} logger + * @returns {Promise>} + */ + resolveNumberEvaluation (flagKey, defaultValue, context, logger) { + return super.resolveNumberEvaluation(flagKey, defaultValue, context, logger) + .then(result => this._tagSpans(flagKey, context, result)) + } + + /** + * @param {string} flagKey + * @param {import('@openfeature/core').JsonValue} defaultValue + * @param {import('@openfeature/core').EvaluationContext} context + * @param {import('@openfeature/core').Logger} logger + * @returns {Promise>} + */ + resolveObjectEvaluation (flagKey, defaultValue, context, logger) { + return super.resolveObjectEvaluation(flagKey, defaultValue, context, logger) + .then(result => this._tagSpans(flagKey, context, result)) + } + /** * Internal method to update flag configuration from Remote Config. * This method is called automatically when Remote Config delivers UFC updates. diff --git a/packages/dd-trace/src/openfeature/span_tagger.js b/packages/dd-trace/src/openfeature/span_tagger.js new file mode 100644 index 00000000000..0882abc3b29 --- /dev/null +++ b/packages/dd-trace/src/openfeature/span_tagger.js @@ -0,0 +1,76 @@ +'use strict' + +const { FF_TAG_PREFIX } = require('./constants/constants') + +/** + * Builds feature flag tags for a given evaluation. + * + * @param {string} flagKey - The feature flag key + * @param {string} [variantKey] - The variant key from the evaluation result + * @param {string} [subjectId] - The subject/targeting key from the evaluation context + * @returns {{[key: string]: string}} Tag key-value pairs + */ +function buildFeatureFlagTags (flagKey, variantKey, subjectId) { + const tags = {} + + if (variantKey !== undefined) { + tags[`${FF_TAG_PREFIX}.${flagKey}.variant.key`] = variantKey + } + + if (subjectId !== undefined) { + tags[`${FF_TAG_PREFIX}.${flagKey}.subject.id`] = subjectId + } + + return tags +} + +/** + * Counts the number of unique feature flag keys in the trace-level tags. + * + * @param {import('../opentracing/span')} span + * @returns {number} + */ +function countFlagTags (span) { + const traceTags = span.context()._trace.tags + const prefix = `${FF_TAG_PREFIX}.` + let count = 0 + + for (const key in traceTags) { + if (key.startsWith(prefix) && key.endsWith('.variant.key')) { + count++ + } + } + + return count +} + +/** + * Tags the active span and trace with feature flag evaluation metadata. + * Span-level tags go on the active span via addTags(). Trace-level tags go on + * _trace.tags, which the formatter automatically applies to the root/chunk span. + * Skips tagging if the number of flags already on the trace reaches maxFlagTags. + * + * @param {import('../tracer')} tracer - Datadog tracer instance + * @param {object} params + * @param {string} params.flagKey - The feature flag key + * @param {string} [params.variantKey] - The variant key from the evaluation result + * @param {string} [params.subjectId] - The subject/targeting key from the evaluation context + * @param {number} [params.maxFlagTags] - Maximum number of flag tags per trace + */ +function tagSpansForEvaluation (tracer, { flagKey, variantKey, subjectId, maxFlagTags }) { + const activeSpan = tracer.scope().active() + if (!activeSpan) return + + if (maxFlagTags !== undefined && countFlagTags(activeSpan) >= maxFlagTags) return + + const tags = buildFeatureFlagTags(flagKey, variantKey, subjectId) + + activeSpan.addTags(tags) + Object.assign(activeSpan.context()._trace.tags, tags) +} + +module.exports = { + countFlagTags, + buildFeatureFlagTags, + tagSpansForEvaluation, +} diff --git a/packages/dd-trace/test/openfeature/flagging_provider.spec.js b/packages/dd-trace/test/openfeature/flagging_provider.spec.js index c628c2b16ef..0f81af82afe 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider.spec.js @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') -const { describe, it, beforeEach } = require('mocha') +const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') const proxyquire = require('proxyquire') @@ -15,10 +15,12 @@ describe('FlaggingProvider', () => { let mockChannel let log let channelStub + let tagSpansForEvaluation beforeEach(() => { mockTracer = { _config: { service: 'test-service' }, + scope: () => ({ active: () => ({}) }), } mockConfig = { @@ -29,6 +31,7 @@ describe('FlaggingProvider', () => { flaggingProvider: { enabled: true, initializationTimeoutMs: 30_000, + maxFlagTags: 300, }, }, } @@ -45,14 +48,21 @@ describe('FlaggingProvider', () => { warn: sinon.spy(), } + tagSpansForEvaluation = sinon.spy() + FlaggingProvider = proxyquire('../../src/openfeature/flagging_provider', { 'dc-polyfill': { channel: channelStub, }, '../log': log, + './span_tagger': { tagSpansForEvaluation }, }) }) + afterEach(() => { + sinon.restore() + }) + describe('constructor', () => { it('should initialize with tracer and config', () => { const provider = new FlaggingProvider(mockTracer, mockConfig) @@ -72,7 +82,7 @@ describe('FlaggingProvider', () => { const provider = new FlaggingProvider(mockTracer, mockConfig) assert.ok(provider) - sinon.assert.calledWith(log.debug, 'FlaggingProvider created with timeout: 30000ms') + sinon.assert.calledWith(log.debug, '%s created with timeout: %dms', 'FlaggingProvider', 30000) }) }) @@ -85,7 +95,7 @@ describe('FlaggingProvider', () => { provider._setConfiguration(ufc) sinon.assert.calledOnceWithExactly(setConfigSpy, ufc) - sinon.assert.calledWith(log.debug, 'FlaggingProvider provider configuration updated') + sinon.assert.calledWith(log.debug, '%s provider configuration updated', 'FlaggingProvider') }) it('should handle null/undefined configuration gracefully', () => { @@ -104,4 +114,76 @@ describe('FlaggingProvider', () => { assert.ok(provider instanceof DatadogNodeServerProvider) }) }) + + describe('span tagging on resolve methods', () => { + const resolveMethodConfigs = [ + { method: 'resolveBooleanEvaluation', defaultValue: false }, + { method: 'resolveStringEvaluation', defaultValue: 'default' }, + { method: 'resolveNumberEvaluation', defaultValue: 0 }, + { method: 'resolveObjectEvaluation', defaultValue: {} }, + ] + + for (const { method, defaultValue } of resolveMethodConfigs) { + describe(method, () => { + it('should call tagSpansForEvaluation with correct params', async () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + const context = { targetingKey: 'user-1' } + + await provider[method]('test-flag', defaultValue, context) + + sinon.assert.calledOnce(tagSpansForEvaluation) + const [tracer, params] = tagSpansForEvaluation.firstCall.args + assert.strictEqual(tracer, mockTracer) + assert.strictEqual(params.flagKey, 'test-flag') + assert.strictEqual(params.subjectId, 'user-1') + assert.strictEqual(params.maxFlagTags, 300) + }) + + it('should still return the evaluation result', async () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + + const result = await provider[method]('test-flag', defaultValue, { targetingKey: 'user-1' }) + + assert.ok(Object.hasOwn(result, 'value')) + }) + }) + } + + it('should pass variant from result when present', async () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + const parentProto = Object.getPrototypeOf(Object.getPrototypeOf(provider)) + const stub = sinon.stub(parentProto, 'resolveBooleanEvaluation').resolves({ + value: true, + variant: 'treatment', + reason: 'TARGETING_MATCH', + }) + + try { + await provider.resolveBooleanEvaluation('test-flag', false, { targetingKey: 'user-1' }) + + const [, params] = tagSpansForEvaluation.firstCall.args + assert.strictEqual(params.variantKey, 'treatment') + } finally { + stub.restore() + } + }) + + it('should fall back to stringified value when variant is absent', async () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + const parentProto = Object.getPrototypeOf(Object.getPrototypeOf(provider)) + const stub = sinon.stub(parentProto, 'resolveBooleanEvaluation').resolves({ + value: false, + reason: 'DEFAULT', + }) + + try { + await provider.resolveBooleanEvaluation('test-flag', false, { targetingKey: 'user-1' }) + + const [, params] = tagSpansForEvaluation.firstCall.args + assert.strictEqual(params.variantKey, 'false') + } finally { + stub.restore() + } + }) + }) }) diff --git a/packages/dd-trace/test/openfeature/span_tagger.spec.js b/packages/dd-trace/test/openfeature/span_tagger.spec.js new file mode 100644 index 00000000000..4e2dd4e34ff --- /dev/null +++ b/packages/dd-trace/test/openfeature/span_tagger.spec.js @@ -0,0 +1,172 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it, beforeEach } = require('mocha') + +require('../setup/core') + +const { + buildFeatureFlagTags, + countFlagTags, + tagSpansForEvaluation, +} = require('../../src/openfeature/span_tagger') + +describe('span_tagger', () => { + describe('buildFeatureFlagTags', () => { + it('should build tags with variant and subject', () => { + const tags = buildFeatureFlagTags('my-feature-flag', 'control', 'foo@bar.com') + + assert.deepStrictEqual(tags, { + 'feature_flags.my-feature-flag.variant.key': 'control', + 'feature_flags.my-feature-flag.subject.id': 'foo@bar.com', + }) + }) + + it('should handle flag keys with dots', () => { + const tags = buildFeatureFlagTags('org.feature.enabled', 'variant-a', 'user-1') + + assert.deepStrictEqual(tags, { + 'feature_flags.org.feature.enabled.variant.key': 'variant-a', + 'feature_flags.org.feature.enabled.subject.id': 'user-1', + }) + }) + + it('should handle flag keys with hyphens', () => { + const tags = buildFeatureFlagTags('my-cool-feature', 'treatment', 'user-abc') + + assert.deepStrictEqual(tags, { + 'feature_flags.my-cool-feature.variant.key': 'treatment', + 'feature_flags.my-cool-feature.subject.id': 'user-abc', + }) + }) + + it('should omit variant tag when variantKey is undefined', () => { + const tags = buildFeatureFlagTags('flag-1', undefined, 'user-1') + + assert.deepStrictEqual(tags, { + 'feature_flags.flag-1.subject.id': 'user-1', + }) + }) + + it('should omit subject tag when subjectId is undefined', () => { + const tags = buildFeatureFlagTags('flag-1', 'control', undefined) + + assert.deepStrictEqual(tags, { + 'feature_flags.flag-1.variant.key': 'control', + }) + }) + + it('should return empty tags when both variant and subject are undefined', () => { + const tags = buildFeatureFlagTags('flag-1', undefined, undefined) + + assert.deepStrictEqual(tags, {}) + }) + }) + + describe('tagSpansForEvaluation', () => { + let activeSpan, traceTags, tracer + + beforeEach(() => { + traceTags = {} + + const activeContext = { + _spanId: 'span-2', + _parentId: 'span-1', + _trace: { tags: traceTags }, + _tags: {}, + } + + activeSpan = { + addTags (tags) { Object.assign(activeContext._tags, tags) }, + get _tags () { return activeContext._tags }, + context: () => activeContext, + } + + tracer = { + scope: () => ({ + active: () => activeSpan, + }), + } + }) + + it('should tag the active span', () => { + tagSpansForEvaluation(tracer, { + flagKey: 'test-flag', + variantKey: 'control', + subjectId: 'user-123', + }) + + assert.deepStrictEqual(activeSpan._tags, { + 'feature_flags.test-flag.variant.key': 'control', + 'feature_flags.test-flag.subject.id': 'user-123', + }) + }) + + it('should add tags to _trace.tags for trace-level propagation', () => { + tagSpansForEvaluation(tracer, { + flagKey: 'test-flag', + variantKey: 'control', + subjectId: 'user-123', + }) + + assert.deepStrictEqual(traceTags, { + 'feature_flags.test-flag.variant.key': 'control', + 'feature_flags.test-flag.subject.id': 'user-123', + }) + }) + + it('should be a no-op when there is no active span', () => { + const noActiveTracer = { + scope: () => ({ + active: () => undefined, + }), + } + + // Should not throw + tagSpansForEvaluation(noActiveTracer, { + flagKey: 'test-flag', + variantKey: 'control', + subjectId: 'user-123', + }) + }) + + it('should handle undefined variant and subject gracefully', () => { + tagSpansForEvaluation(tracer, { + flagKey: 'test-flag', + variantKey: undefined, + subjectId: undefined, + }) + + assert.deepStrictEqual(activeSpan._tags, {}) + assert.deepStrictEqual(traceTags, {}) + }) + + it('should skip tagging when maxFlagTags is reached', () => { + tagSpansForEvaluation(tracer, { + flagKey: 'flag-1', variantKey: 'v1', subjectId: 'u1', maxFlagTags: 2, + }) + tagSpansForEvaluation(tracer, { + flagKey: 'flag-2', variantKey: 'v2', subjectId: 'u2', maxFlagTags: 2, + }) + tagSpansForEvaluation(tracer, { + flagKey: 'flag-3', variantKey: 'v3', subjectId: 'u3', maxFlagTags: 2, + }) + + assert.strictEqual(countFlagTags(activeSpan), 2) + assert.strictEqual(traceTags['feature_flags.flag-1.variant.key'], 'v1') + assert.strictEqual(traceTags['feature_flags.flag-2.variant.key'], 'v2') + assert.strictEqual(traceTags['feature_flags.flag-3.variant.key'], undefined) + }) + + it('should not enforce limit when maxFlagTags is undefined', () => { + for (let i = 0; i < 5; i++) { + tagSpansForEvaluation(tracer, { + flagKey: `flag-${i}`, variantKey: `v${i}`, subjectId: `u${i}`, + }) + } + + assert.strictEqual(countFlagTags(activeSpan), 5) + }) + }) +})