From 7ffa2a344bd54cccfd43fb04bd6d2c304683b545 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 23:26:51 -0400 Subject: [PATCH 01/15] feat(openfeature): resolve configuration delivery Define source selection, managed and custom endpoint derivation, staging and GovCloud routing, and bounded timing semantics. Keep this policy layer independent from transport and provider activation, with focused resolver tests. --- .../src/openfeature/configuration_source.js | 153 ++++++++++++++++ .../openfeature/configuration_source.spec.js | 168 ++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 packages/dd-trace/src/openfeature/configuration_source.js create mode 100644 packages/dd-trace/test/openfeature/configuration_source.spec.js diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js new file mode 100644 index 00000000000..3860d5fde37 --- /dev/null +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -0,0 +1,153 @@ +'use strict' + +const log = require('../log') + +const CONFIGURATION_SOURCE_AGENTLESS = 'agentless' +const CONFIGURATION_SOURCE_REMOTE_CONFIG = 'remote_config' + +const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' +const DEFAULT_POLL_INTERVAL_SECONDS = 30 +const DEFAULT_REQUEST_TIMEOUT_SECONDS = 2 +const MAX_POLL_INTERVAL_SECONDS = 60 * 60 + +/** + * Resolves Feature Flagging configuration-source settings. + * + * @param {import('../config/config-base')} config - Tracer configuration. + * @returns {object} Resolved source settings. + */ +function resolve (config) { + const flaggingProvider = config.experimental.flaggingProvider + const mode = resolveMode(config) + + if (mode === CONFIGURATION_SOURCE_REMOTE_CONFIG) { + return { mode } + } + + return { + mode, + endpoint: endpoint(config, flaggingProvider.agentlessBaseUrl), + pollIntervalMs: positiveMilliseconds( + flaggingProvider.agentlessPollIntervalSeconds, + DEFAULT_POLL_INTERVAL_SECONDS, + 'poll interval', + MAX_POLL_INTERVAL_SECONDS + ), + requestTimeoutMs: positiveMilliseconds( + flaggingProvider.agentlessRequestTimeoutSeconds, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + 'request timeout' + ), + apiKey: config.DD_API_KEY, + } +} + +/** + * Reports whether the explicit Remote Config source is selected. + * + * Invalid source values fail closed and do not enable Remote Config delivery. + * + * @param {import('../config/config-base')} config - Tracer configuration. + * @returns {boolean} Whether Remote Config should own UFC delivery. + */ +function isRemoteConfig (config) { + try { + return resolveMode(config) === CONFIGURATION_SOURCE_REMOTE_CONFIG + } catch (error) { + log.error('Unable to configure Feature Flagging configuration source', error) + return false + } +} + +/** + * Normalizes and validates source selection without resolving agentless + * endpoint or timing configuration. + * + * @param {import('../config/config-base')} config - Tracer configuration. + * @returns {string} Selected configuration-source mode. + */ +function resolveMode (config) { + const value = config.experimental.flaggingProvider.configurationSource + const mode = String(value ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS + if (mode !== CONFIGURATION_SOURCE_AGENTLESS && mode !== CONFIGURATION_SOURCE_REMOTE_CONFIG) { + throw new Error(`Unsupported Feature Flagging configuration source: ${mode}`) + } + return mode +} + +/** + * Builds the agentless rules-based server endpoint. + * + * A configured URL with a non-root path is treated as the exact endpoint. A + * configured origin (or root URL) receives the standard rules-based server + * path. + * + * @param {import('../config/config-base')} config - Tracer configuration. + * @param {string | undefined} configuredBaseUrl - Optional endpoint or origin override. + * @returns {URL} Agentless endpoint. + */ +function endpoint (config, configuredBaseUrl) { + const configured = configuredBaseUrl?.trim() + + if (!configured) { + const url = new URL(`https://ufc-server.ff-cdn.${String(config.site).toLowerCase()}${DEFAULT_AGENTLESS_PATH}`) + if (config.env) url.searchParams.set('dd_env', config.env) + return url + } + + let url + try { + url = new URL(configured) + } catch (error) { + throw new Error(`Invalid Feature Flagging agentless URL: ${configured}`, { cause: error }) + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Feature Flagging agentless URL must use HTTP or HTTPS') + } + + if (url.pathname === '' || url.pathname === '/') { + url.pathname = DEFAULT_AGENTLESS_PATH + } + + return url +} + +/** + * Converts a positive number of seconds to milliseconds, falling back for + * invalid or non-positive values. + * + * @param {unknown} value - Configured seconds. + * @param {number} fallbackSeconds - Default seconds. + * @param {string} setting - Human-readable setting name. + * @param {number} [maximumSeconds] - Optional inclusive maximum. + * @returns {number} Positive milliseconds. + */ +function positiveMilliseconds (value, fallbackSeconds, setting, maximumSeconds) { + const seconds = Number(value) + if (!Number.isFinite(seconds) || seconds <= 0) { + log.warn( + 'Invalid Feature Flagging agentless %s: %s. The value must be positive; using %ss', + setting, + value, + fallbackSeconds + ) + return fallbackSeconds * 1000 + } + if (maximumSeconds !== undefined && seconds > maximumSeconds) { + log.warn( + 'Feature Flagging agentless %s %s exceeds the maximum of %ss; using %ss', + setting, + value, + maximumSeconds, + maximumSeconds + ) + return maximumSeconds * 1000 + } + return Math.max(1, Math.round(seconds * 1000)) +} + +module.exports = { + isRemoteConfig, + resolve, +} diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js new file mode 100644 index 00000000000..81a7d471e77 --- /dev/null +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -0,0 +1,168 @@ +'use strict' + +const assert = require('node:assert/strict') +const { beforeEach, describe, it } = require('mocha') +const proxyquire = require('proxyquire') +const sinon = require('sinon') + +require('../setup/core') + +describe('OpenFeature configuration source', () => { + let config + let configurationSource + let log + + beforeEach(() => { + config = { + DD_API_KEY: 'test-api-key', + site: 'datadoghq.com', + env: 'my env', + experimental: { + flaggingProvider: { + configurationSource: 'agentless', + agentlessBaseUrl: undefined, + agentlessPollIntervalSeconds: 30, + agentlessRequestTimeoutSeconds: 2, + }, + }, + } + log = { + debug: sinon.spy(), + error: sinon.spy(), + warn: sinon.spy(), + } + configurationSource = proxyquire('../../src/openfeature/configuration_source', { + '../log': log, + }) + }) + + for (const value of [undefined, null, '', ' ', ' AgEnTlEsS ']) { + it(`normalizes ${JSON.stringify(value)} to the default agentless source`, () => { + config.experimental.flaggingProvider.configurationSource = value + + assert.strictEqual(configurationSource.resolve(config).mode, 'agentless') + }) + } + + it('defaults to the Datadog UFC CDN endpoint and includes the environment', () => { + config.DD_SITE = 'raw-env-key.invalid' + const resolved = configurationSource.resolve(config) + + assert.strictEqual( + resolved.endpoint.toString(), + '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.pollIntervalMs, 30_000) + assert.strictEqual(resolved.requestTimeoutMs, 2000) + }) + + it('derives the staging UFC CDN endpoint from DD_SITE', () => { + config.site = 'datad0g.com' + config.env = 'staging' + + assert.strictEqual( + configurationSource.resolve(config).endpoint.toString(), + 'https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging' + ) + }) + + it('appends the standard path to a configured origin', () => { + config.experimental.flaggingProvider.agentlessBaseUrl = 'http://127.0.0.1:8080/' + + const resolved = configurationSource.resolve(config) + assert.strictEqual( + resolved.endpoint.toString(), + 'http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server' + ) + }) + + it('preserves an exact configured path and query', () => { + config.experimental.flaggingProvider.agentlessBaseUrl = 'https://example.com/custom/ufc?tenant=one' + + assert.strictEqual( + configurationSource.resolve(config).endpoint.toString(), + 'https://example.com/custom/ufc?tenant=one' + ) + }) + + it('derives the managed GovCloud endpoint without hard-coding availability', () => { + config.site = 'DDOG-GOV.COM' + config.env = 'prod' + + const resolved = configurationSource.resolve(config) + + assert.strictEqual( + resolved.endpoint.toString(), + 'https://ufc-server.ff-cdn.ddog-gov.com/api/v2/feature-flagging/config/rules-based/server?dd_env=prod' + ) + sinon.assert.notCalled(log.warn) + }) + + it('allows an operator-owned agentless endpoint on GovCloud', () => { + config.site = 'ddog-gov.com' + config.experimental.flaggingProvider.agentlessBaseUrl = 'https://flags.example.test/custom/ufc?tenant=test' + + const resolved = configurationSource.resolve(config) + + assert.strictEqual(resolved.endpoint.toString(), 'https://flags.example.test/custom/ufc?tenant=test') + sinon.assert.notCalled(log.warn) + }) + + it('rejects non-HTTP endpoints', () => { + config.experimental.flaggingProvider.agentlessBaseUrl = 'file:///tmp/ufc.json' + + assert.throws( + () => configurationSource.resolve(config), + /must use HTTP or HTTPS/ + ) + }) + + it('recognizes explicit Remote Config without resolving agentless settings', () => { + config.experimental.flaggingProvider.configurationSource = ' REMOTE_CONFIG ' + delete config.site + + assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), true) + }) + + for (const value of ['offline', 'other']) { + it(`fails closed for the unsupported ${value} source`, () => { + config.experimental.flaggingProvider.configurationSource = value + + assert.throws(() => configurationSource.resolve(config), /Unsupported Feature Flagging configuration source/) + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + sinon.assert.calledOnce(log.error) + }) + } + + it('falls back to positive timing defaults with warnings', () => { + config.experimental.flaggingProvider.agentlessPollIntervalSeconds = 0 + config.experimental.flaggingProvider.agentlessRequestTimeoutSeconds = -1 + + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + sinon.assert.notCalled(log.warn) + + const resolved = configurationSource.resolve(config) + + assert.strictEqual(resolved.pollIntervalMs, 30_000) + assert.strictEqual(resolved.requestTimeoutMs, 2000) + sinon.assert.calledTwice(log.warn) + }) + + it('caps the polling interval at one hour', () => { + config.experimental.flaggingProvider.agentlessPollIntervalSeconds = 4 * 60 * 60 + + const resolved = configurationSource.resolve(config) + + assert.strictEqual(resolved.pollIntervalMs, 60 * 60 * 1000) + sinon.assert.calledOnceWithExactly( + log.warn, + 'Feature Flagging agentless %s %s exceeds the maximum of %ss; using %ss', + 'poll interval', + 4 * 60 * 60, + 60 * 60, + 60 * 60 + ) + }) +}) From 7b0b67edcf1e4b336e38f71d661831346c9a3c44 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 23:27:47 -0400 Subject: [PATCH 02/15] feat(openfeature): load agentless UFC snapshots Fetch one authenticated UFC snapshot with the runtime transport, suppress tracer instrumentation, validate the JSON:API resource, and only advance last-known-good state after successful application. Cover gzip, ETags, malformed payloads, and custom endpoints alongside the loader. --- .../agentless_configuration_source.js | 165 +++++++++++ .../agentless_configuration_source.spec.js | 256 ++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 packages/dd-trace/src/openfeature/agentless_configuration_source.js create mode 100644 packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js new file mode 100644 index 00000000000..418232a7c67 --- /dev/null +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -0,0 +1,165 @@ +'use strict' + +const { storage } = require('../../../datadog-core') +const log = require('../log') + +const legacyStorage = storage('legacy') + +class AgentlessConfigurationSource { + /** + * @param {object} config - Resolved agentless settings. + * @param {Function} applyConfiguration - Applies a parsed UFC configuration. + * @param {object} [options] - Runtime dependencies. + */ + constructor (config, applyConfiguration, options = {}) { + this._config = config + this._applyConfiguration = applyConfiguration + this._fetch = options.fetch || fetch + this._apiKey = config.apiKey + this._etag = undefined + } + + /** + * Performs one agentless configuration request. + * + * @param {Function} callback - Receives the poll outcome. + * @returns {void} + */ + pollOnce (callback) { + this._request((error, response) => { + if (error) { + log.debug('Feature Flagging agentless poll failed', error) + callback(error) + return + } + callback(null, this._apply(response)) + }) + } + + /** + * Sends one HTTP request to the agentless endpoint. + * + * @param {Function} callback - Receives a request error or buffered response. + * @returns {void} + */ + _request (callback) { + const headers = { 'Accept-Encoding': 'gzip' } + if (this._apiKey) headers['DD-API-KEY'] = this._apiKey + if (this._etag) headers['If-None-Match'] = this._etag + + legacyStorage.run({ noop: true }, () => { + // TODO: Give the polling source an explicitly reusable connection once + // transport ownership is designed and covered by system tests. + this._fetch(this._config.endpoint, { + method: 'GET', + headers, + redirect: 'manual', + }).then(response => { + const result = { + statusCode: response.status, + etag: response.headers.get('etag') ?? undefined, + body: '', + } + if (response.status !== 200) { + response.body?.cancel?.().catch(() => {}) + callback(null, result) + return + } + response.text().then(body => { + result.body = body + callback(null, result) + }, error => { + const message = response.headers.get('content-encoding')?.toLowerCase() === 'gzip' + ? 'Feature Flagging agentless gzip response could not be decompressed' + : 'Feature Flagging agentless response body could not be read' + callback(new Error(message, { cause: error })) + }) + }, error => callback(requestError(error))) + }) + } + + /** + * Applies a successful response while preserving last-known-good state on + * every failure path. + * + * @param {object} response - Buffered HTTP response. + * @returns {object} Poll outcome. + */ + _apply (response) { + const status = response.statusCode + if (status === 304) return { notModified: true } + + if (status === 401 || status === 403) { + log.warn( + 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + status + ) + return { rejected: true, statusCode: status } + } + + if (status !== 200) return { rejected: true, statusCode: status } + + let configuration + try { + configuration = parseConfiguration(response.body) + } catch (error) { + log.debug('Feature Flagging agentless endpoint returned malformed UFC payload', error) + return { rejected: true, malformed: true } + } + + try { + this._applyConfiguration(configuration) + } catch (error) { + log.debug('Feature Flagging agentless UFC payload could not be applied', error) + return { rejected: true, applicationFailed: true } + } + + this._etag = response.etag?.trim() ? response.etag : undefined + return { applied: true } + } +} + +/** + * Parses enough of the UFC envelope to reject malformed or unrelated JSON + * before it can replace the last-known-good configuration. + * + * @param {string} body - HTTP response body. + * @returns {object} Parsed UFC configuration. + */ +function parseConfiguration (body) { + const parsed = JSON.parse(body) + if (!parsed || typeof parsed !== 'object' || !parsed.data || typeof parsed.data !== 'object') { + throw new Error('Expected a JSON:API Universal Flag Configuration response') + } + if (parsed.data.type !== 'universal-flag-configuration') { + throw new Error('Expected a JSON:API Universal Flag Configuration resource') + } + const configuration = parsed.data.attributes + + if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration) || + typeof configuration.createdAt !== 'string' || + (configuration.format !== undefined && typeof configuration.format !== 'string') || + !configuration.environment || typeof configuration.environment !== 'object' || + typeof configuration.environment.name !== 'string' || + !configuration.flags || typeof configuration.flags !== 'object' || Array.isArray(configuration.flags)) { + const keys = configuration && typeof configuration === 'object' + ? Object.keys(configuration).join(',') + : typeof configuration + throw new Error(`Expected a Universal Flag Configuration v1 object; received ${keys}`) + } + return configuration +} + +/** + * Wraps a network error and marks it retryable. + * + * @param {Error} cause - Network error. + * @returns {Error} Retryable error. + */ +function requestError (cause) { + const error = new Error('Feature Flagging agentless request failed', { cause }) + error.retryable = true + return error +} + +module.exports = AgentlessConfigurationSource diff --git a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js new file mode 100644 index 00000000000..912cb8a3f22 --- /dev/null +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -0,0 +1,256 @@ +'use strict' + +const assert = require('node:assert/strict') +const { beforeEach, describe, it } = require('mocha') +const proxyquire = require('proxyquire') +const sinon = require('sinon') + +require('../setup/core') + +const VALID_UFC = JSON.stringify({ + createdAt: '2026-01-01T00:00:00.000Z', + format: 'SERVER', + environment: { name: 'test' }, + flags: {}, +}) +const VALID_RESPONSE = JSON.stringify({ + data: { + id: '1', + type: 'universal-flag-configuration', + attributes: JSON.parse(VALID_UFC), + }, +}) + +describe('AgentlessConfigurationSource', () => { + let AgentlessConfigurationSource + let applyConfiguration + let config + let fetch + let log + let requests + let responses + let runInNoopContext + + beforeEach(() => { + applyConfiguration = sinon.stub() + config = { + endpoint: new URL('http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server'), + pollIntervalMs: 30_000, + requestTimeoutMs: 2000, + apiKey: 'test-api-key', + } + log = { + debug: sinon.spy(), + warn: sinon.spy(), + } + requests = [] + responses = [] + runInNoopContext = sinon.spy((_store, callback) => callback()) + fetch = sinon.spy((url, options) => { + const request = { url, options } + requests.push(request) + const next = responses.shift() + + if (!next || next.pending) { + return new Promise((resolve, reject) => { + request.resolve = resolve + request.reject = reject + const abort = () => reject(options.signal.reason || new Error('aborted')) + if (options.signal.aborted) abort() + else options.signal.addEventListener('abort', abort, { once: true }) + }) + } + if (next.error) return Promise.reject(next.error) + + return Promise.resolve({ + status: next.statusCode, + headers: new Headers(next.headers), + text: () => next.bodyError ? Promise.reject(next.bodyError) : Promise.resolve(next.body || ''), + }) + }) + AgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { + '../../../datadog-core': { + storage: () => ({ run: runInNoopContext }), + }, + '../log': log, + }) + }) + + function source (options = {}) { + return new AgentlessConfigurationSource(config, applyConfiguration, { + fetch, + ...options, + }) + } + + function poll (configurationSource) { + return new Promise(resolve => { + configurationSource.pollOnce((error, result) => resolve({ error, result })) + }) + } + + it('fetches, applies, and reuses the accepted ETag', async () => { + responses.push( + { statusCode: 200, headers: { etag: '"ufc-v1"' }, body: VALID_RESPONSE }, + { statusCode: 304, headers: {}, body: '' } + ) + const configurationSource = source() + const first = await poll(configurationSource) + const second = await poll(configurationSource) + + sinon.assert.calledOnceWithExactly(applyConfiguration, JSON.parse(VALID_UFC)) + assert.deepStrictEqual(first, { error: null, result: { applied: true } }) + assert.deepStrictEqual(second, { error: null, result: { notModified: true } }) + assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key') + assert.strictEqual(requests[0].options.headers['Accept-Encoding'], 'gzip') + assert.strictEqual(requests[0].options.headers['If-None-Match'], undefined) + assert.strictEqual(requests[1].options.headers['If-None-Match'], '"ufc-v1"') + assert.strictEqual(requests[0].options.redirect, 'manual') + }) + + it('suppresses tracing around agentless requests', () => { + responses.push({ statusCode: 200, body: VALID_RESPONSE }) + + source().pollOnce(() => {}) + + sinon.assert.calledOnceWithMatch(runInNoopContext, { noop: true }, sinon.match.func) + }) + + it('accepts a JSON API Universal Flag Configuration without optional format', async () => { + const expected = JSON.parse(VALID_UFC) + delete expected.format + responses.push({ + statusCode: 200, + body: JSON.stringify({ + data: { + id: '1', + type: 'universal-flag-configuration', + attributes: expected, + }, + }), + }) + + await poll(source()) + + sinon.assert.calledOnceWithExactly(applyConfiguration, expected) + }) + + it('applies gzip JSON API responses decoded by fetch', async () => { + responses.push({ + statusCode: 200, + headers: { 'content-encoding': 'gzip' }, + body: VALID_RESPONSE, + }) + + const outcome = await poll(source()) + + assert.deepStrictEqual(outcome, { error: null, result: { applied: true } }) + sinon.assert.calledOnceWithExactly(applyConfiguration, JSON.parse(VALID_UFC)) + }) + + it('preserves last-known-good configuration and ETag after invalid gzip', async () => { + responses.push( + { statusCode: 200, headers: { etag: '"good"' }, body: VALID_RESPONSE }, + { + statusCode: 200, + headers: { etag: '"bad"', 'content-encoding': 'gzip' }, + bodyError: new TypeError('terminated'), + }, + { statusCode: 304, headers: {}, body: '' } + ) + const configurationSource = source() + + assert.ifError((await poll(configurationSource)).error) + const invalid = await poll(configurationSource) + assert.match(invalid.error.message, /gzip response could not be decompressed/) + const last = await poll(configurationSource) + + assert.deepStrictEqual(last, { error: null, result: { notModified: true } }) + sinon.assert.calledOnce(applyConfiguration) + assert.strictEqual(requests[2].options.headers['If-None-Match'], '"good"') + sinon.assert.calledOnce(log.debug) + }) + + it('accepts managed JSON API payloads larger than 500 KB', async () => { + const expected = JSON.parse(VALID_UFC) + expected.flags.large = { description: 'x'.repeat(500 * 1024) } + responses.push({ + statusCode: 200, + body: JSON.stringify({ + data: { + id: 'opaque-id', + type: 'universal-flag-configuration', + attributes: expected, + }, + }), + }) + + await poll(source()) + + sinon.assert.calledOnceWithExactly(applyConfiguration, expected) + }) + + it('requires JSON API at custom endpoints', async () => { + responses.push({ statusCode: 200, body: VALID_UFC }) + + await poll(source()) + + sinon.assert.notCalled(applyConfiguration) + sinon.assert.calledOnce(log.debug) + }) + + it('rejects unrelated or incomplete JSON API resources', async () => { + responses.push( + { + statusCode: 200, + body: JSON.stringify({ data: { id: '1', type: 'other-configuration', attributes: {} } }), + }, + { + statusCode: 200, + body: JSON.stringify({ data: { id: '1', type: 'universal-flag-configuration' } }), + } + ) + const configurationSource = source() + + await poll(configurationSource) + await poll(configurationSource) + + sinon.assert.notCalled(applyConfiguration) + sinon.assert.calledTwice(log.debug) + }) + + it('preserves last-known-good configuration and ETag after malformed JSON', async () => { + responses.push( + { statusCode: 200, headers: { etag: '"good"' }, body: VALID_RESPONSE }, + { statusCode: 200, headers: { etag: '"bad"' }, body: '{"flags":[' }, + { statusCode: 304, headers: {}, body: '' } + ) + const configurationSource = source() + + await poll(configurationSource) + await poll(configurationSource) + await poll(configurationSource) + + sinon.assert.calledOnce(applyConfiguration) + assert.strictEqual(requests[2].options.headers['If-None-Match'], '"good"') + sinon.assert.calledOnce(log.debug) + }) + + it('clears a stale ETag when an accepted response omits it', async () => { + responses.push( + { statusCode: 200, headers: { etag: '"first"' }, body: VALID_RESPONSE }, + { statusCode: 200, headers: {}, body: VALID_RESPONSE }, + { statusCode: 200, headers: {}, body: VALID_RESPONSE } + ) + const configurationSource = source() + + await poll(configurationSource) + await poll(configurationSource) + await poll(configurationSource) + + assert.strictEqual(requests[1].options.headers['If-None-Match'], '"first"') + assert.strictEqual(requests[2].options.headers['If-None-Match'], undefined) + sinon.assert.calledThrice(applyConfiguration) + }) + +}) From b83caab00b8cba18a266c6231372bce31cd680b7 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 23:28:20 -0400 Subject: [PATCH 03/15] feat(openfeature): harden agentless polling Turn snapshot loading into a fixed-delay lifecycle with bounded retries, independent timeouts, overlap prevention, rate-limited warnings, TLS-aware credential handling, and idempotent shutdown. Exercise timing, failure, and cancellation boundaries with fake timers. --- .../agentless_configuration_source.js | 231 +++++++++++++++++- .../agentless_configuration_source.spec.js | 230 ++++++++++++++++- 2 files changed, 449 insertions(+), 12 deletions(-) diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index 418232a7c67..9def471e7f1 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -1,10 +1,19 @@ 'use strict' +const net = require('node:net') const { storage } = require('../../../datadog-core') const log = require('../log') const legacyStorage = storage('legacy') +const MAX_ATTEMPTS = 3 +const FIRST_RETRY_MIN_MS = 2000 +const FIRST_RETRY_MAX_MS = 10_000 +const SECOND_RETRY_MIN_MS = 5000 +const SECOND_RETRY_MAX_MS = 30_000 +const RETRY_JITTER = 0.2 +const FAILURE_WARNING_INTERVAL_MS = 5 * 60 * 1000 + class AgentlessConfigurationSource { /** * @param {object} config - Resolved agentless settings. @@ -15,20 +24,109 @@ class AgentlessConfigurationSource { this._config = config this._applyConfiguration = applyConfiguration this._fetch = options.fetch || fetch - this._apiKey = config.apiKey + this._apiKey = config.apiKey && canSendApiKey(config.endpoint) ? config.apiKey : undefined + this._random = options.random || Math.random + this._now = options.now || Date.now + this._setTimeout = options.setTimeout || setTimeout + this._clearTimeout = options.clearTimeout || clearTimeout + this._started = false + this._closed = false + this._polling = false this._etag = undefined + this._timer = undefined + this._activeRequest = undefined + this._lastFailureWarning = -Infinity + } + + /** + * Starts fixed-delay polling. Repeated calls are idempotent. + * + * @returns {void} + */ + start () { + if (this._started || this._closed) return + this._started = true + this.pollOnce(() => {}) + } + + /** + * Stops polling and aborts an active request. Repeated calls are idempotent. + * + * @returns {void} + */ + stop () { + if (this._closed) return + this._closed = true + this._started = false + if (this._timer) { + this._clearTimeout(this._timer) + this._timer = undefined + } + this._activeRequest?.abort() + this._activeRequest = undefined } /** - * Performs one agentless configuration request. + * Performs one poll, including bounded retries. Concurrent calls are skipped. * * @param {Function} callback - Receives the poll outcome. * @returns {void} */ pollOnce (callback) { + if (this._closed) { + callback(null, { stopped: true }) + return + } + if (this._polling) { + callback(null, { skipped: true }) + return + } + + this._polling = true + this._attempt(1, (error, result) => { + this._polling = false + if (error && !this._closed) { + log.debug('Feature Flagging agentless poll failed', error) + } + callback(error, result) + if (this._started && !this._closed) { + this._timer = this._setTimeout(() => { + this._timer = undefined + this.pollOnce(() => {}) + }, this._config.pollIntervalMs) + this._timer.unref?.() + } + }) + } + + /** + * Executes one request attempt and schedules a retry when appropriate. + * + * @param {number} attempt - One-based attempt number. + * @param {Function} callback - Receives the final poll outcome. + * @returns {void} + */ + _attempt (attempt, callback) { this._request((error, response) => { + if (this._closed) { + callback(null, { stopped: true }) + return + } + + const retryable = error?.retryable || isRetryableStatus(response?.statusCode) + if (retryable && attempt < MAX_ATTEMPTS) { + const delay = retryDelay(this._config.pollIntervalMs, attempt, this._random()) + this._timer = this._setTimeout(() => { + this._timer = undefined + this._attempt(attempt + 1, callback) + }, delay) + this._timer.unref?.() + return + } + + if (retryable) this._warnFailure(response?.statusCode, error) + if (error) { - log.debug('Feature Flagging agentless poll failed', error) callback(error) return } @@ -47,6 +145,26 @@ class AgentlessConfigurationSource { if (this._apiKey) headers['DD-API-KEY'] = this._apiKey if (this._etag) headers['If-None-Match'] = this._etag + const controller = new AbortController() + const timeout = this._setTimeout(() => { + const error = new Error( + `Feature Flagging agentless request timed out after ${this._config.requestTimeoutMs}ms` + ) + controller.abort(error) + finish(requestError(error)) + }, this._config.requestTimeoutMs) + timeout.unref?.() + + let settled = false + const finish = (error, response) => { + if (settled) return + settled = true + this._clearTimeout(timeout) + if (this._activeRequest === controller) this._activeRequest = undefined + callback(error, response) + } + + this._activeRequest = controller legacyStorage.run({ noop: true }, () => { // TODO: Give the polling source an explicitly reusable connection once // transport ownership is designed and covered by system tests. @@ -54,6 +172,7 @@ class AgentlessConfigurationSource { method: 'GET', headers, redirect: 'manual', + signal: controller.signal, }).then(response => { const result = { statusCode: response.status, @@ -62,19 +181,19 @@ class AgentlessConfigurationSource { } if (response.status !== 200) { response.body?.cancel?.().catch(() => {}) - callback(null, result) + finish(null, result) return } response.text().then(body => { result.body = body - callback(null, result) + finish(null, result) }, error => { const message = response.headers.get('content-encoding')?.toLowerCase() === 'gzip' ? 'Feature Flagging agentless gzip response could not be decompressed' : 'Feature Flagging agentless response body could not be read' - callback(new Error(message, { cause: error })) + finish(new Error(message, { cause: error })) }) - }, error => callback(requestError(error))) + }, error => finish(requestError(error))) }) } @@ -90,10 +209,7 @@ class AgentlessConfigurationSource { if (status === 304) return { notModified: true } if (status === 401 || status === 403) { - log.warn( - 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', - status - ) + this._warnFailure(status) return { rejected: true, statusCode: status } } @@ -117,6 +233,30 @@ class AgentlessConfigurationSource { this._etag = response.etag?.trim() ? response.etag : undefined return { applied: true } } + + /** + * Emits a rate-limited warning for authentication or exhausted transient failures. + * + * @param {number | undefined} statusCode - Final HTTP status, when available. + * @param {Error | undefined} error - Final network error, when available. + * @returns {void} + */ + _warnFailure (statusCode, error) { + const now = this._now() + if (now - this._lastFailureWarning < FAILURE_WARNING_INTERVAL_MS) return + this._lastFailureWarning = now + + if (statusCode === 401 || statusCode === 403) { + log.warn( + 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + statusCode + ) + } else if (statusCode) { + log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, MAX_ATTEMPTS) + } else { + log.warn('Feature Flagging agentless request failed after %d attempts', MAX_ATTEMPTS, error) + } + } } /** @@ -162,4 +302,73 @@ function requestError (cause) { return error } +/** + * Keeps API keys off cleartext non-loopback connections while allowing local + * controlled endpoints used by tests and development. + * + * @param {URL} endpoint - Agentless endpoint. + * @returns {boolean} Whether an API key may be attached. + */ +function canSendApiKey (endpoint) { + if (endpoint.protocol !== 'http:' || isControlledLocalHost(endpoint.hostname)) return true + log.error( + 'Not sending the Datadog API key over a non-TLS connection to %s. Configure an https Feature Flagging URL.', + endpoint.hostname + ) + return false +} + +/** + * Keeps the cleartext exception local to controlled Feature Flagging test and + * development endpoints instead of changing authentication for all exporters. + * + * @param {string} hostname - Parsed endpoint hostname. + * @returns {boolean} Whether the hostname is a controlled local target. + */ +function isControlledLocalHost (hostname) { + return hostname === 'localhost' || + hostname === 'host.docker.internal' || + hostname === '::1' || + hostname === '[::1]' || + (hostname.startsWith('127.') && net.isIPv4(hostname)) +} + +/** + * Reports whether an HTTP response should be retried. + * + * @param {number | undefined} status - HTTP status. + * @returns {boolean} Whether the status is transient. + */ +function isRetryableStatus (status) { + return status === 408 || status === 429 || (status >= 500 && status <= 599) +} + +/** + * Computes the Java-compatible bounded retry delay with plus or minus 20% + * jitter. + * + * @param {number} pollIntervalMs - Poll interval. + * @param {number} attempt - Failed attempt number, one or two. + * @param {number} random - Random value in the half-open interval [0, 1). + * @returns {number} Retry delay in milliseconds. + */ +function retryDelay (pollIntervalMs, attempt, random) { + const base = attempt === 1 + ? clamp(pollIntervalMs / 6, FIRST_RETRY_MIN_MS, FIRST_RETRY_MAX_MS) + : clamp(pollIntervalMs / 3, SECOND_RETRY_MIN_MS, SECOND_RETRY_MAX_MS) + return Math.max(1, Math.round(base * (1 - RETRY_JITTER + random * RETRY_JITTER * 2))) +} + +/** + * Clamps a value to an inclusive range. + * + * @param {number} value - Input value. + * @param {number} minimum - Inclusive minimum. + * @param {number} maximum - Inclusive maximum. + * @returns {number} Clamped value. + */ +function clamp (value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, value)) +} + module.exports = AgentlessConfigurationSource 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 912cb8a3f22..02543fa6576 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -1,7 +1,7 @@ 'use strict' const assert = require('node:assert/strict') -const { beforeEach, describe, it } = require('mocha') +const { afterEach, beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire') const sinon = require('sinon') @@ -24,6 +24,7 @@ const VALID_RESPONSE = JSON.stringify({ describe('AgentlessConfigurationSource', () => { let AgentlessConfigurationSource let applyConfiguration + let clock let config let fetch let log @@ -32,6 +33,7 @@ describe('AgentlessConfigurationSource', () => { let runInNoopContext beforeEach(() => { + clock = sinon.useFakeTimers() applyConfiguration = sinon.stub() config = { endpoint: new URL('http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server'), @@ -41,6 +43,7 @@ describe('AgentlessConfigurationSource', () => { } log = { debug: sinon.spy(), + error: sinon.spy(), warn: sinon.spy(), } requests = [] @@ -76,13 +79,22 @@ describe('AgentlessConfigurationSource', () => { }) }) + afterEach(() => { + clock?.restore() + }) + function source (options = {}) { return new AgentlessConfigurationSource(config, applyConfiguration, { fetch, + random: () => 0.5, ...options, }) } + function completeScheduledResponse () { + return clock.tickAsync(0) + } + function poll (configurationSource) { return new Promise(resolve => { configurationSource.pollOnce((error, result) => resolve({ error, result })) @@ -116,6 +128,30 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledOnceWithMatch(runInNoopContext, { noop: true }, sinon.match.func) }) + it('does not send the API key over cleartext non-loopback connections', async () => { + config.endpoint = new URL('http://flags.example.test/custom/ufc') + responses.push({ statusCode: 200, body: VALID_RESPONSE }) + + await poll(source()) + + assert.strictEqual(requests[0].options.headers['DD-API-KEY'], undefined) + sinon.assert.calledOnceWithExactly( + log.error, + 'Not sending the Datadog API key over a non-TLS connection to %s. Configure an https Feature Flagging URL.', + 'flags.example.test' + ) + }) + + it('sends the API key to the local Docker host gateway', async () => { + config.endpoint = new URL('http://host.docker.internal/custom/ufc') + responses.push({ statusCode: 200, body: VALID_RESPONSE }) + + await poll(source()) + + assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key') + sinon.assert.notCalled(log.error) + }) + it('accepts a JSON API Universal Flag Configuration without optional format', async () => { const expected = JSON.parse(VALID_UFC) delete expected.format @@ -253,4 +289,196 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledThrice(applyConfiguration) }) + it('does not advance the ETag and keeps scheduled polling after a listener failure', async () => { + applyConfiguration.onFirstCall().throws(new Error('listener failed')) + responses.push( + { statusCode: 200, headers: { etag: '"failed"' }, body: VALID_RESPONSE }, + { statusCode: 200, headers: { etag: '"accepted"' }, body: VALID_RESPONSE } + ) + const configurationSource = source() + + configurationSource.start() + await completeScheduledResponse() + await clock.tickAsync(30_000) + + assert.strictEqual(requests.length, 2) + assert.strictEqual(requests[1].options.headers['If-None-Match'], undefined) + sinon.assert.calledTwice(applyConfiguration) + sinon.assert.calledOnce(log.debug) + }) + + it('retries 429 and 5xx responses with bounded delays', async () => { + responses.push( + { statusCode: 500, bodyError: new Error('must not decode error responses') }, + { statusCode: 429, bodyError: new Error('must not decode error responses') }, + { statusCode: 200, body: VALID_RESPONSE } + ) + const outcome = poll(source()) + + await completeScheduledResponse() + assert.strictEqual(requests.length, 1) + + await clock.tickAsync(4999) + assert.strictEqual(requests.length, 1) + await clock.tickAsync(1) + assert.strictEqual(requests.length, 2) + + await clock.tickAsync(9999) + assert.strictEqual(requests.length, 2) + await clock.tickAsync(1) + + assert.strictEqual(requests.length, 3) + sinon.assert.calledOnce(applyConfiguration) + assert.deepStrictEqual(await outcome, { error: null, result: { applied: true } }) + }) + + it('retries request timeout responses', async () => { + responses.push( + { statusCode: 408, body: '' }, + { statusCode: 200, body: VALID_RESPONSE } + ) + + const outcome = poll(source()) + await completeScheduledResponse() + await clock.tickAsync(5000) + + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnce(applyConfiguration) + assert.deepStrictEqual(await outcome, { error: null, result: { applied: true } }) + }) + + it('settles a request timeout even when fetch does not reject after abort', async () => { + const delayedFetch = sinon.stub().returns(new Promise(() => {})) + const callback = sinon.spy() + + source({ fetch: delayedFetch })._request(callback) + await clock.tickAsync(2000) + + sinon.assert.calledOnce(callback) + assert.strictEqual(callback.firstCall.args[0].retryable, true) + assert.strictEqual(delayedFetch.firstCall.args[1].signal.aborted, true) + }) + + it('warns after retryable HTTP responses exhaust all attempts', async () => { + responses.push( + { statusCode: 500, body: '' }, + { statusCode: 500, body: '' }, + { statusCode: 500, body: '' } + ) + + const outcome = poll(source()) + await completeScheduledResponse() + await clock.tickAsync(5000) + await clock.tickAsync(10_000) + await outcome + + sinon.assert.calledOnceWithExactly( + log.warn, + 'Feature Flagging agentless endpoint returned HTTP %d after %d attempts', + 500, + 3 + ) + }) + + it('warns after request timeouts exhaust all attempts', async () => { + responses.push({ pending: true }, { pending: true }, { pending: true }) + + const outcome = poll(source()) + await clock.tickAsync(2000) + await clock.tickAsync(5000) + await clock.tickAsync(2000) + await clock.tickAsync(10_000) + await clock.tickAsync(2000) + await outcome + + sinon.assert.calledOnceWithMatch( + log.warn, + 'Feature Flagging agentless request failed after %d attempts', + 3, + sinon.match.instanceOf(Error) + ) + }) + + it('does not retry authentication failures and rate-limits the warning', async () => { + responses.push( + { statusCode: 401, body: '' }, + { statusCode: 403, body: '' }, + { statusCode: 401, body: '' } + ) + const configurationSource = source() + + await poll(configurationSource) + await poll(configurationSource) + await clock.tickAsync(5 * 60 * 1000) + await poll(configurationSource) + + assert.strictEqual(requests.length, 3) + sinon.assert.calledTwice(log.warn) + sinon.assert.notCalled(applyConfiguration) + }) + + it('retries request timeouts without overlapping requests', async () => { + responses.push({ pending: true }, { statusCode: 200, body: VALID_RESPONSE }) + const configurationSource = source() + const first = sinon.spy() + const overlapping = sinon.spy() + + configurationSource.pollOnce(first) + configurationSource.pollOnce(overlapping) + sinon.assert.calledOnceWithExactly(overlapping, null, { skipped: true }) + + await clock.tickAsync(2000) + await clock.tickAsync(5000) + + sinon.assert.calledOnce(applyConfiguration) + sinon.assert.calledWith(first, null, { applied: true }) + assert.strictEqual(requests.length, 2) + }) + + it('uses fixed-delay polling and never schedules while a request is active', async () => { + config.requestTimeoutMs = 60_000 + responses.push( + { pending: true }, + { statusCode: 200, body: VALID_RESPONSE } + ) + const configurationSource = source() + + configurationSource.start() + await clock.tickAsync(30_000) + assert.strictEqual(requests.length, 1) + + requests[0].reject(new Error('network failure')) + await completeScheduledResponse() + await clock.tickAsync(5000) + assert.strictEqual(requests.length, 2) + + await clock.tickAsync(29_999) + assert.strictEqual(requests.length, 2) + await clock.tickAsync(1) + assert.strictEqual(requests.length, 3) + }) + + it('stops retry timers and aborts an active request', async () => { + responses.push({ pending: true }) + const configurationSource = source() + + configurationSource.start() + configurationSource.stop() + configurationSource.stop() + await completeScheduledResponse() + await clock.tickAsync(60_000) + + assert.strictEqual(requests.length, 1) + assert.strictEqual(requests[0].options.signal.aborted, true) + }) + + it('starts only once', () => { + responses.push({ pending: true }) + const configurationSource = source() + + configurationSource.start() + configurationSource.start() + + assert.strictEqual(requests.length, 1) + }) }) From ac9b55d84e462e08bca18a39986917cbbe64eef8 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 23:50:18 -0400 Subject: [PATCH 04/15] feat(openfeature): activate agentless delivery Expose the configuration surface, attach the selected source to the provider lifecycle, and keep Agent Remote Configuration behind explicit opt-in. Pair the activation path with fleet-wide config, provider, shutdown, and packaged-application tests. --- index.d.ts | 34 ++++- index.d.v5.ts | 34 ++++- .../openfeature/app/agentless-evaluation.js | 34 +++++ .../openfeature/openfeature-agentless.spec.js | 129 ++++++++++++++++++ .../openfeature-exposure-events.spec.js | 4 + .../src/config/generated-config-types.d.ts | 8 ++ .../src/config/supported-configurations.json | 40 ++++++ .../src/openfeature/configuration_source.js | 29 ++++ .../src/openfeature/flagging_provider.js | 21 +++ packages/dd-trace/src/openfeature/register.js | 10 +- .../dd-trace/src/openfeature/remote_config.js | 9 +- packages/dd-trace/test/config/index.spec.js | 36 +++++ .../openfeature/configuration_source.spec.js | 10 +- .../openfeature/flagging_provider.spec.js | 25 ++++ .../test/openfeature/register.spec.js | 32 +++++ .../test/openfeature/remote_config.spec.js | 11 ++ 16 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 integration-tests/openfeature/app/agentless-evaluation.js create mode 100644 integration-tests/openfeature/openfeature-agentless.spec.js diff --git a/index.d.ts b/index.d.ts index eae9b35db3f..596ecfb44b1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -800,7 +800,6 @@ declare namespace tracer { flaggingProvider?: { /** * Whether to enable the feature flagging provider. - * Requires Remote Config to be properly configured. * Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED environment variable. * * @default false @@ -818,6 +817,39 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ initializationTimeoutMs?: number + /** + * Where Universal Flag Configuration is loaded from. Agentless delivery + * fetches from the Datadog UFC CDN endpoint and evaluates locally. + * + * @default 'agentless' + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE + * Programmatic configuration takes precedence over the environment variables listed above. + */ + configurationSource?: 'agentless' | 'remote_config' + /** + * Optional agentless configuration endpoint or base URL. A base URL + * receives the standard rules-based server path. + * + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL + * Programmatic configuration takes precedence over the environment variables listed above. + */ + agentlessBaseUrl?: string + /** + * Agentless configuration polling interval in seconds, capped at one hour. + * + * @default 30 + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS + * Programmatic configuration takes precedence over the environment variables listed above. + */ + agentlessPollIntervalSeconds?: number + /** + * Agentless configuration request timeout in seconds. + * + * @default 2 + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS + * Programmatic configuration takes precedence over the environment variables listed above. + */ + agentlessRequestTimeoutSeconds?: number /** * Configuration for span enrichment with feature flag evaluation data. */ diff --git a/index.d.v5.ts b/index.d.v5.ts index 6bbc6648996..90102d49e15 100644 --- a/index.d.v5.ts +++ b/index.d.v5.ts @@ -870,7 +870,6 @@ declare namespace tracer { flaggingProvider?: { /** * Whether to enable the feature flagging provider. - * Requires Remote Config to be properly configured. * Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED environment variable. * * @default false @@ -888,6 +887,39 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ initializationTimeoutMs?: number + /** + * Where Universal Flag Configuration is loaded from. Agentless delivery + * fetches from the Datadog UFC CDN endpoint and evaluates locally. + * + * @default 'agentless' + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE + * Programmatic configuration takes precedence over the environment variables listed above. + */ + configurationSource?: 'agentless' | 'remote_config' + /** + * Optional agentless configuration endpoint or base URL. A base URL + * receives the standard rules-based server path. + * + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL + * Programmatic configuration takes precedence over the environment variables listed above. + */ + agentlessBaseUrl?: string + /** + * Agentless configuration polling interval in seconds, capped at one hour. + * + * @default 30 + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS + * Programmatic configuration takes precedence over the environment variables listed above. + */ + agentlessPollIntervalSeconds?: number + /** + * Agentless configuration request timeout in seconds. + * + * @default 2 + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS + * Programmatic configuration takes precedence over the environment variables listed above. + */ + agentlessRequestTimeoutSeconds?: number /** * Configuration for span enrichment with feature flag evaluation data. */ diff --git a/integration-tests/openfeature/app/agentless-evaluation.js b/integration-tests/openfeature/app/agentless-evaluation.js new file mode 100644 index 00000000000..f5ea41a9d1d --- /dev/null +++ b/integration-tests/openfeature/app/agentless-evaluation.js @@ -0,0 +1,34 @@ +'use strict' + +const tracer = require('dd-trace') +const http = require('node:http') +const { OpenFeature } = require('@openfeature/server-sdk') + +tracer.init({ + env: 'integration', + service: 'ffe-agentless-integration', +}) + +OpenFeature.setProvider(tracer.openfeature) +const client = OpenFeature.getClient() + +const server = http.createServer((request, response) => { + if (request.url !== '/evaluate') { + response.writeHead(404).end() + return + } + + client.getStringDetails('agentless-integration-flag', 'default', { + targetingKey: 'integration-user', + }).then(details => { + response.setHeader('Content-Type', 'application/json') + response.end(JSON.stringify(details)) + }, error => { + response.writeHead(500, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ error: error.message })) + }) +}) + +server.listen(0, function () { + process.send?.({ port: this.address().port }) +}) diff --git a/integration-tests/openfeature/openfeature-agentless.spec.js b/integration-tests/openfeature/openfeature-agentless.spec.js new file mode 100644 index 00000000000..543b01c42a5 --- /dev/null +++ b/integration-tests/openfeature/openfeature-agentless.spec.js @@ -0,0 +1,129 @@ +'use strict' + +const assert = require('node:assert/strict') +const http = require('node:http') +const path = require('node:path') +const zlib = require('node:zlib') +const { afterEach, before, beforeEach, describe, it } = require('mocha') +const { sandboxCwd, useSandbox, spawnProc, stopProc } = require('../helpers') + +const UFC = { + createdAt: '2026-01-01T00:00:00.000Z', + environment: { name: 'integration' }, + flags: { + 'agentless-integration-flag': { + key: 'agentless-integration-flag', + enabled: true, + variationType: 'STRING', + variations: { + local: { key: 'local', value: 'loaded-from-agentless' }, + }, + allocations: [ + { + key: 'agentless-integration-allocation', + splits: [{ variationKey: 'local', shards: [] }], + doLog: false, + }, + ], + }, + }, +} + +describe('OpenFeature agentless configuration integration', () => { + let appFile + let backend + let backendUrl + let cwd + let observedRequests + let proc + + useSandbox( + ['@openfeature/server-sdk', '@openfeature/core'], + false, + [path.join(__dirname, 'app')] + ) + + before(() => { + cwd = sandboxCwd() + appFile = path.join(cwd, 'app', 'agentless-evaluation.js') + }) + + beforeEach(async () => { + observedRequests = [] + backend = http.createServer((request, response) => { + observedRequests.push({ + url: request.url, + headers: request.headers, + }) + + if (request.headers['if-none-match'] === '"agentless-integration"') { + response.writeHead(304).end() + return + } + + response.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + ETag: '"agentless-integration"', + }) + const body = JSON.stringify({ + data: { + id: '1', + type: 'universal-flag-configuration', + attributes: UFC, + }, + }) + response.end(zlib.gzipSync(body)) + }) + await new Promise((resolve, reject) => { + backend.once('error', reject) + backend.listen(0, '127.0.0.1', resolve) + }) + backendUrl = `http://127.0.0.1:${backend.address().port}` + + proc = await spawnProc(appFile, { + cwd, + env: { + DD_API_KEY: 'integration-api-key', + DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: backendUrl, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: '5', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: '1', + DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'false', + DD_REMOTE_CONFIGURATION_ENABLED: 'false', + }, + }) + }) + + afterEach(async () => { + await stopProc(proc) + await new Promise(resolve => backend.close(resolve)) + }) + + it('loads UFC from the default agentless source and evaluates locally', async () => { + let details + for (let attempt = 0; attempt < 50; attempt++) { + const response = await fetch(`${proc.url}/evaluate`) + details = await response.json() + if (details.value === 'loaded-from-agentless') break + await new Promise(resolve => setTimeout(resolve, 20)) + } + + assert.strictEqual(details.value, 'loaded-from-agentless') + assert.notStrictEqual(details.reason, 'ERROR') + + for (let attempt = 0; observedRequests.length < 2 && attempt < 350; attempt++) { + await new Promise(resolve => setTimeout(resolve, 20)) + } + + assert.ok(observedRequests.length >= 2) + assert.strictEqual( + observedRequests[0].url, + '/api/v2/feature-flagging/config/rules-based/server' + ) + assert.strictEqual(observedRequests[0].headers['dd-api-key'], 'integration-api-key') + assert.strictEqual(observedRequests[0].headers['accept-encoding'], 'gzip') + assert.strictEqual(observedRequests[0].headers['dd-flagging-source-mode'], undefined) + assert.strictEqual(observedRequests[1].headers['if-none-match'], '"agentless-integration"') + }) +}) diff --git a/integration-tests/openfeature/openfeature-exposure-events.spec.js b/integration-tests/openfeature/openfeature-exposure-events.spec.js index 34b9934d8e8..9ed5a8ebef8 100644 --- a/integration-tests/openfeature/openfeature-exposure-events.spec.js +++ b/integration-tests/openfeature/openfeature-exposure-events.spec.js @@ -61,6 +61,8 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { DD_TRACE_AGENT_PORT: agent.port, DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1', DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + // Preserve the existing RC exposure path until agentless emission is supported. + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config', }, }) }) @@ -160,6 +162,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { DD_TRACE_AGENT_PORT: agent.port, DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1', DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config', }, }) }) @@ -243,6 +246,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { DD_TRACE_AGENT_PORT: agent.port, DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1', DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config', }, }) }) 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 3535817f4eb..9c208719e01 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -424,6 +424,10 @@ export interface GeneratedConfig { enableGetRumData: boolean; exporter: string; flaggingProvider: { + agentlessBaseUrl: string | undefined; + agentlessPollIntervalSeconds: number; + agentlessRequestTimeoutSeconds: number; + configurationSource: string; enabled: boolean; initializationTimeoutMs: number; spanEnrichment: { @@ -688,6 +692,10 @@ 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: string; + 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; DD_GIT_BRANCH: string | undefined; DD_GIT_COMMIT_AUTHOR_DATE: string | undefined; DD_GIT_COMMIT_AUTHOR_EMAIL: string | undefined; diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 9e18849ea4e..dd5deb6b2ba 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -827,6 +827,46 @@ "default": "false" } ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": [ + { + "implementation": "A", + "type": "string", + "configurationNames": [ + "experimental.flaggingProvider.configurationSource" + ], + "default": "agentless" + } + ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": [ + { + "implementation": "A", + "type": "string", + "configurationNames": [ + "experimental.flaggingProvider.agentlessBaseUrl" + ], + "default": null + } + ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": [ + { + "implementation": "A", + "type": "int", + "configurationNames": [ + "experimental.flaggingProvider.agentlessPollIntervalSeconds" + ], + "default": "30" + } + ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": [ + { + "implementation": "A", + "type": "int", + "configurationNames": [ + "experimental.flaggingProvider.agentlessRequestTimeoutSeconds" + ], + "default": "2" + } + ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ { "implementation": "B", diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 3860d5fde37..a0588b0e01c 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -42,6 +42,34 @@ function resolve (config) { } } +/** + * Starts the selected first-party configuration source. + * + * Remote Config is installed separately because its lifecycle is owned by the + * tracer Remote Config client. + * + * @param {import('../config/config-base')} config - Tracer configuration. + * @param {Function} getOpenfeatureProxy - Returns the active provider. + * @returns {void} + */ +function enable (config, getOpenfeatureProxy) { + let sourceConfig + try { + sourceConfig = resolve(config) + } catch (error) { + log.error('Unable to configure Feature Flagging configuration source', error) + return + } + + if (sourceConfig.mode === CONFIGURATION_SOURCE_AGENTLESS) { + const AgentlessConfigurationSource = require('./agentless_configuration_source') + const source = new AgentlessConfigurationSource(sourceConfig, ufc => { + getOpenfeatureProxy()._setConfiguration(ufc) + }) + getOpenfeatureProxy()._setConfigurationSource(source) + } +} + /** * Reports whether the explicit Remote Config source is selected. * @@ -148,6 +176,7 @@ function positiveMilliseconds (value, fallbackSeconds, setting, maximumSeconds) } module.exports = { + enable, isRemoteConfig, resolve, } diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index b793c29e1ca..a8b37444a1c 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -17,6 +17,9 @@ class FlaggingProvider extends DatadogNodeServerProvider { /** @type {SpanEnrichmentHook?} */ #spanEnrichmentHook + /** @type {{ start: Function, stop: Function }?} */ + #configurationSource + /** * @param {import('../tracer')} tracer - Datadog tracer instance * @param {import('../config')} config - Tracer configuration object @@ -50,9 +53,27 @@ class FlaggingProvider extends DatadogNodeServerProvider { * Cleans up resources including channel subscriptions. */ onClose () { + this.#configurationSource?.stop() + this.#configurationSource = null this.#spanEnrichmentHook?.destroy() } + /** + * Attaches and starts the provider's first-party configuration source. + * Repeated calls preserve the original source and dispose of the duplicate. + * + * @internal + * @param {{ start: Function, stop: Function }} source - Configuration source lifecycle. + */ + _setConfigurationSource (source) { + if (this.#configurationSource) { + source.stop() + return + } + this.#configurationSource = source + source.start() + } + /** * 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/register.js b/packages/dd-trace/src/openfeature/register.js index 3c7afd48866..aa4e30a3251 100644 --- a/packages/dd-trace/src/openfeature/register.js +++ b/packages/dd-trace/src/openfeature/register.js @@ -25,8 +25,14 @@ registerFeature({ * @param {import('../proxy')} proxy */ remoteConfig (rc, config, proxy) { + const configurationSource = require('./configuration_source') const openfeatureRemoteConfig = require('./remote_config') - openfeatureRemoteConfig.enable(rc, config, () => proxy.openfeature) + openfeatureRemoteConfig.enable( + rc, + config, + () => proxy.openfeature, + configurationSource.isRemoteConfig(config) + ) }, /** @@ -41,6 +47,8 @@ registerFeature({ if (!hasFlaggingProvider(proxy)) { lazyProxy(proxy, 'openfeature', () => require('./flagging_provider'), tracer, config) } + const configurationSource = require('./configuration_source') + configurationSource.enable(config, () => proxy.openfeature) } }, }) diff --git a/packages/dd-trace/src/openfeature/remote_config.js b/packages/dd-trace/src/openfeature/remote_config.js index 06fbe967bc5..0215a2c16c1 100644 --- a/packages/dd-trace/src/openfeature/remote_config.js +++ b/packages/dd-trace/src/openfeature/remote_config.js @@ -8,14 +8,17 @@ const RemoteConfigCapabilities = require('../remote_config/capabilities') * @param {object} rc - RemoteConfig instance * @param {object} config - Tracer config * @param {Function} getOpenfeatureProxy - Function that returns the OpenFeature proxy from tracer + * @param {boolean} [subscribe] - Whether Agent Remote Config owns UFC delivery */ -function enable (rc, config, getOpenfeatureProxy) { +function enable (rc, config, getOpenfeatureProxy, subscribe = true) { // Always enable capability for feature flag configuration // This indicates the library supports this capability via remote config rc.updateCapabilities(RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, true) - // Only register product handler if the experimental feature is enabled - if (!config.experimental.flaggingProvider.enabled) return + // Only register the product handler when the provider is enabled and Agent + // Remote Config is the selected UFC source. The capability stays enabled + // because it describes library support, not the active delivery mode. + if (!config.experimental.flaggingProvider.enabled || !subscribe) return // Set product handler for FFE_FLAGS rc.setProductHandler('FFE_FLAGS', (action, conf) => { diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 6f0d132e096..95e9c4de6e7 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -4938,6 +4938,42 @@ rules: }) }) + context('Feature Flagging configuration source', () => { + it('defaults to agentless delivery with cross-SDK timings', () => { + const config = getConfig() + + assertObjectContains(config.experimental.flaggingProvider, { + configurationSource: 'agentless', + agentlessBaseUrl: undefined, + agentlessPollIntervalSeconds: 30, + agentlessRequestTimeoutSeconds: 2, + }) + }) + + it('reads the configuration source environment variable', () => { + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + + const config = getConfig() + + assert.strictEqual(config.experimental.flaggingProvider.configurationSource, 'remote_config') + }) + + it('reads the canonical agentless environment variables', () => { + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/ufc' + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = '20' + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = '5' + + const config = getConfig() + + assertObjectContains(config.experimental.flaggingProvider, { + configurationSource: 'agentless', + agentlessBaseUrl: 'https://example.com/ufc', + agentlessPollIntervalSeconds: 20, + agentlessRequestTimeoutSeconds: 5, + }) + }) + }) + describe('should detect when service name is inferred', () => { it('should set isServiceNameInferred to false when DD_SERVICE is defined ', () => { process.env.DD_SERVICE = 'test-service' diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index 81a7d471e77..aaaece42183 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -11,6 +11,7 @@ describe('OpenFeature configuration source', () => { let config let configurationSource let log + let AgentlessConfigurationSource beforeEach(() => { config = { @@ -31,8 +32,10 @@ describe('OpenFeature configuration source', () => { error: sinon.spy(), warn: sinon.spy(), } + AgentlessConfigurationSource = sinon.stub() configurationSource = proxyquire('../../src/openfeature/configuration_source', { '../log': log, + './agentless_configuration_source': AgentlessConfigurationSource, }) }) @@ -86,16 +89,21 @@ describe('OpenFeature configuration source', () => { ) }) - it('derives the managed GovCloud endpoint without hard-coding availability', () => { + it('derives and starts the managed GovCloud endpoint without hard-coding availability', () => { config.site = 'DDOG-GOV.COM' config.env = 'prod' + const provider = { _setConfigurationSource: sinon.spy() } const resolved = configurationSource.resolve(config) + configurationSource.enable(config, () => provider) assert.strictEqual( resolved.endpoint.toString(), 'https://ufc-server.ff-cdn.ddog-gov.com/api/v2/feature-flagging/config/rules-based/server?dd_env=prod' ) + sinon.assert.calledOnce(AgentlessConfigurationSource) + sinon.assert.calledWithNew(AgentlessConfigurationSource) + sinon.assert.calledOnce(provider._setConfigurationSource) sinon.assert.notCalled(log.warn) }) diff --git a/packages/dd-trace/test/openfeature/flagging_provider.spec.js b/packages/dd-trace/test/openfeature/flagging_provider.spec.js index 4ab2e7f827e..2e793de0a9c 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider.spec.js @@ -200,6 +200,31 @@ describe('FlaggingProvider', () => { sinon.assert.notCalled(mockSpanEnrichmentHook.destroy) }) + + it('stops the attached configuration source', () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + const source = { start: sinon.spy(), stop: sinon.spy() } + provider._setConfigurationSource(source) + + provider.onClose() + + sinon.assert.calledOnce(source.start) + sinon.assert.calledOnce(source.stop) + }) + + it('keeps the first configuration source on repeated attachment', () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + const first = { start: sinon.spy(), stop: sinon.spy() } + const duplicate = { start: sinon.spy(), stop: sinon.spy() } + + provider._setConfigurationSource(first) + provider._setConfigurationSource(duplicate) + + sinon.assert.calledOnce(first.start) + sinon.assert.notCalled(first.stop) + sinon.assert.notCalled(duplicate.start) + sinon.assert.calledOnce(duplicate.stop) + }) }) describe('inheritance', () => { diff --git a/packages/dd-trace/test/openfeature/register.spec.js b/packages/dd-trace/test/openfeature/register.spec.js index 80a6170a11d..3eebf71c6db 100644 --- a/packages/dd-trace/test/openfeature/register.spec.js +++ b/packages/dd-trace/test/openfeature/register.spec.js @@ -10,9 +10,11 @@ require('../setup/core') describe('OpenFeature register', () => { let config + let configurationSource let feature let lazyProxy let openfeatureModule + let openfeatureRemoteConfig let proxy let registerFeature let tracer @@ -31,11 +33,20 @@ describe('OpenFeature register', () => { enable: sinon.spy(), disable: sinon.spy(), } + openfeatureRemoteConfig = { + enable: sinon.spy(), + } + configurationSource = { + enable: sinon.spy(), + isRemoteConfig: sinon.stub().returns(false), + } delete require.cache[require.resolve('../../src/openfeature/register')] proxyquire('../../src/openfeature/register', { '../feature-registry': { registerFeature }, './flagging_provider': FlaggingProvider, + './configuration_source': configurationSource, + './remote_config': openfeatureRemoteConfig, './index': openfeatureModule, './noop': NoopFlaggingProvider, }) @@ -77,6 +88,7 @@ describe('OpenFeature register', () => { sinon.assert.calledOnce(lazyProxy) assert.ok(proxy.openfeature instanceof FlaggingProvider) assert.deepStrictEqual(proxy.openfeature.args, [tracer, config]) + sinon.assert.calledOnceWithExactly(configurationSource.enable, config, sinon.match.func) }) it('keeps an existing flagging provider on repeated enable calls', () => { @@ -87,6 +99,7 @@ describe('OpenFeature register', () => { sinon.assert.calledTwice(proxy._modules.openfeature.enable) sinon.assert.calledOnce(lazyProxy) + sinon.assert.calledTwice(configurationSource.enable) assert.strictEqual(proxy.openfeature, provider) }) @@ -99,4 +112,23 @@ describe('OpenFeature register', () => { sinon.assert.notCalled(lazyProxy) assert.strictEqual(proxy.openfeature, feature.noop) }) + + it('installs Remote Config delivery only when explicitly selected', () => { + const rc = {} + configurationSource.isRemoteConfig.returns(true) + + feature.remoteConfig(rc, config, proxy) + + sinon.assert.calledOnceWithExactly(configurationSource.isRemoteConfig, config) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, config, sinon.match.func, true) + }) + + it('advertises Remote Config support without installing delivery for the default agentless source', () => { + const rc = {} + + feature.remoteConfig(rc, config, proxy) + + sinon.assert.calledOnceWithExactly(configurationSource.isRemoteConfig, config) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, config, sinon.match.func, false) + }) }) diff --git a/packages/dd-trace/test/openfeature/remote_config.spec.js b/packages/dd-trace/test/openfeature/remote_config.spec.js index 9e61cd1dece..f95038bfcfc 100644 --- a/packages/dd-trace/test/openfeature/remote_config.spec.js +++ b/packages/dd-trace/test/openfeature/remote_config.spec.js @@ -118,5 +118,16 @@ describe('OpenFeature Remote Config', () => { true ) }) + + it('should advertise capability without registering a handler in agentless mode', () => { + enable(rc, config, getOpenfeatureProxy, false) + + sinon.assert.calledOnceWithExactly( + rc.updateCapabilities, + RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, + true + ) + sinon.assert.notCalled(rc.setProductHandler) + }) }) }) From beeeaeb84f46656004d2f31b19682f4cf24a4669 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 00:18:07 -0400 Subject: [PATCH 05/15] test(openfeature): cover agentless delivery branches --- .../agentless_configuration_source.spec.js | 39 ++++++++++++++++++- .../openfeature/configuration_source.spec.js | 26 ++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) 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 02543fa6576..31d1d0d24ca 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -207,6 +207,18 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledOnce(log.debug) }) + it('reports non-gzip response body failures', async () => { + responses.push({ + statusCode: 200, + bodyError: new TypeError('terminated'), + }) + + const outcome = await poll(source()) + + assert.match(outcome.error.message, /response body could not be read/) + sinon.assert.notCalled(applyConfiguration) + }) + it('accepts managed JSON API payloads larger than 500 KB', async () => { const expected = JSON.parse(VALID_UFC) expected.flags.large = { description: 'x'.repeat(500 * 1024) } @@ -244,15 +256,26 @@ describe('AgentlessConfigurationSource', () => { { statusCode: 200, body: JSON.stringify({ data: { id: '1', type: 'universal-flag-configuration' } }), + }, + { + statusCode: 200, + body: JSON.stringify({ + data: { + id: '1', + type: 'universal-flag-configuration', + attributes: { createdAt: '2026-01-01T00:00:00.000Z' }, + }, + }), } ) const configurationSource = source() + await poll(configurationSource) await poll(configurationSource) await poll(configurationSource) sinon.assert.notCalled(applyConfiguration) - sinon.assert.calledTwice(log.debug) + sinon.assert.calledThrice(log.debug) }) it('preserves last-known-good configuration and ETag after malformed JSON', async () => { @@ -472,6 +495,20 @@ describe('AgentlessConfigurationSource', () => { assert.strictEqual(requests[0].options.signal.aborted, true) }) + it('stops a scheduled poll and reports subsequent polls as stopped', async () => { + responses.push({ statusCode: 200, body: VALID_RESPONSE }) + const configurationSource = source() + + configurationSource.start() + await completeScheduledResponse() + configurationSource.stop() + const outcome = await poll(configurationSource) + await clock.tickAsync(30_000) + + assert.deepStrictEqual(outcome, { error: null, result: { stopped: true } }) + assert.strictEqual(requests.length, 1) + }) + it('starts only once', () => { responses.push({ pending: true }) const configurationSource = source() diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index aaaece42183..d6328002d6d 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -92,10 +92,15 @@ describe('OpenFeature configuration source', () => { it('derives and starts the managed GovCloud endpoint without hard-coding availability', () => { config.site = 'DDOG-GOV.COM' config.env = 'prod' - const provider = { _setConfigurationSource: sinon.spy() } + const provider = { + _setConfiguration: sinon.spy(), + _setConfigurationSource: sinon.spy(), + } + const configuration = { flags: {} } const resolved = configurationSource.resolve(config) configurationSource.enable(config, () => provider) + AgentlessConfigurationSource.firstCall.args[1](configuration) assert.strictEqual( resolved.endpoint.toString(), @@ -103,6 +108,7 @@ describe('OpenFeature configuration source', () => { ) sinon.assert.calledOnce(AgentlessConfigurationSource) sinon.assert.calledWithNew(AgentlessConfigurationSource) + sinon.assert.calledOnceWithExactly(provider._setConfiguration, configuration) sinon.assert.calledOnce(provider._setConfigurationSource) sinon.assert.notCalled(log.warn) }) @@ -126,6 +132,24 @@ describe('OpenFeature configuration source', () => { ) }) + it('rejects malformed endpoints without enabling a source', () => { + config.experimental.flaggingProvider.agentlessBaseUrl = 'not a URL' + const provider = { _setConfigurationSource: sinon.spy() } + + assert.throws( + () => configurationSource.resolve(config), + /Invalid Feature Flagging agentless URL: not a URL/ + ) + configurationSource.enable(config, () => provider) + + sinon.assert.calledOnceWithMatch( + log.error, + 'Unable to configure Feature Flagging configuration source', + sinon.match.instanceOf(Error) + ) + sinon.assert.notCalled(provider._setConfigurationSource) + }) + it('recognizes explicit Remote Config without resolving agentless settings', () => { config.experimental.flaggingProvider.configurationSource = ' REMOTE_CONFIG ' delete config.site From 5b8365432e0491f8d5960a0a1cce606cca988432 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 17:02:48 -0400 Subject: [PATCH 06/15] fix(openfeature): keep delivery configuration environment-only --- index.d.ts | 33 ----------- index.d.v5.ts | 33 ----------- .../src/config/generated-config-types.d.ts | 8 +-- packages/dd-trace/src/config/helper.js | 1 + .../src/config/supported-configurations.json | 25 +++------ .../src/openfeature/configuration_source.js | 9 ++- packages/dd-trace/test/config/index.spec.js | 56 +++++++++++++++---- .../openfeature/configuration_source.spec.js | 35 ++++++------ 8 files changed, 79 insertions(+), 121 deletions(-) diff --git a/index.d.ts b/index.d.ts index 596ecfb44b1..d8b90bf7c16 100644 --- a/index.d.ts +++ b/index.d.ts @@ -817,39 +817,6 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ initializationTimeoutMs?: number - /** - * Where Universal Flag Configuration is loaded from. Agentless delivery - * fetches from the Datadog UFC CDN endpoint and evaluates locally. - * - * @default 'agentless' - * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE - * Programmatic configuration takes precedence over the environment variables listed above. - */ - configurationSource?: 'agentless' | 'remote_config' - /** - * Optional agentless configuration endpoint or base URL. A base URL - * receives the standard rules-based server path. - * - * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL - * Programmatic configuration takes precedence over the environment variables listed above. - */ - agentlessBaseUrl?: string - /** - * Agentless configuration polling interval in seconds, capped at one hour. - * - * @default 30 - * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS - * Programmatic configuration takes precedence over the environment variables listed above. - */ - agentlessPollIntervalSeconds?: number - /** - * Agentless configuration request timeout in seconds. - * - * @default 2 - * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS - * Programmatic configuration takes precedence over the environment variables listed above. - */ - agentlessRequestTimeoutSeconds?: number /** * Configuration for span enrichment with feature flag evaluation data. */ diff --git a/index.d.v5.ts b/index.d.v5.ts index 90102d49e15..36bf1a7de91 100644 --- a/index.d.v5.ts +++ b/index.d.v5.ts @@ -887,39 +887,6 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ initializationTimeoutMs?: number - /** - * Where Universal Flag Configuration is loaded from. Agentless delivery - * fetches from the Datadog UFC CDN endpoint and evaluates locally. - * - * @default 'agentless' - * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE - * Programmatic configuration takes precedence over the environment variables listed above. - */ - configurationSource?: 'agentless' | 'remote_config' - /** - * Optional agentless configuration endpoint or base URL. A base URL - * receives the standard rules-based server path. - * - * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL - * Programmatic configuration takes precedence over the environment variables listed above. - */ - agentlessBaseUrl?: string - /** - * Agentless configuration polling interval in seconds, capped at one hour. - * - * @default 30 - * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS - * Programmatic configuration takes precedence over the environment variables listed above. - */ - agentlessPollIntervalSeconds?: number - /** - * Agentless configuration request timeout in seconds. - * - * @default 2 - * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS - * Programmatic configuration takes precedence over the environment variables listed above. - */ - agentlessRequestTimeoutSeconds?: number /** * Configuration for span enrichment with feature flag evaluation data. */ 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 9c208719e01..ab4a1d4032e 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -83,6 +83,10 @@ export interface GeneratedConfig { 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: string; + 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; DD_GIT_BRANCH: string | undefined; DD_GIT_COMMIT_AUTHOR_DATE: string | undefined; DD_GIT_COMMIT_AUTHOR_EMAIL: string | undefined; @@ -424,10 +428,6 @@ export interface GeneratedConfig { enableGetRumData: boolean; exporter: string; flaggingProvider: { - agentlessBaseUrl: string | undefined; - agentlessPollIntervalSeconds: number; - agentlessRequestTimeoutSeconds: number; - configurationSource: string; enabled: boolean; initializationTimeoutMs: number; spanEnrichment: { diff --git a/packages/dd-trace/src/config/helper.js b/packages/dd-trace/src/config/helper.js index 1f01bf71007..592b4e8728a 100644 --- a/packages/dd-trace/src/config/helper.js +++ b/packages/dd-trace/src/config/helper.js @@ -13,6 +13,7 @@ * @property {string} [namespace] Nests the canonical env name under this property path (e.g. `telemetry`). * @property {string} [transform] * @property {string} [allowed] + * @property {string} [description] * @property {string|boolean} [deprecated] * @property {boolean} [sensitive] Excludes the configuration value from configuration telemetry. */ diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index dd5deb6b2ba..742eb6e869a 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -831,40 +831,33 @@ { "implementation": "A", "type": "string", - "configurationNames": [ - "experimental.flaggingProvider.configurationSource" - ], - "default": "agentless" + "default": "agentless", + "description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config." } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": [ { "implementation": "A", "type": "string", - "configurationNames": [ - "experimental.flaggingProvider.agentlessBaseUrl" - ], - "default": null + "default": null, + "description": "Experimental: Override the agentless Feature Flagging UFC endpoint or base URL.", + "sensitive": true } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": [ { "implementation": "A", "type": "int", - "configurationNames": [ - "experimental.flaggingProvider.agentlessPollIntervalSeconds" - ], - "default": "30" + "default": "30", + "description": "Experimental: Set the agentless Feature Flagging UFC polling interval in seconds, capped at one hour." } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": [ { "implementation": "A", "type": "int", - "configurationNames": [ - "experimental.flaggingProvider.agentlessRequestTimeoutSeconds" - ], - "default": "2" + "default": "2", + "description": "Experimental: Set the agentless Feature Flagging UFC request timeout in seconds." } ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index a0588b0e01c..84e09a46dca 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -17,7 +17,6 @@ const MAX_POLL_INTERVAL_SECONDS = 60 * 60 * @returns {object} Resolved source settings. */ function resolve (config) { - const flaggingProvider = config.experimental.flaggingProvider const mode = resolveMode(config) if (mode === CONFIGURATION_SOURCE_REMOTE_CONFIG) { @@ -26,15 +25,15 @@ function resolve (config) { return { mode, - endpoint: endpoint(config, flaggingProvider.agentlessBaseUrl), + endpoint: endpoint(config, config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL), pollIntervalMs: positiveMilliseconds( - flaggingProvider.agentlessPollIntervalSeconds, + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, DEFAULT_POLL_INTERVAL_SECONDS, 'poll interval', MAX_POLL_INTERVAL_SECONDS ), requestTimeoutMs: positiveMilliseconds( - flaggingProvider.agentlessRequestTimeoutSeconds, + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, DEFAULT_REQUEST_TIMEOUT_SECONDS, 'request timeout' ), @@ -95,7 +94,7 @@ function isRemoteConfig (config) { * @returns {string} Selected configuration-source mode. */ function resolveMode (config) { - const value = config.experimental.flaggingProvider.configurationSource + const value = config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE const mode = String(value ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS if (mode !== CONFIGURATION_SOURCE_AGENTLESS && mode !== CONFIGURATION_SOURCE_REMOTE_CONFIG) { throw new Error(`Unsupported Feature Flagging configuration source: ${mode}`) diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 95e9c4de6e7..b378b8422dd 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -516,6 +516,8 @@ describe('Config', () => { const SENTINELS = { DD_API_KEY: 'SENTINEL_DD_API_KEY', DD_APP_KEY: 'SENTINEL_DD_APP_KEY', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: + 'https://SENTINEL_AGENTLESS_ENDPOINT.example/ufc', OTEL_EXPORTER_OTLP_HEADERS: 'dd-api-key=SENTINEL_OTLP_BASE', OTEL_EXPORTER_OTLP_TRACES_HEADERS: 'dd-api-key=SENTINEL_OTLP_TRACES', OTEL_EXPORTER_OTLP_METRICS_HEADERS: 'dd-api-key=SENTINEL_OTLP_METRICS', @@ -4942,11 +4944,11 @@ rules: it('defaults to agentless delivery with cross-SDK timings', () => { const config = getConfig() - assertObjectContains(config.experimental.flaggingProvider, { - configurationSource: 'agentless', - agentlessBaseUrl: undefined, - agentlessPollIntervalSeconds: 30, - agentlessRequestTimeoutSeconds: 2, + assertObjectContains(config, { + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: undefined, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: 30, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 2, }) }) @@ -4955,7 +4957,7 @@ rules: const config = getConfig() - assert.strictEqual(config.experimental.flaggingProvider.configurationSource, 'remote_config') + assert.strictEqual(config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'remote_config') }) it('reads the canonical agentless environment variables', () => { @@ -4965,13 +4967,45 @@ rules: const config = getConfig() - assertObjectContains(config.experimental.flaggingProvider, { - configurationSource: 'agentless', - agentlessBaseUrl: 'https://example.com/ufc', - agentlessPollIntervalSeconds: 20, - agentlessRequestTimeoutSeconds: 5, + assertObjectContains(config, { + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: 'https://example.com/ufc', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: 20, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 5, }) }) + + it('does not accept programmatic configuration-source options', () => { + const config = getConfig({ + experimental: { + flaggingProvider: { + enabled: false, + configurationSource: 'remote_config', + agentlessBaseUrl: 'https://example.com/programmatic', + agentlessPollIntervalSeconds: 20, + agentlessRequestTimeoutSeconds: 5, + }, + }, + }) + + assert.strictEqual(config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'agentless') + assert.strictEqual(config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, undefined) + assert.strictEqual(config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, 30) + assert.strictEqual(config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, 2) + for (const name of [ + 'configurationSource', + 'agentlessBaseUrl', + 'agentlessPollIntervalSeconds', + 'agentlessRequestTimeoutSeconds', + ]) { + sinon.assert.calledWithExactly( + log.warn, + 'Unknown option %s with value %o', + `experimental.flaggingProvider.${name}`, + sinon.match.defined + ) + } + }) }) describe('should detect when service name is inferred', () => { diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index d6328002d6d..7fc6900a39e 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -16,16 +16,12 @@ describe('OpenFeature configuration source', () => { beforeEach(() => { config = { DD_API_KEY: 'test-api-key', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: undefined, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: 30, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 2, site: 'datadoghq.com', env: 'my env', - experimental: { - flaggingProvider: { - configurationSource: 'agentless', - agentlessBaseUrl: undefined, - agentlessPollIntervalSeconds: 30, - agentlessRequestTimeoutSeconds: 2, - }, - }, } log = { debug: sinon.spy(), @@ -41,7 +37,7 @@ describe('OpenFeature configuration source', () => { for (const value of [undefined, null, '', ' ', ' AgEnTlEsS ']) { it(`normalizes ${JSON.stringify(value)} to the default agentless source`, () => { - config.experimental.flaggingProvider.configurationSource = value + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value assert.strictEqual(configurationSource.resolve(config).mode, 'agentless') }) @@ -71,7 +67,7 @@ describe('OpenFeature configuration source', () => { }) it('appends the standard path to a configured origin', () => { - config.experimental.flaggingProvider.agentlessBaseUrl = 'http://127.0.0.1:8080/' + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://127.0.0.1:8080/' const resolved = configurationSource.resolve(config) assert.strictEqual( @@ -81,7 +77,7 @@ describe('OpenFeature configuration source', () => { }) it('preserves an exact configured path and query', () => { - config.experimental.flaggingProvider.agentlessBaseUrl = 'https://example.com/custom/ufc?tenant=one' + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/custom/ufc?tenant=one' assert.strictEqual( configurationSource.resolve(config).endpoint.toString(), @@ -115,7 +111,8 @@ describe('OpenFeature configuration source', () => { it('allows an operator-owned agentless endpoint on GovCloud', () => { config.site = 'ddog-gov.com' - config.experimental.flaggingProvider.agentlessBaseUrl = 'https://flags.example.test/custom/ufc?tenant=test' + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + 'https://flags.example.test/custom/ufc?tenant=test' const resolved = configurationSource.resolve(config) @@ -124,7 +121,7 @@ describe('OpenFeature configuration source', () => { }) it('rejects non-HTTP endpoints', () => { - config.experimental.flaggingProvider.agentlessBaseUrl = 'file:///tmp/ufc.json' + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'file:///tmp/ufc.json' assert.throws( () => configurationSource.resolve(config), @@ -133,7 +130,7 @@ describe('OpenFeature configuration source', () => { }) it('rejects malformed endpoints without enabling a source', () => { - config.experimental.flaggingProvider.agentlessBaseUrl = 'not a URL' + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'not a URL' const provider = { _setConfigurationSource: sinon.spy() } assert.throws( @@ -151,7 +148,7 @@ describe('OpenFeature configuration source', () => { }) it('recognizes explicit Remote Config without resolving agentless settings', () => { - config.experimental.flaggingProvider.configurationSource = ' REMOTE_CONFIG ' + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = ' REMOTE_CONFIG ' delete config.site assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) @@ -160,7 +157,7 @@ describe('OpenFeature configuration source', () => { for (const value of ['offline', 'other']) { it(`fails closed for the unsupported ${value} source`, () => { - config.experimental.flaggingProvider.configurationSource = value + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value assert.throws(() => configurationSource.resolve(config), /Unsupported Feature Flagging configuration source/) assert.strictEqual(configurationSource.isRemoteConfig(config), false) @@ -169,8 +166,8 @@ describe('OpenFeature configuration source', () => { } it('falls back to positive timing defaults with warnings', () => { - config.experimental.flaggingProvider.agentlessPollIntervalSeconds = 0 - config.experimental.flaggingProvider.agentlessRequestTimeoutSeconds = -1 + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = 0 + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = -1 assert.strictEqual(configurationSource.isRemoteConfig(config), false) sinon.assert.notCalled(log.warn) @@ -183,7 +180,7 @@ describe('OpenFeature configuration source', () => { }) it('caps the polling interval at one hour', () => { - config.experimental.flaggingProvider.agentlessPollIntervalSeconds = 4 * 60 * 60 + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = 4 * 60 * 60 const resolved = configurationSource.resolve(config) From fe915648df4195772a6b9898a869e68192223ae6 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 17:03:05 -0400 Subject: [PATCH 07/15] fix(openfeature): send canonical client library headers --- .../openfeature/openfeature-agentless.spec.js | 3 +++ .../common/client-library-headers.js | 21 +++++++++++++++++++ .../agentless_configuration_source.js | 6 +++++- packages/dd-trace/src/telemetry/send-data.js | 4 ++-- .../agentless_configuration_source.spec.js | 3 +++ .../dd-trace/test/telemetry/send-data.spec.js | 8 +++---- 6 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 packages/dd-trace/src/exporters/common/client-library-headers.js diff --git a/integration-tests/openfeature/openfeature-agentless.spec.js b/integration-tests/openfeature/openfeature-agentless.spec.js index 543b01c42a5..5acdab41e71 100644 --- a/integration-tests/openfeature/openfeature-agentless.spec.js +++ b/integration-tests/openfeature/openfeature-agentless.spec.js @@ -5,6 +5,7 @@ const http = require('node:http') const path = require('node:path') const zlib = require('node:zlib') const { afterEach, before, beforeEach, describe, it } = require('mocha') +const { VERSION } = require('../../version') const { sandboxCwd, useSandbox, spawnProc, stopProc } = require('../helpers') const UFC = { @@ -123,6 +124,8 @@ describe('OpenFeature agentless configuration integration', () => { ) assert.strictEqual(observedRequests[0].headers['dd-api-key'], 'integration-api-key') assert.strictEqual(observedRequests[0].headers['accept-encoding'], 'gzip') + assert.strictEqual(observedRequests[0].headers['dd-client-library-language'], 'nodejs') + assert.strictEqual(observedRequests[0].headers['dd-client-library-version'], VERSION) assert.strictEqual(observedRequests[0].headers['dd-flagging-source-mode'], undefined) assert.strictEqual(observedRequests[1].headers['if-none-match'], '"agentless-integration"') }) diff --git a/packages/dd-trace/src/exporters/common/client-library-headers.js b/packages/dd-trace/src/exporters/common/client-library-headers.js new file mode 100644 index 00000000000..650bb0c4189 --- /dev/null +++ b/packages/dd-trace/src/exporters/common/client-library-headers.js @@ -0,0 +1,21 @@ +'use strict' + +const { VERSION } = require('../../../../../version') + +const LANGUAGE = 'nodejs' + +/** + * Returns the canonical client-library identification headers. + * + * @param {string} [language] - Client library language name. + * @param {string} [version] - Client library version. + * @returns {Record} Client library identification headers. + */ +function getClientLibraryHeaders (language = LANGUAGE, version = VERSION) { + return { + 'DD-Client-Library-Language': language, + 'DD-Client-Library-Version': version, + } +} + +module.exports = { getClientLibraryHeaders } diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index 9def471e7f1..e467250a886 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -2,6 +2,7 @@ const net = require('node:net') const { storage } = require('../../../datadog-core') +const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers') const log = require('../log') const legacyStorage = storage('legacy') @@ -141,7 +142,10 @@ class AgentlessConfigurationSource { * @returns {void} */ _request (callback) { - const headers = { 'Accept-Encoding': 'gzip' } + const headers = { + ...getClientLibraryHeaders(), + 'Accept-Encoding': 'gzip', + } if (this._apiKey) headers['DD-API-KEY'] = this._apiKey if (this._etag) headers['If-None-Match'] = this._etag diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 28a2d019f8b..7342c9ce353 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -1,5 +1,6 @@ 'use strict' +const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers') const request = require('../exporters/common/request') const log = require('../log') @@ -77,11 +78,10 @@ let agentTelemetry = true */ function getHeaders (config, application, reqType) { const headers = { + ...getClientLibraryHeaders(application.language_name, application.tracer_version), 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': reqType, - 'dd-client-library-language': application.language_name, - 'dd-client-library-version': application.tracer_version, 'dd-session-id': config.tags['runtime-id'], } if (config.DD_ROOT_JS_SESSION_ID) { 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 31d1d0d24ca..3b45f032be1 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -5,6 +5,7 @@ const { afterEach, beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire') const sinon = require('sinon') +const { VERSION } = require('../../../../version') require('../setup/core') const VALID_UFC = JSON.stringify({ @@ -115,6 +116,8 @@ describe('AgentlessConfigurationSource', () => { assert.deepStrictEqual(second, { error: null, result: { notModified: true } }) assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key') assert.strictEqual(requests[0].options.headers['Accept-Encoding'], 'gzip') + assert.strictEqual(requests[0].options.headers['DD-Client-Library-Language'], 'nodejs') + assert.strictEqual(requests[0].options.headers['DD-Client-Library-Version'], VERSION) assert.strictEqual(requests[0].options.headers['If-None-Match'], undefined) assert.strictEqual(requests[1].options.headers['If-None-Match'], '"ufc-v1"') assert.strictEqual(requests[0].options.redirect, 'manual') diff --git a/packages/dd-trace/test/telemetry/send-data.spec.js b/packages/dd-trace/test/telemetry/send-data.spec.js index aefe0dced35..5c984fe63a0 100644 --- a/packages/dd-trace/test/telemetry/send-data.spec.js +++ b/packages/dd-trace/test/telemetry/send-data.spec.js @@ -43,8 +43,8 @@ describe('sendData', () => { 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': 'req-type', - 'dd-client-library-language': application.language_name, - 'dd-client-library-version': application.tracer_version, + 'DD-Client-Library-Language': application.language_name, + 'DD-Client-Library-Version': application.tracer_version, 'dd-session-id': '123', }, url: undefined, @@ -69,8 +69,8 @@ describe('sendData', () => { 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': 'req-type', - 'dd-client-library-language': application.language_name, - 'dd-client-library-version': application.tracer_version, + 'DD-Client-Library-Language': application.language_name, + 'DD-Client-Library-Version': application.tracer_version, 'dd-session-id': '123', }, url: 'unix:/foo/bar/baz', From a59fc069b3d1b09df6087903ebcc7005a2945e67 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 17:32:44 -0400 Subject: [PATCH 08/15] fix(telemetry): preserve client library header casing --- packages/dd-trace/src/telemetry/send-data.js | 4 ++-- packages/dd-trace/test/telemetry/send-data.spec.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 7342c9ce353..28a2d019f8b 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -1,6 +1,5 @@ 'use strict' -const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers') const request = require('../exporters/common/request') const log = require('../log') @@ -78,10 +77,11 @@ let agentTelemetry = true */ function getHeaders (config, application, reqType) { const headers = { - ...getClientLibraryHeaders(application.language_name, application.tracer_version), 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': reqType, + 'dd-client-library-language': application.language_name, + 'dd-client-library-version': application.tracer_version, 'dd-session-id': config.tags['runtime-id'], } if (config.DD_ROOT_JS_SESSION_ID) { diff --git a/packages/dd-trace/test/telemetry/send-data.spec.js b/packages/dd-trace/test/telemetry/send-data.spec.js index 5c984fe63a0..aefe0dced35 100644 --- a/packages/dd-trace/test/telemetry/send-data.spec.js +++ b/packages/dd-trace/test/telemetry/send-data.spec.js @@ -43,8 +43,8 @@ describe('sendData', () => { 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': 'req-type', - 'DD-Client-Library-Language': application.language_name, - 'DD-Client-Library-Version': application.tracer_version, + 'dd-client-library-language': application.language_name, + 'dd-client-library-version': application.tracer_version, 'dd-session-id': '123', }, url: undefined, @@ -69,8 +69,8 @@ describe('sendData', () => { 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': 'req-type', - 'DD-Client-Library-Language': application.language_name, - 'DD-Client-Library-Version': application.tracer_version, + 'dd-client-library-language': application.language_name, + 'dd-client-library-version': application.tracer_version, 'dd-session-id': '123', }, url: 'unix:/foo/bar/baz', From 50c9f101ff1b8af80f37198ff297280c74b53cb6 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 18:26:01 -0400 Subject: [PATCH 09/15] refactor(telemetry): reuse client library headers --- packages/dd-trace/src/telemetry/send-data.js | 4 ++-- packages/dd-trace/test/telemetry/send-data.spec.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 28a2d019f8b..7342c9ce353 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -1,5 +1,6 @@ 'use strict' +const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers') const request = require('../exporters/common/request') const log = require('../log') @@ -77,11 +78,10 @@ let agentTelemetry = true */ function getHeaders (config, application, reqType) { const headers = { + ...getClientLibraryHeaders(application.language_name, application.tracer_version), 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': reqType, - 'dd-client-library-language': application.language_name, - 'dd-client-library-version': application.tracer_version, 'dd-session-id': config.tags['runtime-id'], } if (config.DD_ROOT_JS_SESSION_ID) { diff --git a/packages/dd-trace/test/telemetry/send-data.spec.js b/packages/dd-trace/test/telemetry/send-data.spec.js index aefe0dced35..5c984fe63a0 100644 --- a/packages/dd-trace/test/telemetry/send-data.spec.js +++ b/packages/dd-trace/test/telemetry/send-data.spec.js @@ -43,8 +43,8 @@ describe('sendData', () => { 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': 'req-type', - 'dd-client-library-language': application.language_name, - 'dd-client-library-version': application.tracer_version, + 'DD-Client-Library-Language': application.language_name, + 'DD-Client-Library-Version': application.tracer_version, 'dd-session-id': '123', }, url: undefined, @@ -69,8 +69,8 @@ describe('sendData', () => { 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': 'req-type', - 'dd-client-library-language': application.language_name, - 'dd-client-library-version': application.tracer_version, + 'DD-Client-Library-Language': application.language_name, + 'DD-Client-Library-Version': application.tracer_version, 'dd-session-id': '123', }, url: 'unix:/foo/bar/baz', From 053684dccb6f4c23784298dde0cdc41abe6633ff Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 20:41:56 -0400 Subject: [PATCH 10/15] fix(openfeature): report agentless base URL configuration --- packages/dd-trace/src/config/supported-configurations.json | 3 +-- packages/dd-trace/test/config/index.spec.js | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 742eb6e869a..2317184b699 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -840,8 +840,7 @@ "implementation": "A", "type": "string", "default": null, - "description": "Experimental: Override the agentless Feature Flagging UFC endpoint or base URL.", - "sensitive": true + "description": "Experimental: Override the agentless Feature Flagging UFC endpoint or base URL." } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": [ diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index b378b8422dd..986f6be515b 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -516,8 +516,6 @@ describe('Config', () => { const SENTINELS = { DD_API_KEY: 'SENTINEL_DD_API_KEY', DD_APP_KEY: 'SENTINEL_DD_APP_KEY', - DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: - 'https://SENTINEL_AGENTLESS_ENDPOINT.example/ufc', OTEL_EXPORTER_OTLP_HEADERS: 'dd-api-key=SENTINEL_OTLP_BASE', OTEL_EXPORTER_OTLP_TRACES_HEADERS: 'dd-api-key=SENTINEL_OTLP_TRACES', OTEL_EXPORTER_OTLP_METRICS_HEADERS: 'dd-api-key=SENTINEL_OTLP_METRICS', From 4f57fe88db30782406e39bf76e8dcaf2cf9287fd Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 20 Jul 2026 11:10:09 -0600 Subject: [PATCH 11/15] feat(openfeature): preserve configuration source semantics --- .../openfeature/openfeature-agentless.spec.js | 2 +- .../openfeature-exposure-events.spec.js | 8 +-- .../src/config/generated-config-types.d.ts | 2 + .../src/config/supported-configurations.json | 7 +++ .../src/openfeature/configuration_source.js | 39 ++++++++++++- packages/dd-trace/src/openfeature/index.js | 2 - packages/dd-trace/src/openfeature/register.js | 51 +++++++++++++---- .../dd-trace/src/openfeature/remote_config.js | 14 ++--- packages/dd-trace/test/config/index.spec.js | 23 +++++++- .../openfeature/configuration_source.spec.js | 55 +++++++++++++++++++ .../test/openfeature/register.spec.js | 46 ++++++++++++---- .../test/openfeature/remote_config.spec.js | 49 +++-------------- packages/dd-trace/test/proxy.spec.js | 2 +- 13 files changed, 217 insertions(+), 83 deletions(-) diff --git a/integration-tests/openfeature/openfeature-agentless.spec.js b/integration-tests/openfeature/openfeature-agentless.spec.js index 5acdab41e71..3869fc01d5a 100644 --- a/integration-tests/openfeature/openfeature-agentless.spec.js +++ b/integration-tests/openfeature/openfeature-agentless.spec.js @@ -86,7 +86,7 @@ describe('OpenFeature agentless configuration integration', () => { cwd, env: { DD_API_KEY: 'integration-api-key', - DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + DD_FEATURE_FLAGS_ENABLED: 'true', DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: backendUrl, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: '5', DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: '1', diff --git a/integration-tests/openfeature/openfeature-exposure-events.spec.js b/integration-tests/openfeature/openfeature-exposure-events.spec.js index 9ed5a8ebef8..ed1e047dbb9 100644 --- a/integration-tests/openfeature/openfeature-exposure-events.spec.js +++ b/integration-tests/openfeature/openfeature-exposure-events.spec.js @@ -60,7 +60,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { env: { DD_TRACE_AGENT_PORT: agent.port, DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1', - DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + DD_FEATURE_FLAGS_ENABLED: 'true', // Preserve the existing RC exposure path until agentless emission is supported. DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config', }, @@ -161,7 +161,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { env: { DD_TRACE_AGENT_PORT: agent.port, DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1', - DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + DD_FEATURE_FLAGS_ENABLED: 'true', DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config', }, }) @@ -245,7 +245,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { env: { DD_TRACE_AGENT_PORT: agent.port, DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1', - DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true', + DD_FEATURE_FLAGS_ENABLED: 'true', DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config', }, }) @@ -307,7 +307,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { cwd, env: { DD_TRACE_AGENT_PORT: agent.port, - DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'false', + DD_FEATURE_FLAGS_ENABLED: 'false', }, }) }) 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 ab4a1d4032e..073e68a8ab6 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -87,6 +87,7 @@ export interface GeneratedConfig { 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; + DD_FEATURE_FLAGS_ENABLED: boolean; DD_GIT_BRANCH: string | undefined; DD_GIT_COMMIT_AUTHOR_DATE: string | undefined; DD_GIT_COMMIT_AUTHOR_EMAIL: string | undefined; @@ -696,6 +697,7 @@ export interface GeneratedEnvVarConfig { 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; + DD_FEATURE_FLAGS_ENABLED: boolean; DD_GIT_BRANCH: string | undefined; DD_GIT_COMMIT_AUTHOR_DATE: string | undefined; DD_GIT_COMMIT_AUTHOR_EMAIL: string | undefined; diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 2317184b699..e1a8dd37ea7 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -817,6 +817,13 @@ "default": "false" } ], + "DD_FEATURE_FLAGS_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "true" + } + ], "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [ { "implementation": "A", diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 84e09a46dca..514b5453b93 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -3,6 +3,7 @@ const log = require('../log') const CONFIGURATION_SOURCE_AGENTLESS = 'agentless' +const CONFIGURATION_SOURCE_DISABLED = 'disabled' const CONFIGURATION_SOURCE_REMOTE_CONFIG = 'remote_config' const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' @@ -19,7 +20,7 @@ const MAX_POLL_INTERVAL_SECONDS = 60 * 60 function resolve (config) { const mode = resolveMode(config) - if (mode === CONFIGURATION_SOURCE_REMOTE_CONFIG) { + if (mode !== CONFIGURATION_SOURCE_AGENTLESS) { return { mode } } @@ -86,6 +87,23 @@ function isRemoteConfig (config) { } } +/** + * Reports whether the provider should be exposed for the resolved source. + * + * Invalid source values fail closed. + * + * @param {import('../config/config-base')} config - Tracer configuration. + * @returns {boolean} Whether Feature Flagging is enabled. + */ +function isEnabled (config) { + try { + return resolveMode(config) !== CONFIGURATION_SOURCE_DISABLED + } catch (error) { + log.error('Unable to configure Feature Flagging configuration source', error) + return false + } +} + /** * Normalizes and validates source selection without resolving agentless * endpoint or timing configuration. @@ -94,7 +112,25 @@ function isRemoteConfig (config) { * @returns {string} Selected configuration-source mode. */ function resolveMode (config) { + if (config.DD_FEATURE_FLAGS_ENABLED === false) return CONFIGURATION_SOURCE_DISABLED + const value = config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE + const origin = config.getOrigin?.('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE') + const hasExplicitSource = origin ? origin !== 'default' : value !== undefined && value !== null + + if (!hasExplicitSource) { + const legacyEnabled = config.experimental?.flaggingProvider?.enabled + const legacyOrigin = config.getOrigin?.('experimental.flaggingProvider.enabled') + const hasExplicitLegacySetting = legacyOrigin + ? legacyOrigin !== 'default' + : legacyEnabled !== undefined && legacyEnabled !== null + + if (hasExplicitLegacySetting) { + if (legacyEnabled === true) return CONFIGURATION_SOURCE_REMOTE_CONFIG + if (legacyEnabled === false) return CONFIGURATION_SOURCE_DISABLED + } + } + const mode = String(value ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS if (mode !== CONFIGURATION_SOURCE_AGENTLESS && mode !== CONFIGURATION_SOURCE_REMOTE_CONFIG) { throw new Error(`Unsupported Feature Flagging configuration source: ${mode}`) @@ -176,6 +212,7 @@ function positiveMilliseconds (value, fallbackSeconds, setting, maximumSeconds) module.exports = { enable, + isEnabled, isRemoteConfig, resolve, } diff --git a/packages/dd-trace/src/openfeature/index.js b/packages/dd-trace/src/openfeature/index.js index cc2029ba90e..283417f9edf 100644 --- a/packages/dd-trace/src/openfeature/index.js +++ b/packages/dd-trace/src/openfeature/index.js @@ -47,8 +47,6 @@ function enable (config) { setAgentStrategy(config, hasAgent => { exposuresWriter?.setEnabled(hasAgent) }) - - log.debug('OpenFeature module enabled') } /** diff --git a/packages/dd-trace/src/openfeature/register.js b/packages/dd-trace/src/openfeature/register.js index aa4e30a3251..b9bb03ea5f5 100644 --- a/packages/dd-trace/src/openfeature/register.js +++ b/packages/dd-trace/src/openfeature/register.js @@ -11,7 +11,36 @@ const noop = new (require('./noop'))() function hasFlaggingProvider (proxy) { const descriptor = Reflect.getOwnPropertyDescriptor(proxy, 'openfeature') - return descriptor?.value !== undefined && descriptor.value !== noop + return descriptor?.get !== undefined || (descriptor?.value !== undefined && descriptor.value !== noop) +} + +/** + * Exposes the provider without constructing it until application code reads it. + * The generic tracer lazy proxy is eager outside serverless environments, while + * agentless delivery must remain silent until the application uses OpenFeature. + * + * @param {import('../proxy')} proxy + * @param {import('../tracer')} tracer + * @param {import('../config/config-base')} config + * @param {object} configurationSource + */ +function defineFlaggingProvider (proxy, tracer, config, configurationSource) { + Reflect.defineProperty(proxy, 'openfeature', { + get () { + const FlaggingProvider = require('./flagging_provider') + const provider = new FlaggingProvider(tracer, config) + + Reflect.defineProperty(proxy, 'openfeature', { + value: provider, + configurable: true, + enumerable: true, + }) + configurationSource.enable(config, () => provider) + return provider + }, + configurable: true, + enumerable: true, + }) } registerFeature({ @@ -27,11 +56,11 @@ registerFeature({ remoteConfig (rc, config, proxy) { const configurationSource = require('./configuration_source') const openfeatureRemoteConfig = require('./remote_config') + const subscribe = configurationSource.isRemoteConfig(config) openfeatureRemoteConfig.enable( rc, - config, () => proxy.openfeature, - configurationSource.isRemoteConfig(config) + subscribe ) }, @@ -39,16 +68,14 @@ registerFeature({ * @param {import('../config/config-base')} config * @param {import('../tracer')} tracer * @param {import('../proxy')} proxy - * @param {Function} lazyProxy */ - enable (config, tracer, proxy, lazyProxy) { - if (config.experimental.flaggingProvider.enabled) { - proxy._modules.openfeature.enable(config) - if (!hasFlaggingProvider(proxy)) { - lazyProxy(proxy, 'openfeature', () => require('./flagging_provider'), tracer, config) - } - const configurationSource = require('./configuration_source') - configurationSource.enable(config, () => proxy.openfeature) + enable (config, tracer, proxy) { + const configurationSource = require('./configuration_source') + if (!configurationSource.isEnabled(config)) return + + proxy._modules.openfeature.enable(config) + if (!hasFlaggingProvider(proxy)) { + defineFlaggingProvider(proxy, tracer, config, configurationSource) } }, }) diff --git a/packages/dd-trace/src/openfeature/remote_config.js b/packages/dd-trace/src/openfeature/remote_config.js index 0215a2c16c1..01615b67a3b 100644 --- a/packages/dd-trace/src/openfeature/remote_config.js +++ b/packages/dd-trace/src/openfeature/remote_config.js @@ -6,19 +6,15 @@ const RemoteConfigCapabilities = require('../remote_config/capabilities') * Configures remote config handlers for openfeature feature flagging * * @param {object} rc - RemoteConfig instance - * @param {object} config - Tracer config * @param {Function} getOpenfeatureProxy - Function that returns the OpenFeature proxy from tracer * @param {boolean} [subscribe] - Whether Agent Remote Config owns UFC delivery */ -function enable (rc, config, getOpenfeatureProxy, subscribe = true) { - // Always enable capability for feature flag configuration - // This indicates the library supports this capability via remote config - rc.updateCapabilities(RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, true) +function enable (rc, getOpenfeatureProxy, subscribe = true) { + // Capability advertisement and product subscription both opt into the billed + // Agent Remote Config delivery path. + if (!subscribe) return - // Only register the product handler when the provider is enabled and Agent - // Remote Config is the selected UFC source. The capability stays enabled - // because it describes library support, not the active delivery mode. - if (!config.experimental.flaggingProvider.enabled || !subscribe) return + rc.updateCapabilities(RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, true) // Set product handler for FFE_FLAGS rc.setProductHandler('FFE_FLAGS', (action, conf) => { diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 986f6be515b..faa04271313 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -4939,17 +4939,38 @@ rules: }) context('Feature Flagging configuration source', () => { - it('defaults to agentless delivery with cross-SDK timings', () => { + it('defaults to an enabled provider with agentless delivery and cross-SDK timings', () => { const config = getConfig() assertObjectContains(config, { + DD_FEATURE_FLAGS_ENABLED: true, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: undefined, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: 30, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 2, }) + assert.strictEqual(config.experimental.flaggingProvider.enabled, false) }) + it('reads the stable provider kill switch', () => { + process.env.DD_FEATURE_FLAGS_ENABLED = 'false' + + const config = getConfig() + + assert.strictEqual(config.DD_FEATURE_FLAGS_ENABLED, false) + }) + + for (const value of ['true', 'false']) { + it(`retains the deprecated experimental provider setting when set to ${value}`, () => { + process.env.DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = value + + const config = getConfig() + + assert.strictEqual(config.DD_FEATURE_FLAGS_ENABLED, true) + assert.strictEqual(config.experimental.flaggingProvider.enabled, value === 'true') + }) + } + it('reads the configuration source environment variable', () => { process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index 7fc6900a39e..aa4e1ed3682 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -16,10 +16,17 @@ describe('OpenFeature configuration source', () => { beforeEach(() => { config = { DD_API_KEY: 'test-api-key', + DD_FEATURE_FLAGS_ENABLED: true, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: undefined, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: 30, DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 2, + experimental: { + flaggingProvider: { + enabled: false, + }, + }, + getOrigin: sinon.stub().returns('default'), site: 'datadoghq.com', env: 'my env', } @@ -43,6 +50,54 @@ describe('OpenFeature configuration source', () => { }) } + it('grandfathers the legacy enabled setting onto Remote Config when the source is defaulted', () => { + config.experimental.flaggingProvider.enabled = true + config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), true) + assert.strictEqual(configurationSource.isEnabled(config), true) + }) + + it('keeps the provider disabled for the legacy false setting when the source is defaulted', () => { + config.experimental.flaggingProvider.enabled = false + config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'disabled' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + assert.strictEqual(configurationSource.isEnabled(config), false) + }) + + it('lets an explicit agentless source override legacy enablement', () => { + config.experimental.flaggingProvider.enabled = true + config.getOrigin.returns('env_var') + + assert.strictEqual(configurationSource.resolve(config).mode, 'agentless') + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + assert.strictEqual(configurationSource.isEnabled(config), true) + }) + + it('lets an explicit Remote Config source override legacy disablement', () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + config.experimental.flaggingProvider.enabled = false + config.getOrigin.returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), true) + assert.strictEqual(configurationSource.isEnabled(config), true) + }) + + it('lets the stable kill switch override every configuration source', () => { + config.DD_FEATURE_FLAGS_ENABLED = false + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + config.experimental.flaggingProvider.enabled = true + config.getOrigin.returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'disabled' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + assert.strictEqual(configurationSource.isEnabled(config), false) + }) + it('defaults to the Datadog UFC CDN endpoint and includes the environment', () => { config.DD_SITE = 'raw-env-key.invalid' const resolved = configurationSource.resolve(config) diff --git a/packages/dd-trace/test/openfeature/register.spec.js b/packages/dd-trace/test/openfeature/register.spec.js index 3eebf71c6db..6e57b49f129 100644 --- a/packages/dd-trace/test/openfeature/register.spec.js +++ b/packages/dd-trace/test/openfeature/register.spec.js @@ -38,6 +38,7 @@ describe('OpenFeature register', () => { } configurationSource = { enable: sinon.spy(), + isEnabled: sinon.stub().returns(true), isRemoteConfig: sinon.stub().returns(false), } @@ -81,54 +82,75 @@ describe('OpenFeature register', () => { assert.strictEqual(feature.factory(), openfeatureModule) }) - it('defines the flagging provider when enabled', () => { + it('does not construct the flagging provider until application code accesses it', () => { feature.enable(config, tracer, proxy, lazyProxy) sinon.assert.calledOnceWithExactly(proxy._modules.openfeature.enable, config) - sinon.assert.calledOnce(lazyProxy) - assert.ok(proxy.openfeature instanceof FlaggingProvider) - assert.deepStrictEqual(proxy.openfeature.args, [tracer, config]) + sinon.assert.notCalled(lazyProxy) + sinon.assert.notCalled(configurationSource.enable) + assert.strictEqual(typeof Reflect.getOwnPropertyDescriptor(proxy, 'openfeature').get, 'function') + + const provider = proxy.openfeature + + assert.ok(provider instanceof FlaggingProvider) + assert.deepStrictEqual(provider.args, [tracer, config]) sinon.assert.calledOnceWithExactly(configurationSource.enable, config, sinon.match.func) }) it('keeps an existing flagging provider on repeated enable calls', () => { feature.enable(config, tracer, proxy, lazyProxy) - const provider = proxy.openfeature + feature.enable(config, tracer, proxy, lazyProxy) + sinon.assert.notCalled(configurationSource.enable) + const provider = proxy.openfeature feature.enable(config, tracer, proxy, lazyProxy) - sinon.assert.calledTwice(proxy._modules.openfeature.enable) - sinon.assert.calledOnce(lazyProxy) - sinon.assert.calledTwice(configurationSource.enable) + sinon.assert.calledThrice(proxy._modules.openfeature.enable) + sinon.assert.notCalled(lazyProxy) + sinon.assert.calledOnce(configurationSource.enable) assert.strictEqual(proxy.openfeature, provider) }) it('does not define the flagging provider when disabled', () => { config.experimental.flaggingProvider.enabled = false + configurationSource.isEnabled.returns(false) feature.enable(config, tracer, proxy, lazyProxy) + sinon.assert.calledOnceWithExactly(configurationSource.isEnabled, config) sinon.assert.notCalled(proxy._modules.openfeature.enable) sinon.assert.notCalled(lazyProxy) assert.strictEqual(proxy.openfeature, feature.noop) + sinon.assert.notCalled(configurationSource.enable) }) - it('installs Remote Config delivery only when explicitly selected', () => { + it('installs Remote Config delivery when the provider is enabled and Remote Config is selected', () => { const rc = {} configurationSource.isRemoteConfig.returns(true) feature.remoteConfig(rc, config, proxy) sinon.assert.calledOnceWithExactly(configurationSource.isRemoteConfig, config) - sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, config, sinon.match.func, true) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, true) + }) + + it('does not install Remote Config delivery when the provider is disabled', () => { + const rc = {} + config.experimental.flaggingProvider.enabled = false + configurationSource.isRemoteConfig.returns(false) + + feature.remoteConfig(rc, config, proxy) + + sinon.assert.calledOnceWithExactly(configurationSource.isRemoteConfig, config) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, false) }) - it('advertises Remote Config support without installing delivery for the default agentless source', () => { + it('does not install Remote Config delivery unless explicitly selected', () => { const rc = {} feature.remoteConfig(rc, config, proxy) sinon.assert.calledOnceWithExactly(configurationSource.isRemoteConfig, config) - sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, config, sinon.match.func, false) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, false) }) }) diff --git a/packages/dd-trace/test/openfeature/remote_config.spec.js b/packages/dd-trace/test/openfeature/remote_config.spec.js index f95038bfcfc..d9284beefbd 100644 --- a/packages/dd-trace/test/openfeature/remote_config.spec.js +++ b/packages/dd-trace/test/openfeature/remote_config.spec.js @@ -10,7 +10,6 @@ require('../setup/mocha') describe('OpenFeature Remote Config', () => { let rc - let config let openfeatureProxy let getOpenfeatureProxy let handlers @@ -25,14 +24,6 @@ describe('OpenFeature Remote Config', () => { }), } - config = { - experimental: { - flaggingProvider: { - enabled: true, - }, - }, - } - openfeatureProxy = { _setConfiguration: sinon.spy(), } @@ -42,7 +33,7 @@ describe('OpenFeature Remote Config', () => { describe('enable', () => { it('should enable FFE_FLAG_CONFIGURATION_RULES capability', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) sinon.assert.calledOnceWithExactly( rc.updateCapabilities, @@ -52,13 +43,13 @@ describe('OpenFeature Remote Config', () => { }) it('should register FFE_FLAGS product handler', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) sinon.assert.calledOnceWithExactly(rc.setProductHandler, 'FFE_FLAGS', sinon.match.func) }) it('should call _setConfiguration on apply action when feature is enabled', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') @@ -69,7 +60,7 @@ describe('OpenFeature Remote Config', () => { }) it('should call _setConfiguration on modify action when feature is enabled', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) const flagConfig = { flags: { 'modified-flag': {} } } const handler = handlers.get('FFE_FLAGS') @@ -80,7 +71,7 @@ describe('OpenFeature Remote Config', () => { }) it('should call _setConfiguration(null) on unapply action to clear config', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') @@ -91,7 +82,7 @@ describe('OpenFeature Remote Config', () => { }) it('should not call _setConfiguration on unknown action', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') @@ -101,32 +92,10 @@ describe('OpenFeature Remote Config', () => { sinon.assert.notCalled(openfeatureProxy._setConfiguration) }) - it('should not register product handler when experimental feature is disabled', () => { - config.experimental.flaggingProvider.enabled = false - enable(rc, config, getOpenfeatureProxy) - - sinon.assert.notCalled(rc.setProductHandler) - }) - - it('should still enable capability even when experimental feature is disabled', () => { - config.experimental.flaggingProvider.enabled = false - enable(rc, config, getOpenfeatureProxy) + it('should not advertise capability or register a handler without explicit Remote Config opt-in', () => { + enable(rc, getOpenfeatureProxy, false) - sinon.assert.calledOnceWithExactly( - rc.updateCapabilities, - RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, - true - ) - }) - - it('should advertise capability without registering a handler in agentless mode', () => { - enable(rc, config, getOpenfeatureProxy, false) - - sinon.assert.calledOnceWithExactly( - rc.updateCapabilities, - RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, - true - ) + sinon.assert.notCalled(rc.updateCapabilities) sinon.assert.notCalled(rc.setProductHandler) }) }) diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index f4f013be536..62fa18391b5 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -283,7 +283,7 @@ describe('TracerProxy', () => { noop: noopOpenfeature, factory: () => openfeature, remoteConfig (rc, config, proxy) { - openfeatureRcEnable(rc, config, () => proxy.openfeature) + openfeatureRcEnable(rc, () => proxy.openfeature) }, enable (config, tracer, proxy, lazyProxy) { if (config.experimental.flaggingProvider.enabled) { From 23a9e2bb7851d14a89c7be9fb4b7efdb6b0aa3de Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 20 Jul 2026 16:33:13 -0600 Subject: [PATCH 12/15] fix(openfeature): separate enablement from source selection --- .../src/openfeature/configuration_source.js | 38 ++++++++++--------- packages/dd-trace/test/config/index.spec.js | 5 +++ .../openfeature/configuration_source.spec.js | 28 +++++++------- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 514b5453b93..0ec8bdc550e 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -3,9 +3,12 @@ const log = require('../log') const CONFIGURATION_SOURCE_AGENTLESS = 'agentless' -const CONFIGURATION_SOURCE_DISABLED = 'disabled' const CONFIGURATION_SOURCE_REMOTE_CONFIG = 'remote_config' +const DISABLED_RESOLUTION = Object.freeze({ enabled: false }) +const AGENTLESS_CONFIGURATION = Object.freeze({ enabled: true, source: CONFIGURATION_SOURCE_AGENTLESS }) +const REMOTE_CONFIG_CONFIGURATION = Object.freeze({ enabled: true, source: CONFIGURATION_SOURCE_REMOTE_CONFIG }) + const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' const DEFAULT_POLL_INTERVAL_SECONDS = 30 const DEFAULT_REQUEST_TIMEOUT_SECONDS = 2 @@ -18,14 +21,14 @@ const MAX_POLL_INTERVAL_SECONDS = 60 * 60 * @returns {object} Resolved source settings. */ function resolve (config) { - const mode = resolveMode(config) + const configuration = resolveConfiguration(config) - if (mode !== CONFIGURATION_SOURCE_AGENTLESS) { - return { mode } + if (!configuration.enabled || configuration.source !== CONFIGURATION_SOURCE_AGENTLESS) { + return configuration } return { - mode, + ...configuration, endpoint: endpoint(config, config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL), pollIntervalMs: positiveMilliseconds( config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, @@ -61,7 +64,7 @@ function enable (config, getOpenfeatureProxy) { return } - if (sourceConfig.mode === CONFIGURATION_SOURCE_AGENTLESS) { + if (sourceConfig.source === CONFIGURATION_SOURCE_AGENTLESS) { const AgentlessConfigurationSource = require('./agentless_configuration_source') const source = new AgentlessConfigurationSource(sourceConfig, ufc => { getOpenfeatureProxy()._setConfiguration(ufc) @@ -80,7 +83,7 @@ function enable (config, getOpenfeatureProxy) { */ function isRemoteConfig (config) { try { - return resolveMode(config) === CONFIGURATION_SOURCE_REMOTE_CONFIG + return resolveConfiguration(config).source === CONFIGURATION_SOURCE_REMOTE_CONFIG } catch (error) { log.error('Unable to configure Feature Flagging configuration source', error) return false @@ -97,7 +100,7 @@ function isRemoteConfig (config) { */ function isEnabled (config) { try { - return resolveMode(config) !== CONFIGURATION_SOURCE_DISABLED + return resolveConfiguration(config).enabled } catch (error) { log.error('Unable to configure Feature Flagging configuration source', error) return false @@ -109,10 +112,10 @@ function isEnabled (config) { * endpoint or timing configuration. * * @param {import('../config/config-base')} config - Tracer configuration. - * @returns {string} Selected configuration-source mode. + * @returns {{enabled: boolean, source?: string}} Resolved enablement and source selection. */ -function resolveMode (config) { - if (config.DD_FEATURE_FLAGS_ENABLED === false) return CONFIGURATION_SOURCE_DISABLED +function resolveConfiguration (config) { + if (config.DD_FEATURE_FLAGS_ENABLED === false) return DISABLED_RESOLUTION const value = config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE const origin = config.getOrigin?.('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE') @@ -126,16 +129,15 @@ function resolveMode (config) { : legacyEnabled !== undefined && legacyEnabled !== null if (hasExplicitLegacySetting) { - if (legacyEnabled === true) return CONFIGURATION_SOURCE_REMOTE_CONFIG - if (legacyEnabled === false) return CONFIGURATION_SOURCE_DISABLED + if (legacyEnabled === true) return REMOTE_CONFIG_CONFIGURATION + if (legacyEnabled === false) return DISABLED_RESOLUTION } } - const mode = String(value ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS - if (mode !== CONFIGURATION_SOURCE_AGENTLESS && mode !== CONFIGURATION_SOURCE_REMOTE_CONFIG) { - throw new Error(`Unsupported Feature Flagging configuration source: ${mode}`) - } - return mode + const source = String(value ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS + if (source === CONFIGURATION_SOURCE_AGENTLESS) return AGENTLESS_CONFIGURATION + if (source === CONFIGURATION_SOURCE_REMOTE_CONFIG) return REMOTE_CONFIG_CONFIGURATION + throw new Error(`Unsupported Feature Flagging configuration source: ${source}`) } /** diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index faa04271313..c83a48aeb9d 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -4950,6 +4950,8 @@ rules: DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 2, }) assert.strictEqual(config.experimental.flaggingProvider.enabled, false) + assert.strictEqual(config.getOrigin('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'default') + assert.strictEqual(config.getOrigin('experimental.flaggingProvider.enabled'), 'default') }) it('reads the stable provider kill switch', () => { @@ -4958,6 +4960,7 @@ rules: const config = getConfig() assert.strictEqual(config.DD_FEATURE_FLAGS_ENABLED, false) + assert.strictEqual(config.getOrigin('DD_FEATURE_FLAGS_ENABLED'), 'env_var') }) for (const value of ['true', 'false']) { @@ -4968,6 +4971,7 @@ rules: assert.strictEqual(config.DD_FEATURE_FLAGS_ENABLED, true) assert.strictEqual(config.experimental.flaggingProvider.enabled, value === 'true') + assert.strictEqual(config.getOrigin('experimental.flaggingProvider.enabled'), 'env_var') }) } @@ -4977,6 +4981,7 @@ rules: const config = getConfig() assert.strictEqual(config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'remote_config') + assert.strictEqual(config.getOrigin('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'env_var') }) it('reads the canonical agentless environment variables', () => { diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index aa4e1ed3682..ca90d5d15fe 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -46,7 +46,7 @@ describe('OpenFeature configuration source', () => { it(`normalizes ${JSON.stringify(value)} to the default agentless source`, () => { config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value - assert.strictEqual(configurationSource.resolve(config).mode, 'agentless') + assert.strictEqual(configurationSource.resolve(config).source, 'agentless') }) } @@ -54,7 +54,7 @@ describe('OpenFeature configuration source', () => { config.experimental.flaggingProvider.enabled = true config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') - assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: true, source: 'remote_config' }) assert.strictEqual(configurationSource.isRemoteConfig(config), true) assert.strictEqual(configurationSource.isEnabled(config), true) }) @@ -63,7 +63,7 @@ describe('OpenFeature configuration source', () => { config.experimental.flaggingProvider.enabled = false config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') - assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'disabled' }) + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: false }) assert.strictEqual(configurationSource.isRemoteConfig(config), false) assert.strictEqual(configurationSource.isEnabled(config), false) }) @@ -72,7 +72,7 @@ describe('OpenFeature configuration source', () => { config.experimental.flaggingProvider.enabled = true config.getOrigin.returns('env_var') - assert.strictEqual(configurationSource.resolve(config).mode, 'agentless') + assert.strictEqual(configurationSource.resolve(config).source, 'agentless') assert.strictEqual(configurationSource.isRemoteConfig(config), false) assert.strictEqual(configurationSource.isEnabled(config), true) }) @@ -82,7 +82,7 @@ describe('OpenFeature configuration source', () => { config.experimental.flaggingProvider.enabled = false config.getOrigin.returns('env_var') - assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: true, source: 'remote_config' }) assert.strictEqual(configurationSource.isRemoteConfig(config), true) assert.strictEqual(configurationSource.isEnabled(config), true) }) @@ -93,7 +93,7 @@ describe('OpenFeature configuration source', () => { config.experimental.flaggingProvider.enabled = true config.getOrigin.returns('env_var') - assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'disabled' }) + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: false }) assert.strictEqual(configurationSource.isRemoteConfig(config), false) assert.strictEqual(configurationSource.isEnabled(config), false) }) @@ -206,19 +206,17 @@ describe('OpenFeature configuration source', () => { config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = ' REMOTE_CONFIG ' delete config.site - assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: true, source: 'remote_config' }) assert.strictEqual(configurationSource.isRemoteConfig(config), true) }) - for (const value of ['offline', 'other']) { - it(`fails closed for the unsupported ${value} source`, () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + it('fails closed for an unsupported source', () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'other' - assert.throws(() => configurationSource.resolve(config), /Unsupported Feature Flagging configuration source/) - assert.strictEqual(configurationSource.isRemoteConfig(config), false) - sinon.assert.calledOnce(log.error) - }) - } + assert.throws(() => configurationSource.resolve(config), /Unsupported Feature Flagging configuration source/) + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + sinon.assert.calledOnce(log.error) + }) it('falls back to positive timing defaults with warnings', () => { config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = 0 From e9abe8f7d5a48e88bb565e6c5b1e776648c0186a Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 20 Jul 2026 17:20:11 -0600 Subject: [PATCH 13/15] test(openfeature): gate proxy RC subscription --- packages/dd-trace/test/proxy.spec.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index 62fa18391b5..c8a09ff4c01 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -7,6 +7,7 @@ const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') const proxyquire = require('proxyquire') const featureRegistry = require('../src/feature-registry') +const RemoteConfigCapabilities = require('../src/remote_config/capabilities') require('./setup/core') @@ -283,7 +284,7 @@ describe('TracerProxy', () => { noop: noopOpenfeature, factory: () => openfeature, remoteConfig (rc, config, proxy) { - openfeatureRcEnable(rc, () => proxy.openfeature) + openfeatureRcEnable(rc, () => proxy.openfeature, config.experimental.flaggingProvider.enabled === true) }, enable (config, tracer, proxy, lazyProxy) { if (config.experimental.flaggingProvider.enabled) { @@ -420,6 +421,17 @@ describe('TracerProxy', () => { sinon.assert.calledWith(openfeatureProvider._setConfiguration, flagConfig) }) + it('should not setup FFE_FLAGS Remote Config when openfeature provider is disabled', () => { + proxy.init() + + assert.strictEqual(handlers.has('FFE_FLAGS'), false) + sinon.assert.neverCalledWith( + rc.updateCapabilities, + RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, + true + ) + }) + it('should handle FFE_FLAGS modify action', () => { config.experimental.flaggingProvider.enabled = true From f62b5ec43d35284b2d0508a00209437ee911a01e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 20 Jul 2026 17:21:31 -0600 Subject: [PATCH 14/15] fix(openfeature): treat blank source as unset --- .../src/openfeature/configuration_source.js | 6 +++-- .../openfeature/configuration_source.spec.js | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 0ec8bdc550e..80ba3c8fce0 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -118,8 +118,10 @@ function resolveConfiguration (config) { if (config.DD_FEATURE_FLAGS_ENABLED === false) return DISABLED_RESOLUTION const value = config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE + const configuredSource = String(value ?? '').trim() const origin = config.getOrigin?.('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE') - const hasExplicitSource = origin ? origin !== 'default' : value !== undefined && value !== null + const hasExplicitSource = configuredSource !== '' && + (origin ? origin !== 'default' : value !== undefined && value !== null) if (!hasExplicitSource) { const legacyEnabled = config.experimental?.flaggingProvider?.enabled @@ -134,7 +136,7 @@ function resolveConfiguration (config) { } } - const source = String(value ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS + const source = configuredSource.toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS if (source === CONFIGURATION_SOURCE_AGENTLESS) return AGENTLESS_CONFIGURATION if (source === CONFIGURATION_SOURCE_REMOTE_CONFIG) return REMOTE_CONFIG_CONFIGURATION throw new Error(`Unsupported Feature Flagging configuration source: ${source}`) diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index ca90d5d15fe..17e0e9af5b2 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -50,6 +50,33 @@ describe('OpenFeature configuration source', () => { }) } + for (const value of ['', ' ']) { + it(`treats explicit ${JSON.stringify(value)} as absent for legacy Remote Config grandfathering`, () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + config.experimental.flaggingProvider.enabled = true + config.getOrigin.withArgs('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE').returns('env_var') + config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: true, source: 'remote_config' }) + }) + + it(`treats explicit ${JSON.stringify(value)} as absent for legacy disablement`, () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + config.experimental.flaggingProvider.enabled = false + config.getOrigin.withArgs('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE').returns('env_var') + config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: false }) + }) + + it(`treats explicit ${JSON.stringify(value)} as absent for the default agentless source`, () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + config.getOrigin.withArgs('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE').returns('env_var') + + assert.strictEqual(configurationSource.resolve(config).source, 'agentless') + }) + } + it('grandfathers the legacy enabled setting onto Remote Config when the source is defaulted', () => { config.experimental.flaggingProvider.enabled = true config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') From 94a16dc3017bf63056561645521a47a5ef625e93 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 20 Jul 2026 21:03:54 -0600 Subject: [PATCH 15/15] fix(openfeature): defer module startup until access --- packages/dd-trace/src/openfeature/register.js | 15 ++++++++++++++- .../dd-trace/test/openfeature/register.spec.js | 8 +++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/dd-trace/src/openfeature/register.js b/packages/dd-trace/src/openfeature/register.js index b9bb03ea5f5..4dd2a2ef971 100644 --- a/packages/dd-trace/src/openfeature/register.js +++ b/packages/dd-trace/src/openfeature/register.js @@ -14,6 +14,16 @@ function hasFlaggingProvider (proxy) { return descriptor?.get !== undefined || (descriptor?.value !== undefined && descriptor.value !== noop) } +/** + * @param {import('../proxy')} proxy + * @returns {boolean} + */ +function hasConstructedFlaggingProvider (proxy) { + const descriptor = Reflect.getOwnPropertyDescriptor(proxy, 'openfeature') + + return descriptor?.value !== undefined && descriptor.value !== noop +} + /** * Exposes the provider without constructing it until application code reads it. * The generic tracer lazy proxy is eager outside serverless environments, while @@ -27,6 +37,8 @@ function hasFlaggingProvider (proxy) { function defineFlaggingProvider (proxy, tracer, config, configurationSource) { Reflect.defineProperty(proxy, 'openfeature', { get () { + proxy._modules.openfeature.enable(config) + const FlaggingProvider = require('./flagging_provider') const provider = new FlaggingProvider(tracer, config) @@ -73,9 +85,10 @@ registerFeature({ const configurationSource = require('./configuration_source') if (!configurationSource.isEnabled(config)) return - proxy._modules.openfeature.enable(config) if (!hasFlaggingProvider(proxy)) { defineFlaggingProvider(proxy, tracer, config, configurationSource) + } else if (hasConstructedFlaggingProvider(proxy)) { + proxy._modules.openfeature.enable(config) } }, }) diff --git a/packages/dd-trace/test/openfeature/register.spec.js b/packages/dd-trace/test/openfeature/register.spec.js index 6e57b49f129..d6180560f17 100644 --- a/packages/dd-trace/test/openfeature/register.spec.js +++ b/packages/dd-trace/test/openfeature/register.spec.js @@ -82,10 +82,10 @@ describe('OpenFeature register', () => { assert.strictEqual(feature.factory(), openfeatureModule) }) - it('does not construct the flagging provider until application code accesses it', () => { + it('does not initialize the module or construct the flagging provider until application code accesses it', () => { feature.enable(config, tracer, proxy, lazyProxy) - sinon.assert.calledOnceWithExactly(proxy._modules.openfeature.enable, config) + sinon.assert.notCalled(proxy._modules.openfeature.enable) sinon.assert.notCalled(lazyProxy) sinon.assert.notCalled(configurationSource.enable) assert.strictEqual(typeof Reflect.getOwnPropertyDescriptor(proxy, 'openfeature').get, 'function') @@ -94,18 +94,20 @@ describe('OpenFeature register', () => { assert.ok(provider instanceof FlaggingProvider) assert.deepStrictEqual(provider.args, [tracer, config]) + sinon.assert.calledOnceWithExactly(proxy._modules.openfeature.enable, config) sinon.assert.calledOnceWithExactly(configurationSource.enable, config, sinon.match.func) }) it('keeps an existing flagging provider on repeated enable calls', () => { feature.enable(config, tracer, proxy, lazyProxy) feature.enable(config, tracer, proxy, lazyProxy) + sinon.assert.notCalled(proxy._modules.openfeature.enable) sinon.assert.notCalled(configurationSource.enable) const provider = proxy.openfeature feature.enable(config, tracer, proxy, lazyProxy) - sinon.assert.calledThrice(proxy._modules.openfeature.enable) + sinon.assert.calledTwice(proxy._modules.openfeature.enable) sinon.assert.notCalled(lazyProxy) sinon.assert.calledOnce(configurationSource.enable) assert.strictEqual(proxy.openfeature, provider)