From a59b02f47d345c357f3c50ab3deb60c0c6a841f5 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 07:39:23 -0400 Subject: [PATCH 1/6] feat(openfeature): resolve configuration sources --- .../src/openfeature/configuration_source.js | 175 ++++++++++++++++++ .../openfeature/configuration_source.spec.js | 158 ++++++++++++++++ 2 files changed, 333 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 0000000000..75519f6e9d --- /dev/null +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -0,0 +1,175 @@ +'use strict' + +const log = require('../log') + +const CONFIGURATION_SOURCE_AGENTLESS = 'agentless' +const CONFIGURATION_SOURCE_REMOTE_CONFIG = 'remote_config' +const CONFIGURATION_SOURCE_OFFLINE = 'offline' + +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 GOV_CLOUD_SITE = 'ddog-gov.com' + +/** + * 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 = String(flaggingProvider.configurationSource ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS + + if (mode === CONFIGURATION_SOURCE_REMOTE_CONFIG || mode === CONFIGURATION_SOURCE_OFFLINE) { + return { mode } + } + + if (mode !== CONFIGURATION_SOURCE_AGENTLESS) { + throw new Error(`Unsupported Feature Flagging configuration source: ${mode}`) + } + + const configuredBaseUrl = flaggingProvider.agentlessBaseUrl?.trim() + if (!configuredBaseUrl && String(config.DD_SITE).trim().toLowerCase() === GOV_CLOUD_SITE) { + log.warn( + 'Datadog-managed Feature Flagging agentless delivery is not supported on GovCloud; evaluations will use defaults' + ) + return { mode } + } + + return { + mode, + endpoint: endpoint(config, configuredBaseUrl), + allowRawConfiguration: Boolean(configuredBaseUrl), + pollIntervalMs: positiveMilliseconds( + flaggingProvider.agentlessPollIntervalSeconds, + DEFAULT_POLL_INTERVAL_SECONDS, + 'poll interval' + ), + requestTimeoutMs: positiveMilliseconds( + flaggingProvider.agentlessRequestTimeoutSeconds, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + 'request timeout' + ), + apiKey: config.DD_API_KEY, + } +} + +/** + * 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 && sourceConfig.endpoint) { + const AgentlessConfigurationSource = require('./agentless_configuration_source') + const source = new AgentlessConfigurationSource(sourceConfig, ufc => { + getOpenfeatureProxy()._setConfiguration(ufc) + }) + getOpenfeatureProxy()._setConfigurationSource(source) + } else if (sourceConfig.mode === CONFIGURATION_SOURCE_OFFLINE) { + log.debug('Feature Flagging offline configuration source selected; no configuration source started') + } +} + +/** + * 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 resolve(config).mode === CONFIGURATION_SOURCE_REMOTE_CONFIG + } catch (error) { + log.error('Unable to configure Feature Flagging configuration source', error) + return false + } +} + +/** + * 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.DD_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. + * @returns {number} Positive milliseconds. + */ +function positiveMilliseconds (value, fallbackSeconds, setting) { + 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 + } + return Math.max(1, Math.round(seconds * 1000)) +} + +module.exports = { + CONFIGURATION_SOURCE_AGENTLESS, + CONFIGURATION_SOURCE_OFFLINE, + CONFIGURATION_SOURCE_REMOTE_CONFIG, + DEFAULT_AGENTLESS_PATH, + enable, + endpoint, + 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 0000000000..230d713721 --- /dev/null +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -0,0 +1,158 @@ +'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 + let AgentlessConfigurationSource + + beforeEach(() => { + config = { + DD_API_KEY: 'test-api-key', + DD_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(), + } + AgentlessConfigurationSource = sinon.stub() + configurationSource = proxyquire('../../src/openfeature/configuration_source', { + '../log': log, + './agentless_configuration_source': AgentlessConfigurationSource, + }) + }) + + 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', () => { + 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.allowRawConfiguration, false) + assert.strictEqual(resolved.pollIntervalMs, 30_000) + assert.strictEqual(resolved.requestTimeoutMs, 2000) + }) + + 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' + ) + assert.strictEqual(resolved.allowRawConfiguration, true) + }) + + 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('allows an explicitly configured HTTP backend', () => { + config.experimental.flaggingProvider.agentlessBaseUrl = 'http://example.com/custom/ufc' + + assert.strictEqual( + configurationSource.resolve(config).endpoint.hostname, + 'example.com' + ) + }) + + it('leaves evaluations in default mode for managed agentless delivery on GovCloud', () => { + config.DD_SITE = 'DDOG-GOV.COM' + const provider = { _setConfigurationSource: sinon.spy() } + + configurationSource.enable(config, () => provider) + + sinon.assert.notCalled(AgentlessConfigurationSource) + sinon.assert.notCalled(provider._setConfigurationSource) + sinon.assert.calledOnceWithExactly( + log.warn, + 'Datadog-managed Feature Flagging agentless delivery is not supported on GovCloud; evaluations will use defaults' + ) + }) + + it('allows an operator-owned agentless endpoint on GovCloud', () => { + config.DD_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') + assert.strictEqual(resolved.allowRawConfiguration, true) + 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.DD_SITE + + assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), true) + }) + + it('reserves offline mode without starting a network source', () => { + config.experimental.flaggingProvider.configurationSource = 'offline' + + assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'offline' }) + }) + + it('fails closed for an unsupported source', () => { + config.experimental.flaggingProvider.configurationSource = 'other' + + 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 + + const resolved = configurationSource.resolve(config) + + assert.strictEqual(resolved.pollIntervalMs, 30_000) + assert.strictEqual(resolved.requestTimeoutMs, 2000) + sinon.assert.calledTwice(log.warn) + }) +}) From 6817d5397f23f2b9c096dbeb91a9fa79686d8d76 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 07:41:44 -0400 Subject: [PATCH 2/6] feat(openfeature): load agentless UFC payloads --- .../agentless_configuration_source.js | 170 ++++++++++++++++++ .../agentless_configuration_source.spec.js | 167 +++++++++++++++++ 2 files changed, 337 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 0000000000..82666a8944 --- /dev/null +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -0,0 +1,170 @@ +'use strict' + +const http = require('node:http') +const https = require('node:https') +const log = require('../log') + +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._http = options.http || http + this._https = options.https || https + 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 = {} + if (this._config.apiKey) headers['DD-API-KEY'] = this._config.apiKey + if (this._etag) headers['If-None-Match'] = this._etag + + const transport = this._config.endpoint.protocol === 'https:' ? this._https : this._http + let settled = false + const finish = (error, response) => { + if (settled) return + settled = true + callback(error, response) + } + + const request = transport.request(this._config.endpoint, { method: 'GET', headers }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('error', error => finish(requestError(error))) + response.on('end', () => { + finish(null, { + statusCode: response.statusCode, + etag: response.headers.etag, + body: Buffer.concat(chunks).toString('utf8'), + }) + }) + }) + + request.setTimeout(this._config.requestTimeoutMs, () => { + const error = new Error(`Feature Flagging agentless request timed out after ${this._config.requestTimeoutMs}ms`) + error.retryable = true + request.destroy(error) + }) + request.on('error', error => finish(requestError(error))) + request.end() + } + + /** + * 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, this._config.allowRawConfiguration) + } 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. + * @param {boolean} allowRawConfiguration - Allows legacy raw UFC from an explicit custom endpoint. + * @returns {object} Parsed UFC configuration. + */ +function parseConfiguration (body, allowRawConfiguration) { + const parsed = JSON.parse(body) + let configuration + + if (parsed && typeof parsed === 'object' && Object.hasOwn(parsed, 'data')) { + if (!parsed.data || typeof parsed.data !== 'object' || + parsed.data.type !== 'universal-flag-configuration') { + throw new Error('Expected a JSON:API Universal Flag Configuration resource') + } + configuration = parsed.data.attributes + } else if (allowRawConfiguration) { + configuration = parsed + } else { + throw new Error('Expected a JSON:API Universal Flag Configuration response') + } + + 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 0000000000..ef358fda2b --- /dev/null +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -0,0 +1,167 @@ +'use strict' + +const assert = require('node:assert/strict') +const { EventEmitter } = require('node:events') +const { afterEach, 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: {}, +}) + +describe('AgentlessConfigurationSource', () => { + let AgentlessConfigurationSource + let applyConfiguration + let clock + let config + let log + let requests + let responses + let transport + + 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'), + allowRawConfiguration: true, + pollIntervalMs: 30_000, + requestTimeoutMs: 2000, + apiKey: 'test-api-key', + } + log = { + debug: sinon.spy(), + warn: sinon.spy(), + } + requests = [] + responses = [] + transport = { + request: sinon.spy((url, options, onResponse) => { + const request = new EventEmitter() + request.url = url + request.options = options + request.setTimeout = sinon.spy((timeout, onTimeout) => { + request.timeout = timeout + request.onTimeout = onTimeout + }) + request.destroy = sinon.spy(error => { + if (error) request.emit('error', error) + }) + request.end = sinon.spy(() => { + const next = responses.shift() + if (!next || next.pending) return + if (next.error) { + request.emit('error', next.error) + return + } + const response = new EventEmitter() + response.statusCode = next.statusCode + response.headers = next.headers || {} + onResponse(response) + if (next.body) response.emit('data', Buffer.from(next.body)) + response.emit('end') + }) + requests.push(request) + return request + }), + } + AgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { + 'node:http': transport, + '../log': log, + }) + }) + + afterEach(() => { + clock.restore() + }) + + function source (options = {}) { + return new AgentlessConfigurationSource(config, applyConfiguration, { + http: transport, + random: () => 0.5, + ...options, + }) + } + + function completeScheduledResponse () { + clock.tick(0) + } + + it('fetches, applies, and reuses the accepted ETag', () => { + responses.push( + { statusCode: 200, headers: { etag: '"ufc-v1"' }, body: VALID_UFC }, + { statusCode: 304, headers: {}, body: '' } + ) + const configurationSource = source() + const first = sinon.spy() + const second = sinon.spy() + + configurationSource.pollOnce(first) + completeScheduledResponse() + configurationSource.pollOnce(second) + completeScheduledResponse() + + sinon.assert.calledOnceWithExactly(applyConfiguration, JSON.parse(VALID_UFC)) + sinon.assert.calledWith(first, null, { applied: true }) + sinon.assert.calledWith(second, null, { notModified: true }) + assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key') + 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].timeout, 2000) + }) + + it('unwraps a JSON API Universal Flag Configuration response', () => { + 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, + }, + }), + }) + + source().pollOnce(() => {}) + + sinon.assert.calledOnceWithExactly(applyConfiguration, expected) + }) + + it('accepts managed JSON API payloads larger than 500 KB', () => { + config.allowRawConfiguration = false + 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, + }, + }), + }) + + source().pollOnce(() => {}) + + sinon.assert.calledOnceWithExactly(applyConfiguration, expected) + }) + + it('requires JSON API at the first-party endpoint', () => { + config.allowRawConfiguration = false + responses.push({ statusCode: 200, body: VALID_UFC }) + + source().pollOnce(() => {}) + + sinon.assert.notCalled(applyConfiguration) + sinon.assert.calledOnce(log.debug) + }) +}) From ac78c62a0cbd0fbebe07165848427d40d337378d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 07:42:34 -0400 Subject: [PATCH 3/6] feat(openfeature): harden agentless polling --- .../agentless_configuration_source.js | 170 ++++++++++++- .../agentless_configuration_source.spec.js | 239 ++++++++++++++++++ 2 files changed, 403 insertions(+), 6 deletions(-) diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index 82666a8944..4337a8b38f 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -4,6 +4,14 @@ const http = require('node:http') const https = require('node:https') const log = require('../log') +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,19 +23,108 @@ class AgentlessConfigurationSource { this._applyConfiguration = applyConfiguration this._http = options.http || http this._https = options.https || https + 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?.destroy() + 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 } @@ -51,6 +148,7 @@ class AgentlessConfigurationSource { const finish = (error, response) => { if (settled) return settled = true + this._activeRequest = undefined callback(error, response) } @@ -67,6 +165,7 @@ class AgentlessConfigurationSource { }) }) + this._activeRequest = request request.setTimeout(this._config.requestTimeoutMs, () => { const error = new Error(`Feature Flagging agentless request timed out after ${this._config.requestTimeoutMs}ms`) error.retryable = true @@ -88,10 +187,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 } } @@ -115,6 +211,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) + } + } } /** @@ -167,4 +287,42 @@ function requestError (cause) { return error } +/** + * 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 ef358fda2b..4ce19d52c3 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -164,4 +164,243 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.notCalled(applyConfiguration) sinon.assert.calledOnce(log.debug) }) + + it('rejects unrelated or incomplete JSON API resources', () => { + 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() + + configurationSource.pollOnce(() => {}) + configurationSource.pollOnce(() => {}) + + sinon.assert.notCalled(applyConfiguration) + sinon.assert.calledTwice(log.debug) + }) + + it('preserves last-known-good configuration and ETag after malformed JSON', () => { + responses.push( + { statusCode: 200, headers: { etag: '"good"' }, body: VALID_UFC }, + { statusCode: 200, headers: { etag: '"bad"' }, body: '{"flags":[' }, + { statusCode: 304, headers: {}, body: '' } + ) + const configurationSource = source() + + configurationSource.pollOnce(() => {}) + completeScheduledResponse() + configurationSource.pollOnce(() => {}) + completeScheduledResponse() + configurationSource.pollOnce(() => {}) + completeScheduledResponse() + + 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', () => { + responses.push( + { statusCode: 200, headers: { etag: '"first"' }, body: VALID_UFC }, + { statusCode: 200, headers: {}, body: VALID_UFC }, + { statusCode: 200, headers: {}, body: VALID_UFC } + ) + const configurationSource = source() + + configurationSource.pollOnce(() => {}) + configurationSource.pollOnce(() => {}) + configurationSource.pollOnce(() => {}) + + assert.strictEqual(requests[1].options.headers['If-None-Match'], '"first"') + assert.strictEqual(requests[2].options.headers['If-None-Match'], undefined) + sinon.assert.calledThrice(applyConfiguration) + }) + + it('does not advance the ETag and keeps scheduled polling after a listener failure', () => { + applyConfiguration.onFirstCall().throws(new Error('listener failed')) + responses.push( + { statusCode: 200, headers: { etag: '"failed"' }, body: VALID_UFC }, + { statusCode: 200, headers: { etag: '"accepted"' }, body: VALID_UFC } + ) + const configurationSource = source() + + configurationSource.start() + clock.tick(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', () => { + responses.push( + { statusCode: 500, body: '' }, + { statusCode: 429, body: '' }, + { statusCode: 200, body: VALID_UFC } + ) + const callback = sinon.spy() + + source().pollOnce(callback) + completeScheduledResponse() + assert.strictEqual(requests.length, 1) + + clock.tick(4999) + assert.strictEqual(requests.length, 1) + clock.tick(1) + completeScheduledResponse() + assert.strictEqual(requests.length, 2) + + clock.tick(9999) + assert.strictEqual(requests.length, 2) + clock.tick(1) + completeScheduledResponse() + + assert.strictEqual(requests.length, 3) + sinon.assert.calledOnce(applyConfiguration) + sinon.assert.calledWith(callback, null, { applied: true }) + }) + + it('retries request timeout responses', () => { + responses.push( + { statusCode: 408, body: '' }, + { statusCode: 200, body: VALID_UFC } + ) + + source().pollOnce(() => {}) + clock.tick(5000) + completeScheduledResponse() + + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnce(applyConfiguration) + }) + + it('warns after retryable HTTP responses exhaust all attempts', () => { + responses.push( + { statusCode: 500, body: '' }, + { statusCode: 500, body: '' }, + { statusCode: 500, body: '' } + ) + + source().pollOnce(() => {}) + clock.tick(5000) + clock.tick(10_000) + + 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', () => { + responses.push({ pending: true }, { pending: true }, { pending: true }) + + source().pollOnce(() => {}) + requests[0].onTimeout() + clock.tick(5000) + requests[1].onTimeout() + clock.tick(10_000) + requests[2].onTimeout() + + 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', () => { + responses.push( + { statusCode: 401, body: '' }, + { statusCode: 403, body: '' }, + { statusCode: 401, body: '' } + ) + const configurationSource = source() + + configurationSource.pollOnce(() => {}) + completeScheduledResponse() + configurationSource.pollOnce(() => {}) + completeScheduledResponse() + clock.tick(5 * 60 * 1000) + configurationSource.pollOnce(() => {}) + completeScheduledResponse() + + assert.strictEqual(requests.length, 3) + sinon.assert.calledTwice(log.warn) + sinon.assert.notCalled(applyConfiguration) + }) + + it('retries request timeouts without overlapping requests', () => { + responses.push({ pending: true }, { statusCode: 200, body: VALID_UFC }) + const configurationSource = source() + const first = sinon.spy() + const overlapping = sinon.spy() + + configurationSource.pollOnce(first) + configurationSource.pollOnce(overlapping) + sinon.assert.calledOnceWithExactly(overlapping, null, { skipped: true }) + + requests[0].onTimeout() + completeScheduledResponse() + clock.tick(5000) + completeScheduledResponse() + + 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', () => { + responses.push( + { pending: true }, + { statusCode: 200, body: VALID_UFC } + ) + const configurationSource = source() + + configurationSource.start() + clock.tick(30_000) + assert.strictEqual(requests.length, 1) + + requests[0].emit('error', new Error('network failure')) + clock.tick(5000) + completeScheduledResponse() + assert.strictEqual(requests.length, 2) + + clock.tick(29_999) + assert.strictEqual(requests.length, 2) + clock.tick(1) + assert.strictEqual(requests.length, 3) + }) + + it('stops retry timers and aborts an active request', () => { + responses.push({ pending: true }) + const configurationSource = source() + + configurationSource.start() + configurationSource.stop() + configurationSource.stop() + clock.tick(60_000) + + assert.strictEqual(requests.length, 1) + sinon.assert.calledOnce(requests[0].destroy) + }) + + it('starts only once', () => { + responses.push({ pending: true }) + const configurationSource = source() + + configurationSource.start() + configurationSource.start() + + assert.strictEqual(requests.length, 1) + }) }) From eb1f84c77e76e702a69f55c7acc0a3e96fd5b9ac Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 07:56:31 -0400 Subject: [PATCH 4/6] feat(openfeature): wire agentless delivery --- index.d.ts | 34 ++++- index.d.v5.ts | 34 ++++- .../openfeature/app/agentless-evaluation.js | 34 +++++ .../openfeature/openfeature-agentless.spec.js | 125 ++++++++++++++++++ .../openfeature-exposure-events.spec.js | 3 + .../src/config/generated-config-types.d.ts | 8 ++ .../src/config/supported-configurations.json | 40 ++++++ .../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 | 29 ++++ .../openfeature/flagging_provider.spec.js | 25 ++++ .../test/openfeature/register.spec.js | 32 +++++ .../test/openfeature/remote_config.spec.js | 11 ++ 14 files changed, 409 insertions(+), 6 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 eae9b35db3..f4a8a93c1c 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' | 'offline' + /** + * 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. + * + * @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 6bbc664899..e3af73f96d 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' | 'offline' + /** + * 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. + * + * @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 0000000000..f5ea41a9d1 --- /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 0000000000..aa46dd375e --- /dev/null +++ b/integration-tests/openfeature/openfeature-agentless.spec.js @@ -0,0 +1,125 @@ +'use strict' + +const assert = require('node:assert/strict') +const http = require('node:http') +const path = require('node:path') +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', + ETag: '"agentless-integration"', + }) + response.end(JSON.stringify({ + data: { + id: '1', + type: 'universal-flag-configuration', + attributes: UFC, + }, + })) + }) + 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: '1', + 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 < 150; 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['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 34b9934d8e..3689ed830f 100644 --- a/integration-tests/openfeature/openfeature-exposure-events.spec.js +++ b/integration-tests/openfeature/openfeature-exposure-events.spec.js @@ -61,6 +61,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', }, }) }) @@ -160,6 +161,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 +245,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 3535817f4e..9c208719e0 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 9e18849ea4..dd5deb6b2b 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/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index b793c29e1c..a8b37444a1 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 3c7afd4886..aa4e30a325 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 06fbe967bc..0215a2c16c 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 6f0d132e09..18932ee8d4 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -4938,6 +4938,35 @@ rules: }) }) + context('Feature Flagging configuration source', () => { + it('defaults to agentless delivery with Java-compatible timings', () => { + const config = getConfig() + + assertObjectContains(config.experimental.flaggingProvider, { + configurationSource: 'agentless', + agentlessBaseUrl: undefined, + agentlessPollIntervalSeconds: 30, + agentlessRequestTimeoutSeconds: 2, + }) + }) + + it('reads the canonical agentless environment variables', () => { + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + 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: 'remote_config', + 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/flagging_provider.spec.js b/packages/dd-trace/test/openfeature/flagging_provider.spec.js index 4ab2e7f827..2e793de0a9 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 80a6170a11..3eebf71c6d 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 9e61cd1dec..f95038bfcf 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 a3634248869c86faa752ccdd711df71938ed2029 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 08:30:42 -0400 Subject: [PATCH 5/6] test(openfeature): lock staging CDN routing --- .../test/openfeature/configuration_source.spec.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index 230d713721..04aae46ec5 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -60,6 +60,16 @@ describe('OpenFeature configuration source', () => { assert.strictEqual(resolved.requestTimeoutMs, 2000) }) + it('derives the staging UFC CDN endpoint from DD_SITE', () => { + config.DD_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/' From ce6b6c942a90cf0d197b64de827fc0af67a5d89f Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 09:14:34 -0400 Subject: [PATCH 6/6] fix(openfeature): narrow agentless source contract --- index.d.ts | 2 +- index.d.v5.ts | 2 +- .../agentless_configuration_source.js | 60 +++++++++---------- .../src/openfeature/configuration_source.js | 7 +-- .../agentless_configuration_source.spec.js | 47 ++++++++++----- .../openfeature/configuration_source.spec.js | 23 +++---- 6 files changed, 72 insertions(+), 69 deletions(-) diff --git a/index.d.ts b/index.d.ts index f4a8a93c1c..291018c867 100644 --- a/index.d.ts +++ b/index.d.ts @@ -825,7 +825,7 @@ declare namespace tracer { * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE * Programmatic configuration takes precedence over the environment variables listed above. */ - configurationSource?: 'agentless' | 'remote_config' | 'offline' + configurationSource?: 'agentless' | 'remote_config' /** * Optional agentless configuration endpoint or base URL. A base URL * receives the standard rules-based server path. diff --git a/index.d.v5.ts b/index.d.v5.ts index e3af73f96d..98d36b7644 100644 --- a/index.d.v5.ts +++ b/index.d.v5.ts @@ -895,7 +895,7 @@ declare namespace tracer { * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE * Programmatic configuration takes precedence over the environment variables listed above. */ - configurationSource?: 'agentless' | 'remote_config' | 'offline' + configurationSource?: 'agentless' | 'remote_config' /** * Optional agentless configuration endpoint or base URL. A base URL * receives the standard rules-based server path. diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index 4337a8b38f..e43b74123b 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -2,8 +2,11 @@ const http = require('node:http') const https = require('node:https') +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 @@ -152,27 +155,29 @@ class AgentlessConfigurationSource { callback(error, response) } - const request = transport.request(this._config.endpoint, { method: 'GET', headers }, response => { - const chunks = [] - response.on('data', chunk => chunks.push(chunk)) - response.on('error', error => finish(requestError(error))) - response.on('end', () => { - finish(null, { - statusCode: response.statusCode, - etag: response.headers.etag, - body: Buffer.concat(chunks).toString('utf8'), + legacyStorage.run({ noop: true }, () => { + const request = transport.request(this._config.endpoint, { method: 'GET', headers }, response => { + const chunks = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('error', error => finish(requestError(error))) + response.on('end', () => { + finish(null, { + statusCode: response.statusCode, + etag: response.headers.etag, + body: Buffer.concat(chunks).toString('utf8'), + }) }) }) - }) - this._activeRequest = request - request.setTimeout(this._config.requestTimeoutMs, () => { - const error = new Error(`Feature Flagging agentless request timed out after ${this._config.requestTimeoutMs}ms`) - error.retryable = true - request.destroy(error) + this._activeRequest = request + request.setTimeout(this._config.requestTimeoutMs, () => { + const error = new Error(`Feature Flagging agentless request timed out after ${this._config.requestTimeoutMs}ms`) + error.retryable = true + request.destroy(error) + }) + request.on('error', error => finish(requestError(error))) + request.end() }) - request.on('error', error => finish(requestError(error))) - request.end() } /** @@ -195,7 +200,7 @@ class AgentlessConfigurationSource { let configuration try { - configuration = parseConfiguration(response.body, this._config.allowRawConfiguration) + configuration = parseConfiguration(response.body) } catch (error) { log.debug('Feature Flagging agentless endpoint returned malformed UFC payload', error) return { rejected: true, malformed: true } @@ -242,24 +247,17 @@ class AgentlessConfigurationSource { * before it can replace the last-known-good configuration. * * @param {string} body - HTTP response body. - * @param {boolean} allowRawConfiguration - Allows legacy raw UFC from an explicit custom endpoint. * @returns {object} Parsed UFC configuration. */ -function parseConfiguration (body, allowRawConfiguration) { +function parseConfiguration (body) { const parsed = JSON.parse(body) - let configuration - - if (parsed && typeof parsed === 'object' && Object.hasOwn(parsed, 'data')) { - if (!parsed.data || typeof parsed.data !== 'object' || - parsed.data.type !== 'universal-flag-configuration') { - throw new Error('Expected a JSON:API Universal Flag Configuration resource') - } - configuration = parsed.data.attributes - } else if (allowRawConfiguration) { - configuration = parsed - } else { + 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' || diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 75519f6e9d..8f620c3ecf 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -4,7 +4,6 @@ const log = require('../log') const CONFIGURATION_SOURCE_AGENTLESS = 'agentless' const CONFIGURATION_SOURCE_REMOTE_CONFIG = 'remote_config' -const CONFIGURATION_SOURCE_OFFLINE = 'offline' const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' const DEFAULT_POLL_INTERVAL_SECONDS = 30 @@ -21,7 +20,7 @@ function resolve (config) { const flaggingProvider = config.experimental.flaggingProvider const mode = String(flaggingProvider.configurationSource ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS - if (mode === CONFIGURATION_SOURCE_REMOTE_CONFIG || mode === CONFIGURATION_SOURCE_OFFLINE) { + if (mode === CONFIGURATION_SOURCE_REMOTE_CONFIG) { return { mode } } @@ -40,7 +39,6 @@ function resolve (config) { return { mode, endpoint: endpoint(config, configuredBaseUrl), - allowRawConfiguration: Boolean(configuredBaseUrl), pollIntervalMs: positiveMilliseconds( flaggingProvider.agentlessPollIntervalSeconds, DEFAULT_POLL_INTERVAL_SECONDS, @@ -80,8 +78,6 @@ function enable (config, getOpenfeatureProxy) { getOpenfeatureProxy()._setConfiguration(ufc) }) getOpenfeatureProxy()._setConfigurationSource(source) - } else if (sourceConfig.mode === CONFIGURATION_SOURCE_OFFLINE) { - log.debug('Feature Flagging offline configuration source selected; no configuration source started') } } @@ -165,7 +161,6 @@ function positiveMilliseconds (value, fallbackSeconds, setting) { module.exports = { CONFIGURATION_SOURCE_AGENTLESS, - CONFIGURATION_SOURCE_OFFLINE, CONFIGURATION_SOURCE_REMOTE_CONFIG, DEFAULT_AGENTLESS_PATH, enable, 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 4ce19d52c3..fe180d515d 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -14,6 +14,13 @@ const VALID_UFC = JSON.stringify({ 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 @@ -23,6 +30,7 @@ describe('AgentlessConfigurationSource', () => { let log let requests let responses + let runInNoopContext let transport beforeEach(() => { @@ -30,7 +38,6 @@ describe('AgentlessConfigurationSource', () => { applyConfiguration = sinon.stub() config = { endpoint: new URL('http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server'), - allowRawConfiguration: true, pollIntervalMs: 30_000, requestTimeoutMs: 2000, apiKey: 'test-api-key', @@ -41,6 +48,7 @@ describe('AgentlessConfigurationSource', () => { } requests = [] responses = [] + runInNoopContext = sinon.spy((_store, callback) => callback()) transport = { request: sinon.spy((url, options, onResponse) => { const request = new EventEmitter() @@ -72,6 +80,9 @@ describe('AgentlessConfigurationSource', () => { }), } AgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { + '../../../datadog-core': { + storage: () => ({ run: runInNoopContext }), + }, 'node:http': transport, '../log': log, }) @@ -95,7 +106,7 @@ describe('AgentlessConfigurationSource', () => { it('fetches, applies, and reuses the accepted ETag', () => { responses.push( - { statusCode: 200, headers: { etag: '"ufc-v1"' }, body: VALID_UFC }, + { statusCode: 200, headers: { etag: '"ufc-v1"' }, body: VALID_RESPONSE }, { statusCode: 304, headers: {}, body: '' } ) const configurationSource = source() @@ -116,6 +127,14 @@ describe('AgentlessConfigurationSource', () => { assert.strictEqual(requests[0].timeout, 2000) }) + 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('unwraps a JSON API Universal Flag Configuration response', () => { const expected = JSON.parse(VALID_UFC) delete expected.format @@ -136,7 +155,6 @@ describe('AgentlessConfigurationSource', () => { }) it('accepts managed JSON API payloads larger than 500 KB', () => { - config.allowRawConfiguration = false const expected = JSON.parse(VALID_UFC) expected.flags.large = { description: 'x'.repeat(500 * 1024) } responses.push({ @@ -155,8 +173,7 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledOnceWithExactly(applyConfiguration, expected) }) - it('requires JSON API at the first-party endpoint', () => { - config.allowRawConfiguration = false + it('requires JSON API at custom endpoints', () => { responses.push({ statusCode: 200, body: VALID_UFC }) source().pollOnce(() => {}) @@ -187,7 +204,7 @@ describe('AgentlessConfigurationSource', () => { it('preserves last-known-good configuration and ETag after malformed JSON', () => { responses.push( - { statusCode: 200, headers: { etag: '"good"' }, body: VALID_UFC }, + { statusCode: 200, headers: { etag: '"good"' }, body: VALID_RESPONSE }, { statusCode: 200, headers: { etag: '"bad"' }, body: '{"flags":[' }, { statusCode: 304, headers: {}, body: '' } ) @@ -207,9 +224,9 @@ describe('AgentlessConfigurationSource', () => { it('clears a stale ETag when an accepted response omits it', () => { responses.push( - { statusCode: 200, headers: { etag: '"first"' }, body: VALID_UFC }, - { statusCode: 200, headers: {}, body: VALID_UFC }, - { statusCode: 200, headers: {}, body: VALID_UFC } + { statusCode: 200, headers: { etag: '"first"' }, body: VALID_RESPONSE }, + { statusCode: 200, headers: {}, body: VALID_RESPONSE }, + { statusCode: 200, headers: {}, body: VALID_RESPONSE } ) const configurationSource = source() @@ -225,8 +242,8 @@ describe('AgentlessConfigurationSource', () => { it('does not advance the ETag and keeps scheduled polling after a listener failure', () => { applyConfiguration.onFirstCall().throws(new Error('listener failed')) responses.push( - { statusCode: 200, headers: { etag: '"failed"' }, body: VALID_UFC }, - { statusCode: 200, headers: { etag: '"accepted"' }, body: VALID_UFC } + { statusCode: 200, headers: { etag: '"failed"' }, body: VALID_RESPONSE }, + { statusCode: 200, headers: { etag: '"accepted"' }, body: VALID_RESPONSE } ) const configurationSource = source() @@ -243,7 +260,7 @@ describe('AgentlessConfigurationSource', () => { responses.push( { statusCode: 500, body: '' }, { statusCode: 429, body: '' }, - { statusCode: 200, body: VALID_UFC } + { statusCode: 200, body: VALID_RESPONSE } ) const callback = sinon.spy() @@ -270,7 +287,7 @@ describe('AgentlessConfigurationSource', () => { it('retries request timeout responses', () => { responses.push( { statusCode: 408, body: '' }, - { statusCode: 200, body: VALID_UFC } + { statusCode: 200, body: VALID_RESPONSE } ) source().pollOnce(() => {}) @@ -340,7 +357,7 @@ describe('AgentlessConfigurationSource', () => { }) it('retries request timeouts without overlapping requests', () => { - responses.push({ pending: true }, { statusCode: 200, body: VALID_UFC }) + responses.push({ pending: true }, { statusCode: 200, body: VALID_RESPONSE }) const configurationSource = source() const first = sinon.spy() const overlapping = sinon.spy() @@ -362,7 +379,7 @@ describe('AgentlessConfigurationSource', () => { it('uses fixed-delay polling and never schedules while a request is active', () => { responses.push( { pending: true }, - { statusCode: 200, body: VALID_UFC } + { statusCode: 200, body: VALID_RESPONSE } ) 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 04aae46ec5..236559fbe9 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -55,7 +55,6 @@ describe('OpenFeature configuration source', () => { 'https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=my+env' ) assert.strictEqual(resolved.apiKey, 'test-api-key') - assert.strictEqual(resolved.allowRawConfiguration, false) assert.strictEqual(resolved.pollIntervalMs, 30_000) assert.strictEqual(resolved.requestTimeoutMs, 2000) }) @@ -78,7 +77,6 @@ describe('OpenFeature configuration source', () => { resolved.endpoint.toString(), 'http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server' ) - assert.strictEqual(resolved.allowRawConfiguration, true) }) it('preserves an exact configured path and query', () => { @@ -120,7 +118,6 @@ describe('OpenFeature configuration source', () => { const resolved = configurationSource.resolve(config) assert.strictEqual(resolved.endpoint.toString(), 'https://flags.example.test/custom/ufc?tenant=test') - assert.strictEqual(resolved.allowRawConfiguration, true) sinon.assert.notCalled(log.warn) }) @@ -141,19 +138,15 @@ describe('OpenFeature configuration source', () => { assert.strictEqual(configurationSource.isRemoteConfig(config), true) }) - it('reserves offline mode without starting a network source', () => { - config.experimental.flaggingProvider.configurationSource = 'offline' - - assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'offline' }) - }) - - it('fails closed for an unsupported source', () => { - config.experimental.flaggingProvider.configurationSource = 'other' + 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) - }) + 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