From 8a11b63794c670e0e35fd6b7032d027610f78ec4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 21 Jul 2026 17:04:41 -0600 Subject: [PATCH 1/3] fix(openfeature): support custom agentless endpoints --- packages/dd-trace/src/config/parsers.js | 4 ++ .../src/config/supported-configurations.json | 3 +- .../dd-trace/src/exporters/common/request.js | 6 ++- .../agentless_configuration_source.js | 7 +++- .../src/openfeature/configuration_source.js | 10 +---- packages/dd-trace/test/config/index.spec.js | 26 +++++++++---- .../test/exporters/common/request.spec.js | 17 ++++++++ .../agentless_configuration_source.spec.js | 23 ++++++++++- .../openfeature/configuration_source.spec.js | 39 ++++++++----------- 9 files changed, 88 insertions(+), 47 deletions(-) diff --git a/packages/dd-trace/src/config/parsers.js b/packages/dd-trace/src/config/parsers.js index beb5a59a17..b992a3d9f2 100644 --- a/packages/dd-trace/src/config/parsers.js +++ b/packages/dd-trace/src/config/parsers.js @@ -100,6 +100,10 @@ const transformers = { } return configValue }, + normalizeFeatureFlagsConfigurationSource (value) { + const source = String(value).trim().toLowerCase() + return source || undefined + }, /** * Parses DD_PROFILING_DEBUG_UPLOAD_COMPRESSION ('on' | 'off' | 'gzip[-1..9]' | 'zstd[-1..22]') * into the codec and level the profiler uploads with. The value's shape is already range-checked diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index bb8fbb2d4c..2abce09738 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -840,10 +840,9 @@ "implementation": "A", "type": "string", "default": "agentless", - "allowed": "agentless|remote_config", "description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config.", "namespace": "featureFlags", - "transform": "toLowerCase" + "transform": "normalizeFeatureFlagsConfigurationSource" } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": [ diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index dc050a4522..251cda8938 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -50,11 +50,13 @@ function request (data, options, callback) { } // Never put the Datadog API key on a cleartext connection to a non-loopback host; that would - // expose it on the wire. Loopback (local agent, dev proxy, tests) is exempt. Strip the key + // expose it on the wire. Loopback (local agent, dev proxy, tests) and explicit operator-owned + // endpoints are exempt. Strip the key // rather than drop the request: the agent proxies telemetry with its own key, while an https // intake URL is required to authenticate agentless traffic. const hasApiKey = options.headers['dd-api-key'] !== undefined || options.headers['DD-API-KEY'] !== undefined - if (hasApiKey && options.protocol === 'http:' && !isLoopbackHost(options.hostname)) { + const insecureApiKey = options.protocol === 'http:' && !isLoopbackHost(options.hostname) + if (hasApiKey && insecureApiKey && !options.allowInsecureApiKey) { log.error( 'Not sending the Datadog API key over a non-TLS connection to %s. Configure an https intake URL.', options.hostname diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index fc50bf1711..f11c581373 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -18,9 +18,10 @@ const RETRY_JITTER = 0.2 /** * @typedef {object} AgentlessSourceConfig * @property {URL} endpoint + * @property {boolean} allowInsecureApiKey * @property {number} pollIntervalMs * @property {number} requestTimeoutMs - * @property {string} apiKey + * @property {string | undefined} apiKey */ /** @@ -138,7 +139,7 @@ class AgentlessConfigurationSource { #request (signal) { const headers = getClientLibraryHeaders() headers['Accept-Encoding'] = 'gzip' - headers['DD-API-KEY'] = this.#config.apiKey + if (this.#config.apiKey) headers['DD-API-KEY'] = this.#config.apiKey if (this.#etag) headers['If-None-Match'] = this.#etag /** @@ -164,6 +165,8 @@ class AgentlessConfigurationSource { url: this.#config.endpoint, method: 'GET', headers, + // An explicit operator override may be a cleartext development or dogfooding proxy. + allowInsecureApiKey: this.#config.allowInsecureApiKey, retry: false, signal, timeout: this.#config.requestTimeoutMs, diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 0bd7327953..819fe69fb5 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -1,6 +1,5 @@ 'use strict' -const { isLoopbackHost } = require('../exporters/common/url') const log = require('../log') const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' @@ -28,13 +27,10 @@ function create (config, applyConfiguration) { } try { - if (!config.DD_API_KEY) { - throw new Error('DD_API_KEY is required for Feature Flagging agentless delivery') - } - const AgentlessConfigurationSource = require('./agentless_configuration_source') return new AgentlessConfigurationSource({ endpoint: endpoint(config, baseUrl), + allowInsecureApiKey: Boolean(baseUrl?.trim()), pollIntervalMs: Math.min(pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS) * 1000, requestTimeoutMs: requestTimeoutSeconds * 1000, apiKey: config.DD_API_KEY, @@ -74,10 +70,6 @@ function endpoint (config, configuredBaseUrl) { if (url.protocol !== 'https:' && url.protocol !== 'http:') { throw new Error('Feature Flagging agentless URL must use HTTP or HTTPS') } - if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) { - throw new Error('Feature Flagging agentless URL must use HTTPS unless it targets loopback') - } - if (url.pathname === '' || url.pathname === '/') { url.pathname = DEFAULT_AGENTLESS_PATH } diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 1c3bdcb307..23456ce169 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -5034,16 +5034,26 @@ rules: expected: { enabled: true, source: 'remote_config' }, }, { - name: 'falls back from an invalid source to legacy enablement', + name: 'fails closed for an invalid source', + source: 'other', + expected: { enabled: true, source: 'other' }, + }, + { + name: 'fails closed for the reserved offline source', + source: 'offline', + expected: { enabled: true, source: 'offline' }, + }, + { + name: 'fails closed for an invalid source despite legacy enablement', source: 'other', legacyEnabled: 'true', - expected: { enabled: true, source: 'remote_config' }, + expected: { enabled: true, source: 'other' }, }, { - name: 'falls back from the reserved offline source to legacy enablement', + name: 'fails closed for the reserved offline source despite legacy enablement', source: 'offline', legacyEnabled: 'true', - expected: { enabled: true, source: 'remote_config' }, + expected: { enabled: true, source: 'offline' }, }, ]) { it(name, () => { @@ -5069,7 +5079,7 @@ rules: }) } - it('falls back from an invalid source through calculated legacy precedence', () => { + it('preserves an explicit invalid source over calculated legacy precedence', () => { process.env.DD_FEATURE_FLAGS_ENABLED = 'true' process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'offline' process.env.DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = 'true' @@ -5077,14 +5087,14 @@ rules: const config = getConfig() assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_ENABLED, true) - assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'remote_config') + assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'offline') assert.strictEqual(config.experimental.flaggingProvider.enabled, true) assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_ENABLED'), 'env_var') - assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'calculated') + assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'env_var') assert.strictEqual(config.getOrigin('experimental.flaggingProvider.enabled'), 'env_var') assertConfigUpdateContains(updateConfig.getCall(0).args[0], [ { name: 'DD_FEATURE_FLAGS_ENABLED', value: true, origin: 'env_var' }, - { name: 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE', value: 'remote_config', origin: 'calculated' }, + { name: 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE', value: 'offline', origin: 'env_var' }, { name: 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', value: true, origin: 'env_var' }, ]) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index eceaf687c6..f4b8d058ff 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -847,6 +847,23 @@ describe('request', function () { }) }) + it('keeps dd-api-key over http for an explicitly trusted custom endpoint', (done) => { + nock('http://flags.dev.internal', { reqheaders: { 'dd-api-key': 'secret-key' } }) + .post('/v1/input') + .reply(200, 'OK') + + request(Buffer.from(''), { + method: 'POST', + url: new URL('http://flags.dev.internal/v1/input'), + headers: { 'dd-api-key': 'secret-key' }, + allowInsecureApiKey: true, + }, (err, res) => { + assert.strictEqual(res, 'OK') + sinon.assert.notCalled(log.error) + done(err) + }) + }) + for (const loopbackHost of ['127.0.0.1', '127.1.2.3', 'localhost', '[::1]']) { it(`keeps dd-api-key over http to the loopback host ${loopbackHost}`, (done) => { nock(`http://${loopbackHost}:9999`, { diff --git a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js index d8816abb8f..e5f1e6fb18 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -48,6 +48,7 @@ describe('AgentlessConfigurationSource', () => { applyConfiguration = sinon.stub() config = { endpoint: new URL('http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server'), + allowInsecureApiKey: true, pollIntervalMs: 30_000, requestTimeoutMs: 2000, apiKey: 'test-api-key', @@ -146,6 +147,7 @@ describe('AgentlessConfigurationSource', () => { assert.strictEqual(requests[0].data, '') assert.strictEqual(requests[0].options.url, config.endpoint) assert.strictEqual(requests[0].options.method, 'GET') + assert.strictEqual(requests[0].options.allowInsecureApiKey, config.allowInsecureApiKey) assert.strictEqual(requests[0].options.retry, false) assert.strictEqual(requests[0].options.timeout, 2000) assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key') @@ -181,13 +183,14 @@ describe('AgentlessConfigurationSource', () => { clock.restore() clock = undefined const body = zlib.gzipSync(responseBody()) - nock('http://127.0.0.1:8080', { + config.endpoint = new URL('http://flags.dev.internal:8080/custom/ufc') + nock('http://flags.dev.internal:8080', { reqheaders: { 'accept-encoding': 'gzip', 'dd-api-key': 'test-api-key', }, }) - .get('/api/v2/feature-flagging/config/rules-based/server') + .get('/custom/ufc') .reply(200, body, { 'content-encoding': 'gzip', etag: '"real-path"', @@ -491,6 +494,22 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.notCalled(applyConfiguration) }) + it('omits a missing API key and reports the endpoint authentication failure', async () => { + delete config.apiKey + responses.push({ statusCode: 401 }) + + source().start() + await flush() + + assert.strictEqual(Object.hasOwn(requests[0].options.headers, 'DD-API-KEY'), false) + sinon.assert.calledOnceWithExactly( + log.warn, + 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + 401 + ) + sinon.assert.notCalled(applyConfiguration) + }) + it('uses fixed-delay polling after a request completes', async () => { responses.push( { pending: true }, diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index 8ac81dba43..c20f896a04 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -52,6 +52,7 @@ describe('OpenFeature configuration source', () => { 'https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=my+env' ) assert.strictEqual(resolved.apiKey, 'test-api-key') + assert.strictEqual(resolved.allowInsecureApiKey, false) assert.strictEqual(resolved.pollIntervalMs, 30_000) assert.strictEqual(resolved.requestTimeoutMs, 5000) }) @@ -93,6 +94,18 @@ describe('OpenFeature configuration source', () => { }) } + it('allows a cleartext custom endpoint for local development and proxies', () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + 'http://flags.dev.internal:8080' + + const resolved = createSourceConfig() + assert.strictEqual( + resolved.endpoint.toString(), + 'http://flags.dev.internal:8080/api/v2/feature-flagging/config/rules-based/server' + ) + assert.strictEqual(resolved.allowInsecureApiKey, true) + }) + it('preserves an exact configured path and query', () => { config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/custom/ufc?tenant=one' @@ -156,20 +169,6 @@ describe('OpenFeature configuration source', () => { sinon.assert.notCalled(AgentlessConfigurationSource) }) - it('rejects cleartext non-loopback endpoints', () => { - config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://flags.example.test' - - const source = configurationSource.create(config, sinon.spy()) - - assert.strictEqual(source, undefined) - sinon.assert.calledOnceWithMatch( - log.error, - 'Unable to configure Feature Flagging configuration source', - sinon.match.instanceOf(Error) - ) - sinon.assert.notCalled(AgentlessConfigurationSource) - }) - it('rejects malformed endpoints without logging their sensitive value', () => { const sentinel = 'sensitive-value' config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = `https://${sentinel} value` @@ -187,18 +186,14 @@ describe('OpenFeature configuration source', () => { assert.strictEqual(log.error.firstCall.args[1].cause, undefined) }) - it('requires an API key without enabling a source', () => { + it('creates a source without an API key so the endpoint can report an authentication failure', () => { delete config.DD_API_KEY const source = configurationSource.create(config, sinon.spy()) - sinon.assert.calledOnceWithMatch( - log.error, - 'Unable to configure Feature Flagging configuration source', - sinon.match.instanceOf(Error) - ) - assert.strictEqual(source, undefined) - sinon.assert.notCalled(AgentlessConfigurationSource) + assert.ok(source instanceof AgentlessConfigurationSource) + assert.strictEqual(AgentlessConfigurationSource.firstCall.args[0].apiKey, undefined) + sinon.assert.notCalled(log.error) }) it('does not create an agentless source for Remote Config delivery', () => { From d58eb1dd35ff6b0fcf4a5cc6cb57ec49028ca63e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 21 Jul 2026 17:25:11 -0600 Subject: [PATCH 2/3] fix(openfeature): reject unsupported configuration sources --- .../openfeature-configuration-sources.spec.js | 22 ++++++++++++++----- packages/dd-trace/src/config/defaults.js | 13 ++++++----- .../src/config/generated-config-types.d.ts | 4 ++-- packages/dd-trace/src/config/parsers.js | 4 ---- .../src/config/supported-configurations.json | 5 +++-- .../src/openfeature/configuration_source.js | 6 ++++- .../openfeature/configuration_source.spec.js | 12 ++++++++++ 7 files changed, 46 insertions(+), 20 deletions(-) diff --git a/integration-tests/openfeature/openfeature-configuration-sources.spec.js b/integration-tests/openfeature/openfeature-configuration-sources.spec.js index 566c36639b..3efc09eabb 100644 --- a/integration-tests/openfeature/openfeature-configuration-sources.spec.js +++ b/integration-tests/openfeature/openfeature-configuration-sources.spec.js @@ -65,7 +65,7 @@ const AGENTLESS_RESPONSE = zlib.gzipSync(JSON.stringify({ })) /** @typedef {'absent'|'true'|'false'} BooleanSetting */ -/** @typedef {'agentless'|'remote_config'|'disabled'} Delivery */ +/** @typedef {'agentless'|'remote_config'|'disabled'|'error'} Delivery */ /** @typedef {{ name: string, value?: string }} SourceSetting */ /** * @typedef {object} ConfigurationCase @@ -135,9 +135,10 @@ describe('OpenFeature configuration source contract', () => { assert.strictEqual(configurationCases.length, 63) assert.deepStrictEqual(countDeliveries(configurationCases), { - agentless: 16, - remote_config: 16, - disabled: 31, + agentless: 12, + remote_config: 12, + disabled: 27, + error: 12, }) await runCases(configurationCases) @@ -241,12 +242,19 @@ async function runCase (testCase) { waitForObservation(agentlessRequests, testCase.identifier, 'agentless', hasObservation), sendCommand(proc, accessCommand), ]) + } else if (testCase.expected === 'error') { + await assert.rejects( + sendCommand(proc, accessCommand), + /Unsupported Feature Flagging configuration source: (offline|unsupported)/ + ) } else { await sendCommand(proc, accessCommand) } - const { details } = await sendCommand(proc, { command: 'evaluate' }) - assertEvaluation(testCase, details) + if (testCase.expected !== 'error') { + const { details } = await sendCommand(proc, { command: 'evaluate' }) + assertEvaluation(testCase, details) + } await waitForObservation( remoteConfigRequests, @@ -639,6 +647,7 @@ function countDeliveries (cases) { agentless: 0, remote_config: 0, disabled: 0, + error: 0, } for (const testCase of cases) counts[testCase.expected]++ return counts @@ -677,6 +686,7 @@ function expectedDelivery (stable, source, legacy) { if (stable === 'false') return 'disabled' if (source.name === 'agentless') return 'agentless' if (source.name === 'remote_config') return 'remote_config' + if (source.name === 'offline' || source.name === 'invalid') return 'error' if (legacy === 'true') return 'remote_config' if (legacy === 'false') return 'disabled' return 'agentless' diff --git a/packages/dd-trace/src/config/defaults.js b/packages/dd-trace/src/config/defaults.js index d5b1f2e882..e586647340 100644 --- a/packages/dd-trace/src/config/defaults.js +++ b/packages/dd-trace/src/config/defaults.js @@ -32,13 +32,14 @@ const parseErrors = new Map() * @param {string} source - The source of the value. * @param {string} baseMessage - The base message to use for the warning. * @param {Error} [error] - An error that was thrown while parsing the value. + * @param {boolean} [pickedDefault] - Whether the invalid value was discarded in favor of a fallback. */ -function warnInvalidValue (value, optionName, source, baseMessage, error) { +function warnInvalidValue (value, optionName, source, baseMessage, error, pickedDefault = true) { const canonicalName = (optionsTable[optionName]?.canonicalName ?? optionName) + source // Lazy load log module to avoid circular dependency if (!parseErrors.has(canonicalName)) { - // TODO: Rephrase: It will fallback to former source (or default if not set) - let message = `${baseMessage}: ${util.inspect(value)} for ${optionName} (source: ${source}), picked default` + let message = `${baseMessage}: ${util.inspect(value)} for ${optionName} (source: ${source})` + if (pickedDefault) message += ', picked default' if (error) { error.stack = error.toString() message += `\n\n${util.inspect(error)}` @@ -242,8 +243,10 @@ for (const [canonicalName, entries] of Object.entries(supportedConfigurations)) const originalTransform = transformer transformer = (value, optionName, source) => { if (!allowed.test(value)) { - warnInvalidValue(value, optionName, source, 'Invalid value') - return + const preserveInvalidSource = canonicalName === 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE' && + String(value).trim() !== '' + warnInvalidValue(value, optionName, source, 'Invalid value', undefined, !preserveInvalidSource) + if (!preserveInvalidSource) return } if (originalTransform) { value = originalTransform(value) diff --git a/packages/dd-trace/src/config/generated-config-types.d.ts b/packages/dd-trace/src/config/generated-config-types.d.ts index 9dec6c4d28..9cf5124c5d 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -432,7 +432,7 @@ export interface GeneratedConfig { }; }; featureFlags: { - DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config"; + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config" | "offline"; DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: string | undefined; DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: number; DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: number; @@ -697,7 +697,7 @@ export interface GeneratedEnvVarConfig { DD_EXPERIMENTAL_TEST_OPT_VITEST_NO_WORKER_INIT: boolean | undefined; DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE: boolean; DD_EXTERNAL_ENV: string | undefined; - DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config"; + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config" | "offline"; DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: string | undefined; DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: number; DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: number; diff --git a/packages/dd-trace/src/config/parsers.js b/packages/dd-trace/src/config/parsers.js index b992a3d9f2..beb5a59a17 100644 --- a/packages/dd-trace/src/config/parsers.js +++ b/packages/dd-trace/src/config/parsers.js @@ -100,10 +100,6 @@ const transformers = { } return configValue }, - normalizeFeatureFlagsConfigurationSource (value) { - const source = String(value).trim().toLowerCase() - return source || undefined - }, /** * Parses DD_PROFILING_DEBUG_UPLOAD_COMPRESSION ('on' | 'off' | 'gzip[-1..9]' | 'zstd[-1..22]') * into the codec and level the profiler uploads with. The value's shape is already range-checked diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 2abce09738..d769a13767 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -840,9 +840,10 @@ "implementation": "A", "type": "string", "default": "agentless", - "description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config.", + "allowed": "agentless|remote_config|offline", + "description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config; offline is reserved and currently unsupported.", "namespace": "featureFlags", - "transform": "normalizeFeatureFlagsConfigurationSource" + "transform": "toLowerCase" } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": [ diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 819fe69fb5..19eb228407 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -22,10 +22,14 @@ function create (config, applyConfiguration) { DD_FEATURE_FLAGS_ENABLED: enabled, } = config.featureFlags - if (!enabled || source !== 'agentless') { + if (!enabled || source === 'remote_config') { return } + if (source !== 'agentless') { + throw new Error(`Unsupported Feature Flagging configuration source: ${source}`) + } + try { const AgentlessConfigurationSource = require('./agentless_configuration_source') return new AgentlessConfigurationSource({ diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index c20f896a04..82f5413caa 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -206,6 +206,18 @@ describe('OpenFeature configuration source', () => { sinon.assert.notCalled(AgentlessConfigurationSource) }) + for (const source of ['offline', 'invalid']) { + it(`throws for the unsupported ${source} source`, () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = source + + assert.throws( + () => configurationSource.create(config, sinon.spy()), + new RegExp(`Unsupported Feature Flagging configuration source: ${source}`) + ) + sinon.assert.notCalled(AgentlessConfigurationSource) + }) + } + it('does not create an agentless source when Feature Flags are disabled', () => { config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false delete config.site From 6ee9987831336ca15627be1a1e60cc678a5cc9eb Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 21 Jul 2026 17:25:19 -0600 Subject: [PATCH 3/3] chore(openfeature): update node server provider --- package.json | 2 +- yarn.lock | 25 ++++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 5f36c3a0d6..2ea99f956c 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,7 @@ "@datadog/native-appsec": "11.0.1", "@datadog/native-iast-taint-tracking": "4.2.0", "@datadog/native-metrics": "3.1.2", - "@datadog/openfeature-node-server": "2.0.0", + "@datadog/openfeature-node-server": "2.0.1", "@datadog/pprof": "5.15.1", "@datadog/wasm-js-rewriter": "5.0.1", "@opentelemetry/api": ">=1.0.0 <1.10.0", diff --git a/yarn.lock b/yarn.lock index 88658bc136..6379545904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -245,13 +245,19 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== -"@datadog/flagging-core@1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@datadog/flagging-core/-/flagging-core-1.2.1.tgz#1bb2d1ecfd749033ed2570eccc8fb0697b8adfac" - integrity sha512-qeDkki9fFlqyoZBrn7tneT6pZ04EKKvf3xxisYw1a74zbJihvQui/ARUsjXCurRpzpFqGGTJw/oz+HnXaKhcdw== +"@datadog/flagging-core@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@datadog/flagging-core/-/flagging-core-2.0.1.tgz#be4c8b361629f3dac80b5710b417297856c84212" + integrity sha512-UDAVquISiAzubDpP6mWspZF4mXMbKu5eAKWfNIj79qmXPRy2OISbrAI5us3DqUJWLwwiNhmltk/45AQvL65N1A== dependencies: + "@datadog/js-core" "0.0.3" spark-md5 "^3.0.2" +"@datadog/js-core@0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@datadog/js-core/-/js-core-0.0.3.tgz#3c19fc6df6f303454ca5f7db5ed24e1cdeebbb0d" + integrity sha512-1jdV0/tB/v3H7AJAb1QmN+iOz9CAD6HezaaTrXVh3R9wHLHeDy2gca261/sSKD0HqBhWqgX4gd9BixlpMhKeUA== + "@datadog/libdatadog@0.9.4": version "0.9.4" resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.9.4.tgz#3d39c3561fa11a702c0e5e7819c83f4e03c07b78" @@ -279,12 +285,13 @@ node-addon-api "^6.1.0" node-gyp-build "^3.9.0" -"@datadog/openfeature-node-server@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@datadog/openfeature-node-server/-/openfeature-node-server-2.0.0.tgz#94e787f0bb0b1b0c87e728ad760950c0ea5f3328" - integrity sha512-Ummu/Bd7ZJpCCNdFnZUt/JI+L1+8OMK53+MyIXbS7dCt4JXWWwelbpzSGqmC2jWbWQ/mTo4hEfnFw3kYOINbXA== +"@datadog/openfeature-node-server@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@datadog/openfeature-node-server/-/openfeature-node-server-2.0.1.tgz#1bb8c4e9fb859058fa90212db1948a396804ecae" + integrity sha512-RQNpzWxXcpwhPSIP6zoBtQ477UafpEgmIDbS+lg+2rrt+kYfHv70P5mXkZP7cacBs25LBltxK/QWif+4wbQYSg== dependencies: - "@datadog/flagging-core" "1.2.1" + "@datadog/flagging-core" "2.0.1" + "@datadog/js-core" "0.0.3" "@datadog/pprof@5.15.1": version "5.15.1"