From 45d8656b1d74f3998c81a0da8bfa0a642775a519 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 23:26:51 -0400 Subject: [PATCH 01/16] 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 0000000000..3860d5fde3 --- /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 0000000000..81a7d471e7 --- /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 068f78ab89ff3535fafd8eddc5cc7675f2793bbe Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 23:27:47 -0400 Subject: [PATCH 02/16] 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 0000000000..418232a7c6 --- /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 0000000000..912cb8a3f2 --- /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 bdc3e3c151b979f7e8af60f7a5e73d8a45c5e3ca Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 16 Jul 2026 23:28:20 -0400 Subject: [PATCH 03/16] 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 418232a7c6..9def471e7f 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 912cb8a3f2..02543fa657 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 9cf5ed6842a5f7dfc6cb3aa0879c21be710f80cf Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 00:18:07 -0400 Subject: [PATCH 04/16] test(openfeature): cover agentless delivery branches --- .../agentless_configuration_source.spec.js | 39 ++++++++++++++++++- .../openfeature/configuration_source.spec.js | 32 +++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) 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 02543fa657..31d1d0d24c 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 81a7d471e7..20e338956c 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, }) }) @@ -89,13 +92,24 @@ describe('OpenFeature configuration source', () => { it('derives the managed GovCloud endpoint without hard-coding availability', () => { config.site = 'DDOG-GOV.COM' config.env = 'prod' + 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(), '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.calledOnceWithExactly(provider._setConfiguration, configuration) + sinon.assert.calledOnce(provider._setConfigurationSource) sinon.assert.notCalled(log.warn) }) @@ -118,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 b403305f34e6cf7712c29f9303ccdd9b6f475925 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 17:02:48 -0400 Subject: [PATCH 05/16] fix(openfeature): keep delivery configuration environment-only --- .../src/config/generated-config-types.d.ts | 4 ++ packages/dd-trace/src/config/helper.js | 1 + .../src/config/supported-configurations.json | 33 +++++++++ .../src/openfeature/configuration_source.js | 9 ++- packages/dd-trace/test/config/index.spec.js | 70 +++++++++++++++++++ .../openfeature/configuration_source.spec.js | 35 +++++----- 6 files changed, 128 insertions(+), 24 deletions(-) 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 806db8de9b..2acd0ee7b3 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; diff --git a/packages/dd-trace/src/config/helper.js b/packages/dd-trace/src/config/helper.js index 1f01bf7100..592b4e8728 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 f89e3e53d8..bbd1fc40a1 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -835,6 +835,39 @@ "default": "false" } ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": [ + { + "implementation": "A", + "type": "string", + "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", + "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", + "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", + "default": "2", + "description": "Experimental: Set the agentless Feature Flagging UFC request timeout in seconds." + } + ], "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 3860d5fde3..a3cd6962d5 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' ), @@ -67,7 +66,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 2bb84343e3..a09e2cb77e 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', @@ -4952,6 +4954,74 @@ rules: }) }) + context('Feature Flagging configuration source', () => { + it('defaults to agentless delivery with cross-SDK timings', () => { + const config = getConfig() + + 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, + }) + }) + + it('reads the configuration source environment variable', () => { + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + + const config = getConfig() + + assert.strictEqual(config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, '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, { + 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', () => { 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 20e338956c..b35cf0f815 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 3104f1a20fd22b3a1b8b21f44e4878c7e76af9e8 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 17:03:05 -0400 Subject: [PATCH 06/16] fix(openfeature): send canonical client library headers --- .../openfeature/openfeature-agentless.spec.js | 132 ++++++++++++++++++ .../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, 167 insertions(+), 7 deletions(-) create mode 100644 integration-tests/openfeature/openfeature-agentless.spec.js 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 new file mode 100644 index 0000000000..5acdab41e7 --- /dev/null +++ b/integration-tests/openfeature/openfeature-agentless.spec.js @@ -0,0 +1,132 @@ +'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 { VERSION } = require('../../version') +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-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 0000000000..650bb0c418 --- /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 9def471e7f..e467250a88 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 28a2d019f8..7342c9ce35 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 31d1d0d24c..3b45f032be 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 aefe0dced3..5c984fe63a 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 09fbeb5ee2d1ae26b916304aa242caf0a7e52aba Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 17:32:44 -0400 Subject: [PATCH 07/16] 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 7342c9ce35..28a2d019f8 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 5c984fe63a..aefe0dced3 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 e5b4cbfeb919d5e4a889754f401f5d4d26d7786c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 18:26:01 -0400 Subject: [PATCH 08/16] 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 28a2d019f8..7342c9ce35 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 aefe0dced3..5c984fe63a 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 852c76fecbcd0c52c2968f1818917fec1e29c939 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 17 Jul 2026 20:41:56 -0400 Subject: [PATCH 09/16] 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 bbd1fc40a1..54601a31e5 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -848,8 +848,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 a09e2cb77e..e30a3d8ec1 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 089c8aaf7d2f40a36c369b4ae4e6f17a9032c12e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 20 Jul 2026 03:12:30 -0600 Subject: [PATCH 10/16] fix(openfeature): trim accepted ETags --- .../agentless_configuration_source.js | 3 ++- .../agentless_configuration_source.spec.js | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index e467250a88..7a43637cd2 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -234,7 +234,8 @@ class AgentlessConfigurationSource { return { rejected: true, applicationFailed: true } } - this._etag = response.etag?.trim() ? response.etag : undefined + const etag = response.etag?.trim() + this._etag = etag || undefined return { applied: true } } 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 3b45f032be..6cdcf269b3 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -123,6 +123,26 @@ describe('AgentlessConfigurationSource', () => { assert.strictEqual(requests[0].options.redirect, 'manual') }) + it('trims an accepted ETag before reusing it', async () => { + const paddedEtagFetch = sinon.stub() + paddedEtagFetch.onFirstCall().resolves({ + status: 200, + headers: { get: name => name === 'etag' ? ' W/"ufc-v1" ' : null }, + text: () => Promise.resolve(VALID_RESPONSE), + }) + paddedEtagFetch.onSecondCall().resolves({ + status: 304, + headers: new Headers(), + }) + const configurationSource = source({ fetch: paddedEtagFetch }) + + await poll(configurationSource) + await poll(configurationSource) + + assert.strictEqual(paddedEtagFetch.secondCall.args[1].headers['If-None-Match'], 'W/"ufc-v1"') + sinon.assert.calledOnceWithExactly(applyConfiguration, JSON.parse(VALID_UFC)) + }) + it('suppresses tracing around agentless requests', () => { responses.push({ statusCode: 200, body: VALID_RESPONSE }) From b84e412ff0a952c193fe27fa5e1cc2ccf13b99d9 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 20 Jul 2026 08:15:32 -0600 Subject: [PATCH 11/16] fix(openfeature): harden configuration source handling --- .../agentless_configuration_source.js | 12 +++--- .../src/openfeature/configuration_source.js | 6 +-- .../src/openfeature/flagging_provider.js | 42 +++++++++++++++++++ .../agentless_configuration_source.spec.js | 25 +++++++++++ .../openfeature/flagging_provider.spec.js | 30 +++++++++++++ 5 files changed, 107 insertions(+), 8 deletions(-) diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index 7a43637cd2..8f31b89b62 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -32,7 +32,7 @@ class AgentlessConfigurationSource { this._clearTimeout = options.clearTimeout || clearTimeout this._started = false this._closed = false - this._polling = false + this._pollInFlight = false this._etag = undefined this._timer = undefined this._activeRequest = undefined @@ -78,14 +78,14 @@ class AgentlessConfigurationSource { callback(null, { stopped: true }) return } - if (this._polling) { + if (this._pollInFlight) { callback(null, { skipped: true }) return } - this._polling = true + this._pollInFlight = true this._attempt(1, (error, result) => { - this._polling = false + this._pollInFlight = false if (error && !this._closed) { log.debug('Feature Flagging agentless poll failed', error) } @@ -273,7 +273,8 @@ class AgentlessConfigurationSource { */ function parseConfiguration (body) { const parsed = JSON.parse(body) - if (!parsed || typeof parsed !== 'object' || !parsed.data || typeof parsed.data !== 'object') { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || + !parsed.data || typeof parsed.data !== 'object' || Array.isArray(parsed.data)) { throw new Error('Expected a JSON:API Universal Flag Configuration response') } if (parsed.data.type !== 'universal-flag-configuration') { @@ -285,6 +286,7 @@ function parseConfiguration (body) { typeof configuration.createdAt !== 'string' || (configuration.format !== undefined && typeof configuration.format !== 'string') || !configuration.environment || typeof configuration.environment !== 'object' || + Array.isArray(configuration.environment) || typeof configuration.environment.name !== 'string' || !configuration.flags || typeof configuration.flags !== 'object' || Array.isArray(configuration.flags)) { const keys = configuration && typeof configuration === 'object' diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index a3cd6962d5..2f015bb54e 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -26,13 +26,13 @@ function resolve (config) { return { mode, endpoint: endpoint(config, config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL), - pollIntervalMs: positiveMilliseconds( + pollIntervalMs: positiveMillisecondsFromSeconds( config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, DEFAULT_POLL_INTERVAL_SECONDS, 'poll interval', MAX_POLL_INTERVAL_SECONDS ), - requestTimeoutMs: positiveMilliseconds( + requestTimeoutMs: positiveMillisecondsFromSeconds( config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, DEFAULT_REQUEST_TIMEOUT_SECONDS, 'request timeout' @@ -122,7 +122,7 @@ function endpoint (config, configuredBaseUrl) { * @param {number} [maximumSeconds] - Optional inclusive maximum. * @returns {number} Positive milliseconds. */ -function positiveMilliseconds (value, fallbackSeconds, setting, maximumSeconds) { +function positiveMillisecondsFromSeconds (value, fallbackSeconds, setting, maximumSeconds) { const seconds = Number(value) if (!Number.isFinite(seconds) || seconds <= 0) { log.warn( diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index 174155f28e..792ecf23b6 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -15,6 +15,9 @@ class FlaggingProvider extends DatadogNodeServerProvider { /** @type {SpanEnrichmentHook | undefined} */ #spanEnrichmentHook + /** @type {{ start: Function, stop: Function } | undefined} */ + #configurationSource + /** * @param {import('../tracer')} tracer - Datadog tracer instance * @param {import('../config/config-base')} config - Tracer configuration object @@ -50,10 +53,49 @@ class FlaggingProvider extends DatadogNodeServerProvider { * Cleans up resources including channel subscriptions. */ onClose () { + this.#configurationSource?.stop() + this.#configurationSource = undefined 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) { + log.warn('%s already has a configuration source; ignoring duplicate source', this.constructor.name) + source.stop() + return + } + this.#configurationSource = source + source.start() + } + + /** +<<<<<<< HEAD +======= + * 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) { + log.warn('%s already has a configuration source; ignoring duplicate source', this.constructor.name) + source.stop() + return + } + this.#configurationSource = source + source.start() + } + + /** +>>>>>>> bd5962c651 (fix(openfeature): harden configuration source handling) * 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/test/openfeature/agentless_configuration_source.spec.js b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js index 6cdcf269b3..aa3f2e7111 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -301,6 +301,31 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledThrice(log.debug) }) + it('rejects arrays where UFC envelope objects are required', async () => { + const configuration = JSON.parse(VALID_UFC) + responses.push( + { statusCode: 200, body: JSON.stringify([]) }, + { statusCode: 200, body: JSON.stringify({ data: [] }) }, + { + statusCode: 200, + body: JSON.stringify({ + data: { + type: 'universal-flag-configuration', + attributes: { ...configuration, environment: [] }, + }, + }), + } + ) + const configurationSource = source() + + await poll(configurationSource) + await poll(configurationSource) + await poll(configurationSource) + + sinon.assert.notCalled(applyConfiguration) + sinon.assert.calledThrice(log.debug) + }) + it('preserves last-known-good configuration and ETag after malformed JSON', async () => { responses.push( { statusCode: 200, headers: { etag: '"good"' }, body: VALID_RESPONSE }, diff --git a/packages/dd-trace/test/openfeature/flagging_provider.spec.js b/packages/dd-trace/test/openfeature/flagging_provider.spec.js index 6d437fdaf7..c2bc4bb241 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider.spec.js @@ -201,6 +201,36 @@ 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) + sinon.assert.calledOnceWithExactly( + log.warn, + '%s already has a configuration source; ignoring duplicate source', + 'FlaggingProvider' + ) + }) }) describe('inheritance', () => { From 350043bfdebb93b0e4f86d466a5117f60bc920bd Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 21 Jul 2026 19:37:56 +0200 Subject: [PATCH 12/16] feat(openfeature): support agentless feature flag configuration Feature flag configuration can now be delivered from the agentless endpoint while keeping startup lazy and polling cancellable. The source retains the last known-good response when polling fails and avoids assembling unrelated JSON fields during streaming responses. --- LICENSE-3rdparty.csv | 2 + .../app/configuration-source-evaluation.js | 73 ++ .../openfeature/openfeature-agentless.spec.js | 2 +- .../openfeature-configuration-sources.spec.js | 686 ++++++++++++++++ .../openfeature-exposure-events.spec.js | 12 +- .../src/config/generated-config-types.d.ts | 16 +- packages/dd-trace/src/config/index.js | 10 + .../src/config/supported-configurations.json | 25 +- .../dd-trace/src/exporters/common/request.js | 43 +- .../agentless_configuration_source.js | 623 ++++++++------ .../src/openfeature/configuration_source.js | 124 +-- .../src/openfeature/flagging_provider.js | 66 +- packages/dd-trace/src/openfeature/index.js | 2 - packages/dd-trace/src/openfeature/noop.js | 29 +- packages/dd-trace/src/openfeature/register.js | 61 +- .../dd-trace/src/openfeature/remote_config.js | 33 +- packages/dd-trace/test/config/index.spec.js | 195 ++++- .../test/exporters/common/request.spec.js | 78 ++ .../agentless_configuration_source.spec.js | 758 ++++++++++-------- .../openfeature/configuration_source.spec.js | 154 ++-- .../openfeature/flagging_provider.spec.js | 83 +- .../flagging_provider_timeout.spec.js | 7 +- .../dd-trace/test/openfeature/noop.spec.js | 55 +- .../test/openfeature/register.spec.js | 67 +- .../test/openfeature/remote_config.spec.js | 56 +- packages/dd-trace/test/proxy.spec.js | 42 +- vendor/package-lock.json | 22 + vendor/package.json | 1 + vendor/rspack.config.js | 5 +- vendor/stream-json.js | 11 + 30 files changed, 2286 insertions(+), 1055 deletions(-) create mode 100644 integration-tests/openfeature/app/configuration-source-evaluation.js create mode 100644 integration-tests/openfeature/openfeature-configuration-sources.spec.js create mode 100644 vendor/stream-json.js diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 38964991e2..51b0c1e461 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -82,6 +82,8 @@ "shell-quote","https://github.com/ljharb/shell-quote","['MIT']","['James Halliday']" "source-map","https://github.com/mozilla/source-map","['BSD-3-Clause']","['Nick Fitzgerald']" "spark-md5","https://github.com/satazor/js-spark-md5","['(WTFPL OR MIT)']","['André Cruz']" +"stream-chain","https://github.com/uhop/stream-chain","['BSD-3-Clause']","['Eugene Lazutkin']" +"stream-json","https://github.com/uhop/stream-json","['BSD-3-Clause']","['Eugene Lazutkin']" "tlhunter-sorted-set","https://github.com/tlhunter/node-sorted-set","['MIT']","['Thomas Hunter II']" "tslib","https://github.com/microsoft/tslib","['0BSD']","['Microsoft Corp.']" "ttl-set","https://github.com/watson/ttl-set","['MIT']","['Thomas Watson']" diff --git a/integration-tests/openfeature/app/configuration-source-evaluation.js b/integration-tests/openfeature/app/configuration-source-evaluation.js new file mode 100644 index 0000000000..49e0000bee --- /dev/null +++ b/integration-tests/openfeature/app/configuration-source-evaluation.js @@ -0,0 +1,73 @@ +'use strict' + +const tracer = require('dd-trace') + +const { once } = require('node:events') +const http = require('node:http') + +const { OpenFeature } = require('@openfeature/server-sdk') + +const { + TEST_DEFAULT_VALUE, + TEST_FLAG_KEY, + TEST_SERVICE, + TEST_TARGETING_KEY, +} = process.env + +tracer.init({ + env: 'integration', + flushInterval: 0, + service: TEST_SERVICE, +}) + +let client + +/** + * @param {object} message + * @param {'access'|'evaluate'|'trace'} message.command + * @param {string} [message.spanName] + * @param {string} [message.url] + */ +async function handleMessage (message) { + try { + if (message.command === 'access') { + OpenFeature.setProvider(tracer.openfeature) + client = OpenFeature.getClient() + send({ accessed: true }) + return + } + + if (message.command === 'evaluate') { + const details = await client.getStringDetails(TEST_FLAG_KEY, TEST_DEFAULT_VALUE, { + targetingKey: TEST_TARGETING_KEY, + userId: TEST_TARGETING_KEY, + }) + send({ details }) + return + } + + if (message.command === 'trace') { + await tracer.trace(message.spanName, async () => { + if (message.url) { + const request = http.get(message.url) + const [response] = await once(request, 'response') + response.resume() + await once(response, 'end') + } + }) + send({ traced: true }) + } + } catch (error) { + send({ error: error.stack || error.message }) + } +} + +/** + * @param {object} message + */ +function send (message) { + process.send?.({ port: 0, ...message }) +} + +process.on('message', handleMessage) +send({ ready: true }) diff --git a/integration-tests/openfeature/openfeature-agentless.spec.js b/integration-tests/openfeature/openfeature-agentless.spec.js index 5acdab41e7..3869fc01d5 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-configuration-sources.spec.js b/integration-tests/openfeature/openfeature-configuration-sources.spec.js new file mode 100644 index 0000000000..f3bce168ff --- /dev/null +++ b/integration-tests/openfeature/openfeature-configuration-sources.spec.js @@ -0,0 +1,686 @@ +'use strict' + +const assert = require('node:assert/strict') +const { EventEmitter, once } = require('node:events') +const http = require('node:http') +const path = require('node:path') +const { after, before, describe, it } = require('mocha') + +const { ACKNOWLEDGED } = require('../../packages/dd-trace/src/remote_config/apply_states') +const { FakeAgent, sandboxCwd, spawnProc, stopProc, useSandbox } = require('../helpers') + +const AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' +const AGENTLESS_SPAN = 'configuration-source.agentless-poll' +const APPLICATION_SPAN = 'configuration-source.before-access' +const COMMAND_TIMEOUT_MS = 5_000 +const DEFAULT_VALUE = 'configuration-source-default' +const EXPECTED_VALUE = 'configuration-source-loaded' +const FLAG_KEY = 'configuration-source-flag' +const OBSERVATION_TIMEOUT_MS = 3_000 +const RC_CONFIG_ID = 'configuration-source-contract' +const RC_PRODUCT = 'FFE_FLAGS' +const TARGETING_KEY = '12345' +const WORKER_COUNT = 7 + +const BOOLEAN_SETTINGS = ['absent', 'true', 'false'] +const SOURCE_SETTINGS = [ + { name: 'absent' }, + { name: 'empty', value: '' }, + { name: 'whitespace', value: ' ' }, + { name: 'agentless', value: 'agentless' }, + { name: 'remote_config', value: 'remote_config' }, + { name: 'offline', value: 'offline' }, + { name: 'invalid', value: 'unsupported' }, +] + +const CONFIGURATION = { + createdAt: '2026-01-01T00:00:00.000Z', + format: 'SERVER', + environment: { name: 'integration' }, + flags: { + [FLAG_KEY]: { + key: FLAG_KEY, + enabled: true, + variationType: 'STRING', + variations: { + selected: { key: 'selected', value: EXPECTED_VALUE }, + }, + allocations: [{ + key: 'configuration-source-allocation', + rules: [], + splits: [{ variationKey: 'selected', shards: [] }], + }], + }, + }, +} + +const AGENTLESS_RESPONSE = JSON.stringify({ + data: { + id: RC_CONFIG_ID, + type: 'universal-flag-configuration', + attributes: CONFIGURATION, + }, +}) + +/** @typedef {'absent'|'true'|'false'} BooleanSetting */ +/** @typedef {'agentless'|'remote_config'|'disabled'} Delivery */ +/** @typedef {{ name: string, value?: string }} SourceSetting */ +/** + * @typedef {object} ConfigurationCase + * @property {string} identifier + * @property {string} label + * @property {string} service + * @property {BooleanSetting} stable + * @property {SourceSetting} source + * @property {BooleanSetting} legacy + * @property {Delivery} expected + */ + +const configurationCases = buildConfigurationCases() +const observations = new EventEmitter() +const accessedCases = new Set() +const agentlessRequests = new Map() +const applicationRequests = new Map() +const remoteConfigRequests = new Map() +const spansByService = new Map() +const observedSpans = [] + +let agent +let appFile +let backend +let backendUrl +let cwd + +describe('OpenFeature configuration source contract', () => { + useSandbox( + ['@openfeature/server-sdk', '@openfeature/core'], + false, + [path.join(__dirname, 'app')] + ) + + before(async () => { + cwd = sandboxCwd() + appFile = path.join(cwd, 'app', 'configuration-source-evaluation.js') + agent = new FakeAgent() + backend = http.createServer(handleBackendRequest) + + const backendListening = once(backend, 'listening') + backend.listen(0, '127.0.0.1') + await Promise.all([agent.start(), backendListening]) + + backendUrl = `http://127.0.0.1:${backend.address().port}` + agent.addRemoteConfig({ + product: RC_PRODUCT, + id: RC_CONFIG_ID, + config: CONFIGURATION, + }) + agent.on('message', recordTraceMessage) + agent.on('remote-config-request', recordRemoteConfigRequest) + }) + + after(async () => { + const agentStopped = agent?.stop() + let backendStopped + if (backend?.listening) { + backendStopped = once(backend, 'close') + backend.close() + } + await Promise.all([agentStopped, backendStopped]) + }) + + it('covers all 63 stable/source/legacy combinations without tracing CDN polling', async function () { + this.timeout(60_000) + + assert.strictEqual(configurationCases.length, 63) + assert.deepStrictEqual(countDeliveries(configurationCases), { + agentless: 16, + remote_config: 16, + disabled: 31, + }) + + await runCases(configurationCases) + + const selfTraces = [] + for (const span of observedSpans) { + if (isAgentlessHttpSpan(span)) selfTraces.push(span) + } + assert.deepStrictEqual(selfTraces, []) + }) +}) + +/** + * @param {ConfigurationCase[]} cases + */ +async function runCases (cases) { + const errors = [] + const workers = [] + for (let i = 0; i < WORKER_COUNT; i++) { + workers.push(runCaseRange(cases, i, WORKER_COUNT, errors)) + } + await Promise.all(workers) + if (errors.length > 0) { + throw new AggregateError(errors, formatCaseFailures(errors)) + } +} + +/** + * @param {Error[]} errors + */ +function formatCaseFailures (errors) { + const lines = [`${errors.length} configuration source cases failed:`] + for (const error of errors) { + const cause = error.cause instanceof Error ? error.cause.message : String(error.cause) + lines.push(`${error.message}: ${cause}`) + } + return lines.join('\n') +} + +/** + * @param {ConfigurationCase[]} cases + * @param {number} offset + * @param {number} stride + * @param {Error[]} errors + */ +async function runCaseRange (cases, offset, stride, errors) { + for (let i = offset; i < cases.length; i += stride) { + try { + await runCase(cases[i]) + } catch (error) { + errors.push(new Error(cases[i].label, { cause: error })) + } + } +} + +/** + * @param {ConfigurationCase} testCase + */ +async function runCase (testCase) { + let proc + try { + proc = await spawnProc(appFile, { + cwd, + env: buildEnvironment(testCase), + silent: true, + }) + + const startupRemoteConfig = waitForObservation( + remoteConfigRequests, + testCase.service, + 'remote-config', + hasObservation + ) + await Promise.all([ + startupRemoteConfig, + waitForSpan(testCase.service, APPLICATION_SPAN), + sendCommand(proc, { command: 'trace', spanName: APPLICATION_SPAN }), + ]) + + assert.strictEqual(requestsFor(agentlessRequests, testCase.identifier).length, 0, testCase.label) + assertStartupRemoteConfig(testCase) + + if (testCase.expected === 'remote_config') { + await waitForObservation( + remoteConfigRequests, + testCase.service, + 'remote-config', + hasConfigurationAcknowledgment + ) + } + + const remoteConfigStartIndex = requestsFor(remoteConfigRequests, testCase.service).length + accessedCases.add(testCase.identifier) + + if (testCase.expected === 'agentless') { + await Promise.all([ + waitForObservation(agentlessRequests, testCase.identifier, 'agentless', hasObservation), + sendCommand(proc, { command: 'access' }), + ]) + } else { + await sendCommand(proc, { command: 'access' }) + } + + const details = await evaluateUntilSettled(proc, testCase) + assertEvaluation(testCase, details) + + await waitForObservation( + remoteConfigRequests, + testCase.service, + 'remote-config', + hasObservation, + remoteConfigStartIndex + ) + + if (testCase.expected === 'agentless') { + await traceDeliberateRequest(proc, testCase) + } + + assertDeliveryTraffic(testCase) + } finally { + await stopProc(proc) + } +} + +/** + * @param {import('node:child_process').ChildProcess} proc + * @param {ConfigurationCase} testCase + */ +async function traceDeliberateRequest (proc, testCase) { + const spanStartIndex = requestsFor(spansByService, testCase.service).length + const requestStartIndex = requestsFor(applicationRequests, testCase.identifier).length + const url = `${backendUrl}/deliberate/${testCase.identifier}` + + await Promise.all([ + waitForSpan(testCase.service, AGENTLESS_SPAN, spanStartIndex), + waitForObservation( + applicationRequests, + testCase.identifier, + 'application-request', + hasObservation, + requestStartIndex + ), + sendCommand(proc, { command: 'trace', spanName: AGENTLESS_SPAN, url }), + ]) + + let deliberateHttpSpan + for (const span of observedSpans) { + if (isDeliberateHttpSpan(span, testCase.identifier)) { + deliberateHttpSpan = span + break + } + } + assert.ok(deliberateHttpSpan, `${testCase.label}: deliberate fetch was not traced`) + + const selfTraces = [] + for (const span of observedSpans) { + if (isAgentlessHttpSpan(span)) selfTraces.push(span) + } + assert.deepStrictEqual(selfTraces, [], testCase.label) +} + +/** + * @param {import('node:child_process').ChildProcess} proc + * @param {ConfigurationCase} testCase + */ +async function evaluateUntilSettled (proc, testCase) { + const attempts = testCase.expected === 'disabled' ? 1 : 10 + let details + for (let attempt = 0; attempt < attempts; attempt++) { + ({ details } = await sendCommand(proc, { command: 'evaluate' })) + if (details.value === EXPECTED_VALUE) break + } + return details +} + +/** + * @param {ConfigurationCase} testCase + * @param {object} details + */ +function assertEvaluation (testCase, details) { + const expectedValue = testCase.expected === 'disabled' ? DEFAULT_VALUE : EXPECTED_VALUE + assert.strictEqual(details.value, expectedValue, testCase.label) + if (testCase.expected !== 'disabled') { + assert.notStrictEqual(details.reason, 'ERROR', testCase.label) + } +} + +/** + * @param {ConfigurationCase} testCase + */ +function assertStartupRemoteConfig (testCase) { + const requests = requestsFor(remoteConfigRequests, testCase.service) + if (testCase.expected === 'remote_config') { + assert.ok(requests.some(hasFfeProduct), testCase.label) + } else { + assert.ok(requests.every(withoutFfeProduct), testCase.label) + } +} + +/** + * @param {ConfigurationCase} testCase + */ +function assertDeliveryTraffic (testCase) { + const cdnRequests = requestsFor(agentlessRequests, testCase.identifier) + const rcRequests = requestsFor(remoteConfigRequests, testCase.service) + + if (testCase.expected === 'agentless') { + assert.ok(cdnRequests.length >= 1, testCase.label) + assert.ok(cdnRequests.every(wasAfterAccess), testCase.label) + assert.ok(rcRequests.every(withoutFfeProduct), testCase.label) + for (const request of cdnRequests) { + assert.strictEqual(request.url, `${AGENTLESS_PATH}?case=${testCase.identifier}`, testCase.label) + assert.strictEqual(request.headers['dd-api-key'], 'integration-api-key', testCase.label) + } + return + } + + assert.strictEqual(cdnRequests.length, 0, testCase.label) + if (testCase.expected === 'remote_config') { + assert.ok(rcRequests.some(hasFfeProduct), testCase.label) + } else { + assert.ok(rcRequests.every(withoutFfeProduct), testCase.label) + } +} + +/** + * @param {ConfigurationCase} testCase + */ +function buildEnvironment (testCase) { + const env = { + DD_API_KEY: 'integration-api-key', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: + `${backendUrl}/?case=${testCase.identifier}`, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: '30', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: '1', + DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'false', + DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.05', + DD_TRACE_AGENT_HOSTNAME: '127.0.0.1', + DD_TRACE_AGENT_PORT: String(agent.port), + DD_TRACE_STARTUP_LOGS: 'false', + TEST_DEFAULT_VALUE: DEFAULT_VALUE, + TEST_FLAG_KEY: FLAG_KEY, + TEST_SERVICE: testCase.service, + TEST_TARGETING_KEY: TARGETING_KEY, + } + + setBooleanEnvironment(env, 'DD_FEATURE_FLAGS_ENABLED', testCase.stable) + setBooleanEnvironment(env, 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', testCase.legacy) + if (Object.hasOwn(testCase.source, 'value')) { + env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = testCase.source.value + } + return env +} + +/** + * @param {Record} env + * @param {string} name + * @param {BooleanSetting} setting + */ +function setBooleanEnvironment (env, name, setting) { + if (setting !== 'absent') env[name] = setting +} + +/** + * @param {import('node:child_process').ChildProcess} proc + * @param {object} command + * @param {'access'|'evaluate'|'trace'} command.command + * @param {string} [command.spanName] + * @param {string} [command.url] + */ +async function sendCommand (proc, command) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), COMMAND_TIMEOUT_MS) + try { + const response = once(proc, 'message', { signal: controller.signal }) + proc.send(command) + const [message] = await response + if (message.error) throw new Error(message.error) + return message + } catch (error) { + if (error.name === 'AbortError') { + throw new Error(`Timed out waiting for child command ${command.command}`) + } + throw error + } finally { + clearTimeout(timeout) + } +} + +/** + * @param {string} service + * @param {string} spanName + * @param {number} [startIndex] + */ +function waitForSpan (service, spanName, startIndex = 0) { + /** + * @param {object} span + */ + function hasSpanName (span) { + return span.name === spanName + } + + return waitForObservation(spansByService, service, 'span', hasSpanName, startIndex) +} + +/** + * @param {Map} collection + * @param {string} key + * @param {string} eventName + * @param {(value: object) => boolean} predicate + * @param {number} [startIndex] + */ +function waitForObservation (collection, key, eventName, predicate, startIndex = 0) { + const current = findObservation(requestsFor(collection, key), predicate, startIndex) + if (current !== undefined) return Promise.resolve(current) + const observationEvent = `${eventName}:${key}` + + /** + * @param {(value: object) => void} resolve + * @param {(error: Error) => void} reject + */ + function observe (resolve, reject) { + const timeout = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for ${eventName} observation for ${key}`)) + }, OBSERVATION_TIMEOUT_MS) + + /** + * @param {string} observedKey + */ + function handleObservation (observedKey) { + if (observedKey !== key) return + const match = findObservation(requestsFor(collection, key), predicate, startIndex) + if (match === undefined) return + cleanup() + resolve(match) + } + + function cleanup () { + clearTimeout(timeout) + observations.removeListener(observationEvent, handleObservation) + } + + observations.on(observationEvent, handleObservation) + } + + return new Promise(observe) +} + +/** + * @param {object[]} values + * @param {(value: object) => boolean} predicate + * @param {number} startIndex + */ +function findObservation (values, predicate, startIndex) { + for (let i = startIndex; i < values.length; i++) { + if (predicate(values[i])) return values[i] + } +} + +/** + * @param {Map} collection + * @param {string} key + */ +function requestsFor (collection, key) { + return collection.get(key) ?? [] +} + +/** + * @param {Map} collection + * @param {string} key + * @param {string} eventName + * @param {object} value + */ +function recordObservation (collection, key, eventName, value) { + let values = collection.get(key) + if (!values) { + values = [] + collection.set(key, values) + } + values.push(value) + observations.emit(`${eventName}:${key}`, key) +} + +/** + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + */ +function handleBackendRequest (request, response) { + const url = new URL(request.url, 'http://127.0.0.1') + let identifier = url.searchParams.get('case') + if (url.pathname.startsWith('/deliberate/')) { + identifier = url.pathname.slice('/deliberate/'.length) + recordObservation(applicationRequests, identifier, 'application-request', { + headers: request.headers, + url: request.url, + }) + response.writeHead(204, { Connection: 'close' }).end() + return + } + + if (url.pathname !== AGENTLESS_PATH || identifier === null) { + response.writeHead(404, { Connection: 'close' }).end() + return + } + + recordObservation(agentlessRequests, identifier, 'agentless', { + afterAccess: accessedCases.has(identifier), + headers: request.headers, + url: request.url, + }) + response.writeHead(200, { + Connection: 'close', + 'Content-Type': 'application/json', + }) + response.end(AGENTLESS_RESPONSE) +} + +/** + * @param {object} request + */ +function recordRemoteConfigRequest (request) { + const service = request.client?.client_tracer?.service + if (typeof service === 'string') { + recordObservation(remoteConfigRequests, service, 'remote-config', request) + } +} + +/** + * @param {object} message + * @param {object[][]} message.payload + */ +function recordTraceMessage ({ payload }) { + if (!Array.isArray(payload)) return + for (const trace of payload) { + if (!Array.isArray(trace)) continue + for (const span of trace) { + observedSpans.push(span) + if (typeof span.service === 'string') { + recordObservation(spansByService, span.service, 'span', span) + } + } + } +} + +/** + * @param {object} request + */ +function hasFfeProduct (request) { + return request.client?.products?.includes(RC_PRODUCT) === true +} + +/** + * @param {object} request + */ +function withoutFfeProduct (request) { + return !hasFfeProduct(request) +} + +/** + * @param {object} request + */ +function hasConfigurationAcknowledgment (request) { + const states = request.client?.state?.config_states + if (!Array.isArray(states)) return false + for (const state of states) { + if (state.id === RC_CONFIG_ID && state.apply_state === ACKNOWLEDGED) return true + } + return false +} + +function hasObservation () { + return true +} + +/** + * @param {object} request + */ +function wasAfterAccess (request) { + return request.afterAccess === true +} + +/** + * @param {object} span + */ +function isAgentlessHttpSpan (span) { + return span.name === 'http.request' && span.meta?.['http.url']?.includes(AGENTLESS_PATH) === true +} + +/** + * @param {object} span + * @param {string} identifier + */ +function isDeliberateHttpSpan (span, identifier) { + return span.name === 'http.request' && + span.meta?.['http.url']?.includes(`/deliberate/${identifier}`) === true +} + +/** + * @param {ConfigurationCase[]} cases + */ +function countDeliveries (cases) { + const counts = { + agentless: 0, + remote_config: 0, + disabled: 0, + } + for (const testCase of cases) counts[testCase.expected]++ + return counts +} + +function buildConfigurationCases () { + const cases = [] + let caseNumber = 0 + for (const stable of BOOLEAN_SETTINGS) { + for (const source of SOURCE_SETTINGS) { + for (const legacy of BOOLEAN_SETTINGS) { + const expected = expectedDelivery(stable, source, legacy) + const identifier = String(caseNumber++) + cases.push({ + identifier, + label: `stable=${stable}, source=${source.name}, legacy=${legacy} -> ${expected}`, + service: `configuration-source-${identifier}`, + stable, + source, + legacy, + expected, + }) + } + } + } + return cases +} + +/** + * @param {BooleanSetting} stable + * @param {SourceSetting} source + * @param {BooleanSetting} legacy + * @returns {Delivery} + */ +function expectedDelivery (stable, source, legacy) { + if (stable === 'false') return 'disabled' + if (source.name === 'agentless') return 'agentless' + if (source.name === 'remote_config') return 'remote_config' + if (legacy === 'true') return 'remote_config' + if (legacy === 'false') return 'disabled' + return 'agentless' +} diff --git a/integration-tests/openfeature/openfeature-exposure-events.spec.js b/integration-tests/openfeature/openfeature-exposure-events.spec.js index 34b9934d8e..ed1e047dbb 100644 --- a/integration-tests/openfeature/openfeature-exposure-events.spec.js +++ b/integration-tests/openfeature/openfeature-exposure-events.spec.js @@ -60,7 +60,9 @@ 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', }, }) }) @@ -159,7 +161,8 @@ 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', }, }) }) @@ -242,7 +245,8 @@ 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', }, }) }) @@ -303,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 2acd0ee7b3..9dec6c4d28 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -83,10 +83,6 @@ 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; @@ -435,6 +431,13 @@ export interface GeneratedConfig { }; }; }; + featureFlags: { + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config"; + 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; + }; flushInterval: number; flushMinSpans: number; headerTags: string[]; @@ -694,6 +697,11 @@ export interface GeneratedEnvVarConfig { DD_EXPERIMENTAL_TEST_OPT_VITEST_NO_WORKER_INIT: boolean | undefined; DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE: boolean; DD_EXTERNAL_ENV: string | undefined; + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config"; + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_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/index.js b/packages/dd-trace/src/config/index.js index 2699a38c11..5e952a6653 100644 --- a/packages/dd-trace/src/config/index.js +++ b/packages/dd-trace/src/config/index.js @@ -333,6 +333,16 @@ class Config extends ConfigBase { #applyCalculated () { undo(this, 'calculated') + if (this.featureFlags.DD_FEATURE_FLAGS_ENABLED && + !trackedConfigOrigins.has('featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE') && + trackedConfigOrigins.has('experimental.flaggingProvider.enabled')) { + if (this.experimental.flaggingProvider.enabled) { + setAndTrack(this, 'featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE', 'remote_config') + } else { + setAndTrack(this, 'featureFlags.DD_FEATURE_FLAGS_ENABLED', false) + } + } + if (this.url || os.type() !== 'Windows_NT' && !trackedConfigOrigins.has('hostname') && diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 54601a31e5..74f3dff192 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -840,7 +840,10 @@ "implementation": "A", "type": "string", "default": "agentless", - "description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config." + "allowed": "agentless|remote_config", + "description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config.", + "namespace": "featureFlags", + "transform": "toLowerCase" } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": [ @@ -848,7 +851,9 @@ "implementation": "A", "type": "string", "default": null, - "description": "Experimental: Override the agentless Feature Flagging UFC endpoint or base URL." + "description": "Experimental: Override the agentless Feature Flagging UFC endpoint or base URL.", + "namespace": "featureFlags", + "sensitive": true } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": [ @@ -856,7 +861,9 @@ "implementation": "A", "type": "int", "default": "30", - "description": "Experimental: Set the agentless Feature Flagging UFC polling interval in seconds, capped at one hour." + "description": "Experimental: Set the agentless Feature Flagging UFC polling interval in seconds, capped at one hour.", + "allowed": "[1-9]\\d*", + "namespace": "featureFlags" } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": [ @@ -864,7 +871,17 @@ "implementation": "A", "type": "int", "default": "2", - "description": "Experimental: Set the agentless Feature Flagging UFC request timeout in seconds." + "description": "Experimental: Set the agentless Feature Flagging UFC request timeout in seconds.", + "allowed": "[1-9]\\d*", + "namespace": "featureFlags" + } + ], + "DD_FEATURE_FLAGS_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "true", + "namespace": "featureFlags" } ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index e5baca2e5e..c0113df9c5 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -6,7 +6,7 @@ const { Readable } = require('stream') const http = require('http') const https = require('https') -const net = require('net') +const net = require('node:net') const zlib = require('zlib') const { storage } = require('../../../../datadog-core') @@ -29,6 +29,7 @@ let activeBufferSize = 0 /** * @param {string} hostname Host as resolved by {@link parseUrl}; IPv6 is unbracketed (`::1`). + * @returns {boolean} */ function isLoopbackHost (hostname) { // The 127.0.0.0/8 block is loopback, but only when the host is an actual IPv4 literal: a @@ -41,7 +42,9 @@ function isLoopbackHost (hostname) { /** * @param {Buffer|string|Readable|Array} data * @param {object} options - * @param {(error: Error|null, result: string, statusCode: number) => void} callback + * @param {(error: Error|null, result?: string|import('node:http').IncomingMessage|null, + * statusCode?: number, headers?: import('node:http').IncomingHttpHeaders) => void} callback + * @returns {import('node:http').ClientRequest | undefined} */ function request (data, options, callback) { if (!options.headers) { @@ -95,6 +98,7 @@ function request (data, options, callback) { const timeout = options.timeout || 2000 const isSecure = options.protocol === 'https:' const client = isSecure ? https : http + const streamResponse = options.responseType === 'stream' let dataArray = data if (!Array.isArray(data)) { @@ -106,9 +110,19 @@ function request (data, options, callback) { options.agent = isSecure ? httpsAgent : httpAgent + /** + * @param {import('node:http').IncomingMessage} res + * @param {() => void} finalize + */ const onResponse = (res, finalize) => { markEndpointReached(options) + if (streamResponse) { + res.setTimeout(timeout) + callback(null, res, res.statusCode, res.headers) + return + } + const chunks = [] res.setTimeout(timeout) @@ -162,30 +176,43 @@ function request (data, options, callback) { // Retries always run via setTimeout so the AsyncLocalStorage store survives // the gap before socket.connect(); ALS.run() does not call ALS.enterWith() // outside AsyncContextFrame, so a synchronous re-entry would lose the store. + /** + * @param {number} attemptIndex + * @returns {import('node:http').ClientRequest | undefined} + */ const attempt = attemptIndex => { if (!request.writable) { log.debug('Maximum number of active requests reached: payload is discarded.') - return callback(null) + callback(null) + return } activeBufferSize += options.headers['Content-Length'] ?? 0 - legacyStorage.run({ noop: true }, () => { + return legacyStorage.run({ noop: true }, () => { let finished = false + let responseReceived = false const finalize = () => { if (finished) return finished = true activeBufferSize -= options.headers['Content-Length'] ?? 0 } - const req = client.request(options, (res) => onResponse(res, finalize)) + const req = client.request(options, (res) => { + responseReceived = true + onResponse(res, finalize) + }) req.once('close', finalize) req.once('timeout', finalize) req.once('error', error => { finalize() - if (attemptIndex < getMaxAttempts(options) && isRetriableNetworkError(error)) { + if (streamResponse && responseReceived) return + + if (options.retry !== false && + attemptIndex < getMaxAttempts(options) && + isRetriableNetworkError(error)) { // Unref so a pending retry never keeps the host process alive past // its natural exit point; long-running apps still retry because the // event loop is held open by their own work. @@ -209,10 +236,12 @@ function request (data, options, callback) { for (const buffer of dataArray) req.write(buffer) req.end() + + return req }) } - attempt(1) + return attempt(1) } function byteLength (data) { diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index 8f31b89b62..bfe7989547 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -1,12 +1,13 @@ 'use strict' -const net = require('node:net') -const { storage } = require('../../../datadog-core') +const { pipeline } = require('node:stream') +const { createGunzip } = require('node:zlib') + +const { parser, pick, streamValues } = require('../../../../vendor/dist/stream-json') +const request = require('../exporters/common/request') const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers') 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 @@ -15,241 +16,401 @@ const SECOND_RETRY_MAX_MS = 30_000 const RETRY_JITTER = 0.2 const FAILURE_WARNING_INTERVAL_MS = 5 * 60 * 1000 +/** + * @typedef {object} AgentlessSourceConfig + * @property {URL} endpoint + * @property {number} pollIntervalMs + * @property {number} requestTimeoutMs + * @property {string} apiKey + */ + +/** + * @typedef {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1} UniversalFlagConfiguration + */ + +/** + * @typedef {object} PollResponse + * @property {number} statusCode + * @property {string | undefined} etag + * @property {UniversalFlagConfiguration | undefined} configuration + */ + +class RetryableRequestError extends Error {} +class MalformedPayloadError extends Error {} +class ResponseReadError extends Error {} + class AgentlessConfigurationSource { + /** @type {import('node:http').ClientRequest | undefined} */ + #activeRequest + + /** @type {(configuration: UniversalFlagConfiguration) => void} */ + #applyConfiguration + + #closed = false + + /** @type {AgentlessSourceConfig} */ + #config + + /** @type {string | undefined} */ + #etag + + #lastFailureWarning = -Infinity + + /** @type {(() => void) | undefined} */ + #resumeRetry + + #started = false + + /** @type {NodeJS.Timeout | undefined} */ + #timer + /** - * @param {object} config - Resolved agentless settings. - * @param {Function} applyConfiguration - Applies a parsed UFC configuration. - * @param {object} [options] - Runtime dependencies. + * @param {AgentlessSourceConfig} config + * @param {(configuration: UniversalFlagConfiguration) => void} applyConfiguration */ - constructor (config, applyConfiguration, options = {}) { - this._config = config - this._applyConfiguration = applyConfiguration - this._fetch = options.fetch || fetch - 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._pollInFlight = false - this._etag = undefined - this._timer = undefined - this._activeRequest = undefined - this._lastFailureWarning = -Infinity + constructor (config, applyConfiguration) { + this.#config = config + this.#applyConfiguration = applyConfiguration } /** - * Starts fixed-delay polling. Repeated calls are idempotent. - * * @returns {void} */ start () { - if (this._started || this._closed) return - this._started = true - this.pollOnce(() => {}) + if (this.#closed || this.#started) return + this.#started = true + this.#poll() } /** - * 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 + if (this.#closed) return + this.#closed = true + + if (this.#timer) { + clearTimeout(this.#timer) + this.#timer = undefined } - this._activeRequest?.abort() - this._activeRequest = undefined + + const resumeRetry = this.#resumeRetry + this.#resumeRetry = undefined + resumeRetry?.() + + this.#activeRequest?.destroy() + this.#activeRequest = undefined } /** - * 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 + #poll () { + this.#attempt(1).then( + this.#finishPoll.bind(this, undefined), + this.#finishPoll.bind(this) + ) + } + + /** + * @param {Error | undefined} error + * @returns {void} + */ + #finishPoll (error) { + if (error && !this.#closed) { + if (error instanceof MalformedPayloadError) { + log.debug('Feature Flagging agentless endpoint returned malformed UFC payload: %s', error.message) + } else { + log.debug('Feature Flagging agentless poll failed: %s', error.message) + if (error instanceof ResponseReadError) this.#warnFailure(undefined, error, 1) + } } - if (this._pollInFlight) { - callback(null, { skipped: true }) - return + + if (!this.#closed) { + this.#timer = setTimeout(() => { + this.#timer = undefined + this.#poll() + }, this.#config.pollIntervalMs) + this.#timer.unref?.() } + } - this._pollInFlight = true - this._attempt(1, (error, result) => { - this._pollInFlight = 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?.() - } - }) + /** + * @param {number} attempt + * @returns {Promise} + */ + #attempt (attempt) { + return this.#request().then( + this.#handleResponse.bind(this, attempt), + this.#handleError.bind(this, attempt) + ) } /** - * 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} + * @param {number} attempt + * @param {PollResponse} response + * @returns {object | Promise} */ - _attempt (attempt, callback) { - this._request((error, response) => { - if (this._closed) { - callback(null, { stopped: true }) - return - } + #handleResponse (attempt, response) { + if (this.#closed) return { stopped: true } - 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 (isRetryableStatus(response.statusCode)) { + if (attempt < MAX_ATTEMPTS) return this.#waitAndRetry(attempt) + this.#warnFailure(response.statusCode, undefined, attempt) + } - if (retryable) this._warnFailure(response?.statusCode, error) + return this.#apply(response) + } - if (error) { - callback(error) - return - } - callback(null, this._apply(response)) - }) + /** + * @param {number} attempt + * @param {Error} error + * @returns {object | Promise} + */ + #handleError (attempt, error) { + if (this.#closed) return { stopped: true } + + if (error instanceof RetryableRequestError) { + if (attempt < MAX_ATTEMPTS) return this.#waitAndRetry(attempt) + this.#warnFailure(undefined, error, attempt) + } + + throw error } /** - * Sends one HTTP request to the agentless endpoint. - * - * @param {Function} callback - Receives a request error or buffered response. - * @returns {void} + * @param {number} attempt + * @returns {Promise} */ - _request (callback) { + #waitAndRetry (attempt) { + const delay = retryDelay(this.#config.pollIntervalMs, attempt, Math.random()) + + /** + * @param {() => void} resolve + */ + const wait = (resolve) => { + this.#resumeRetry = resolve + this.#timer = setTimeout(() => { + this.#timer = undefined + this.#resumeRetry = undefined + resolve() + }, delay) + this.#timer.unref?.() + } + + return new Promise(wait).then( + this.#continueAttempt.bind(this, attempt + 1) + ) + } + + /** + * @param {number} attempt + * @returns {object | Promise} + */ + #continueAttempt (attempt) { + return this.#closed ? { stopped: true } : this.#attempt(attempt) + } + + /** + * @returns {Promise} + */ + #request () { const headers = { ...getClientLibraryHeaders(), 'Accept-Encoding': 'gzip', + 'DD-API-KEY': this.#config.apiKey, } - if (this._apiKey) headers['DD-API-KEY'] = this._apiKey - if (this._etag) headers['If-None-Match'] = this._etag + if (this.#etag) headers['If-None-Match'] = this.#etag + + /** + * @param {(response: PollResponse) => void} resolve + * @param {(error: Error) => void} reject + */ + const execute = (resolve, reject) => { + /** + * @param {Error | null} error + * @param {import('node:http').IncomingMessage | null | undefined} response + */ + const onResponse = (error, response) => { + if (error) { + this.#activeRequest = undefined + reject(new RetryableRequestError('Feature Flagging agentless request failed', { cause: error })) + return + } - 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) - } + if (!response) { + this.#activeRequest = undefined + reject(new RetryableRequestError('Feature Flagging agentless request was not sent')) + return + } - 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. - this._fetch(this._config.endpoint, { - method: 'GET', - headers, - redirect: 'manual', - signal: controller.signal, - }).then(response => { + const { headers: responseHeaders, statusCode } = response + const etag = responseHeaders.etag const result = { - statusCode: response.status, - etag: response.headers.get('etag') ?? undefined, - body: '', + statusCode, + etag: Array.isArray(etag) ? etag[0] : etag, + configuration: undefined, } - if (response.status !== 200) { - response.body?.cancel?.().catch(() => {}) - finish(null, result) + + if (statusCode !== 200) { + this.#activeRequest = undefined + response.destroy() + resolve(result) return } - response.text().then(body => { - result.body = body - 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' - finish(new Error(message, { cause: error })) + + this.#parseResponse(response, responseHeaders['content-encoding'], (parseError, configuration) => { + this.#activeRequest = undefined + if (parseError) { + reject(parseError) + } else { + result.configuration = configuration + resolve(result) + } }) - }, error => finish(requestError(error))) - }) + } + + this.#activeRequest = request('', { + url: this.#config.endpoint, + method: 'GET', + headers, + responseType: 'stream', + retry: false, + timeout: this.#config.requestTimeoutMs, + }, onResponse) + } + + return new Promise(execute) + } + + /** + * @param {import('node:http').IncomingMessage} response + * @param {string | string[] | undefined} contentEncoding + * @param {(error: Error | null, configuration?: UniversalFlagConfiguration) => void} callback + * @returns {void} + */ + #parseResponse (response, contentEncoding, callback) { + let attributes + let resourceType + let responseError + let gzipError + + /** + * @param {{ value: unknown }} entry + */ + const collectConfiguration = (entry) => { + if (entry.value && typeof entry.value === 'object' && !Array.isArray(entry.value)) { + resourceType = entry.value.type + attributes = entry.value.attributes + } else { + resourceType = undefined + attributes = undefined + } + } + + /** + * @param {Error} error + */ + const rememberResponseError = (error) => { + responseError = error + } + + /** + * @param {Error} error + */ + const rememberGzipError = (error) => { + gzipError = error + } + + /** + * @param {Error | null | undefined} error + */ + const finish = (error) => { + if (error) { + if (gzipError) { + callback(new ResponseReadError( + 'Feature Flagging agentless gzip response could not be decompressed', + { cause: gzipError } + )) + } else if (responseError) { + callback(new ResponseReadError( + 'Feature Flagging agentless response body could not be read', + { cause: responseError } + )) + } else { + callback(new MalformedPayloadError( + 'Feature Flagging agentless response was malformed', + { cause: error } + )) + } + return + } + + try { + callback(null, validateConfiguration(resourceType, attributes)) + } catch (validationError) { + callback(new MalformedPayloadError( + 'Feature Flagging agentless response was not a valid UFC resource', + { cause: validationError } + )) + } + } + + const jsonParser = parser() + const configurationPicker = pick({ filter: 'data' }) + const configurationValues = streamValues({ reviver: preservePrototypeKey }) + const streams = [response] + + response.once('error', rememberResponseError) + const encoding = Array.isArray(contentEncoding) ? contentEncoding[0] : contentEncoding + if (encoding?.toLowerCase() === 'gzip') { + const gunzip = createGunzip() + gunzip.once('error', rememberGzipError) + streams.push(gunzip) + } + streams.push(jsonParser, configurationPicker, configurationValues) + + configurationValues.on('data', collectConfiguration) + pipeline(...streams, finish) } /** - * Applies a successful response while preserving last-known-good state on - * every failure path. - * - * @param {object} response - Buffered HTTP response. - * @returns {object} Poll outcome. + * @param {PollResponse} response + * @returns {object} */ - _apply (response) { + #apply (response) { const status = response.statusCode if (status === 304) return { notModified: true } if (status === 401 || status === 403) { - this._warnFailure(status) + this.#warnFailure(status, undefined, 1) return { rejected: true, statusCode: status } } if (status !== 200) return { rejected: true, statusCode: status } - let configuration try { - configuration = parseConfiguration(response.body) + this.#applyConfiguration(response.configuration) } 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) + log.debug('Feature Flagging agentless UFC payload could not be applied: %s', error.message) return { rejected: true, applicationFailed: true } } const etag = response.etag?.trim() - this._etag = etag || undefined + this.#etag = 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. + * @param {number | undefined} statusCode + * @param {Error | undefined} error + * @param {number} attempts * @returns {void} */ - _warnFailure (statusCode, error) { - const now = this._now() - if (now - this._lastFailureWarning < FAILURE_WARNING_INTERVAL_MS) return - this._lastFailureWarning = now + #warnFailure (statusCode, error, attempts) { + const now = Date.now() + if (now - this.#lastFailureWarning < FAILURE_WARNING_INTERVAL_MS) return + this.#lastFailureWarning = now if (statusCode === 401 || statusCode === 403) { log.warn( @@ -257,107 +418,73 @@ class AgentlessConfigurationSource { statusCode ) } else if (statusCode) { - log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, MAX_ATTEMPTS) + log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, attempts) + } else if (attempts > 1) { + log.warn('Feature Flagging agentless request failed after %d attempts: %s', attempts, error.message) } else { - log.warn('Feature Flagging agentless request failed after %d attempts', MAX_ATTEMPTS, error) + log.warn('Feature Flagging agentless request failed: %s', error.message) } } } /** - * 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. + * @param {unknown} resourceType + * @param {unknown} attributes + * @returns {UniversalFlagConfiguration} */ -function parseConfiguration (body) { - const parsed = JSON.parse(body) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || - !parsed.data || typeof parsed.data !== 'object' || Array.isArray(parsed.data)) { - throw new Error('Expected a JSON:API Universal Flag Configuration response') - } - if (parsed.data.type !== 'universal-flag-configuration') { +function validateConfiguration (resourceType, attributes) { + if (resourceType !== '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' || - Array.isArray(configuration.environment) || - 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 + + if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes) || + typeof attributes.createdAt !== 'string' || + (attributes.format !== undefined && typeof attributes.format !== 'string') || + !attributes.environment || typeof attributes.environment !== 'object' || + Array.isArray(attributes.environment) || + typeof attributes.environment.name !== 'string' || + !attributes.flags || typeof attributes.flags !== 'object' || Array.isArray(attributes.flags)) { + const keys = attributes && typeof attributes === 'object' + ? Object.keys(attributes).join(',') + : typeof attributes 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 + return attributes } /** - * 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. + * @this {Record} + * @param {string} key + * @param {unknown} value */ -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 -} +function preservePrototypeKey (key, value) { + if (key === '__proto__') { + Object.defineProperty(this, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) + return + } -/** - * 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)) + return value } /** - * Reports whether an HTTP response should be retried. - * - * @param {number | undefined} status - HTTP status. - * @returns {boolean} Whether the status is transient. + * @param {number | undefined} status + * @returns {boolean} */ 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. + * @param {number} pollIntervalMs + * @param {number} attempt + * @param {number} random + * @returns {number} */ function retryDelay (pollIntervalMs, attempt, random) { const base = attempt === 1 @@ -367,12 +494,10 @@ function retryDelay (pollIntervalMs, attempt, random) { } /** - * 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. + * @param {number} value + * @param {number} minimum + * @param {number} maximum + * @returns {number} */ function clamp (value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)) diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 2f015bb54e..f789a3c850 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -2,78 +2,47 @@ 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. + * @typedef {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1} UniversalFlagConfiguration */ -function resolve (config) { - const mode = resolveMode(config) - - if (mode === CONFIGURATION_SOURCE_REMOTE_CONFIG) { - return { mode } - } - - return { - mode, - endpoint: endpoint(config, config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL), - pollIntervalMs: positiveMillisecondsFromSeconds( - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, - DEFAULT_POLL_INTERVAL_SECONDS, - 'poll interval', - MAX_POLL_INTERVAL_SECONDS - ), - requestTimeoutMs: positiveMillisecondsFromSeconds( - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, - 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. + * @param {import('../config/config-base')} config + * @param {(configuration: UniversalFlagConfiguration) => void} applyConfiguration */ -function isRemoteConfig (config) { +function create (config, applyConfiguration) { + const { + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: source, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: baseUrl, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: pollIntervalSeconds, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: requestTimeoutSeconds, + DD_FEATURE_FLAGS_ENABLED: enabled, + } = config.featureFlags + + if (!enabled || source !== 'agentless') { + return + } + try { - return resolveMode(config) === CONFIGURATION_SOURCE_REMOTE_CONFIG + if (!config.DD_API_KEY) { + throw new Error('DD_API_KEY is required for Feature Flagging agentless delivery') + } + + const AgentlessConfigurationSource = require('./agentless_configuration_source') + return new AgentlessConfigurationSource({ + endpoint: endpoint(config, baseUrl), + pollIntervalMs: Math.min(pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS) * 1000, + requestTimeoutMs: requestTimeoutSeconds * 1000, + apiKey: config.DD_API_KEY, + }, applyConfiguration) } 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.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}`) - } - return mode -} - /** * Builds the agentless rules-based server endpoint. * @@ -89,7 +58,7 @@ 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}`) + const url = new URL(`https://ufc-server.ff-cdn.${config.site.toLowerCase()}${DEFAULT_AGENTLESS_PATH}`) if (config.env) url.searchParams.set('dd_env', config.env) return url } @@ -112,41 +81,6 @@ function endpoint (config, configuredBaseUrl) { 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 positiveMillisecondsFromSeconds (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, + create, } diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index 792ecf23b6..fe82a7b9c4 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -1,12 +1,15 @@ 'use strict' const { channel } = require('dc-polyfill') +const requireOptionalPeer = require('../../../datadog-instrumentations/src/helpers/require-optional-peer') const log = require('../log') +const configurationSource = require('./configuration_source') const { EXPOSURE_CHANNEL } = require('./constants/constants') const EvalMetricsHook = require('./eval-metrics-hook') -const { DatadogNodeServerProvider } = require('./require-provider') const SpanEnrichmentHook = require('./span-enrichment-hook') +const { DatadogNodeServerProvider } = requireOptionalPeer('@datadog/openfeature-node-server') + /** * OpenFeature provider that integrates with Datadog's feature flagging system. * Extends DatadogNodeServerProvider to add tracer integration and configuration management. @@ -23,16 +26,11 @@ class FlaggingProvider extends DatadogNodeServerProvider { * @param {import('../config/config-base')} config - Tracer configuration object */ constructor (tracer, config) { - // Call parent constructor with required options and timeout super({ exposureChannel: channel(EXPOSURE_CHANNEL), initializationTimeoutMs: config.experimental.flaggingProvider.initializationTimeoutMs, }) - this._tracer = tracer - this._config = config - - // @ts-expect-error The upstream constructor always initializes its optional hooks property. this.hooks.push(new EvalMetricsHook(config)) if (config.experimental.flaggingProvider.spanEnrichment?.enabled) { @@ -46,6 +44,9 @@ class FlaggingProvider extends DatadogNodeServerProvider { log.debug('%s created with timeout: %dms', this.constructor.name, config.experimental.flaggingProvider.initializationTimeoutMs) + + this.#configurationSource = configurationSource.create(config, this.setConfiguration.bind(this)) + this.#configurationSource?.start() } /** @@ -56,58 +57,7 @@ class FlaggingProvider extends DatadogNodeServerProvider { this.#configurationSource?.stop() this.#configurationSource = undefined 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) { - log.warn('%s already has a configuration source; ignoring duplicate source', this.constructor.name) - source.stop() - return - } - this.#configurationSource = source - source.start() - } - - /** -<<<<<<< HEAD -======= - * 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) { - log.warn('%s already has a configuration source; ignoring duplicate source', this.constructor.name) - source.stop() - return - } - this.#configurationSource = source - source.start() - } - - /** ->>>>>>> bd5962c651 (fix(openfeature): harden configuration source handling) - * Internal method to update flag configuration from Remote Config. - * This method is called automatically when Remote Config delivers UFC updates. - * - * @internal - * @param {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1} ufc - * - Universal Flag Configuration object - */ - _setConfiguration (ufc) { - if (typeof this.setConfiguration === 'function') { - this.setConfiguration(ufc) - } - log.debug('%s provider configuration updated', this.constructor.name) + this.#spanEnrichmentHook = undefined } } diff --git a/packages/dd-trace/src/openfeature/index.js b/packages/dd-trace/src/openfeature/index.js index cc2029ba90..283417f9ed 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/noop.js b/packages/dd-trace/src/openfeature/noop.js index 47e39460b5..4ed31ec3e9 100644 --- a/packages/dd-trace/src/openfeature/noop.js +++ b/packages/dd-trace/src/openfeature/noop.js @@ -20,12 +20,7 @@ function resolveDefault (defaultValue) { * https://openfeature.dev/docs/reference/concepts/provider/ */ class NoopFlaggingProvider { - /** - * @param {object} [noopTracer] - Optional noop tracer instance - */ - constructor (noopTracer) { - this._tracer = noopTracer - this._config = {} + constructor () { this.metadata = { name: 'NoopFlaggingProvider' } this.status = 'NOT_READY' this.runsOn = 'server' @@ -78,28 +73,6 @@ class NoopFlaggingProvider { resolveObjectEvaluation (flagKey, defaultValue, context, logger) { return resolveDefault(defaultValue) } - - /** - * @returns {object} Current configuration - */ - getConfiguration () { - return this._config - } - - /** - * @param {object} config - Configuration to set - */ - setConfiguration (config) { - this._config = config - } - - /** - * @internal - * @param {object} ufc - Universal Flag Configuration object - */ - _setConfiguration (ufc) { - this.setConfiguration(ufc) - } } module.exports = NoopFlaggingProvider diff --git a/packages/dd-trace/src/openfeature/register.js b/packages/dd-trace/src/openfeature/register.js index 3c7afd4886..fa187abcc6 100644 --- a/packages/dd-trace/src/openfeature/register.js +++ b/packages/dd-trace/src/openfeature/register.js @@ -4,6 +4,8 @@ const { registerFeature } = require('../feature-registry') const noop = new (require('./noop'))() +/** @typedef {import('../proxy') & { openfeature: object }} OpenFeatureProxy */ + /** * @param {import('../proxy')} proxy * @returns {boolean} @@ -11,36 +13,77 @@ const noop = new (require('./noop'))() function hasFlaggingProvider (proxy) { const descriptor = Reflect.getOwnPropertyDescriptor(proxy, 'openfeature') + 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 } +/** + * @param {import('../proxy')} proxy + * @param {import('../tracer')} tracer + * @param {import('../config/config-base')} config + */ +function defineFlaggingProvider (proxy, tracer, config) { + Reflect.defineProperty(proxy, 'openfeature', { + get () { + proxy._modules.openfeature.enable(config) + + const FlaggingProvider = require('./flagging_provider') + const provider = new FlaggingProvider(tracer, config) + + Reflect.defineProperty(proxy, 'openfeature', { + value: provider, + configurable: true, + enumerable: true, + }) + return provider + }, + configurable: true, + enumerable: true, + }) +} + registerFeature({ name: 'openfeature', noop, factory: () => require('./index'), /** - * @param {object} rc - RemoteConfig instance + * @param {import('../remote_config')} rc - RemoteConfig instance * @param {import('../config/config-base')} config - * @param {import('../proxy')} proxy + * @param {OpenFeatureProxy} proxy */ remoteConfig (rc, config, proxy) { const openfeatureRemoteConfig = require('./remote_config') - openfeatureRemoteConfig.enable(rc, config, () => proxy.openfeature) + const subscribe = config.featureFlags.DD_FEATURE_FLAGS_ENABLED && + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE === 'remote_config' + openfeatureRemoteConfig.enable( + rc, + () => proxy.openfeature, + subscribe + ) }, /** * @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) { + enable (config, tracer, proxy) { + if (!config.featureFlags.DD_FEATURE_FLAGS_ENABLED) return + + if (!hasFlaggingProvider(proxy)) { + defineFlaggingProvider(proxy, tracer, config) + } else if (hasConstructedFlaggingProvider(proxy)) { proxy._modules.openfeature.enable(config) - if (!hasFlaggingProvider(proxy)) { - lazyProxy(proxy, 'openfeature', () => require('./flagging_provider'), tracer, config) - } } }, }) diff --git a/packages/dd-trace/src/openfeature/remote_config.js b/packages/dd-trace/src/openfeature/remote_config.js index 06fbe967bc..2f41e541ec 100644 --- a/packages/dd-trace/src/openfeature/remote_config.js +++ b/packages/dd-trace/src/openfeature/remote_config.js @@ -5,30 +5,27 @@ 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 {import('../remote_config')} rc - RemoteConfig instance + * @param {() => import('./flagging_provider')} getOpenfeatureProxy + * @param {boolean} subscribe - Whether Agent Remote Config owns UFC delivery */ -function enable (rc, config, getOpenfeatureProxy) { - // 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) { + if (!subscribe) return - // Only register product handler if the experimental feature is enabled - if (!config.experimental.flaggingProvider.enabled) return + rc.updateCapabilities(RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, true) - // Set product handler for FFE_FLAGS - rc.setProductHandler('FFE_FLAGS', (action, conf) => { + /** + * @param {string} action + * @param {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1} conf + */ + const updateConfiguration = (action, conf) => { if (action === 'apply' || action === 'modify') { - // Feed UFC config directly to OpenFeature provider - getOpenfeatureProxy()._setConfiguration(conf) + getOpenfeatureProxy().setConfiguration(conf) } else if (action === 'unapply') { - // Clear the configuration so evaluations return PROVIDER_NOT_READY, - // consistent with Go and Python which also set config to null on RC deletion. - // The evaluator returns PROVIDER_NOT_READY when config is null/undefined. - getOpenfeatureProxy()._setConfiguration(null) + getOpenfeatureProxy().setConfiguration(undefined) } - }) + } + rc.setProductHandler('FFE_FLAGS', updateConfiguration) } module.exports = { diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index e30a3d8ec1..ae638371a7 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_FEATURE_FLAGS_BASE_URL.example', 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', @@ -4953,14 +4955,154 @@ rules: }) context('Feature Flagging configuration source', () => { - it('defaults to agentless delivery with cross-SDK timings', () => { + it('uses agentless as the source default', () => { + assert.strictEqual(defaults['featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'], 'agentless') + assert.strictEqual(getConfig().featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'agentless') + }) + + for (const { + name, + stableEnabled, + source, + legacyEnabled, + legacyOption, + expected, + } of [ + { + name: 'defaults to lazy agentless delivery', + expected: { enabled: true, source: 'agentless' }, + }, + { + name: 'keeps the agentless default when the stable setting is explicitly enabled', + stableEnabled: 'true', + expected: { enabled: true, source: 'agentless' }, + }, + { + name: 'lets the stable kill switch override explicit and legacy settings', + stableEnabled: 'false', + source: 'remote_config', + legacyEnabled: 'true', + expected: { enabled: false }, + }, + { + name: 'grandfathers legacy environment enablement onto Remote Config', + legacyEnabled: 'true', + expected: { enabled: true, source: 'remote_config' }, + }, + { + name: 'preserves legacy environment disablement', + legacyEnabled: 'false', + expected: { enabled: false }, + }, + { + name: 'grandfathers legacy programmatic enablement onto Remote Config', + legacyOption: true, + expected: { enabled: true, source: 'remote_config' }, + }, + { + name: 'preserves legacy programmatic disablement', + legacyOption: false, + expected: { enabled: false }, + }, + { + name: 'treats an empty source as absent before applying legacy enablement', + source: '', + legacyEnabled: 'true', + expected: { enabled: true, source: 'remote_config' }, + }, + { + name: 'treats a whitespace source as absent before applying legacy disablement', + source: ' ', + legacyEnabled: 'false', + expected: { enabled: false }, + }, + { + name: 'defaults a blank source to agentless without a legacy setting', + source: ' ', + expected: { enabled: true, source: 'agentless' }, + }, + { + name: 'lets an explicit agentless source override legacy enablement', + source: 'AgEnTlEsS', + legacyEnabled: 'true', + expected: { enabled: true, source: 'agentless' }, + }, + { + name: 'lets an explicit Remote Config source override legacy disablement', + source: 'REMOTE_CONFIG', + legacyEnabled: 'false', + expected: { enabled: true, source: 'remote_config' }, + }, + { + name: 'falls back from an invalid source to legacy enablement', + source: 'other', + legacyEnabled: 'true', + expected: { enabled: true, source: 'remote_config' }, + }, + { + name: 'falls back from the reserved offline source to legacy enablement', + source: 'offline', + legacyEnabled: 'true', + expected: { enabled: true, source: 'remote_config' }, + }, + ]) { + it(name, () => { + if (stableEnabled !== undefined) { + process.env.DD_FEATURE_FLAGS_ENABLED = stableEnabled + } + if (source !== undefined) { + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = source + } + if (legacyEnabled !== undefined) { + process.env.DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = legacyEnabled + } + const options = legacyOption === undefined + ? undefined + : { experimental: { flaggingProvider: { enabled: legacyOption } } } + + const config = getConfig(options) + const actual = config.featureFlags.DD_FEATURE_FLAGS_ENABLED + ? { enabled: true, source: config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE } + : { enabled: false } + + assert.deepStrictEqual(actual, expected) + }) + } + + it('falls back from an invalid source through calculated legacy precedence', () => { + process.env.DD_FEATURE_FLAGS_ENABLED = 'true' + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'offline' + process.env.DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = 'true' + + const config = getConfig() + + assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_ENABLED, true) + assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'remote_config') + assert.strictEqual(config.experimental.flaggingProvider.enabled, true) + assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_ENABLED'), 'env_var') + assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'calculated') + assert.strictEqual(config.getOrigin('experimental.flaggingProvider.enabled'), 'env_var') + assertConfigUpdateContains(updateConfig.getCall(0).args[0], [ + { name: 'DD_FEATURE_FLAGS_ENABLED', value: true, origin: 'env_var' }, + { name: 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE', value: 'remote_config', origin: 'calculated' }, + { name: 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', value: true, origin: 'env_var' }, + ]) + + config.setRemoteConfig({}) + + sinon.assert.notCalled(log.error) + }) + + it('defaults agentless delivery to cross-SDK timings', () => { const config = getConfig() 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, + featureFlags: { + 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, + }, }) }) @@ -4969,7 +5111,7 @@ rules: const config = getConfig() - assert.strictEqual(config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'remote_config') + assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'remote_config') }) it('reads the canonical agentless environment variables', () => { @@ -4980,13 +5122,32 @@ rules: const config = getConfig() 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, + featureFlags: { + 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('uses registry defaults for non-positive agentless timings', () => { + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = '0' + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = '-1' + + const config = getConfig() + + assert.strictEqual( + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, + 30 + ) + assert.strictEqual( + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, + 2 + ) + sinon.assert.calledTwice(log.warn) + }) + it('does not accept programmatic configuration-source options', () => { const config = getConfig({ experimental: { @@ -5000,10 +5161,16 @@ rules: }, }) - 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) + assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'agentless') + assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, undefined) + assert.strictEqual( + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, + 30 + ) + assert.strictEqual( + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, + 2 + ) for (const name of [ 'configurationSource', 'agentlessBaseUrl', diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index d04bc71c69..a0ff72c8d1 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -44,6 +44,7 @@ describe('request', function () { let docker let maxAttempts let retryStubs + let runInNoopContext beforeEach(() => { log = { @@ -64,7 +65,11 @@ describe('request', function () { getMaxAttempts: sinon.fake(() => maxAttempts), markEndpointReached: sinon.fake(), } + runInNoopContext = sinon.spy((_store, callback) => callback()) request = proxyquire('../../../src/exporters/common/request', { + '../../../../datadog-core': { + storage: () => ({ run: runInNoopContext }), + }, './docker': docker, '../../log': log, './retry': { @@ -105,6 +110,79 @@ describe('request', function () { }) }) + it('streams raw responses and returns the cancellable request', (done) => { + nock('http://test:123') + .get('/path') + .reply(404, ['first', 'second'], { + etag: '"raw"', + }) + + const clientRequest = request(Buffer.from(''), { + protocol: 'http:', + hostname: 'test', + port: 123, + path: '/path', + method: 'GET', + responseType: 'stream', + retry: false, + }, (error, response, statusCode, headers) => { + const chunks = [] + + clientRequest.emit('error', new Error('late response error')) + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => { + assert.ifError(error) + assert.strictEqual(statusCode, 404) + assert.strictEqual(headers.etag, '"raw"') + assert.strictEqual(Buffer.concat(chunks).toString(), '["first","second"]') + sinon.assert.calledOnceWithMatch(runInNoopContext, { noop: true }, sinon.match.func) + done() + }) + }) + + assert.strictEqual(typeof clientRequest.destroy, 'function') + }) + + it('does not retry when retries are disabled', (done) => { + maxAttempts = 5 + const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }) + + nock('http://localhost:80') + .get('/path') + .replyWithError(error) + + request(Buffer.from(''), { + path: '/path', + method: 'GET', + retry: false, + }, (requestError) => { + assert.strictEqual(requestError, error) + sinon.assert.notCalled(retryStubs.getMaxAttempts) + sinon.assert.notCalled(retryStubs.getRetryDelay) + done() + }) + }) + + it('allows callers to cancel a raw response request', (done) => { + nock('http://localhost:80') + .get('/path') + .delayConnection(1000) + .reply(200, 'OK') + + const cancellation = new Error('cancelled') + const clientRequest = request(Buffer.from(''), { + path: '/path', + method: 'GET', + responseType: 'stream', + retry: false, + }, (error) => { + assert.strictEqual(error, cancellation) + done() + }) + + clientRequest.destroy(cancellation) + }) + it('should handle an http error', done => { nock('http://localhost:8080') .put('/path') 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 aa3f2e7111..af7c79fc7f 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -1,37 +1,48 @@ 'use strict' const assert = require('node:assert/strict') +const { Readable } = require('node:stream') +const zlib = require('node:zlib') + const { afterEach, beforeEach, describe, it } = require('mocha') -const proxyquire = require('proxyquire') +const nock = require('nock') +const proxyquire = require('proxyquire').noCallThru() const sinon = require('sinon') const { VERSION } = require('../../../../version') require('../setup/core') -const VALID_UFC = JSON.stringify({ +const VALID_UFC = { 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), - }, -}) +} + +/** + * @param {object} [configuration] + */ +function responseBody (configuration = VALID_UFC) { + return JSON.stringify({ + data: { + id: '1', + type: 'universal-flag-configuration', + attributes: configuration, + }, + }) +} describe('AgentlessConfigurationSource', () => { let AgentlessConfigurationSource let applyConfiguration let clock let config - let fetch let log + let random + let request let requests let responses - let runInNoopContext + let sources beforeEach(() => { clock = sinon.useFakeTimers() @@ -44,526 +55,647 @@ describe('AgentlessConfigurationSource', () => { } log = { debug: sinon.spy(), - error: sinon.spy(), warn: sinon.spy(), } + random = sinon.stub(Math, 'random').returns(0.5) 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 }) + sources = [] + + request = sinon.spy((data, options, callback) => { + const response = responses.shift() + let responseStream + const activeRequest = { + destroy: sinon.spy(() => { + if (responseStream) { + responseStream.destroy(new Error('cancelled')) + } else if (response?.pending && !response.ignoreDestroy) { + queueMicrotask(() => callback(new Error('cancelled'))) + } + }), + } + const requestRecord = { activeRequest, callback, data, options } + requests.push(requestRecord) + + if (response && !response.pending) { + queueMicrotask(() => { + if (response.error) { + callback(response.error) + } else { + responseStream = createResponseStream(response) + callback(null, responseStream) + } }) } - 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 || ''), - }) + return activeRequest }) + AgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { - '../../../datadog-core': { - storage: () => ({ run: runInNoopContext }), - }, + '../exporters/common/request': request, '../log': log, }) }) afterEach(() => { + for (const configurationSource of sources) configurationSource.stop() + random.restore() clock?.restore() + nock.cleanAll() }) - function source (options = {}) { - return new AgentlessConfigurationSource(config, applyConfiguration, { - fetch, - random: () => 0.5, - ...options, + /** + * @param {object} response + */ + function createResponseStream (response) { + const chunks = response.bodyChunks || [response.body || ''] + let pushed = false + + const responseStream = new Readable({ + read () { + if (pushed) return + pushed = true + response.onRead?.() + for (const chunk of chunks) { + this.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + if (response.bodyError) { + this.destroy(response.bodyError) + } else if (!response.bodyPending) { + this.push(null) + } + }, }) + responseStream.statusCode = response.statusCode + responseStream.headers = response.headers || {} + return responseStream } - function completeScheduledResponse () { - return clock.tickAsync(0) + function source () { + const configurationSource = new AgentlessConfigurationSource(config, applyConfiguration) + sources.push(configurationSource) + return configurationSource } - function poll (configurationSource) { - return new Promise(resolve => { - configurationSource.pollOnce((error, result) => resolve({ error, result })) - }) + async function flush () { + for (let i = 0; i < 10; i++) await clock.tickAsync(0) + } + + /** + * @param {number} milliseconds + */ + async function tick (milliseconds) { + await clock.tickAsync(milliseconds) + await flush() } - it('fetches, applies, and reuses the accepted ETag', async () => { + it('fetches, applies, and reuses an accepted ETag', async () => { responses.push( - { statusCode: 200, headers: { etag: '"ufc-v1"' }, body: VALID_RESPONSE }, - { statusCode: 304, headers: {}, body: '' } + { statusCode: 200, headers: { etag: [' W/"ufc-v1" '] }, body: responseBody() }, + { statusCode: 304 } ) 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 } }) + configurationSource.start() + await flush() + + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) + assert.strictEqual(requests[0].data, '') + assert.strictEqual(requests[0].options.url, config.endpoint) + assert.strictEqual(requests[0].options.method, 'GET') + assert.strictEqual(requests[0].options.responseType, 'stream') + assert.strictEqual(requests[0].options.retry, false) + assert.strictEqual(requests[0].options.timeout, 2000) assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key') 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') - }) - - it('trims an accepted ETag before reusing it', async () => { - const paddedEtagFetch = sinon.stub() - paddedEtagFetch.onFirstCall().resolves({ - status: 200, - headers: { get: name => name === 'etag' ? ' W/"ufc-v1" ' : null }, - text: () => Promise.resolve(VALID_RESPONSE), - }) - paddedEtagFetch.onSecondCall().resolves({ - status: 304, - headers: new Headers(), - }) - const configurationSource = source({ fetch: paddedEtagFetch }) - await poll(configurationSource) - await poll(configurationSource) + await tick(30_000) - assert.strictEqual(paddedEtagFetch.secondCall.args[1].headers['If-None-Match'], 'W/"ufc-v1"') - sinon.assert.calledOnceWithExactly(applyConfiguration, JSON.parse(VALID_UFC)) + assert.strictEqual(requests[1].options.headers['If-None-Match'], 'W/"ufc-v1"') + sinon.assert.calledOnce(applyConfiguration) }) - it('suppresses tracing around agentless requests', () => { - responses.push({ statusCode: 200, body: VALID_RESPONSE }) + it('clears an accepted ETag when the next applied response omits it', async () => { + responses.push( + { statusCode: 200, headers: { etag: '"first"' }, body: responseBody() }, + { statusCode: 200, body: responseBody() }, + { statusCode: 304 } + ) + const configurationSource = source() - source().pollOnce(() => {}) + configurationSource.start() + await flush() + await tick(30_000) + await tick(30_000) - sinon.assert.calledOnceWithMatch(runInNoopContext, { noop: true }, sinon.match.func) + assert.strictEqual(requests[1].options.headers['If-None-Match'], '"first"') + assert.strictEqual(requests[2].options.headers['If-None-Match'], undefined) + sinon.assert.calledTwice(applyConfiguration) }) - 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('streams and applies a response through the shared request transport', async () => { + clock.restore() + clock = undefined + nock('http://127.0.0.1:8080', { + reqheaders: { + 'accept-encoding': 'gzip', + 'dd-api-key': 'test-api-key', + }, + }) + .get('/api/v2/feature-flagging/config/rules-based/server') + .reply(200, responseBody(), { etag: '"real-path"' }) - 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 }) + let resolveConfiguration + const applied = new Promise(resolve => { + resolveConfiguration = resolve + }) + const RealAgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { + '../log': log, + }) + const configurationSource = new RealAgentlessConfigurationSource(config, configuration => { + resolveConfiguration(configuration) + }) + sources.push(configurationSource) - await poll(source()) + configurationSource.start() - assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key') - sinon.assert.notCalled(log.error) + assert.deepStrictEqual(await applied, VALID_UFC) + assert.ok(nock.isDone()) }) - it('accepts a JSON API Universal Flag Configuration without optional format', async () => { - const expected = JSON.parse(VALID_UFC) - delete expected.format + it('selects only data type and attributes without a payload-size cap', async () => { + const expected = { + ...VALID_UFC, + flags: { + large: { + description: 'accepted', + }, + }, + } responses.push({ statusCode: 200, body: JSON.stringify({ + ignored: 'x'.repeat(5 * 1024 * 1024 + 1), data: { - id: '1', - type: 'universal-flag-configuration', + ignored: { nested: 'value' }, attributes: expected, + type: 'universal-flag-configuration', }, }), }) - await poll(source()) + source().start() + await flush() sinon.assert.calledOnceWithExactly(applyConfiguration, expected) }) - it('applies gzip JSON API responses decoded by fetch', async () => { + it('matches JSON.parse object order and duplicate-key last-wins behavior', async () => { + const expected = { + environment: { name: 'last' }, + createdAt: '2026-07-21T00:00:00.000Z', + flags: { enabled: { enabled: true } }, + } + const body = '{"data":{"attributes":{"createdAt":"first"},"type":"wrong",' + + `"type":"universal-flag-configuration","attributes":${JSON.stringify(expected)}}}` + responses.push({ statusCode: 200, body }) + + source().start() + await flush() + + sinon.assert.calledOnce(applyConfiguration) + const configuration = applyConfiguration.firstCall.args[0] + assert.deepStrictEqual(configuration, expected) + assert.deepStrictEqual(Object.keys(configuration), Object.keys(expected)) + }) + + it('treats duplicate data members with JSON.parse last-wins behavior', async () => { responses.push({ statusCode: 200, - headers: { 'content-encoding': 'gzip' }, - body: VALID_RESPONSE, + body: `{"data":${JSON.stringify({ + type: 'universal-flag-configuration', + attributes: VALID_UFC, + })},"data":{"attributes":${JSON.stringify(VALID_UFC)}}}`, }) - const outcome = await poll(source()) + source().start() + await flush() - assert.deepStrictEqual(outcome, { error: null, result: { applied: true } }) - sinon.assert.calledOnceWithExactly(applyConfiguration, JSON.parse(VALID_UFC)) + sinon.assert.notCalled(applyConfiguration) + sinon.assert.calledOnce(log.debug) }) - 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() + it('rejects a primitive data member', async () => { + responses.push({ statusCode: 200, body: '{"data":"invalid"}' }) - 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) + source().start() + await flush() - assert.deepStrictEqual(last, { error: null, result: { notModified: true } }) - sinon.assert.calledOnce(applyConfiguration) - assert.strictEqual(requests[2].options.headers['If-None-Match'], '"good"') + sinon.assert.notCalled(applyConfiguration) sinon.assert.calledOnce(log.debug) }) - it('reports non-gzip response body failures', async () => { - responses.push({ - statusCode: 200, - bodyError: new TypeError('terminated'), - }) + it('preserves __proto__ as an own property like JSON.parse', async () => { + const body = '{"data":{"type":"universal-flag-configuration","attributes":' + + '{"createdAt":"2026-01-01T00:00:00.000Z","environment":{"name":"test"},' + + '"flags":{"__proto__":{"enabled":true}}}}}' + const expected = JSON.parse(body).data.attributes + responses.push({ statusCode: 200, body }) - const outcome = await poll(source()) + source().start() + await flush() - assert.match(outcome.error.message, /response body could not be read/) - sinon.assert.notCalled(applyConfiguration) + sinon.assert.calledOnce(applyConfiguration) + const configuration = applyConfiguration.firstCall.args[0] + assert.deepStrictEqual(configuration, expected) + assert.strictEqual(Object.hasOwn(configuration.flags, '__proto__'), true) + assert.strictEqual(Object.getPrototypeOf(configuration.flags), Object.prototype) + assert.strictEqual(Object.prototype.enabled, undefined) }) - 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) } + it('parses split UTF-8 and escaped JSON names and values', async () => { + const expected = { + createdAt: '2026-01-01T00:00:00.000Z', + environment: { name: 'café 🚀' }, + flags: { escaped: { description: 'line\nbreak' } }, + } + const body = '{"data":{"type":"universal-flag-configur\\u0061tion",' + + `"attr\\u0069butes":${JSON.stringify(expected)}}}` + const buffer = Buffer.from(body) + const rocketByte = buffer.indexOf(Buffer.from('🚀')) responses.push({ statusCode: 200, - body: JSON.stringify({ - data: { - id: 'opaque-id', - type: 'universal-flag-configuration', - attributes: expected, - }, - }), + bodyChunks: [ + buffer.subarray(0, rocketByte + 1), + buffer.subarray(rocketByte + 1, rocketByte + 3), + buffer.subarray(rocketByte + 3), + ], }) - await poll(source()) + source().start() + await flush() sinon.assert.calledOnceWithExactly(applyConfiguration, expected) }) - it('requires JSON API at custom endpoints', async () => { - responses.push({ statusCode: 200, body: VALID_UFC }) + it('preserves last-known-good state after malformed and truncated JSON', async () => { + responses.push( + { statusCode: 200, headers: { etag: '"good"' }, body: responseBody() }, + { statusCode: 200, headers: { etag: '"bad"' }, body: `${responseBody()} trailing` }, + { statusCode: 200, headers: { etag: '"bad"' }, body: '{"data":{"type":' }, + { statusCode: 304 } + ) + const configurationSource = source() - await poll(source()) + configurationSource.start() + await flush() + await tick(30_000) + await tick(30_000) + await tick(30_000) - sinon.assert.notCalled(applyConfiguration) - sinon.assert.calledOnce(log.debug) + sinon.assert.calledOnce(applyConfiguration) + sinon.assert.calledTwice(log.debug) + assert.strictEqual(requests[3].options.headers['If-None-Match'], '"good"') }) - it('rejects unrelated or incomplete JSON API resources', async () => { + it('rejects unrelated and invalid UFC 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' } }), + body: JSON.stringify({ data: { type: 'other', attributes: VALID_UFC } }), }, { statusCode: 200, body: JSON.stringify({ data: { - id: '1', type: 'universal-flag-configuration', - attributes: { createdAt: '2026-01-01T00:00:00.000Z' }, + attributes: { ...VALID_UFC, environment: [] }, }, }), - } - ) - const configurationSource = source() - - await poll(configurationSource) - await poll(configurationSource) - await poll(configurationSource) - - sinon.assert.notCalled(applyConfiguration) - sinon.assert.calledThrice(log.debug) - }) - - it('rejects arrays where UFC envelope objects are required', async () => { - const configuration = JSON.parse(VALID_UFC) - responses.push( - { statusCode: 200, body: JSON.stringify([]) }, - { statusCode: 200, body: JSON.stringify({ data: [] }) }, + }, { statusCode: 200, body: JSON.stringify({ data: { type: 'universal-flag-configuration', - attributes: { ...configuration, environment: [] }, + attributes: 'invalid', }, }), } ) const configurationSource = source() - await poll(configurationSource) - await poll(configurationSource) - await poll(configurationSource) + configurationSource.start() + await flush() + await tick(30_000) + await tick(30_000) sinon.assert.notCalled(applyConfiguration) sinon.assert.calledThrice(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() + it('decompresses gzip responses before parsing', async () => { + responses.push({ + statusCode: 200, + headers: { 'content-encoding': ['GZip'] }, + body: zlib.gzipSync(responseBody()), + }) - await poll(configurationSource) - await poll(configurationSource) - await poll(configurationSource) + source().start() + await flush() - sinon.assert.calledOnce(applyConfiguration) - assert.strictEqual(requests[2].options.headers['If-None-Match'], '"good"') - sinon.assert.calledOnce(log.debug) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) }) - it('clears a stale ETag when an accepted response omits it', async () => { + it('preserves last-known-good state after gzip and response read errors', async () => { responses.push( - { statusCode: 200, headers: { etag: '"first"' }, body: VALID_RESPONSE }, - { statusCode: 200, headers: {}, body: VALID_RESPONSE }, - { statusCode: 200, headers: {}, body: VALID_RESPONSE } + { statusCode: 200, headers: { etag: '"good"' }, body: responseBody() }, + { + statusCode: 200, + headers: { etag: '"bad"', 'content-encoding': 'gzip' }, + body: 'not gzip', + }, + { + statusCode: 200, + headers: { etag: '"bad"' }, + bodyError: new Error('read failed'), + }, + { statusCode: 304 } ) const configurationSource = source() - await poll(configurationSource) - await poll(configurationSource) - await poll(configurationSource) + configurationSource.start() + await flush() + await tick(30_000) + await tick(30_000) + await tick(30_000) - assert.strictEqual(requests[1].options.headers['If-None-Match'], '"first"') - assert.strictEqual(requests[2].options.headers['If-None-Match'], undefined) - sinon.assert.calledThrice(applyConfiguration) + sinon.assert.calledOnce(applyConfiguration) + sinon.assert.calledTwice(log.debug) + sinon.assert.calledOnceWithExactly( + log.warn, + 'Feature Flagging agentless request failed: %s', + 'Feature Flagging agentless gzip response could not be decompressed' + ) + assert.strictEqual(requests[3].options.headers['If-None-Match'], '"good"') }) - it('does not advance the ETag and keeps scheduled polling after a listener failure', async () => { - applyConfiguration.onFirstCall().throws(new Error('listener failed')) + it('does not advance the ETag after an application failure', async () => { + applyConfiguration.onSecondCall().throws(new Error('listener failed')) responses.push( - { statusCode: 200, headers: { etag: '"failed"' }, body: VALID_RESPONSE }, - { statusCode: 200, headers: { etag: '"accepted"' }, body: VALID_RESPONSE } + { statusCode: 200, headers: { etag: '"good"' }, body: responseBody() }, + { statusCode: 200, headers: { etag: '"failed"' }, body: responseBody() }, + { statusCode: 304 } ) const configurationSource = source() configurationSource.start() - await completeScheduledResponse() - await clock.tickAsync(30_000) + await flush() + await tick(30_000) + await 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) + assert.strictEqual(requests[2].options.headers['If-None-Match'], '"good"') }) - it('retries 429 and 5xx responses with bounded delays', async () => { + it('retries timeout, rate-limit, and server statuses 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 } + { statusCode: 408 }, + { statusCode: 429 }, + { statusCode: 200, body: responseBody() } ) - const outcome = poll(source()) - - await completeScheduledResponse() - assert.strictEqual(requests.length, 1) + const configurationSource = source() - await clock.tickAsync(4999) + configurationSource.start() + await flush() + await tick(4999) assert.strictEqual(requests.length, 1) - await clock.tickAsync(1) + await tick(1) assert.strictEqual(requests.length, 2) - - await clock.tickAsync(9999) + await tick(9999) assert.strictEqual(requests.length, 2) - await clock.tickAsync(1) + await tick(1) assert.strictEqual(requests.length, 3) - sinon.assert.calledOnce(applyConfiguration) - assert.deepStrictEqual(await outcome, { error: null, result: { applied: true } }) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) }) - it('retries request timeout responses', async () => { + it('retries network and request-timeout errors without transport retries', async () => { responses.push( - { statusCode: 408, body: '' }, - { statusCode: 200, body: VALID_RESPONSE } + { error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) }, + { error: Object.assign(new Error('reset'), { code: 'ECONNRESET' }) }, + { statusCode: 200, body: responseBody() } ) + const configurationSource = source() - const outcome = poll(source()) - await completeScheduledResponse() - await clock.tickAsync(5000) + configurationSource.start() + await flush() + await tick(5000) + await tick(10_000) - assert.strictEqual(requests.length, 2) - sinon.assert.calledOnce(applyConfiguration) - assert.deepStrictEqual(await outcome, { error: null, result: { applied: true } }) + assert.strictEqual(requests.length, 3) + for (const requestRecord of requests) assert.strictEqual(requestRecord.options.retry, false) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) }) - 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() + it('retries when the shared transport cannot send the request', async () => { + responses.push( + { pending: true }, + { statusCode: 200, body: responseBody() } + ) - source({ fetch: delayedFetch })._request(callback) - await clock.tickAsync(2000) + source().start() + requests[0].callback(null) + await flush() + await tick(5000) - sinon.assert.calledOnce(callback) - assert.strictEqual(callback.firstCall.args[0].retryable, true) - assert.strictEqual(delayedFetch.firstCall.args[1].signal.aborted, true) + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) }) - it('warns after retryable HTTP responses exhaust all attempts', async () => { + it('warns after retryable failures exhaust all attempts', async () => { responses.push( - { statusCode: 500, body: '' }, - { statusCode: 500, body: '' }, - { statusCode: 500, body: '' } + { statusCode: 500 }, + { statusCode: 502 }, + { statusCode: 503 } ) - const outcome = poll(source()) - await completeScheduledResponse() - await clock.tickAsync(5000) - await clock.tickAsync(10_000) - await outcome + source().start() + await flush() + await tick(5000) + await tick(10_000) sinon.assert.calledOnceWithExactly( log.warn, 'Feature Flagging agentless endpoint returned HTTP %d after %d attempts', - 500, + 503, 3 ) }) - it('warns after request timeouts exhaust all attempts', async () => { - responses.push({ pending: true }, { pending: true }, { pending: true }) + it('warns after network failures exhaust all attempts', async () => { + responses.push( + { error: new Error('first') }, + { error: new Error('second') }, + { error: new Error('third') } + ) - 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 + source().start() + await flush() + await tick(5000) + await tick(10_000) - sinon.assert.calledOnceWithMatch( + sinon.assert.calledOnceWithExactly( log.warn, - 'Feature Flagging agentless request failed after %d attempts', + 'Feature Flagging agentless request failed after %d attempts: %s', 3, - sinon.match.instanceOf(Error) + 'Feature Flagging agentless request failed' ) }) - it('does not retry authentication failures and rate-limits the warning', async () => { + it('does not retry authentication or other non-retryable statuses', async () => { responses.push( - { statusCode: 401, body: '' }, - { statusCode: 403, body: '' }, - { statusCode: 401, body: '' } + { statusCode: 401 }, + { statusCode: 404 } ) const configurationSource = source() - await poll(configurationSource) - await poll(configurationSource) - await clock.tickAsync(5 * 60 * 1000) - await poll(configurationSource) + configurationSource.start() + await flush() + await tick(15_000) - assert.strictEqual(requests.length, 3) - sinon.assert.calledTwice(log.warn) + assert.strictEqual(requests.length, 1) + sinon.assert.calledOnceWithExactly( + log.warn, + 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + 401 + ) + + await tick(15_000) + + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnce(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 }) + it('rate-limits repeated authentication warnings', async () => { + for (let i = 0; i < 11; i++) responses.push({ statusCode: i % 2 ? 403 : 401 }) - await clock.tickAsync(2000) - await clock.tickAsync(5000) + source().start() + await flush() + for (let i = 0; i < 10; i++) await tick(30_000) - sinon.assert.calledOnce(applyConfiguration) - sinon.assert.calledWith(first, null, { applied: true }) - assert.strictEqual(requests.length, 2) + assert.strictEqual(requests.length, 11) + sinon.assert.calledTwice(log.warn) }) - it('uses fixed-delay polling and never schedules while a request is active', async () => { - config.requestTimeoutMs = 60_000 + it('uses fixed-delay polling after a request completes', async () => { responses.push( { pending: true }, - { statusCode: 200, body: VALID_RESPONSE } + { statusCode: 200, body: responseBody() } ) const configurationSource = source() configurationSource.start() - await clock.tickAsync(30_000) + await tick(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) + requests[0].callback(null, createResponseStream({ + statusCode: 200, + body: responseBody(), + }), 200, {}) + await flush() + await tick(29_999) + assert.strictEqual(requests.length, 1) + await tick(1) - 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 () => { + it('coalesces concurrent and repeated starts', async () => { + responses.push({ pending: true }) + const configurationSource = source() + + configurationSource.start() + configurationSource.start() + configurationSource.start() + await flush() + + assert.strictEqual(requests.length, 1) + + requests[0].callback(null, createResponseStream({ + statusCode: 200, + body: responseBody(), + }), 200, {}) + await flush() + configurationSource.start() + + assert.strictEqual(requests.length, 1) + }) + + it('stops and cancels an active request', async () => { responses.push({ pending: true }) const configurationSource = source() configurationSource.start() configurationSource.stop() configurationSource.stop() - await completeScheduledResponse() - await clock.tickAsync(60_000) + configurationSource.start() + await tick(60_000) + sinon.assert.calledOnce(requests[0].activeRequest.destroy) assert.strictEqual(requests.length, 1) - 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 }) + it('stops and cancels an active response stream', async () => { + responses.push({ + statusCode: 200, + body: '{"data":', + bodyPending: true, + }) const configurationSource = source() configurationSource.start() - await completeScheduledResponse() + await flush() configurationSource.stop() - const outcome = await poll(configurationSource) - await clock.tickAsync(30_000) + await flush() - assert.deepStrictEqual(outcome, { error: null, result: { stopped: true } }) - assert.strictEqual(requests.length, 1) + sinon.assert.calledOnce(requests[0].activeRequest.destroy) + sinon.assert.notCalled(applyConfiguration) }) - it('starts only once', () => { - responses.push({ pending: true }) + it('ignores a response that arrives while stopping', async () => { + responses.push({ pending: true, ignoreDestroy: true }) const configurationSource = source() configurationSource.start() + configurationSource.stop() + requests[0].callback(null, createResponseStream({ + statusCode: 200, + body: responseBody(), + }), 200, {}) + await flush() + + sinon.assert.notCalled(applyConfiguration) + }) + + it('stops a pending retry delay', async () => { + responses.push( + { error: Object.assign(new Error('reset'), { code: 'ECONNRESET' }) }, + { statusCode: 200, body: responseBody() } + ) + const configurationSource = source() + configurationSource.start() + await flush() + configurationSource.stop() + await tick(60_000) assert.strictEqual(requests.length, 1) + sinon.assert.notCalled(applyConfiguration) }) }) diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index b35cf0f815..c818ee2bb1 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -16,10 +16,13 @@ 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, + featureFlags: { + 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, + }, site: 'datadoghq.com', env: 'my env', } @@ -35,17 +38,14 @@ describe('OpenFeature configuration source', () => { }) }) - for (const value of [undefined, null, '', ' ', ' AgEnTlEsS ']) { - it(`normalizes ${JSON.stringify(value)} to the default agentless source`, () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value - - assert.strictEqual(configurationSource.resolve(config).mode, 'agentless') - }) + function createSourceConfig () { + configurationSource.create(config, sinon.spy()) + return AgentlessConfigurationSource.firstCall.args[0] } it('defaults to the Datadog UFC CDN endpoint and includes the environment', () => { config.DD_SITE = 'raw-env-key.invalid' - const resolved = configurationSource.resolve(config) + const resolved = createSourceConfig() assert.strictEqual( resolved.endpoint.toString(), @@ -61,15 +61,21 @@ describe('OpenFeature configuration source', () => { config.env = 'staging' assert.strictEqual( - configurationSource.resolve(config).endpoint.toString(), + createSourceConfig().endpoint.toString(), 'https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging' ) }) + it('caps the polling interval at one hour', () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = 4 * 60 * 60 + + assert.strictEqual(createSourceConfig().pollIntervalMs, 60 * 60 * 1000) + }) + it('appends the standard path to a configured origin', () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://127.0.0.1:8080/' + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://127.0.0.1:8080/' - const resolved = configurationSource.resolve(config) + const resolved = createSourceConfig() assert.strictEqual( resolved.endpoint.toString(), 'http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server' @@ -77,26 +83,22 @@ describe('OpenFeature configuration source', () => { }) it('preserves an exact configured path and query', () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/custom/ufc?tenant=one' + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + 'https://example.com/custom/ufc?tenant=one' assert.strictEqual( - configurationSource.resolve(config).endpoint.toString(), + createSourceConfig().endpoint.toString(), 'https://example.com/custom/ufc?tenant=one' ) }) - it('derives the managed GovCloud endpoint without hard-coding availability', () => { + it('derives and creates the managed GovCloud endpoint without hard-coding availability', () => { config.site = 'DDOG-GOV.COM' config.env = 'prod' - const provider = { - _setConfiguration: sinon.spy(), - _setConfigurationSource: sinon.spy(), - } - const configuration = { flags: {} } + const applyConfiguration = sinon.spy() - const resolved = configurationSource.resolve(config) - configurationSource.enable(config, () => provider) - AgentlessConfigurationSource.firstCall.args[1](configuration) + const source = configurationSource.create(config, applyConfiguration) + const resolved = AgentlessConfigurationSource.firstCall.args[0] assert.strictEqual( resolved.endpoint.toString(), @@ -104,94 +106,90 @@ 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.calledOnceWithExactly( + AgentlessConfigurationSource, + sinon.match({ + endpoint: resolved.endpoint, + apiKey: 'test-api-key', + pollIntervalMs: 30_000, + requestTimeoutMs: 2000, + }), + applyConfiguration + ) + assert.ok(source instanceof AgentlessConfigurationSource) sinon.assert.notCalled(log.warn) }) it('allows an operator-owned agentless endpoint on GovCloud', () => { config.site = 'ddog-gov.com' - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://flags.example.test/custom/ufc?tenant=test' - const resolved = configurationSource.resolve(config) + const resolved = createSourceConfig() assert.strictEqual(resolved.endpoint.toString(), 'https://flags.example.test/custom/ufc?tenant=test') sinon.assert.notCalled(log.warn) }) it('rejects non-HTTP endpoints', () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'file:///tmp/ufc.json' + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'file:///tmp/ufc.json' + + const source = configurationSource.create(config, sinon.spy()) - assert.throws( - () => configurationSource.resolve(config), - /must use HTTP or HTTPS/ + assert.strictEqual(source, undefined) + sinon.assert.calledOnceWithMatch( + log.error, + 'Unable to configure Feature Flagging configuration source', + sinon.match.instanceOf(Error) ) + sinon.assert.notCalled(AgentlessConfigurationSource) }) it('rejects malformed endpoints without enabling a source', () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'not a URL' - const provider = { _setConfigurationSource: sinon.spy() } + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'not a URL' - assert.throws( - () => configurationSource.resolve(config), - /Invalid Feature Flagging agentless URL: not a URL/ - ) - configurationSource.enable(config, () => provider) + const source = configurationSource.create(config, sinon.spy()) 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.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = ' REMOTE_CONFIG ' - delete config.site - - assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' }) - assert.strictEqual(configurationSource.isRemoteConfig(config), true) + assert.strictEqual(source, undefined) + sinon.assert.notCalled(AgentlessConfigurationSource) }) - for (const value of ['offline', 'other']) { - it(`fails closed for the unsupported ${value} source`, () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + it('requires an API key without enabling a source', () => { + delete config.DD_API_KEY - assert.throws(() => configurationSource.resolve(config), /Unsupported Feature Flagging configuration source/) - assert.strictEqual(configurationSource.isRemoteConfig(config), false) - sinon.assert.calledOnce(log.error) - }) - } + const source = configurationSource.create(config, sinon.spy()) - it('falls back to positive timing defaults with warnings', () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = 0 - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = -1 + sinon.assert.calledOnceWithMatch( + log.error, + 'Unable to configure Feature Flagging configuration source', + sinon.match.instanceOf(Error) + ) + assert.strictEqual(source, undefined) + sinon.assert.notCalled(AgentlessConfigurationSource) + }) - assert.strictEqual(configurationSource.isRemoteConfig(config), false) - sinon.assert.notCalled(log.warn) + it('does not create an agentless source for Remote Config delivery', () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + delete config.site - const resolved = configurationSource.resolve(config) + const source = configurationSource.create(config, sinon.spy()) - assert.strictEqual(resolved.pollIntervalMs, 30_000) - assert.strictEqual(resolved.requestTimeoutMs, 2000) - sinon.assert.calledTwice(log.warn) + assert.strictEqual(source, undefined) + sinon.assert.notCalled(AgentlessConfigurationSource) }) - it('caps the polling interval at one hour', () => { - config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = 4 * 60 * 60 + it('does not create an agentless source when Feature Flags are disabled', () => { + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false + delete config.site - const resolved = configurationSource.resolve(config) + const source = configurationSource.create(config, sinon.spy()) - 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 - ) + assert.strictEqual(source, undefined) + sinon.assert.notCalled(AgentlessConfigurationSource) }) }) diff --git a/packages/dd-trace/test/openfeature/flagging_provider.spec.js b/packages/dd-trace/test/openfeature/flagging_provider.spec.js index c2bc4bb241..ba83736731 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider.spec.js @@ -16,6 +16,7 @@ describe('FlaggingProvider', () => { let mockChannel let log let channelStub + let configurationSource let mockEvalMetricsHook let mockEvalMetricsHookClass let mockSpanEnrichmentHook @@ -46,6 +47,9 @@ describe('FlaggingProvider', () => { } channelStub = sinon.stub().returns(mockChannel) + configurationSource = { + create: sinon.stub(), + } log = { debug: sinon.spy(), @@ -69,19 +73,13 @@ describe('FlaggingProvider', () => { channel: channelStub, }, '../log': log, + './configuration_source': configurationSource, './eval-metrics-hook': mockEvalMetricsHookClass, './span-enrichment-hook': mockSpanEnrichmentHookClass, }) }) describe('constructor', () => { - it('should initialize with tracer and config', () => { - const provider = new FlaggingProvider(mockTracer, mockConfig) - - assert.strictEqual(provider._tracer, mockTracer) - assert.strictEqual(provider._config, mockConfig) - }) - it('should create exposure channel', () => { const provider = new FlaggingProvider(mockTracer, mockConfig) @@ -97,36 +95,6 @@ describe('FlaggingProvider', () => { }) }) - describe('_setConfiguration', () => { - it('should call setConfiguration when method exists', () => { - const provider = new FlaggingProvider(mockTracer, mockConfig) - const setConfigSpy = sinon.spy(provider, 'setConfiguration') - const ufc = { flags: { 'test-flag': {} } } - - provider._setConfiguration(ufc) - - sinon.assert.calledOnceWithExactly(setConfigSpy, ufc) - sinon.assert.calledWith(log.debug, '%s provider configuration updated', 'FlaggingProvider') - }) - - it('should handle null/undefined configuration gracefully', () => { - const provider = new FlaggingProvider(mockTracer, mockConfig) - - provider._setConfiguration(null) - provider._setConfiguration(undefined) - }) - - it('should not throw when setConfiguration is not a function', () => { - const provider = new FlaggingProvider(mockTracer, mockConfig) - provider.setConfiguration = null // Remove the method - - provider._setConfiguration({ flags: {} }) - - // Should still log the debug message - sinon.assert.calledWith(log.debug, '%s provider configuration updated', 'FlaggingProvider') - }) - }) - describe('hooks', () => { it('should create EvalMetricsHook with config', () => { new FlaggingProvider(mockTracer, mockConfig) // eslint-disable-line no-new @@ -203,9 +171,9 @@ describe('FlaggingProvider', () => { }) it('stops the attached configuration source', () => { - const provider = new FlaggingProvider(mockTracer, mockConfig) const source = { start: sinon.spy(), stop: sinon.spy() } - provider._setConfigurationSource(source) + configurationSource.create.returns(source) + const provider = new FlaggingProvider(mockTracer, mockConfig) provider.onClose() @@ -213,23 +181,28 @@ describe('FlaggingProvider', () => { sinon.assert.calledOnce(source.stop) }) - it('keeps the first configuration source on repeated attachment', () => { + it('applies source configurations through the provider boundary', () => { + const source = { start: sinon.spy(), stop: sinon.spy() } + configurationSource.create.returns(source) 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) - sinon.assert.calledOnceWithExactly( - log.warn, - '%s already has a configuration source; ignoring duplicate source', - 'FlaggingProvider' - ) + const ufc = { flags: {} } + const applyConfiguration = configurationSource.create.firstCall.args[1] + + applyConfiguration(ufc) + + assert.strictEqual(provider.getConfiguration(), ufc) + }) + + it('closes owned resources only once', () => { + const source = { start: sinon.spy(), stop: sinon.spy() } + configurationSource.create.returns(source) + const provider = new FlaggingProvider(mockTracer, mockConfig) + + provider.onClose() + provider.onClose() + + sinon.assert.calledOnce(source.stop) + sinon.assert.calledOnce(mockSpanEnrichmentHook.destroy) }) }) diff --git a/packages/dd-trace/test/openfeature/flagging_provider_timeout.spec.js b/packages/dd-trace/test/openfeature/flagging_provider_timeout.spec.js index 1449280d19..721ad85cc7 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider_timeout.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider_timeout.spec.js @@ -57,6 +57,9 @@ describe('FlaggingProvider Initialization Timeout', () => { channel: channelStub, }, '../log': log, + './configuration_source': { + create: sinon.stub(), + }, }) }) @@ -114,7 +117,7 @@ describe('FlaggingProvider Initialization Timeout', () => { }, }, } - provider._setConfiguration(ufc) + provider.setConfiguration(ufc) // Wait for initialization to complete await initPromise @@ -174,7 +177,7 @@ describe('FlaggingProvider Initialization Timeout', () => { // Now set configuration after timeout const ufc = { flags: { 'recovery-flag': {} } } - provider._setConfiguration(ufc) + provider.setConfiguration(ufc) // Should emit READY event to signal recovery sinon.assert.calledOnce(readyEventSpy) diff --git a/packages/dd-trace/test/openfeature/noop.spec.js b/packages/dd-trace/test/openfeature/noop.spec.js index f06bf4cb04..6380dfa69b 100644 --- a/packages/dd-trace/test/openfeature/noop.spec.js +++ b/packages/dd-trace/test/openfeature/noop.spec.js @@ -10,23 +10,16 @@ const NoopFlaggingProvider = require('../../src/openfeature/noop') describe('NoopFlaggingProvider', () => { let noopProvider - let mockTracer beforeEach(() => { - mockTracer = {} - noopProvider = new NoopFlaggingProvider(mockTracer) + noopProvider = new NoopFlaggingProvider() }) describe('constructor', () => { - it('should store tracer reference', () => { - assert.strictEqual(noopProvider._tracer, mockTracer) - }) - it('should initialize with OpenFeature Provider properties', () => { assert.deepStrictEqual(noopProvider.metadata, { name: 'NoopFlaggingProvider' }) assert.strictEqual(noopProvider.status, 'NOT_READY') assert.strictEqual(noopProvider.runsOn, 'server') - assert.deepStrictEqual(noopProvider._config, {}) }) }) @@ -98,38 +91,6 @@ describe('NoopFlaggingProvider', () => { }) }) - describe('configuration methods', () => { - it('should handle setConfiguration', () => { - const config = { flags: { 'test-flag': {} } } - noopProvider.setConfiguration(config) - - const result = noopProvider.getConfiguration() - assert.deepStrictEqual(result, config) - }) - - it('should handle _setConfiguration wrapper', () => { - const config = { flags: { 'test-flag': {} } } - noopProvider._setConfiguration(config) - - const result = noopProvider.getConfiguration() - assert.deepStrictEqual(result, config) - }) - - it('should handle empty or null configuration', () => { - noopProvider.setConfiguration(null) - noopProvider.setConfiguration(undefined) - noopProvider._setConfiguration() - noopProvider._setConfiguration(null) - }) - - it('should return stored configuration', () => { - const config = { flags: {} } - noopProvider.setConfiguration(config) - const result = noopProvider.getConfiguration() - assert.strictEqual(result, config) - }) - }) - describe('promise handling', () => { it('should return promises from all evaluation methods', () => { const booleanResult = noopProvider.resolveBooleanEvaluation('test', true, {}, {}) @@ -154,19 +115,5 @@ describe('NoopFlaggingProvider', () => { `Expected a thenable, got: ${inspect(objectResult)}` ) }) - - it('should resolve promises immediately', async () => { - const start = Date.now() - - await Promise.all([ - noopProvider.resolveBooleanEvaluation('test', true, {}, {}), - noopProvider.resolveStringEvaluation('test', 'default', {}, {}), - noopProvider.resolveNumberEvaluation('test', 42, {}, {}), - noopProvider.resolveObjectEvaluation('test', {}, {}, {}), - ]) - - const duration = Date.now() - start - assert.ok(duration < 10, `Expected ${duration} < 10`) - }) }) }) diff --git a/packages/dd-trace/test/openfeature/register.spec.js b/packages/dd-trace/test/openfeature/register.spec.js index 80a6170a11..2487ad5de0 100644 --- a/packages/dd-trace/test/openfeature/register.spec.js +++ b/packages/dd-trace/test/openfeature/register.spec.js @@ -11,8 +11,10 @@ require('../setup/core') describe('OpenFeature register', () => { let config let feature + let flaggingProviderConstructions let lazyProxy let openfeatureModule + let openfeatureRemoteConfig let proxy let registerFeature let tracer @@ -20,6 +22,7 @@ describe('OpenFeature register', () => { function NoopFlaggingProvider () {} function FlaggingProvider (...args) { + flaggingProviderConstructions++ this.args = args } @@ -31,20 +34,25 @@ describe('OpenFeature register', () => { enable: sinon.spy(), disable: sinon.spy(), } + openfeatureRemoteConfig = { + enable: sinon.spy(), + } + flaggingProviderConstructions = 0 +>>>>>>> af80e4e288 (feat(openfeature): support agentless feature flag configuration) delete require.cache[require.resolve('../../src/openfeature/register')] proxyquire('../../src/openfeature/register', { '../feature-registry': { registerFeature }, './flagging_provider': FlaggingProvider, + './remote_config': openfeatureRemoteConfig, './index': openfeatureModule, './noop': NoopFlaggingProvider, }) config = { - experimental: { - flaggingProvider: { - enabled: true, - }, + featureFlags: { + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', + DD_FEATURE_FLAGS_ENABLED: true, }, } tracer = {} @@ -70,33 +78,70 @@ describe('OpenFeature register', () => { assert.strictEqual(feature.factory(), openfeatureModule) }) - it('defines the flagging provider when enabled', () => { + it('does not initialize Feature Flags until application code accesses the provider', () => { feature.enable(config, tracer, proxy, lazyProxy) + assert.strictEqual(flaggingProviderConstructions, 0) + sinon.assert.notCalled(proxy._modules.openfeature.enable) + sinon.assert.notCalled(lazyProxy) + + const provider = proxy.openfeature + + assert.strictEqual(flaggingProviderConstructions, 1) + assert.ok(provider instanceof FlaggingProvider) + assert.deepStrictEqual(provider.args, [tracer, config]) 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]) }) 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) + assert.strictEqual(flaggingProviderConstructions, 0) + sinon.assert.notCalled(proxy._modules.openfeature.enable) + const provider = proxy.openfeature feature.enable(config, tracer, proxy, lazyProxy) + assert.strictEqual(flaggingProviderConstructions, 1) sinon.assert.calledTwice(proxy._modules.openfeature.enable) - sinon.assert.calledOnce(lazyProxy) + sinon.assert.notCalled(lazyProxy) assert.strictEqual(proxy.openfeature, provider) }) it('does not define the flagging provider when disabled', () => { - config.experimental.flaggingProvider.enabled = false + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false feature.enable(config, tracer, proxy, lazyProxy) + assert.strictEqual(flaggingProviderConstructions, 0) sinon.assert.notCalled(proxy._modules.openfeature.enable) sinon.assert.notCalled(lazyProxy) assert.strictEqual(proxy.openfeature, feature.noop) }) + + it('installs Remote Config delivery when selected', () => { + const rc = {} + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + + feature.remoteConfig(rc, config, proxy) + + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, true) + }) + + it('does not install Remote Config delivery when disabled', () => { + const rc = {} + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false + + feature.remoteConfig(rc, config, proxy) + + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, false) + }) + + it('does not install Remote Config delivery for the default agentless source', () => { + const rc = {} + + feature.remoteConfig(rc, config, proxy) + + 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 9e61cd1dec..8fbf2a9d20 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,16 +24,8 @@ describe('OpenFeature Remote Config', () => { }), } - config = { - experimental: { - flaggingProvider: { - enabled: true, - }, - }, - } - openfeatureProxy = { - _setConfiguration: sinon.spy(), + setConfiguration: sinon.spy(), } getOpenfeatureProxy = sinon.stub().returns(openfeatureProxy) @@ -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, true) sinon.assert.calledOnceWithExactly( rc.updateCapabilities, @@ -52,71 +43,60 @@ describe('OpenFeature Remote Config', () => { }) it('should register FFE_FLAGS product handler', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy, true) 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) + it('should call setConfiguration on apply action when feature is enabled', () => { + enable(rc, getOpenfeatureProxy, true) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') handler('apply', flagConfig) - sinon.assert.calledOnceWithExactly(openfeatureProxy._setConfiguration, flagConfig) + sinon.assert.calledOnceWithExactly(openfeatureProxy.setConfiguration, flagConfig) }) - it('should call _setConfiguration on modify action when feature is enabled', () => { - enable(rc, config, getOpenfeatureProxy) + it('should call setConfiguration on modify action when feature is enabled', () => { + enable(rc, getOpenfeatureProxy, true) const flagConfig = { flags: { 'modified-flag': {} } } const handler = handlers.get('FFE_FLAGS') handler('modify', flagConfig) - sinon.assert.calledOnceWithExactly(openfeatureProxy._setConfiguration, flagConfig) + sinon.assert.calledOnceWithExactly(openfeatureProxy.setConfiguration, flagConfig) }) - it('should call _setConfiguration(null) on unapply action to clear config', () => { - enable(rc, config, getOpenfeatureProxy) + it('should call setConfiguration(undefined) on unapply action to clear config', () => { + enable(rc, getOpenfeatureProxy, true) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') handler('unapply', flagConfig) - sinon.assert.calledOnceWithExactly(openfeatureProxy._setConfiguration, null) + sinon.assert.calledOnceWithExactly(openfeatureProxy.setConfiguration, undefined) }) - it('should not call _setConfiguration on unknown action', () => { - enable(rc, config, getOpenfeatureProxy) + it('should not call setConfiguration on unknown action', () => { + enable(rc, getOpenfeatureProxy, true) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') handler('unknown', flagConfig) - sinon.assert.notCalled(openfeatureProxy._setConfiguration) + 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) + it('should not advertise capability or register a handler without Remote Config delivery', () => { + enable(rc, getOpenfeatureProxy, false) + sinon.assert.notCalled(rc.updateCapabilities) 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) - - sinon.assert.calledOnceWithExactly( - rc.updateCapabilities, - RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, - true - ) - }) }) }) diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index f4f013be53..ac45511eed 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') @@ -149,6 +150,10 @@ describe('TracerProxy', () => { config = { DD_TRACE_ENABLED: true, testOptimization: {}, + featureFlags: { + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', + DD_FEATURE_FLAGS_ENABLED: false, + }, experimental: { flaggingProvider: {}, aiguard: { @@ -208,7 +213,7 @@ describe('TracerProxy', () => { OpenFeatureProvider = sinon.stub().callsFake(function () { openfeatureProvider = { - _setConfiguration: sinon.spy(), + setConfiguration: sinon.spy(), } return openfeatureProvider }) @@ -283,10 +288,12 @@ describe('TracerProxy', () => { noop: noopOpenfeature, factory: () => openfeature, remoteConfig (rc, config, proxy) { - openfeatureRcEnable(rc, config, () => proxy.openfeature) + const subscribe = config.featureFlags.DD_FEATURE_FLAGS_ENABLED && + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE === 'remote_config' + openfeatureRcEnable(rc, () => proxy.openfeature, subscribe) }, enable (config, tracer, proxy, lazyProxy) { - if (config.experimental.flaggingProvider.enabled) { + if (config.featureFlags.DD_FEATURE_FLAGS_ENABLED) { proxy._modules.openfeature.enable(config) if (!hasOpenfeatureProvider(proxy)) { lazyProxy(proxy, 'openfeature', () => OpenFeatureProvider, tracer, config) @@ -409,7 +416,8 @@ describe('TracerProxy', () => { }) it('should setup FFE_FLAGS product handler when openfeature provider is enabled', () => { - config.experimental.flaggingProvider.enabled = true + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' proxy.init() proxy.openfeature // Trigger lazy loading @@ -417,11 +425,23 @@ describe('TracerProxy', () => { const flagConfig = { flags: { 'test-flag': {} } } handlers.get('FFE_FLAGS')('apply', flagConfig) - sinon.assert.calledWith(openfeatureProvider._setConfiguration, flagConfig) + sinon.assert.calledWith(openfeatureProvider.setConfiguration, flagConfig) + }) + + it('should not setup FFE_FLAGS Remote Config when Feature Flags are 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 + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' proxy.init() proxy.openfeature // Trigger lazy loading @@ -429,11 +449,12 @@ describe('TracerProxy', () => { const flagConfig = { flags: { 'modified-flag': {} } } handlers.get('FFE_FLAGS')('modify', flagConfig) - sinon.assert.calledWith(openfeatureProvider._setConfiguration, flagConfig) + sinon.assert.calledWith(openfeatureProvider.setConfiguration, flagConfig) }) it('keeps OpenFeature bound to the provider receiving FFE_FLAGS after tracing reconfigures', () => { - config.experimental.flaggingProvider.enabled = true + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' proxy.init() const boundProvider = proxy.openfeature @@ -445,11 +466,12 @@ describe('TracerProxy', () => { sinon.assert.calledOnce(OpenFeatureProvider) assert.strictEqual(proxy.openfeature, boundProvider) - sinon.assert.calledOnceWithExactly(boundProvider._setConfiguration, flagConfig) + sinon.assert.calledOnceWithExactly(boundProvider.setConfiguration, flagConfig) }) it('should re-enable OpenFeature without replacing its provider when remote config re-enables tracing', () => { - config.experimental.flaggingProvider.enabled = true + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' /** @param {{ DD_TRACE_ENABLED: boolean }} remoteConfig */ config.setRemoteConfig = remoteConfig => { config.DD_TRACE_ENABLED = remoteConfig.DD_TRACE_ENABLED diff --git a/vendor/package-lock.json b/vendor/package-lock.json index ff08143367..caed27c423 100644 --- a/vendor/package-lock.json +++ b/vendor/package-lock.json @@ -32,6 +32,7 @@ "semifies": "^1.0.0", "shell-quote": "^1.9.0", "source-map": "^0.7.4", + "stream-json": "2.1.0", "tlhunter-sorted-set": "^0.1.0", "ttl-set": "^1.0.0" }, @@ -731,6 +732,27 @@ "node": ">= 12" } }, + "node_modules/stream-chain": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-3.6.3.tgz", + "integrity": "sha512-JZuELdHUuiZL4Olcr4EllGUvj9VKEaDkGHA6QAP5SruD0bgrr8TwtNXwRfH+fCncysEII7HhWll1+aOwvHYyRw==", + "license": "BSD-3-Clause", + "funding": { + "url": "https://github.com/sponsors/uhop" + } + }, + "node_modules/stream-json": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-2.1.0.tgz", + "integrity": "sha512-9gV/ywtebMn3DdKnNKYCb9iESvgR1dHbucNV+bRGvdvy+jV4c9FFgYKmENhpKv58jSwvs90Wk80RhfKk1KxHPg==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^3.6.1" + }, + "funding": { + "url": "https://github.com/sponsors/uhop" + } + }, "node_modules/tlhunter-sorted-set": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/tlhunter-sorted-set/-/tlhunter-sorted-set-0.1.0.tgz", diff --git a/vendor/package.json b/vendor/package.json index f777411f40..256b74bf9f 100644 --- a/vendor/package.json +++ b/vendor/package.json @@ -29,6 +29,7 @@ "semifies": "^1.0.0", "shell-quote": "^1.9.0", "source-map": "^0.7.4", + "stream-json": "2.1.0", "tlhunter-sorted-set": "^0.1.0", "ttl-set": "^1.0.0" }, diff --git a/vendor/rspack.config.js b/vendor/rspack.config.js index deb1137308..1a45f0566f 100644 --- a/vendor/rspack.config.js +++ b/vendor/rspack.config.js @@ -31,7 +31,10 @@ const exclude = new Set([ const difference = new Set([...include].filter(x => !exclude.has(x))) module.exports = { - entry: Object.fromEntries(difference.entries()), + entry: { + ...Object.fromEntries(difference.entries()), + 'stream-json': join(__dirname, 'stream-json.js'), + }, target: 'node', mode: 'production', // Using `hidden` removes the URL comment from source files since we don't diff --git a/vendor/stream-json.js b/vendor/stream-json.js new file mode 100644 index 0000000000..3042fdaf17 --- /dev/null +++ b/vendor/stream-json.js @@ -0,0 +1,11 @@ +'use strict' + +const { parser } = require('stream-json') +const { pick } = require('stream-json/filters/pick.js') +const { streamValues } = require('stream-json/streamers/stream-values.js') + +module.exports = { + parser: parser.asStream, + pick: pick.asStream, + streamValues: streamValues.asStream, +} From 9d4aa3b90ffca7c46a8db229549372cf1374222e Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 21 Jul 2026 19:48:43 +0200 Subject: [PATCH 13/16] fix(openfeature): align provider loading with current runtime --- packages/dd-trace/src/openfeature/flagging_provider.js | 3 +-- packages/dd-trace/test/openfeature/register.spec.js | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index fe82a7b9c4..8d3d434c9e 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -1,14 +1,13 @@ 'use strict' const { channel } = require('dc-polyfill') -const requireOptionalPeer = require('../../../datadog-instrumentations/src/helpers/require-optional-peer') const log = require('../log') const configurationSource = require('./configuration_source') const { EXPOSURE_CHANNEL } = require('./constants/constants') const EvalMetricsHook = require('./eval-metrics-hook') const SpanEnrichmentHook = require('./span-enrichment-hook') -const { DatadogNodeServerProvider } = requireOptionalPeer('@datadog/openfeature-node-server') +const { DatadogNodeServerProvider } = require('./require-provider') /** * OpenFeature provider that integrates with Datadog's feature flagging system. diff --git a/packages/dd-trace/test/openfeature/register.spec.js b/packages/dd-trace/test/openfeature/register.spec.js index 2487ad5de0..54a2f7d29f 100644 --- a/packages/dd-trace/test/openfeature/register.spec.js +++ b/packages/dd-trace/test/openfeature/register.spec.js @@ -38,7 +38,6 @@ describe('OpenFeature register', () => { enable: sinon.spy(), } flaggingProviderConstructions = 0 ->>>>>>> af80e4e288 (feat(openfeature): support agentless feature flag configuration) delete require.cache[require.resolve('../../src/openfeature/register')] proxyquire('../../src/openfeature/register', { From 05c0567f50aacbecd6c2a7ef671f5c93a6a3ac22 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 21 Jul 2026 22:09:39 +0200 Subject: [PATCH 14/16] refactor(openfeature): simplify agentless configuration delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agentless UFC snapshots are expected to be small, so buffering them through the shared request transport removes duplicate gzip and streaming parser machinery while preserving fixed-delay retries, cancellation, and last-known-good updates. Malformed diagnostics redact string contents before telemetry logging. Native parsing reduced 10 KB / 100 KB / 1 MB snapshots from 1.47 ms / 14.72 ms / 185.26 ms to 21.8 µs / 242 µs / 2.85 ms on Node 24.18.0 (V8 13.6), across seven trials after a one-second warmup with extremes dropped. --- .github/CODEOWNERS | 2 + LICENSE-3rdparty.csv | 2 - .../openfeature/openfeature-agentless.spec.js | 132 ----- .../openfeature-configuration-sources.spec.js | 10 +- .../dd-trace/src/exporters/common/request.js | 34 +- packages/dd-trace/src/feature-registry.js | 3 +- .../agentless_configuration_source.js | 454 ++++++------------ packages/dd-trace/src/openfeature/register.js | 66 +-- packages/dd-trace/src/proxy.js | 44 +- .../test/exporters/common/request.spec.js | 72 ++- .../agentless_configuration_source.spec.js | 436 ++++++----------- .../test/openfeature/register.spec.js | 108 ++--- packages/dd-trace/test/proxy.spec.js | 34 +- vendor/package-lock.json | 22 - vendor/package.json | 1 - vendor/rspack.config.js | 5 +- vendor/stream-json.js | 11 - 17 files changed, 443 insertions(+), 993 deletions(-) delete mode 100644 integration-tests/openfeature/openfeature-agentless.spec.js delete mode 100644 vendor/stream-json.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 00cee53eb4..c0a1ce7a48 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -370,6 +370,8 @@ /packages/dd-trace/src/bootstrap.js @DataDog/lang-platform-js /packages/dd-trace/src/feature-registry.js @DataDog/lang-platform-js /packages/dd-trace/src/exporters/common/ @DataDog/lang-platform-js +/packages/dd-trace/src/exporters/common/client-library-headers.js @DataDog/lang-platform-js @DataDog/feature-flagging-and-experimentation-sdk +/packages/dd-trace/src/proxy.js @DataDog/lang-platform-js /packages/dd-trace/test/agent/ @DataDog/lang-platform-js /packages/dd-trace/test/dd-trace.spec.js @DataDog/lang-platform-js /packages/dd-trace/test/dogstatsd.spec.js @DataDog/lang-platform-js diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 51b0c1e461..38964991e2 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -82,8 +82,6 @@ "shell-quote","https://github.com/ljharb/shell-quote","['MIT']","['James Halliday']" "source-map","https://github.com/mozilla/source-map","['BSD-3-Clause']","['Nick Fitzgerald']" "spark-md5","https://github.com/satazor/js-spark-md5","['(WTFPL OR MIT)']","['André Cruz']" -"stream-chain","https://github.com/uhop/stream-chain","['BSD-3-Clause']","['Eugene Lazutkin']" -"stream-json","https://github.com/uhop/stream-json","['BSD-3-Clause']","['Eugene Lazutkin']" "tlhunter-sorted-set","https://github.com/tlhunter/node-sorted-set","['MIT']","['Thomas Hunter II']" "tslib","https://github.com/microsoft/tslib","['0BSD']","['Microsoft Corp.']" "ttl-set","https://github.com/watson/ttl-set","['MIT']","['Thomas Watson']" diff --git a/integration-tests/openfeature/openfeature-agentless.spec.js b/integration-tests/openfeature/openfeature-agentless.spec.js deleted file mode 100644 index 3869fc01d5..0000000000 --- a/integration-tests/openfeature/openfeature-agentless.spec.js +++ /dev/null @@ -1,132 +0,0 @@ -'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 { VERSION } = require('../../version') -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_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', - 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-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/integration-tests/openfeature/openfeature-configuration-sources.spec.js b/integration-tests/openfeature/openfeature-configuration-sources.spec.js index f3bce168ff..906bfcc920 100644 --- a/integration-tests/openfeature/openfeature-configuration-sources.spec.js +++ b/integration-tests/openfeature/openfeature-configuration-sources.spec.js @@ -4,9 +4,11 @@ const assert = require('node:assert/strict') const { EventEmitter, once } = require('node:events') const http = require('node:http') const path = require('node:path') +const zlib = require('node:zlib') const { after, before, describe, it } = require('mocha') const { ACKNOWLEDGED } = require('../../packages/dd-trace/src/remote_config/apply_states') +const { VERSION } = require('../../version') const { FakeAgent, sandboxCwd, spawnProc, stopProc, useSandbox } = require('../helpers') const AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' @@ -54,13 +56,13 @@ const CONFIGURATION = { }, } -const AGENTLESS_RESPONSE = JSON.stringify({ +const AGENTLESS_RESPONSE = zlib.gzipSync(JSON.stringify({ data: { id: RC_CONFIG_ID, type: 'universal-flag-configuration', attributes: CONFIGURATION, }, -}) +})) /** @typedef {'absent'|'true'|'false'} BooleanSetting */ /** @typedef {'agentless'|'remote_config'|'disabled'} Delivery */ @@ -348,7 +350,10 @@ function assertDeliveryTraffic (testCase) { assert.ok(rcRequests.every(withoutFfeProduct), testCase.label) for (const request of cdnRequests) { assert.strictEqual(request.url, `${AGENTLESS_PATH}?case=${testCase.identifier}`, testCase.label) + assert.strictEqual(request.headers['accept-encoding'], 'gzip', testCase.label) assert.strictEqual(request.headers['dd-api-key'], 'integration-api-key', testCase.label) + assert.strictEqual(request.headers['dd-client-library-language'], 'nodejs', testCase.label) + assert.strictEqual(request.headers['dd-client-library-version'], VERSION, testCase.label) } return } @@ -549,6 +554,7 @@ function handleBackendRequest (request, response) { }) response.writeHead(200, { Connection: 'close', + 'Content-Encoding': 'gzip', 'Content-Type': 'application/json', }) response.end(AGENTLESS_RESPONSE) diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index c0113df9c5..2632abb76e 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -42,9 +42,8 @@ function isLoopbackHost (hostname) { /** * @param {Buffer|string|Readable|Array} data * @param {object} options - * @param {(error: Error|null, result?: string|import('node:http').IncomingMessage|null, - * statusCode?: number, headers?: import('node:http').IncomingHttpHeaders) => void} callback - * @returns {import('node:http').ClientRequest | undefined} + * @param {(error: Error|null, result?: string|null, statusCode?: number, + * headers?: import('node:http').IncomingHttpHeaders) => void} callback */ function request (data, options, callback) { if (!options.headers) { @@ -98,7 +97,6 @@ function request (data, options, callback) { const timeout = options.timeout || 2000 const isSecure = options.protocol === 'https:' const client = isSecure ? https : http - const streamResponse = options.responseType === 'stream' let dataArray = data if (!Array.isArray(data)) { @@ -117,12 +115,6 @@ function request (data, options, callback) { const onResponse = (res, finalize) => { markEndpointReached(options) - if (streamResponse) { - res.setTimeout(timeout) - callback(null, res, res.statusCode, res.headers) - return - } - const chunks = [] res.setTimeout(timeout) @@ -176,40 +168,30 @@ function request (data, options, callback) { // Retries always run via setTimeout so the AsyncLocalStorage store survives // the gap before socket.connect(); ALS.run() does not call ALS.enterWith() // outside AsyncContextFrame, so a synchronous re-entry would lose the store. - /** - * @param {number} attemptIndex - * @returns {import('node:http').ClientRequest | undefined} - */ + /** @param {number} attemptIndex */ const attempt = attemptIndex => { if (!request.writable) { log.debug('Maximum number of active requests reached: payload is discarded.') - callback(null) - return + return callback(null) } activeBufferSize += options.headers['Content-Length'] ?? 0 - return legacyStorage.run({ noop: true }, () => { + legacyStorage.run({ noop: true }, () => { let finished = false - let responseReceived = false const finalize = () => { if (finished) return finished = true activeBufferSize -= options.headers['Content-Length'] ?? 0 } - const req = client.request(options, (res) => { - responseReceived = true - onResponse(res, finalize) - }) + const req = client.request(options, (res) => onResponse(res, finalize)) req.once('close', finalize) req.once('timeout', finalize) req.once('error', error => { finalize() - if (streamResponse && responseReceived) return - if (options.retry !== false && attemptIndex < getMaxAttempts(options) && isRetriableNetworkError(error)) { @@ -236,12 +218,10 @@ function request (data, options, callback) { for (const buffer of dataArray) req.write(buffer) req.end() - - return req }) } - return attempt(1) + attempt(1) } function byteLength (data) { diff --git a/packages/dd-trace/src/feature-registry.js b/packages/dd-trace/src/feature-registry.js index 2b7b5521ce..c30ee21d2d 100644 --- a/packages/dd-trace/src/feature-registry.js +++ b/packages/dd-trace/src/feature-registry.js @@ -5,8 +5,9 @@ * @property {string} name * @property {object} noop * @property {() => object} factory + * @property {(config: import('./config/config-base')) => boolean} isEnabled + * @property {() => Function} provider * @property {Function} [remoteConfig] - * @property {Function} [enable] */ /** @type {{ [name: string]: Feature }} */ diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index bfe7989547..b20ae7e007 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -1,9 +1,10 @@ 'use strict' -const { pipeline } = require('node:stream') -const { createGunzip } = require('node:zlib') +/* eslint-disable no-await-in-loop -- Polls and retries must remain sequential. */ + +const { setTimeout: sleep } = require('node:timers/promises') +const { inspect } = require('node:util') -const { parser, pick, streamValues } = require('../../../../vendor/dist/stream-json') const request = require('../exporters/common/request') const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers') const log = require('../log') @@ -14,7 +15,6 @@ 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 /** * @typedef {object} AgentlessSourceConfig @@ -30,23 +30,20 @@ const FAILURE_WARNING_INTERVAL_MS = 5 * 60 * 1000 /** * @typedef {object} PollResponse - * @property {number} statusCode - * @property {string | undefined} etag - * @property {UniversalFlagConfiguration | undefined} configuration + * @property {Error | null | undefined} error + * @property {string | undefined} body + * @property {number | undefined} statusCode + * @property {import('node:http').IncomingHttpHeaders | undefined} headers */ -class RetryableRequestError extends Error {} -class MalformedPayloadError extends Error {} -class ResponseReadError extends Error {} - class AgentlessConfigurationSource { - /** @type {import('node:http').ClientRequest | undefined} */ - #activeRequest + /** @type {AbortController | undefined} */ + #abortController /** @type {(configuration: UniversalFlagConfiguration) => void} */ #applyConfiguration - #closed = false + #applicationFailureLogged = false /** @type {AgentlessSourceConfig} */ #config @@ -54,15 +51,9 @@ class AgentlessConfigurationSource { /** @type {string | undefined} */ #etag - #lastFailureWarning = -Infinity - - /** @type {(() => void) | undefined} */ - #resumeRetry + #malformedPayloadLogged = false - #started = false - - /** @type {NodeJS.Timeout | undefined} */ - #timer + #pollFailureLogged = false /** * @param {AgentlessSourceConfig} config @@ -77,205 +68,104 @@ class AgentlessConfigurationSource { * @returns {void} */ start () { - if (this.#closed || this.#started) return - this.#started = true - this.#poll() - } - - /** - * @returns {void} - */ - stop () { - if (this.#closed) return - this.#closed = true - - if (this.#timer) { - clearTimeout(this.#timer) - this.#timer = undefined - } + if (this.#abortController) return - const resumeRetry = this.#resumeRetry - this.#resumeRetry = undefined - resumeRetry?.() - - this.#activeRequest?.destroy() - this.#activeRequest = undefined + const abortController = new AbortController() + this.#abortController = abortController + this.#poll(abortController) } /** * @returns {void} */ - #poll () { - this.#attempt(1).then( - this.#finishPoll.bind(this, undefined), - this.#finishPoll.bind(this) - ) + stop () { + this.#abortController?.abort() + this.#abortController = undefined } /** - * @param {Error | undefined} error - * @returns {void} + * @param {AbortController} abortController + * @returns {Promise} */ - #finishPoll (error) { - if (error && !this.#closed) { - if (error instanceof MalformedPayloadError) { - log.debug('Feature Flagging agentless endpoint returned malformed UFC payload: %s', error.message) - } else { - log.debug('Feature Flagging agentless poll failed: %s', error.message) - if (error instanceof ResponseReadError) this.#warnFailure(undefined, error, 1) + async #poll (abortController) { + try { + do { + await this.#pollOnce(abortController) + } while ( + this.#abortController === abortController && + await wait(this.#config.pollIntervalMs, abortController.signal) + ) + } catch (error) { + if (this.#abortController === abortController) { + this.#warnFailure(undefined, error, 1) + } + } finally { + if (this.#abortController === abortController) { + this.#abortController = undefined } } - - if (!this.#closed) { - this.#timer = setTimeout(() => { - this.#timer = undefined - this.#poll() - }, this.#config.pollIntervalMs) - this.#timer.unref?.() - } - } - - /** - * @param {number} attempt - * @returns {Promise} - */ - #attempt (attempt) { - return this.#request().then( - this.#handleResponse.bind(this, attempt), - this.#handleError.bind(this, attempt) - ) } /** - * @param {number} attempt - * @param {PollResponse} response - * @returns {object | Promise} + * @param {AbortController} abortController + * @returns {Promise} */ - #handleResponse (attempt, response) { - if (this.#closed) return { stopped: true } - - if (isRetryableStatus(response.statusCode)) { - if (attempt < MAX_ATTEMPTS) return this.#waitAndRetry(attempt) - this.#warnFailure(response.statusCode, undefined, attempt) - } - - return this.#apply(response) - } + async #pollOnce (abortController) { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const response = await this.#request(abortController.signal) + if (this.#abortController !== abortController) return + + const retryable = response.statusCode === undefined || isRetryableStatus(response.statusCode) + if (!retryable) { + this.#apply(abortController, response) + return + } - /** - * @param {number} attempt - * @param {Error} error - * @returns {object | Promise} - */ - #handleError (attempt, error) { - if (this.#closed) return { stopped: true } + if (attempt === MAX_ATTEMPTS) { + this.#warnFailure(response.statusCode, response.error, attempt) + return + } - if (error instanceof RetryableRequestError) { - if (attempt < MAX_ATTEMPTS) return this.#waitAndRetry(attempt) - this.#warnFailure(undefined, error, attempt) + const delay = retryDelay(this.#config.pollIntervalMs, attempt, Math.random()) + if (!await wait(delay, abortController.signal)) return } - - throw error - } - - /** - * @param {number} attempt - * @returns {Promise} - */ - #waitAndRetry (attempt) { - const delay = retryDelay(this.#config.pollIntervalMs, attempt, Math.random()) - - /** - * @param {() => void} resolve - */ - const wait = (resolve) => { - this.#resumeRetry = resolve - this.#timer = setTimeout(() => { - this.#timer = undefined - this.#resumeRetry = undefined - resolve() - }, delay) - this.#timer.unref?.() - } - - return new Promise(wait).then( - this.#continueAttempt.bind(this, attempt + 1) - ) - } - - /** - * @param {number} attempt - * @returns {object | Promise} - */ - #continueAttempt (attempt) { - return this.#closed ? { stopped: true } : this.#attempt(attempt) } /** + * @param {AbortSignal} signal * @returns {Promise} */ - #request () { - const headers = { - ...getClientLibraryHeaders(), - 'Accept-Encoding': 'gzip', - 'DD-API-KEY': this.#config.apiKey, - } + #request (signal) { + const headers = getClientLibraryHeaders() + headers['Accept-Encoding'] = 'gzip' + headers['DD-API-KEY'] = this.#config.apiKey if (this.#etag) headers['If-None-Match'] = this.#etag /** * @param {(response: PollResponse) => void} resolve - * @param {(error: Error) => void} reject */ - const execute = (resolve, reject) => { + const execute = (resolve) => { /** * @param {Error | null} error - * @param {import('node:http').IncomingMessage | null | undefined} response + * @param {string | import('node:http').IncomingMessage | null | undefined} body + * @param {number | undefined} statusCode + * @param {import('node:http').IncomingHttpHeaders | undefined} responseHeaders */ - const onResponse = (error, response) => { - if (error) { - this.#activeRequest = undefined - reject(new RetryableRequestError('Feature Flagging agentless request failed', { cause: error })) - return - } - - if (!response) { - this.#activeRequest = undefined - reject(new RetryableRequestError('Feature Flagging agentless request was not sent')) - return - } - - const { headers: responseHeaders, statusCode } = response - const etag = responseHeaders.etag - const result = { + const onResponse = (error, body, statusCode, responseHeaders) => { + resolve({ + error, + body: typeof body === 'string' ? body : undefined, statusCode, - etag: Array.isArray(etag) ? etag[0] : etag, - configuration: undefined, - } - - if (statusCode !== 200) { - this.#activeRequest = undefined - response.destroy() - resolve(result) - return - } - - this.#parseResponse(response, responseHeaders['content-encoding'], (parseError, configuration) => { - this.#activeRequest = undefined - if (parseError) { - reject(parseError) - } else { - result.configuration = configuration - resolve(result) - } + headers: responseHeaders, }) } - this.#activeRequest = request('', { + request('', { url: this.#config.endpoint, method: 'GET', headers, - responseType: 'stream', retry: false, + signal, timeout: this.#config.requestTimeoutMs, }, onResponse) } @@ -284,133 +174,56 @@ class AgentlessConfigurationSource { } /** - * @param {import('node:http').IncomingMessage} response - * @param {string | string[] | undefined} contentEncoding - * @param {(error: Error | null, configuration?: UniversalFlagConfiguration) => void} callback + * @param {AbortController} abortController + * @param {PollResponse} response * @returns {void} */ - #parseResponse (response, contentEncoding, callback) { - let attributes - let resourceType - let responseError - let gzipError - - /** - * @param {{ value: unknown }} entry - */ - const collectConfiguration = (entry) => { - if (entry.value && typeof entry.value === 'object' && !Array.isArray(entry.value)) { - resourceType = entry.value.type - attributes = entry.value.attributes - } else { - resourceType = undefined - attributes = undefined - } - } - - /** - * @param {Error} error - */ - const rememberResponseError = (error) => { - responseError = error - } + #apply (abortController, response) { + const statusCode = response.statusCode + if (statusCode === 304) return - /** - * @param {Error} error - */ - const rememberGzipError = (error) => { - gzipError = error + if (statusCode === 401 || statusCode === 403) { + this.#warnFailure(statusCode, undefined, 1) + return } - /** - * @param {Error | null | undefined} error - */ - const finish = (error) => { - if (error) { - if (gzipError) { - callback(new ResponseReadError( - 'Feature Flagging agentless gzip response could not be decompressed', - { cause: gzipError } - )) - } else if (responseError) { - callback(new ResponseReadError( - 'Feature Flagging agentless response body could not be read', - { cause: responseError } - )) - } else { - callback(new MalformedPayloadError( - 'Feature Flagging agentless response was malformed', - { cause: error } - )) - } - return - } + if (statusCode !== 200) return - try { - callback(null, validateConfiguration(resourceType, attributes)) - } catch (validationError) { - callback(new MalformedPayloadError( - 'Feature Flagging agentless response was not a valid UFC resource', - { cause: validationError } - )) + let configuration + try { + configuration = parseConfiguration(response.body) + } catch (error) { + if (!this.#malformedPayloadLogged) { + this.#malformedPayloadLogged = true + log.error('Feature Flagging agentless endpoint returned malformed UFC payload: %s', errorMessage(error)) } + return } - const jsonParser = parser() - const configurationPicker = pick({ filter: 'data' }) - const configurationValues = streamValues({ reviver: preservePrototypeKey }) - const streams = [response] - - response.once('error', rememberResponseError) - const encoding = Array.isArray(contentEncoding) ? contentEncoding[0] : contentEncoding - if (encoding?.toLowerCase() === 'gzip') { - const gunzip = createGunzip() - gunzip.once('error', rememberGzipError) - streams.push(gunzip) - } - streams.push(jsonParser, configurationPicker, configurationValues) - - configurationValues.on('data', collectConfiguration) - pipeline(...streams, finish) - } - - /** - * @param {PollResponse} response - * @returns {object} - */ - #apply (response) { - const status = response.statusCode - if (status === 304) return { notModified: true } - - if (status === 401 || status === 403) { - this.#warnFailure(status, undefined, 1) - return { rejected: true, statusCode: status } - } - - if (status !== 200) return { rejected: true, statusCode: status } - try { - this.#applyConfiguration(response.configuration) + this.#applyConfiguration(configuration) } catch (error) { - log.debug('Feature Flagging agentless UFC payload could not be applied: %s', error.message) - return { rejected: true, applicationFailed: true } + if (!this.#applicationFailureLogged) { + this.#applicationFailureLogged = true + log.warn('Feature Flagging agentless UFC payload could not be applied: %s', errorMessage(error)) + } + return } - const etag = response.etag?.trim() - this.#etag = etag || undefined - return { applied: true } + const etag = response.headers?.etag + const value = Array.isArray(etag) ? etag[0] : etag + this.#etag = value?.trim() || undefined } /** * @param {number | undefined} statusCode - * @param {Error | undefined} error + * @param {unknown} error * @param {number} attempts * @returns {void} */ #warnFailure (statusCode, error, attempts) { - const now = Date.now() - if (now - this.#lastFailureWarning < FAILURE_WARNING_INTERVAL_MS) return - this.#lastFailureWarning = now + if (this.#pollFailureLogged) return + this.#pollFailureLogged = true if (statusCode === 401 || statusCode === 403) { log.warn( @@ -420,56 +233,61 @@ class AgentlessConfigurationSource { } else if (statusCode) { log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, attempts) } else if (attempts > 1) { - log.warn('Feature Flagging agentless request failed after %d attempts: %s', attempts, error.message) + log.warn('Feature Flagging agentless request failed after %d attempts: %s', attempts, errorMessage(error)) } else { - log.warn('Feature Flagging agentless request failed: %s', error.message) + log.warn('Feature Flagging agentless request failed: %s', errorMessage(error)) } } } /** - * @param {unknown} resourceType - * @param {unknown} attributes + * @param {number} delay + * @param {AbortSignal} signal + * @returns {Promise} + */ +async function wait (delay, signal) { + try { + await sleep(delay, undefined, { ref: false, signal }) + return true + } catch (error) { + if (error?.name === 'AbortError') return false + throw error + } +} + +/** + * @param {string | undefined} body * @returns {UniversalFlagConfiguration} */ -function validateConfiguration (resourceType, attributes) { - if (resourceType !== 'universal-flag-configuration') { +function parseConfiguration (body) { + const { data } = JSON.parse(body) + if (data?.type !== 'universal-flag-configuration') { throw new Error('Expected a JSON:API Universal Flag Configuration resource') } - if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes) || + const { attributes } = data + if (typeof attributes?.format !== 'string' || typeof attributes.createdAt !== 'string' || - (attributes.format !== undefined && typeof attributes.format !== 'string') || - !attributes.environment || typeof attributes.environment !== 'object' || - Array.isArray(attributes.environment) || - typeof attributes.environment.name !== 'string' || - !attributes.flags || typeof attributes.flags !== 'object' || Array.isArray(attributes.flags)) { - const keys = attributes && typeof attributes === 'object' - ? Object.keys(attributes).join(',') - : typeof attributes - throw new Error(`Expected a Universal Flag Configuration v1 object; received ${keys}`) + typeof attributes.environment?.name !== 'string' || + !attributes.flags || + typeof attributes.flags !== 'object' || + Array.isArray(attributes.flags)) { + throw new Error( + `Expected a Universal Flag Configuration v1 object; received ${ + inspect(attributes, { depth: 0, maxStringLength: 0 }) + }` + ) } return attributes } /** - * @this {Record} - * @param {string} key - * @param {unknown} value + * @param {unknown} error + * @returns {string} */ -function preservePrototypeKey (key, value) { - if (key === '__proto__') { - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - value, - writable: true, - }) - return - } - - return value +function errorMessage (error) { + return error instanceof Error ? error.message : String(error ?? 'request was not sent') } /** diff --git a/packages/dd-trace/src/openfeature/register.js b/packages/dd-trace/src/openfeature/register.js index fa187abcc6..81153babae 100644 --- a/packages/dd-trace/src/openfeature/register.js +++ b/packages/dd-trace/src/openfeature/register.js @@ -6,55 +6,16 @@ const noop = new (require('./noop'))() /** @typedef {import('../proxy') & { openfeature: object }} OpenFeatureProxy */ -/** - * @param {import('../proxy')} proxy - * @returns {boolean} - */ -function hasFlaggingProvider (proxy) { - const descriptor = Reflect.getOwnPropertyDescriptor(proxy, 'openfeature') - - 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 -} - -/** - * @param {import('../proxy')} proxy - * @param {import('../tracer')} tracer - * @param {import('../config/config-base')} config - */ -function defineFlaggingProvider (proxy, tracer, config) { - Reflect.defineProperty(proxy, 'openfeature', { - get () { - proxy._modules.openfeature.enable(config) - - const FlaggingProvider = require('./flagging_provider') - const provider = new FlaggingProvider(tracer, config) - - Reflect.defineProperty(proxy, 'openfeature', { - value: provider, - configurable: true, - enumerable: true, - }) - return provider - }, - configurable: true, - enumerable: true, - }) -} - registerFeature({ name: 'openfeature', noop, factory: () => require('./index'), + provider: () => require('./flagging_provider'), + + /** @param {import('../config/config-base')} config */ + isEnabled (config) { + return config.featureFlags.DD_FEATURE_FLAGS_ENABLED + }, /** * @param {import('../remote_config')} rc - RemoteConfig instance @@ -71,19 +32,4 @@ registerFeature({ subscribe ) }, - - /** - * @param {import('../config/config-base')} config - * @param {import('../tracer')} tracer - * @param {import('../proxy')} proxy - */ - enable (config, tracer, proxy) { - if (!config.featureFlags.DD_FEATURE_FLAGS_ENABLED) return - - if (!hasFlaggingProvider(proxy)) { - defineFlaggingProvider(proxy, tracer, config) - } else if (hasConstructedFlaggingProvider(proxy)) { - proxy._modules.openfeature.enable(config) - } - }, }) diff --git a/packages/dd-trace/src/proxy.js b/packages/dd-trace/src/proxy.js index d9a8ab1dba..95c8b38b51 100644 --- a/packages/dd-trace/src/proxy.js +++ b/packages/dd-trace/src/proxy.js @@ -38,6 +38,9 @@ const OFFLINE_VALIDATION_EXPORTERS = new Set([ 'playwright_worker', 'vitest_worker', ]) +const FEATURE_STATE_NOOP = 0 +const FEATURE_STATE_LAZY = 1 +const FEATURE_STATE_ACTIVE = 2 class LazyModule { constructor (provider) { @@ -87,6 +90,9 @@ function defineLazily (obj, property, getClass, ...args) { } class Tracer extends NoopProxy { + /** @type {Record | undefined} */ + #featureStates + constructor () { super() @@ -297,6 +303,42 @@ class Tracer extends NoopProxy { } } + /** + * @param {(typeof features)[string]} feature + * @param {import('./config/config-base')} config + */ + #enableFeature (feature, config) { + const states = this.#featureStates ??= {} + const state = states[feature.name] ?? FEATURE_STATE_NOOP + + if (state === FEATURE_STATE_ACTIVE) { + this._modules[feature.name].enable(config) + return + } + + if (state === FEATURE_STATE_LAZY) return + states[feature.name] = FEATURE_STATE_LAZY + + Reflect.defineProperty(this, feature.name, { + get: () => { + this._modules[feature.name].enable(config) + + const Provider = feature.provider() + const provider = new Provider(this._tracer, config) + + Reflect.defineProperty(this, feature.name, { + value: provider, + configurable: true, + enumerable: true, + }) + states[feature.name] = FEATURE_STATE_ACTIVE + return provider + }, + configurable: true, + enumerable: true, + }) + } + /** * @param {import('./config/config-base')} config - Tracer configuration */ @@ -323,7 +365,7 @@ class Tracer extends NoopProxy { this._tracingInitialized = true } for (const feature of Object.values(features)) { - feature.enable?.(config, this._tracer, this, lazyProxy) + if (feature.isEnabled(config)) this.#enableFeature(feature, config) } if (config.experimental?.aiguard?.enabled) { this._modules.aiguard.enable(this._tracer, config) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index a0ff72c8d1..c90429040d 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -110,39 +110,6 @@ describe('request', function () { }) }) - it('streams raw responses and returns the cancellable request', (done) => { - nock('http://test:123') - .get('/path') - .reply(404, ['first', 'second'], { - etag: '"raw"', - }) - - const clientRequest = request(Buffer.from(''), { - protocol: 'http:', - hostname: 'test', - port: 123, - path: '/path', - method: 'GET', - responseType: 'stream', - retry: false, - }, (error, response, statusCode, headers) => { - const chunks = [] - - clientRequest.emit('error', new Error('late response error')) - response.on('data', chunk => chunks.push(chunk)) - response.on('end', () => { - assert.ifError(error) - assert.strictEqual(statusCode, 404) - assert.strictEqual(headers.etag, '"raw"') - assert.strictEqual(Buffer.concat(chunks).toString(), '["first","second"]') - sinon.assert.calledOnceWithMatch(runInNoopContext, { noop: true }, sinon.match.func) - done() - }) - }) - - assert.strictEqual(typeof clientRequest.destroy, 'function') - }) - it('does not retry when retries are disabled', (done) => { maxAttempts = 5 const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }) @@ -163,24 +130,39 @@ describe('request', function () { }) }) - it('allows callers to cancel a raw response request', (done) => { + it('allows callers to cancel a request with an AbortSignal', async () => { nock('http://localhost:80') .get('/path') .delayConnection(1000) .reply(200, 'OK') - const cancellation = new Error('cancelled') - const clientRequest = request(Buffer.from(''), { - path: '/path', - method: 'GET', - responseType: 'stream', - retry: false, - }, (error) => { - assert.strictEqual(error, cancellation) - done() - }) + const abortController = new AbortController() + /** + * @param {() => void} resolve + * @param {(error: Error) => void} reject + */ + const execute = (resolve, reject) => { + /** @param {Error | null} error */ + const onResponse = (error) => { + if (error) { + reject(error) + } else { + resolve() + } + } + + request(Buffer.from(''), { + path: '/path', + method: 'GET', + retry: false, + signal: abortController.signal, + }, onResponse) + } + const completed = new Promise(execute) + + abortController.abort() - clientRequest.destroy(cancellation) + await assert.rejects(completed, { code: 'ABORT_ERR' }) }) it('should handle an http error', done => { 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 af7c79fc7f..7abb916c71 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,6 @@ 'use strict' const assert = require('node:assert/strict') -const { Readable } = require('node:stream') const zlib = require('node:zlib') const { afterEach, beforeEach, describe, it } = require('mocha') @@ -54,7 +53,7 @@ describe('AgentlessConfigurationSource', () => { apiKey: 'test-api-key', } log = { - debug: sinon.spy(), + error: sinon.spy(), warn: sinon.spy(), } random = sinon.stub(Math, 'random').returns(0.5) @@ -62,34 +61,45 @@ describe('AgentlessConfigurationSource', () => { responses = [] sources = [] - request = sinon.spy((data, options, callback) => { + /** + * @param {string} data + * @param {{ signal?: AbortSignal }} options + * @param {Function} callback + */ + const sendRequest = (data, options, callback) => { const response = responses.shift() - let responseStream - const activeRequest = { - destroy: sinon.spy(() => { - if (responseStream) { - responseStream.destroy(new Error('cancelled')) - } else if (response?.pending && !response.ignoreDestroy) { - queueMicrotask(() => callback(new Error('cancelled'))) - } - }), + const requestRecord = { + aborted: false, + callback, + data, + options, } - const requestRecord = { activeRequest, callback, data, options } requests.push(requestRecord) + /** + * @returns {void} + */ + const abort = () => { + requestRecord.aborted = true + if (response?.pending && !response.ignoreAbort) { + const error = Object.assign(new Error('cancelled'), { name: 'AbortError' }) + queueMicrotask(() => callback(error)) + } + } + options.signal?.addEventListener('abort', abort, { once: true }) + if (response && !response.pending) { queueMicrotask(() => { - if (response.error) { - callback(response.error) - } else { - responseStream = createResponseStream(response) - callback(null, responseStream) - } + callback( + response.error ?? null, + response.body, + response.statusCode, + response.headers + ) }) } - - return activeRequest - }) + } + request = sinon.spy(sendRequest) AgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { '../exporters/common/request': request, @@ -104,33 +114,6 @@ describe('AgentlessConfigurationSource', () => { nock.cleanAll() }) - /** - * @param {object} response - */ - function createResponseStream (response) { - const chunks = response.bodyChunks || [response.body || ''] - let pushed = false - - const responseStream = new Readable({ - read () { - if (pushed) return - pushed = true - response.onRead?.() - for (const chunk of chunks) { - this.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - } - if (response.bodyError) { - this.destroy(response.bodyError) - } else if (!response.bodyPending) { - this.push(null) - } - }, - }) - responseStream.statusCode = response.statusCode - responseStream.headers = response.headers || {} - return responseStream - } - function source () { const configurationSource = new AgentlessConfigurationSource(config, applyConfiguration) sources.push(configurationSource) @@ -154,16 +137,14 @@ describe('AgentlessConfigurationSource', () => { { statusCode: 200, headers: { etag: [' W/"ufc-v1" '] }, body: responseBody() }, { statusCode: 304 } ) - const configurationSource = source() - configurationSource.start() + source().start() await flush() sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) assert.strictEqual(requests[0].data, '') assert.strictEqual(requests[0].options.url, config.endpoint) assert.strictEqual(requests[0].options.method, 'GET') - assert.strictEqual(requests[0].options.responseType, 'stream') assert.strictEqual(requests[0].options.retry, false) assert.strictEqual(requests[0].options.timeout, 2000) assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key') @@ -184,9 +165,8 @@ describe('AgentlessConfigurationSource', () => { { statusCode: 200, body: responseBody() }, { statusCode: 304 } ) - const configurationSource = source() - configurationSource.start() + source().start() await flush() await tick(30_000) await tick(30_000) @@ -196,9 +176,10 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledTwice(applyConfiguration) }) - it('streams and applies a response through the shared request transport', async () => { + it('buffers, decompresses, and applies a response through the shared request transport', async () => { clock.restore() clock = undefined + const body = zlib.gzipSync(responseBody()) nock('http://127.0.0.1:8080', { reqheaders: { 'accept-encoding': 'gzip', @@ -206,7 +187,10 @@ describe('AgentlessConfigurationSource', () => { }, }) .get('/api/v2/feature-flagging/config/rules-based/server') - .reply(200, responseBody(), { etag: '"real-path"' }) + .reply(200, body, { + 'content-encoding': 'gzip', + etag: '"real-path"', + }) let resolveConfiguration const applied = new Promise(resolve => { @@ -215,9 +199,7 @@ describe('AgentlessConfigurationSource', () => { const RealAgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { '../log': log, }) - const configurationSource = new RealAgentlessConfigurationSource(config, configuration => { - resolveConfiguration(configuration) - }) + const configurationSource = new RealAgentlessConfigurationSource(config, resolveConfiguration) sources.push(configurationSource) configurationSource.start() @@ -226,81 +208,9 @@ describe('AgentlessConfigurationSource', () => { assert.ok(nock.isDone()) }) - it('selects only data type and attributes without a payload-size cap', async () => { - const expected = { - ...VALID_UFC, - flags: { - large: { - description: 'accepted', - }, - }, - } - responses.push({ - statusCode: 200, - body: JSON.stringify({ - ignored: 'x'.repeat(5 * 1024 * 1024 + 1), - data: { - ignored: { nested: 'value' }, - attributes: expected, - type: 'universal-flag-configuration', - }, - }), - }) - - source().start() - await flush() - - sinon.assert.calledOnceWithExactly(applyConfiguration, expected) - }) - - it('matches JSON.parse object order and duplicate-key last-wins behavior', async () => { - const expected = { - environment: { name: 'last' }, - createdAt: '2026-07-21T00:00:00.000Z', - flags: { enabled: { enabled: true } }, - } - const body = '{"data":{"attributes":{"createdAt":"first"},"type":"wrong",' + - `"type":"universal-flag-configuration","attributes":${JSON.stringify(expected)}}}` - responses.push({ statusCode: 200, body }) - - source().start() - await flush() - - sinon.assert.calledOnce(applyConfiguration) - const configuration = applyConfiguration.firstCall.args[0] - assert.deepStrictEqual(configuration, expected) - assert.deepStrictEqual(Object.keys(configuration), Object.keys(expected)) - }) - - it('treats duplicate data members with JSON.parse last-wins behavior', async () => { - responses.push({ - statusCode: 200, - body: `{"data":${JSON.stringify({ - type: 'universal-flag-configuration', - attributes: VALID_UFC, - })},"data":{"attributes":${JSON.stringify(VALID_UFC)}}}`, - }) - - source().start() - await flush() - - sinon.assert.notCalled(applyConfiguration) - sinon.assert.calledOnce(log.debug) - }) - - it('rejects a primitive data member', async () => { - responses.push({ statusCode: 200, body: '{"data":"invalid"}' }) - - source().start() - await flush() - - sinon.assert.notCalled(applyConfiguration) - sinon.assert.calledOnce(log.debug) - }) - - it('preserves __proto__ as an own property like JSON.parse', async () => { - const body = '{"data":{"type":"universal-flag-configuration","attributes":' + - '{"createdAt":"2026-01-01T00:00:00.000Z","environment":{"name":"test"},' + + it('matches JSON.parse duplicate-key and __proto__ behavior', async () => { + const body = '{"data":{"type":"wrong","type":"universal-flag-configuration","attributes":' + + '{"createdAt":"2026-01-01T00:00:00.000Z","format":"SERVER","environment":{"name":"test"},' + '"flags":{"__proto__":{"enabled":true}}}}}' const expected = JSON.parse(body).data.attributes responses.push({ statusCode: 200, body }) @@ -308,56 +218,31 @@ describe('AgentlessConfigurationSource', () => { source().start() await flush() - sinon.assert.calledOnce(applyConfiguration) - const configuration = applyConfiguration.firstCall.args[0] - assert.deepStrictEqual(configuration, expected) - assert.strictEqual(Object.hasOwn(configuration.flags, '__proto__'), true) - assert.strictEqual(Object.getPrototypeOf(configuration.flags), Object.prototype) - assert.strictEqual(Object.prototype.enabled, undefined) - }) - - it('parses split UTF-8 and escaped JSON names and values', async () => { - const expected = { - createdAt: '2026-01-01T00:00:00.000Z', - environment: { name: 'café 🚀' }, - flags: { escaped: { description: 'line\nbreak' } }, - } - const body = '{"data":{"type":"universal-flag-configur\\u0061tion",' + - `"attr\\u0069butes":${JSON.stringify(expected)}}}` - const buffer = Buffer.from(body) - const rocketByte = buffer.indexOf(Buffer.from('🚀')) - responses.push({ - statusCode: 200, - bodyChunks: [ - buffer.subarray(0, rocketByte + 1), - buffer.subarray(rocketByte + 1, rocketByte + 3), - buffer.subarray(rocketByte + 3), - ], - }) - - source().start() - await flush() - sinon.assert.calledOnceWithExactly(applyConfiguration, expected) + assert.strictEqual(Object.hasOwn(applyConfiguration.firstCall.args[0].flags, '__proto__'), true) + assert.strictEqual(Object.prototype.enabled, undefined) }) - it('preserves last-known-good state after malformed and truncated JSON', async () => { + it('preserves last-known-good state and logs malformed responses once', async () => { responses.push( { statusCode: 200, headers: { etag: '"good"' }, body: responseBody() }, { statusCode: 200, headers: { etag: '"bad"' }, body: `${responseBody()} trailing` }, - { statusCode: 200, headers: { etag: '"bad"' }, body: '{"data":{"type":' }, + { + statusCode: 200, + headers: { etag: '"bad"' }, + body: responseBody({ ...VALID_UFC, format: undefined }), + }, { statusCode: 304 } ) - const configurationSource = source() - configurationSource.start() + source().start() await flush() await tick(30_000) await tick(30_000) await tick(30_000) sinon.assert.calledOnce(applyConfiguration) - sinon.assert.calledTwice(log.debug) + sinon.assert.calledOnce(log.error) assert.strictEqual(requests[3].options.headers['If-None-Match'], '"good"') }) @@ -369,12 +254,7 @@ describe('AgentlessConfigurationSource', () => { }, { statusCode: 200, - body: JSON.stringify({ - data: { - type: 'universal-flag-configuration', - attributes: { ...VALID_UFC, environment: [] }, - }, - }), + body: responseBody({ ...VALID_UFC, environment: [] }), }, { statusCode: 200, @@ -386,80 +266,52 @@ describe('AgentlessConfigurationSource', () => { }), } ) - const configurationSource = source() - configurationSource.start() + source().start() await flush() await tick(30_000) await tick(30_000) sinon.assert.notCalled(applyConfiguration) - sinon.assert.calledThrice(log.debug) + sinon.assert.calledOnce(log.error) }) - it('decompresses gzip responses before parsing', async () => { + it('does not expose malformed payload string values in logs', async () => { responses.push({ statusCode: 200, - headers: { 'content-encoding': ['GZip'] }, - body: zlib.gzipSync(responseBody()), + body: responseBody({ + createdAt: 'secret-created-at', + environment: { name: 'secret-environment' }, + flags: {}, + }), }) source().start() await flush() - sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) - }) - - it('preserves last-known-good state after gzip and response read errors', async () => { - responses.push( - { statusCode: 200, headers: { etag: '"good"' }, body: responseBody() }, - { - statusCode: 200, - headers: { etag: '"bad"', 'content-encoding': 'gzip' }, - body: 'not gzip', - }, - { - statusCode: 200, - headers: { etag: '"bad"' }, - bodyError: new Error('read failed'), - }, - { statusCode: 304 } - ) - const configurationSource = source() - - configurationSource.start() - await flush() - await tick(30_000) - await tick(30_000) - await tick(30_000) - - sinon.assert.calledOnce(applyConfiguration) - sinon.assert.calledTwice(log.debug) - sinon.assert.calledOnceWithExactly( - log.warn, - 'Feature Flagging agentless request failed: %s', - 'Feature Flagging agentless gzip response could not be decompressed' - ) - assert.strictEqual(requests[3].options.headers['If-None-Match'], '"good"') + const message = log.error.firstCall.args[1] + assert.doesNotMatch(message, /secret/) }) - it('does not advance the ETag after an application failure', async () => { + it('does not advance the ETag after an application failure and logs it once', async () => { applyConfiguration.onSecondCall().throws(new Error('listener failed')) + applyConfiguration.onThirdCall().throws(new Error('listener failed again')) responses.push( { statusCode: 200, headers: { etag: '"good"' }, body: responseBody() }, { statusCode: 200, headers: { etag: '"failed"' }, body: responseBody() }, + { statusCode: 200, headers: { etag: '"failed-again"' }, body: responseBody() }, { statusCode: 304 } ) - const configurationSource = source() - configurationSource.start() + source().start() await flush() await tick(30_000) await tick(30_000) + await tick(30_000) - sinon.assert.calledTwice(applyConfiguration) - sinon.assert.calledOnce(log.debug) - assert.strictEqual(requests[2].options.headers['If-None-Match'], '"good"') + sinon.assert.calledThrice(applyConfiguration) + sinon.assert.calledOnce(log.warn) + assert.strictEqual(requests[3].options.headers['If-None-Match'], '"good"') }) it('retries timeout, rate-limit, and server statuses with bounded delays', async () => { @@ -468,9 +320,8 @@ describe('AgentlessConfigurationSource', () => { { statusCode: 429 }, { statusCode: 200, body: responseBody() } ) - const configurationSource = source() - configurationSource.start() + source().start() await flush() await tick(4999) assert.strictEqual(requests.length, 1) @@ -490,9 +341,8 @@ describe('AgentlessConfigurationSource', () => { { error: Object.assign(new Error('reset'), { code: 'ECONNRESET' }) }, { statusCode: 200, body: responseBody() } ) - const configurationSource = source() - configurationSource.start() + source().start() await flush() await tick(5000) await tick(10_000) @@ -504,12 +354,11 @@ describe('AgentlessConfigurationSource', () => { it('retries when the shared transport cannot send the request', async () => { responses.push( - { pending: true }, + {}, { statusCode: 200, body: responseBody() } ) source().start() - requests[0].callback(null) await flush() await tick(5000) @@ -517,17 +366,23 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) }) - it('warns after retryable failures exhaust all attempts', async () => { + it('warns once after repeated retryable failures exhaust all attempts', async () => { responses.push( { statusCode: 500 }, { statusCode: 502 }, - { statusCode: 503 } + { statusCode: 503 }, + { error: new Error('first') }, + { error: new Error('second') }, + { error: new Error('third') } ) source().start() await flush() await tick(5000) await tick(10_000) + await tick(30_000) + await tick(5000) + await tick(10_000) sinon.assert.calledOnceWithExactly( log.warn, @@ -537,11 +392,11 @@ describe('AgentlessConfigurationSource', () => { ) }) - it('warns after network failures exhaust all attempts', async () => { + it('warns after retryable request failures exhaust all attempts', async () => { responses.push( { error: new Error('first') }, { error: new Error('second') }, - { error: new Error('third') } + {} ) source().start() @@ -553,7 +408,36 @@ describe('AgentlessConfigurationSource', () => { log.warn, 'Feature Flagging agentless request failed after %d attempts: %s', 3, - 'Feature Flagging agentless request failed' + 'request was not sent' + ) + }) + + it('stops a failed polling loop and allows a later start', async () => { + const sleep = sinon.stub() + sleep.onFirstCall().rejects(new Error('timer failed')) + sleep.onSecondCall().returns(new Promise(() => {})) + responses.push( + { statusCode: 200, body: responseBody() }, + { statusCode: 200, body: responseBody() } + ) + const TimerFailureSource = proxyquire('../../src/openfeature/agentless_configuration_source', { + 'node:timers/promises': { setTimeout: sleep }, + '../exporters/common/request': request, + '../log': log, + }) + const configurationSource = new TimerFailureSource(config, applyConfiguration) + sources.push(configurationSource) + + configurationSource.start() + await flush() + configurationSource.start() + await flush() + + sinon.assert.calledTwice(applyConfiguration) + sinon.assert.calledOnceWithExactly( + log.warn, + 'Feature Flagging agentless request failed: %s', + 'timer failed' ) }) @@ -562,11 +446,9 @@ describe('AgentlessConfigurationSource', () => { { statusCode: 401 }, { statusCode: 404 } ) - const configurationSource = source() - configurationSource.start() + source().start() await flush() - await tick(15_000) assert.strictEqual(requests.length, 1) sinon.assert.calledOnceWithExactly( @@ -575,39 +457,24 @@ describe('AgentlessConfigurationSource', () => { 401 ) - await tick(15_000) + await tick(30_000) assert.strictEqual(requests.length, 2) sinon.assert.calledOnce(log.warn) sinon.assert.notCalled(applyConfiguration) }) - it('rate-limits repeated authentication warnings', async () => { - for (let i = 0; i < 11; i++) responses.push({ statusCode: i % 2 ? 403 : 401 }) - - source().start() - await flush() - for (let i = 0; i < 10; i++) await tick(30_000) - - assert.strictEqual(requests.length, 11) - sinon.assert.calledTwice(log.warn) - }) - it('uses fixed-delay polling after a request completes', async () => { responses.push( { pending: true }, { statusCode: 200, body: responseBody() } ) - const configurationSource = source() - configurationSource.start() + source().start() await tick(30_000) assert.strictEqual(requests.length, 1) - requests[0].callback(null, createResponseStream({ - statusCode: 200, - body: responseBody(), - }), 200, {}) + requests[0].callback(null, responseBody(), 200, {}) await flush() await tick(29_999) assert.strictEqual(requests.length, 1) @@ -627,63 +494,51 @@ describe('AgentlessConfigurationSource', () => { assert.strictEqual(requests.length, 1) - requests[0].callback(null, createResponseStream({ - statusCode: 200, - body: responseBody(), - }), 200, {}) + requests[0].callback(null, responseBody(), 200, {}) await flush() configurationSource.start() assert.strictEqual(requests.length, 1) }) - it('stops and cancels an active request', async () => { - responses.push({ pending: true }) + it('stops an active request and ignores its response', async () => { + responses.push({ pending: true, ignoreAbort: true }) const configurationSource = source() configurationSource.start() configurationSource.stop() - configurationSource.stop() - configurationSource.start() - await tick(60_000) - - sinon.assert.calledOnce(requests[0].activeRequest.destroy) - assert.strictEqual(requests.length, 1) - }) - - it('stops and cancels an active response stream', async () => { - responses.push({ - statusCode: 200, - body: '{"data":', - bodyPending: true, - }) - const configurationSource = source() - - configurationSource.start() - await flush() - configurationSource.stop() + requests[0].callback(null, responseBody(), 200, {}) await flush() - sinon.assert.calledOnce(requests[0].activeRequest.destroy) + assert.strictEqual(requests[0].aborted, true) sinon.assert.notCalled(applyConfiguration) }) - it('ignores a response that arrives while stopping', async () => { - responses.push({ pending: true, ignoreDestroy: true }) + it('restarts after stop without accepting the previous request', async () => { + const oldConfiguration = { ...VALID_UFC, environment: { name: 'old' } } + const newConfiguration = { ...VALID_UFC, environment: { name: 'new' } } + responses.push( + { pending: true, ignoreAbort: true }, + { statusCode: 200, headers: { etag: '"new"' }, body: responseBody(newConfiguration) }, + { statusCode: 304 } + ) const configurationSource = source() configurationSource.start() configurationSource.stop() - requests[0].callback(null, createResponseStream({ - statusCode: 200, - body: responseBody(), - }), 200, {}) + configurationSource.start() + requests[0].callback(null, responseBody(oldConfiguration), 200, {}) await flush() - sinon.assert.notCalled(applyConfiguration) + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnceWithExactly(applyConfiguration, newConfiguration) + + await tick(30_000) + + assert.strictEqual(requests[2].options.headers['If-None-Match'], '"new"') }) - it('stops a pending retry delay', async () => { + it('stops a pending retry delay and can restart', async () => { responses.push( { error: Object.assign(new Error('reset'), { code: 'ECONNRESET' }) }, { statusCode: 200, body: responseBody() } @@ -693,9 +548,10 @@ describe('AgentlessConfigurationSource', () => { configurationSource.start() await flush() configurationSource.stop() - await tick(60_000) + configurationSource.start() + await flush() - assert.strictEqual(requests.length, 1) - sinon.assert.notCalled(applyConfiguration) + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) }) }) diff --git a/packages/dd-trace/test/openfeature/register.spec.js b/packages/dd-trace/test/openfeature/register.spec.js index 54a2f7d29f..d2c48058ba 100644 --- a/packages/dd-trace/test/openfeature/register.spec.js +++ b/packages/dd-trace/test/openfeature/register.spec.js @@ -1,6 +1,8 @@ 'use strict' const assert = require('node:assert/strict') +const { spawnSync } = require('node:child_process') +const path = require('node:path') const { beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire') @@ -11,25 +13,20 @@ require('../setup/core') describe('OpenFeature register', () => { let config let feature - let flaggingProviderConstructions - let lazyProxy + let FlaggingProvider let openfeatureModule let openfeatureRemoteConfig let proxy let registerFeature - let tracer function NoopFlaggingProvider () {} - function FlaggingProvider (...args) { - flaggingProviderConstructions++ - this.args = args - } - beforeEach(() => { - registerFeature = sinon.spy(registeredFeature => { + /** @param {object} registeredFeature */ + const register = (registeredFeature) => { feature = registeredFeature - }) + } + registerFeature = sinon.spy(register) openfeatureModule = { enable: sinon.spy(), disable: sinon.spy(), @@ -37,7 +34,7 @@ describe('OpenFeature register', () => { openfeatureRemoteConfig = { enable: sinon.spy(), } - flaggingProviderConstructions = 0 + FlaggingProvider = function () {} delete require.cache[require.resolve('../../src/openfeature/register')] proxyquire('../../src/openfeature/register', { @@ -54,68 +51,61 @@ describe('OpenFeature register', () => { DD_FEATURE_FLAGS_ENABLED: true, }, } - tracer = {} - proxy = { - openfeature: feature.noop, - _modules: { - openfeature: { - enable: sinon.spy(), - }, - }, - } - lazyProxy = sinon.spy((target, property, getClass, ...args) => { - const RealClass = getClass() - target[property] = new RealClass(...args) - }) + proxy = { openfeature: feature.noop } }) - it('registers the OpenFeature feature', () => { + it('registers the OpenFeature feature boundaries', () => { sinon.assert.calledOnce(registerFeature) assert.strictEqual(feature.name, 'openfeature') assert.ok(feature.noop instanceof NoopFlaggingProvider) assert.strictEqual(feature.factory(), openfeatureModule) + assert.strictEqual(feature.provider(), FlaggingProvider) }) - it('does not initialize Feature Flags until application code accesses the provider', () => { - feature.enable(config, tracer, proxy, lazyProxy) - - assert.strictEqual(flaggingProviderConstructions, 0) - sinon.assert.notCalled(proxy._modules.openfeature.enable) - sinon.assert.notCalled(lazyProxy) - - const provider = proxy.openfeature - - assert.strictEqual(flaggingProviderConstructions, 1) - assert.ok(provider instanceof FlaggingProvider) - assert.deepStrictEqual(provider.args, [tracer, config]) - sinon.assert.calledOnceWithExactly(proxy._modules.openfeature.enable, config) + it('does not load active OpenFeature modules before application access', () => { + const packagePath = path.join(__dirname, '../..') + const script = ` + const tracer = require(${JSON.stringify(packagePath)}) + tracer.init() + const modules = [ + require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/index'))}), + require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/writers/exposures'))}), + require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/flagging_provider'))}), + require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/require-provider'))}), + require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/configuration_source'))}), + require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/agentless_configuration_source'))}), + require.resolve('@datadog/openfeature-node-server'), + require.resolve('@openfeature/server-sdk'), + require.resolve('@openfeature/core') + ] + process.stdout.write(JSON.stringify(modules.map(module => require.cache[module] !== undefined))) + ` + for (const featureFlagsEnabled of ['false', 'true']) { + for (const remoteConfigurationEnabled of ['false', 'true']) { + const result = spawnSync(process.execPath, ['-e', script], { + encoding: 'utf8', + env: { + ...process.env, + DD_FEATURE_FLAGS_ENABLED: featureFlagsEnabled, + DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'false', + DD_REMOTE_CONFIGURATION_ENABLED: remoteConfigurationEnabled, + DD_TRACE_STARTUP_LOGS: 'false', + }, + }) + + assert.strictEqual(result.status, 0, result.stderr) + assert.deepStrictEqual(JSON.parse(result.stdout), Array(9).fill(false)) + } + } }) - it('keeps an existing flagging provider on repeated enable calls', () => { - feature.enable(config, tracer, proxy, lazyProxy) - feature.enable(config, tracer, proxy, lazyProxy) - assert.strictEqual(flaggingProviderConstructions, 0) - sinon.assert.notCalled(proxy._modules.openfeature.enable) - - const provider = proxy.openfeature - feature.enable(config, tracer, proxy, lazyProxy) + it('selects the provider from the calculated Feature Flags state', () => { + assert.strictEqual(feature.isEnabled(config), true) - assert.strictEqual(flaggingProviderConstructions, 1) - sinon.assert.calledTwice(proxy._modules.openfeature.enable) - sinon.assert.notCalled(lazyProxy) - assert.strictEqual(proxy.openfeature, provider) - }) - - it('does not define the flagging provider when disabled', () => { config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false - feature.enable(config, tracer, proxy, lazyProxy) - - assert.strictEqual(flaggingProviderConstructions, 0) - sinon.assert.notCalled(proxy._modules.openfeature.enable) - sinon.assert.notCalled(lazyProxy) - assert.strictEqual(proxy.openfeature, feature.noop) + assert.strictEqual(feature.isEnabled(config), false) }) it('installs Remote Config delivery when selected', () => { diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index ac45511eed..fd9c2260f0 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -273,33 +273,20 @@ describe('TracerProxy', () => { const { enable: openfeatureRcEnable } = require('../src/openfeature/remote_config') const noopOpenfeature = {} - /** - * @param {object} proxy - * @returns {boolean} - */ - function hasOpenfeatureProvider (proxy) { - const descriptor = Reflect.getOwnPropertyDescriptor(proxy, 'openfeature') - - return descriptor?.value !== undefined && descriptor.value !== noopOpenfeature - } - featureRegistry.registerFeature({ name: 'openfeature', noop: noopOpenfeature, factory: () => openfeature, + provider: () => OpenFeatureProvider, + /** @param {object} config */ + isEnabled (config) { + return config.featureFlags.DD_FEATURE_FLAGS_ENABLED + }, remoteConfig (rc, config, proxy) { const subscribe = config.featureFlags.DD_FEATURE_FLAGS_ENABLED && config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE === 'remote_config' openfeatureRcEnable(rc, () => proxy.openfeature, subscribe) }, - enable (config, tracer, proxy, lazyProxy) { - if (config.featureFlags.DD_FEATURE_FLAGS_ENABLED) { - proxy._modules.openfeature.enable(config) - if (!hasOpenfeatureProvider(proxy)) { - lazyProxy(proxy, 'openfeature', () => OpenFeatureProvider, tracer, config) - } - } - }, }) proxy = new ProxyClass() @@ -415,6 +402,17 @@ describe('TracerProxy', () => { sinon.assert.called(flare.disable) }) + it('does not load OpenFeature before application access', () => { + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true + + proxy.init() + + const descriptor = Reflect.getOwnPropertyDescriptor(proxy, 'openfeature') + assert.strictEqual(typeof descriptor.get, 'function') + sinon.assert.notCalled(OpenFeatureProvider) + sinon.assert.notCalled(openfeature.enable) + }) + it('should setup FFE_FLAGS product handler when openfeature provider is enabled', () => { config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' diff --git a/vendor/package-lock.json b/vendor/package-lock.json index caed27c423..ff08143367 100644 --- a/vendor/package-lock.json +++ b/vendor/package-lock.json @@ -32,7 +32,6 @@ "semifies": "^1.0.0", "shell-quote": "^1.9.0", "source-map": "^0.7.4", - "stream-json": "2.1.0", "tlhunter-sorted-set": "^0.1.0", "ttl-set": "^1.0.0" }, @@ -732,27 +731,6 @@ "node": ">= 12" } }, - "node_modules/stream-chain": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-3.6.3.tgz", - "integrity": "sha512-JZuELdHUuiZL4Olcr4EllGUvj9VKEaDkGHA6QAP5SruD0bgrr8TwtNXwRfH+fCncysEII7HhWll1+aOwvHYyRw==", - "license": "BSD-3-Clause", - "funding": { - "url": "https://github.com/sponsors/uhop" - } - }, - "node_modules/stream-json": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-2.1.0.tgz", - "integrity": "sha512-9gV/ywtebMn3DdKnNKYCb9iESvgR1dHbucNV+bRGvdvy+jV4c9FFgYKmENhpKv58jSwvs90Wk80RhfKk1KxHPg==", - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^3.6.1" - }, - "funding": { - "url": "https://github.com/sponsors/uhop" - } - }, "node_modules/tlhunter-sorted-set": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/tlhunter-sorted-set/-/tlhunter-sorted-set-0.1.0.tgz", diff --git a/vendor/package.json b/vendor/package.json index 256b74bf9f..f777411f40 100644 --- a/vendor/package.json +++ b/vendor/package.json @@ -29,7 +29,6 @@ "semifies": "^1.0.0", "shell-quote": "^1.9.0", "source-map": "^0.7.4", - "stream-json": "2.1.0", "tlhunter-sorted-set": "^0.1.0", "ttl-set": "^1.0.0" }, diff --git a/vendor/rspack.config.js b/vendor/rspack.config.js index 1a45f0566f..deb1137308 100644 --- a/vendor/rspack.config.js +++ b/vendor/rspack.config.js @@ -31,10 +31,7 @@ const exclude = new Set([ const difference = new Set([...include].filter(x => !exclude.has(x))) module.exports = { - entry: { - ...Object.fromEntries(difference.entries()), - 'stream-json': join(__dirname, 'stream-json.js'), - }, + entry: Object.fromEntries(difference.entries()), target: 'node', mode: 'production', // Using `hidden` removes the URL comment from source files since we don't diff --git a/vendor/stream-json.js b/vendor/stream-json.js deleted file mode 100644 index 3042fdaf17..0000000000 --- a/vendor/stream-json.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict' - -const { parser } = require('stream-json') -const { pick } = require('stream-json/filters/pick.js') -const { streamValues } = require('stream-json/streamers/stream-values.js') - -module.exports = { - parser: parser.asStream, - pick: pick.asStream, - streamValues: streamValues.asStream, -} From dcdc37f52275630e027a7e5b82d5a614d66a0653 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 21 Jul 2026 22:46:28 +0200 Subject: [PATCH 15/16] fix(openfeature): harden agentless delivery boundaries Partial HTTP responses could leave polling pending because the shared transport settled only on end. Every terminal response event now settles once, and mixed-case gzip encodings remain supported. Cleartext non-loopback overrides could apply unauthenticated flag data after the transport stripped the API key. They are now rejected before a request, and diagnostics exclude sensitive endpoint and payload data. Feature Flags now activate independently from APM tracing, so Remote Config cannot target the noop provider. Inactive processes still avoid loading the provider, source, and agentless header helper. --- index.d.ts | 16 +-- index.d.v5.ts | 16 +-- .../dd-trace/src/exporters/common/request.js | 78 ++++++++----- packages/dd-trace/src/exporters/common/url.js | 16 ++- packages/dd-trace/src/feature-registry.js | 12 +- .../agentless_configuration_source.js | 28 +++-- .../src/openfeature/configuration_source.js | 8 +- packages/dd-trace/src/proxy.js | 20 +--- packages/dd-trace/src/telemetry/send-data.js | 4 +- .../test/exporters/common/request.spec.js | 107 +++++++++++++++++- .../agentless_configuration_source.spec.js | 60 +++++++--- .../openfeature/configuration_source.spec.js | 32 +++++- .../test/openfeature/register.spec.js | 31 ++--- packages/dd-trace/test/proxy.spec.js | 31 ++++- 14 files changed, 341 insertions(+), 118 deletions(-) diff --git a/index.d.ts b/index.d.ts index 77b5f6557d..5590386b3f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -156,12 +156,12 @@ interface Tracer extends opentracing.Tracer { llmobs: tracer.llmobs.LLMObs; /** - * OpenFeature Provider with Remote Config integration. + * OpenFeature Provider with agentless and Agent Remote Config delivery. * - * Extends DatadogNodeServerProvider with Remote Config integration for dynamic flag configuration. - * Enable with DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true. + * Agentless delivery is enabled by default and starts when the provider is first accessed. * - * @env DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED + * @env DD_FEATURE_FLAGS_ENABLED + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE * @beta This feature is in preview and not ready for production use */ openfeature: tracer.OpenFeatureProvider; @@ -799,9 +799,9 @@ 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. + * Legacy feature flagging provider switch. + * When the stable Feature Flags configuration is unset, true selects Agent Remote Config and false disables + * the provider. * * @default false * @env DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED @@ -1620,7 +1620,7 @@ declare namespace tracer { /** * Flagging Provider (OpenFeature-compatible). * - * Wraps @datadog/openfeature-node-server with Remote Config integration for dynamic flag configuration. + * Wraps @datadog/openfeature-node-server with agentless and Agent Remote Config delivery. * Implements the OpenFeature Provider interface for flag evaluation. * * @beta This feature is in preview and not ready for production use diff --git a/index.d.v5.ts b/index.d.v5.ts index e513059184..0aa1604770 100644 --- a/index.d.v5.ts +++ b/index.d.v5.ts @@ -156,12 +156,12 @@ interface Tracer extends opentracing.Tracer { llmobs: tracer.llmobs.LLMObs; /** - * OpenFeature Provider with Remote Config integration. + * OpenFeature Provider with agentless and Agent Remote Config delivery. * - * Extends DatadogNodeServerProvider with Remote Config integration for dynamic flag configuration. - * Enable with DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true. + * Agentless delivery is enabled by default and starts when the provider is first accessed. * - * @env DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED + * @env DD_FEATURE_FLAGS_ENABLED + * @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE * @beta This feature is in preview and not ready for production use */ openfeature: tracer.OpenFeatureProvider; @@ -869,9 +869,9 @@ 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. + * Legacy feature flagging provider switch. + * When the stable Feature Flags configuration is unset, true selects Agent Remote Config and false disables + * the provider. * * @default false * @env DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED @@ -1732,7 +1732,7 @@ declare namespace tracer { /** * Flagging Provider (OpenFeature-compatible). * - * Wraps @datadog/openfeature-node-server with Remote Config integration for dynamic flag configuration. + * Wraps @datadog/openfeature-node-server with agentless and Agent Remote Config delivery. * Implements the OpenFeature Provider interface for flag evaluation. * * @beta This feature is in preview and not ready for production use diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index 2632abb76e..dc050a4522 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -6,12 +6,11 @@ const { Readable } = require('stream') const http = require('http') const https = require('https') -const net = require('node:net') const zlib = require('zlib') const { storage } = require('../../../../datadog-core') const log = require('../../log') -const { parseUrl } = require('./url') +const { isLoopbackHost, parseUrl } = require('./url') const docker = require('./docker') const { httpAgent, httpsAgent } = require('./agents') const { @@ -27,18 +26,6 @@ const maxActiveBufferSize = 1024 * 1024 * 64 let activeBufferSize = 0 -/** - * @param {string} hostname Host as resolved by {@link parseUrl}; IPv6 is unbracketed (`::1`). - * @returns {boolean} - */ -function isLoopbackHost (hostname) { - // The 127.0.0.0/8 block is loopback, but only when the host is an actual IPv4 literal: a - // hostname like `127.evil.com` shares the prefix yet resolves anywhere, so net.isIPv4 gates it. - return hostname === 'localhost' || - hostname === '::1' || - (hostname.startsWith('127.') && net.isIPv4(hostname)) -} - /** * @param {Buffer|string|Readable|Array} data * @param {object} options @@ -110,36 +97,48 @@ function request (data, options, callback) { /** * @param {import('node:http').IncomingMessage} res - * @param {() => void} finalize + * @param {(error: Error|null, result?: string|null, statusCode?: number, + * headers?: import('node:http').IncomingHttpHeaders) => void} complete + * @param {(error: Error) => void} handleError */ - const onResponse = (res, finalize) => { + const onResponse = (res, complete, handleError) => { markEndpointReached(options) const chunks = [] res.setTimeout(timeout) + res.once('aborted', () => { + handleError(Object.assign(new Error('Response aborted'), { code: 'ECONNRESET' })) + }) + res.once('error', handleError) + res.once('timeout', () => { + const error = Object.assign(new Error('Response timed out'), { code: 'ETIMEDOUT' }) + res.destroy(error) + handleError(error) + }) + res.on('data', chunk => { chunks.push(chunk) }) res.once('end', () => { - finalize() const buffer = Buffer.concat(chunks) if (res.statusCode >= 200 && res.statusCode <= 299) { - const isGzip = res.headers['content-encoding'] === 'gzip' + const contentEncoding = res.headers['content-encoding'] + const isGzip = typeof contentEncoding === 'string' && contentEncoding.toLowerCase() === 'gzip' if (isGzip) { zlib.gunzip(buffer, (err, result) => { if (err) { log.error('Could not gunzip response: %s', err.message) - callback(null, '', res.statusCode, res.headers) + complete(null, '', res.statusCode, res.headers) } else { - callback(null, result.toString(), res.statusCode, res.headers) + complete(null, result.toString(), res.statusCode, res.headers) } }) } else { - callback(null, buffer.toString(), res.statusCode, res.headers) + complete(null, buffer.toString(), res.statusCode, res.headers) } } else { let errorMessage = '' @@ -160,7 +159,7 @@ function request (data, options, callback) { const error = new log.NoTransmitError(errorMessage) error.status = res.statusCode - callback(error, null, res.statusCode, res.headers) + complete(error, null, res.statusCode, res.headers) } }) } @@ -179,30 +178,51 @@ function request (data, options, callback) { legacyStorage.run({ noop: true }, () => { let finished = false + let settled = false const finalize = () => { if (finished) return finished = true activeBufferSize -= options.headers['Content-Length'] ?? 0 } - const req = client.request(options, (res) => onResponse(res, finalize)) + /** + * @param {Error | null} error + * @param {string | null} [result] + * @param {number} [statusCode] + * @param {import('node:http').IncomingHttpHeaders} [headers] + */ + const complete = (error, result, statusCode, headers) => { + if (settled) return + settled = true + finalize() + callback(error, result, statusCode, headers) + } - req.once('close', finalize) - req.once('timeout', finalize) + /** + * @param {Error} error + */ + const handleError = (error) => { + if (settled) return - req.once('error', error => { - finalize() if (options.retry !== false && attemptIndex < getMaxAttempts(options) && isRetriableNetworkError(error)) { + settled = true + finalize() // Unref so a pending retry never keeps the host process alive past // its natural exit point; long-running apps still retry because the // event loop is held open by their own work. setTimeout(attempt, getRetryDelay(options, attemptIndex), attemptIndex + 1).unref?.() } else { - callback(error) + complete(error) } - }) + } + + const req = client.request(options, (res) => onResponse(res, complete, handleError)) + + req.once('close', finalize) + req.once('timeout', finalize) + req.once('error', handleError) req.setTimeout(timeout, () => { try { diff --git a/packages/dd-trace/src/exporters/common/url.js b/packages/dd-trace/src/exporters/common/url.js index a37b78e3ce..7bf2ab9b82 100644 --- a/packages/dd-trace/src/exporters/common/url.js +++ b/packages/dd-trace/src/exporters/common/url.js @@ -1,7 +1,21 @@ 'use strict' +const net = require('node:net') + const { urlToHttpOptions } = require('./url-to-http-options-polyfill') +/** + * @param {string} hostname + * @returns {boolean} + */ +function isLoopbackHost (hostname) { + // Gate the 127/8 prefix on an IPv4 literal so names such as 127.example.com cannot pass. + return hostname === 'localhost' || + hostname === '::1' || + hostname === '[::1]' || + (hostname.startsWith('127.') && net.isIPv4(hostname)) +} + /** * Convert an agent/intake URL into Node http(s) request options. * @@ -31,4 +45,4 @@ function parseUrl (urlObjOrString) { return url } -module.exports = { parseUrl } +module.exports = { isLoopbackHost, parseUrl } diff --git a/packages/dd-trace/src/feature-registry.js b/packages/dd-trace/src/feature-registry.js index c30ee21d2d..5be999ae05 100644 --- a/packages/dd-trace/src/feature-registry.js +++ b/packages/dd-trace/src/feature-registry.js @@ -1,13 +1,19 @@ 'use strict' +/** + * @typedef {{ enable: (config: import('./config/config-base')) => void, disable: () => void }} FeatureModule + * @typedef {new (tracer: import('./tracer'), config: import('./config/config-base')) => object} FeatureProvider + */ + /** * @typedef {object} Feature * @property {string} name * @property {object} noop - * @property {() => object} factory + * @property {() => FeatureModule} factory * @property {(config: import('./config/config-base')) => boolean} isEnabled - * @property {() => Function} provider - * @property {Function} [remoteConfig] + * @property {() => FeatureProvider} provider + * @property {(rc: import('./remote_config'), config: import('./config/config-base'), + * proxy: import('./proxy')) => void} [remoteConfig] */ /** @type {{ [name: string]: Feature }} */ diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index b20ae7e007..fc50bf1711 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -3,7 +3,6 @@ /* eslint-disable no-await-in-loop -- Polls and retries must remain sequential. */ const { setTimeout: sleep } = require('node:timers/promises') -const { inspect } = require('node:util') const request = require('../exporters/common/request') const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers') @@ -51,9 +50,10 @@ class AgentlessConfigurationSource { /** @type {string | undefined} */ #etag - #malformedPayloadLogged = false + /** @type {Set} */ + #failureWarnings = new Set() - #pollFailureLogged = false + #malformedPayloadLogged = false /** * @param {AgentlessSourceConfig} config @@ -117,7 +117,7 @@ class AgentlessConfigurationSource { const retryable = response.statusCode === undefined || isRetryableStatus(response.statusCode) if (!retryable) { - this.#apply(abortController, response) + this.#apply(response) return } @@ -174,11 +174,10 @@ class AgentlessConfigurationSource { } /** - * @param {AbortController} abortController * @param {PollResponse} response * @returns {void} */ - #apply (abortController, response) { + #apply (response) { const statusCode = response.statusCode if (statusCode === 304) return @@ -192,10 +191,10 @@ class AgentlessConfigurationSource { let configuration try { configuration = parseConfiguration(response.body) - } catch (error) { + } catch { if (!this.#malformedPayloadLogged) { this.#malformedPayloadLogged = true - log.error('Feature Flagging agentless endpoint returned malformed UFC payload: %s', errorMessage(error)) + log.error('Feature Flagging agentless endpoint returned malformed UFC payload') } return } @@ -222,8 +221,11 @@ class AgentlessConfigurationSource { * @returns {void} */ #warnFailure (statusCode, error, attempts) { - if (this.#pollFailureLogged) return - this.#pollFailureLogged = true + const category = statusCode === 401 || statusCode === 403 + ? 'authentication' + : statusCode ? 'http' : 'request' + if (this.#failureWarnings.has(category)) return + this.#failureWarnings.add(category) if (statusCode === 401 || statusCode === 403) { log.warn( @@ -272,11 +274,7 @@ function parseConfiguration (body) { !attributes.flags || typeof attributes.flags !== 'object' || Array.isArray(attributes.flags)) { - throw new Error( - `Expected a Universal Flag Configuration v1 object; received ${ - inspect(attributes, { depth: 0, maxStringLength: 0 }) - }` - ) + throw new Error('Expected a Universal Flag Configuration v1 object') } return attributes diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index f789a3c850..0bd7327953 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -1,5 +1,6 @@ 'use strict' +const { isLoopbackHost } = require('../exporters/common/url') const log = require('../log') const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' @@ -66,13 +67,16 @@ function endpoint (config, configuredBaseUrl) { let url try { url = new URL(configured) - } catch (error) { - throw new Error(`Invalid Feature Flagging agentless URL: ${configured}`, { cause: error }) + } catch { + throw new Error('Invalid Feature Flagging agentless URL') } if (url.protocol !== 'https:' && url.protocol !== 'http:') { throw new Error('Feature Flagging agentless URL must use HTTP or HTTPS') } + if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) { + throw new Error('Feature Flagging agentless URL must use HTTPS unless it targets loopback') + } if (url.pathname === '' || url.pathname === '/') { url.pathname = DEFAULT_AGENTLESS_PATH diff --git a/packages/dd-trace/src/proxy.js b/packages/dd-trace/src/proxy.js index 95c8b38b51..8b4c8899f4 100644 --- a/packages/dd-trace/src/proxy.js +++ b/packages/dd-trace/src/proxy.js @@ -311,21 +311,15 @@ class Tracer extends NoopProxy { const states = this.#featureStates ??= {} const state = states[feature.name] ?? FEATURE_STATE_NOOP - if (state === FEATURE_STATE_ACTIVE) { - this._modules[feature.name].enable(config) - return - } - - if (state === FEATURE_STATE_LAZY) return + if (state === FEATURE_STATE_ACTIVE || state === FEATURE_STATE_LAZY) return states[feature.name] = FEATURE_STATE_LAZY Reflect.defineProperty(this, feature.name, { get: () => { - this._modules[feature.name].enable(config) - const Provider = feature.provider() const provider = new Provider(this._tracer, config) + this._modules[feature.name].enable(config) Reflect.defineProperty(this, feature.name, { value: provider, configurable: true, @@ -364,9 +358,6 @@ class Tracer extends NoopProxy { } this._tracingInitialized = true } - for (const feature of Object.values(features)) { - if (feature.isEnabled(config)) this.#enableFeature(feature, config) - } if (config.experimental?.aiguard?.enabled) { this._modules.aiguard.enable(this._tracer, config) } @@ -379,9 +370,10 @@ class Tracer extends NoopProxy { this._modules.aiguard.disable() this._modules.iast.disable() this._modules.llmobs.disable() - for (const feature of Object.values(features)) { - this._modules[feature.name].disable() - } + } + + for (const feature of Object.values(features)) { + if (feature.isEnabled(config)) this.#enableFeature(feature, config) } if (this._tracingInitialized) { diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 7342c9ce35..08d2e26fdc 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,7 +77,8 @@ let agentTelemetry = true */ function getHeaders (config, application, reqType) { const headers = { - ...getClientLibraryHeaders(application.language_name, application.tracer_version), + 'DD-Client-Library-Language': application.language_name, + 'DD-Client-Library-Version': application.tracer_version, 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': reqType, diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index c90429040d..eceaf687c6 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { EventEmitter, once } = require('node:events') const http = require('node:http') const zlib = require('node:zlib') const stream = require('node:stream') @@ -165,6 +166,110 @@ describe('request', function () { await assert.rejects(completed, { code: 'ABORT_ERR' }) }) + it('settles once when a response is truncated', async () => { + /** + * @param {import('node:http').IncomingMessage} incoming + * @param {import('node:http').ServerResponse} response + */ + const truncate = (incoming, response) => { + incoming.resume() + response.writeHead(200) + response.write('partial') + setImmediate(() => response.destroy()) + } + const server = http.createServer(truncate) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + + let callbacks = 0 + /** + * @param {(error: Error | null) => void} resolve + */ + const execute = (resolve) => { + /** @param {Error | null} error */ + const onResponse = (error) => { + callbacks++ + resolve(error) + } + request('', { + method: 'GET', + retry: false, + url: new URL(`http://127.0.0.1:${server.address().port}`), + }, onResponse) + } + + try { + const error = await new Promise(execute) + assert.strictEqual(error.code, 'ECONNRESET') + assert.strictEqual(callbacks, 1) + } finally { + const closed = once(server, 'close') + server.close() + await closed + } + }) + + it('settles once when a response times out', async () => { + const response = new EventEmitter() + response.headers = {} + response.statusCode = 200 + response.setTimeout = sinon.spy() + /** @param {Error} error */ + response.destroy = (error) => { + response.emit('error', error) + response.emit('end') + } + + let respond + const requestMessage = new EventEmitter() + requestMessage.abort = sinon.spy() + requestMessage.setTimeout = sinon.spy() + requestMessage.write = sinon.spy() + requestMessage.end = () => { + respond(response) + response.emit('timeout') + } + + /** + * @param {object} options + * @param {(response: EventEmitter) => void} onResponse + */ + const createRequest = (options, onResponse) => { + assert.strictEqual(options.method, 'GET') + respond = onResponse + return requestMessage + } + const timeoutRequest = proxyquire('../../../src/exporters/common/request', { + '../../../../datadog-core': { + storage: () => ({ run: runInNoopContext }), + }, + http: { ...http, request: createRequest }, + './docker': docker, + '../../log': log, + './retry': { + ...require('../../../src/exporters/common/retry'), + ...retryStubs, + }, + }) + + let callbacks = 0 + /** + * @param {(error: Error | null) => void} resolve + */ + const execute = (resolve) => { + /** @param {Error | null} error */ + const onResponse = (error) => { + callbacks++ + resolve(error) + } + timeoutRequest('', { method: 'GET', retry: false }, onResponse) + } + + const error = await new Promise(execute) + assert.strictEqual(error.code, 'ETIMEDOUT') + assert.strictEqual(callbacks, 1) + }) + it('should handle an http error', done => { nock('http://localhost:8080') .put('/path') @@ -594,7 +699,7 @@ describe('request', function () { }, }) .post('/path') - .reply(200, compressedData, { 'content-encoding': 'gzip' }) + .reply(200, compressedData, { 'content-encoding': 'GZip' }) request(Buffer.from(''), { protocol: 'http:', 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 7abb916c71..32a2482fe3 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -276,21 +276,31 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledOnce(log.error) }) - it('does not expose malformed payload string values in logs', async () => { - responses.push({ - statusCode: 200, - body: responseBody({ - createdAt: 'secret-created-at', - environment: { name: 'secret-environment' }, - flags: {}, - }), - }) + it('does not expose malformed payload data in logs', async () => { + responses.push( + { + statusCode: 200, + body: '{"secret-json-value":', + }, + { + statusCode: 200, + body: responseBody({ + createdAt: 'secret-created-at', + environment: { name: 'secret-environment' }, + flags: {}, + 'secret-property-name': 'secret-property-value', + }), + } + ) source().start() await flush() + await tick(30_000) - const message = log.error.firstCall.args[1] - assert.doesNotMatch(message, /secret/) + sinon.assert.calledOnceWithExactly( + log.error, + 'Feature Flagging agentless endpoint returned malformed UFC payload' + ) }) it('does not advance the ETag after an application failure and logs it once', async () => { @@ -366,14 +376,18 @@ describe('AgentlessConfigurationSource', () => { sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) }) - it('warns once after repeated retryable failures exhaust all attempts', async () => { + it('warns once per failure category', async () => { responses.push( + { statusCode: 500 }, + { statusCode: 502 }, + { statusCode: 503 }, { statusCode: 500 }, { statusCode: 502 }, { statusCode: 503 }, { error: new Error('first') }, { error: new Error('second') }, - { error: new Error('third') } + { error: new Error('third') }, + { statusCode: 401 } ) source().start() @@ -383,13 +397,25 @@ describe('AgentlessConfigurationSource', () => { await tick(30_000) await tick(5000) await tick(10_000) + await tick(30_000) + await tick(5000) + await tick(10_000) + await tick(30_000) - sinon.assert.calledOnceWithExactly( - log.warn, + assert.deepStrictEqual(log.warn.firstCall.args, [ 'Feature Flagging agentless endpoint returned HTTP %d after %d attempts', 503, - 3 - ) + 3, + ]) + assert.deepStrictEqual(log.warn.secondCall.args, [ + 'Feature Flagging agentless request failed after %d attempts: %s', + 3, + 'third', + ]) + assert.deepStrictEqual(log.warn.thirdCall.args, [ + 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + 401, + ]) }) it('warns after retryable request failures exhaust all attempts', async () => { diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index c818ee2bb1..b28ac7825f 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -82,6 +82,17 @@ describe('OpenFeature configuration source', () => { ) }) + for (const baseUrl of ['http://localhost:8080', 'http://127.1.2.3:8080', 'http://[::1]:8080']) { + it(`allows the loopback endpoint ${baseUrl}`, () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = baseUrl + + assert.strictEqual( + createSourceConfig().endpoint.toString(), + `${baseUrl}/api/v2/feature-flagging/config/rules-based/server` + ) + }) + } + it('preserves an exact configured path and query', () => { config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/custom/ufc?tenant=one' @@ -145,8 +156,23 @@ describe('OpenFeature configuration source', () => { sinon.assert.notCalled(AgentlessConfigurationSource) }) - it('rejects malformed endpoints without enabling a source', () => { - config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'not a URL' + it('rejects cleartext non-loopback endpoints', () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://flags.example.test' + + const source = configurationSource.create(config, sinon.spy()) + + assert.strictEqual(source, undefined) + sinon.assert.calledOnceWithMatch( + log.error, + 'Unable to configure Feature Flagging configuration source', + sinon.match.instanceOf(Error) + ) + sinon.assert.notCalled(AgentlessConfigurationSource) + }) + + it('rejects malformed endpoints without logging their sensitive value', () => { + const sentinel = 'sensitive-value' + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = `https://${sentinel} value` const source = configurationSource.create(config, sinon.spy()) @@ -157,6 +183,8 @@ describe('OpenFeature configuration source', () => { ) assert.strictEqual(source, undefined) sinon.assert.notCalled(AgentlessConfigurationSource) + assert.doesNotMatch(log.error.firstCall.args[1].message, new RegExp(sentinel)) + assert.strictEqual(log.error.firstCall.args[1].cause, undefined) }) it('requires an API key without enabling a source', () => { diff --git a/packages/dd-trace/test/openfeature/register.spec.js b/packages/dd-trace/test/openfeature/register.spec.js index d2c48058ba..13d67209ed 100644 --- a/packages/dd-trace/test/openfeature/register.spec.js +++ b/packages/dd-trace/test/openfeature/register.spec.js @@ -69,6 +69,7 @@ describe('OpenFeature register', () => { const tracer = require(${JSON.stringify(packagePath)}) tracer.init() const modules = [ + require.resolve(${JSON.stringify(path.join(packagePath, 'src/exporters/common/client-library-headers'))}), require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/index'))}), require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/writers/exposures'))}), require.resolve(${JSON.stringify(path.join(packagePath, 'src/openfeature/flagging_provider'))}), @@ -83,19 +84,22 @@ describe('OpenFeature register', () => { ` for (const featureFlagsEnabled of ['false', 'true']) { for (const remoteConfigurationEnabled of ['false', 'true']) { - const result = spawnSync(process.execPath, ['-e', script], { - encoding: 'utf8', - env: { - ...process.env, - DD_FEATURE_FLAGS_ENABLED: featureFlagsEnabled, - DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'false', - DD_REMOTE_CONFIGURATION_ENABLED: remoteConfigurationEnabled, - DD_TRACE_STARTUP_LOGS: 'false', - }, - }) - - assert.strictEqual(result.status, 0, result.stderr) - assert.deepStrictEqual(JSON.parse(result.stdout), Array(9).fill(false)) + for (const tracingEnabled of ['false', 'true']) { + const result = spawnSync(process.execPath, ['-e', script], { + encoding: 'utf8', + env: { + ...process.env, + DD_FEATURE_FLAGS_ENABLED: featureFlagsEnabled, + DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'false', + DD_REMOTE_CONFIGURATION_ENABLED: remoteConfigurationEnabled, + DD_TRACE_ENABLED: tracingEnabled, + DD_TRACE_STARTUP_LOGS: 'false', + }, + }) + + assert.strictEqual(result.status, 0, result.stderr) + assert.deepStrictEqual(JSON.parse(result.stdout), Array(10).fill(false)) + } } } }) @@ -115,6 +119,7 @@ describe('OpenFeature register', () => { feature.remoteConfig(rc, config, proxy) sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, true) + assert.strictEqual(openfeatureRemoteConfig.enable.firstCall.args[1](), proxy.openfeature) }) it('does not install Remote Config delivery when disabled', () => { diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index fd9c2260f0..2e35f114a8 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -413,6 +413,16 @@ describe('TracerProxy', () => { sinon.assert.notCalled(openfeature.enable) }) + it('does not enable OpenFeature when provider construction fails', () => { + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true + OpenFeatureProvider.throws(new Error('provider unavailable')) + + proxy.init() + + assert.throws(() => proxy.openfeature, /provider unavailable/) + sinon.assert.notCalled(openfeature.enable) + }) + it('should setup FFE_FLAGS product handler when openfeature provider is enabled', () => { config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' @@ -426,6 +436,21 @@ describe('TracerProxy', () => { sinon.assert.calledWith(openfeatureProvider.setConfiguration, flagConfig) }) + it('applies FFE_FLAGS while tracing is disabled', () => { + config.DD_TRACE_ENABLED = false + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + + proxy.init() + + const flagConfig = { flags: { 'test-flag': {} } } + handlers.get('FFE_FLAGS')('apply', flagConfig) + + sinon.assert.notCalled(DatadogTracer) + sinon.assert.calledOnce(OpenFeatureProvider) + sinon.assert.calledOnceWithExactly(openfeatureProvider.setConfiguration, flagConfig) + }) + it('should not setup FFE_FLAGS Remote Config when Feature Flags are disabled', () => { proxy.init() @@ -467,7 +492,7 @@ describe('TracerProxy', () => { sinon.assert.calledOnceWithExactly(boundProvider.setConfiguration, flagConfig) }) - it('should re-enable OpenFeature without replacing its provider when remote config re-enables tracing', () => { + it('keeps OpenFeature active while tracing is disabled and re-enabled', () => { config.featureFlags.DD_FEATURE_FLAGS_ENABLED = true config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' /** @param {{ DD_TRACE_ENABLED: boolean }} remoteConfig */ @@ -483,8 +508,8 @@ describe('TracerProxy', () => { assert.strictEqual(proxy.openfeature, provider) sinon.assert.calledOnce(OpenFeatureProvider) - sinon.assert.calledTwice(openfeature.enable) - sinon.assert.calledOnce(openfeature.disable) + sinon.assert.calledOnce(openfeature.enable) + sinon.assert.notCalled(openfeature.disable) }) it('should re-enable AI Guard when remote config re-enables tracing', () => { From 30338367a81807b3d9bd8a265dab5a44dc238359 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 21 Jul 2026 23:41:22 +0200 Subject: [PATCH 16/16] fix(openfeature): harden agentless delivery timing Windows could exhaust ten immediate evaluations before the provider applied the agentless response, while the request test double retained abort listeners after completion. Await provider readiness and clean up settled listeners so the tests observe the production lifecycle. Increase the default request timeout from two to five seconds so slow CDN responses can complete before retrying. --- .../app/configuration-source-evaluation.js | 8 +++++- .../openfeature-configuration-sources.spec.js | 25 ++++++------------- .../src/config/supported-configurations.json | 2 +- packages/dd-trace/test/config/index.spec.js | 8 +++--- .../agentless_configuration_source.spec.js | 1 + .../openfeature/configuration_source.spec.js | 6 ++--- 6 files changed, 24 insertions(+), 26 deletions(-) diff --git a/integration-tests/openfeature/app/configuration-source-evaluation.js b/integration-tests/openfeature/app/configuration-source-evaluation.js index 49e0000bee..a8c61dc055 100644 --- a/integration-tests/openfeature/app/configuration-source-evaluation.js +++ b/integration-tests/openfeature/app/configuration-source-evaluation.js @@ -27,11 +27,17 @@ let client * @param {'access'|'evaluate'|'trace'} message.command * @param {string} [message.spanName] * @param {string} [message.url] + * @param {boolean} [message.waitForReady] */ async function handleMessage (message) { try { if (message.command === 'access') { - OpenFeature.setProvider(tracer.openfeature) + const provider = tracer.openfeature + if (message.waitForReady) { + await OpenFeature.setProviderAndWait(provider) + } else { + OpenFeature.setProvider(provider) + } client = OpenFeature.getClient() send({ accessed: true }) return diff --git a/integration-tests/openfeature/openfeature-configuration-sources.spec.js b/integration-tests/openfeature/openfeature-configuration-sources.spec.js index 906bfcc920..566c36639b 100644 --- a/integration-tests/openfeature/openfeature-configuration-sources.spec.js +++ b/integration-tests/openfeature/openfeature-configuration-sources.spec.js @@ -231,17 +231,21 @@ async function runCase (testCase) { const remoteConfigStartIndex = requestsFor(remoteConfigRequests, testCase.service).length accessedCases.add(testCase.identifier) + const accessCommand = { + command: 'access', + waitForReady: testCase.expected !== 'disabled', + } if (testCase.expected === 'agentless') { await Promise.all([ waitForObservation(agentlessRequests, testCase.identifier, 'agentless', hasObservation), - sendCommand(proc, { command: 'access' }), + sendCommand(proc, accessCommand), ]) } else { - await sendCommand(proc, { command: 'access' }) + await sendCommand(proc, accessCommand) } - const details = await evaluateUntilSettled(proc, testCase) + const { details } = await sendCommand(proc, { command: 'evaluate' }) assertEvaluation(testCase, details) await waitForObservation( @@ -299,20 +303,6 @@ async function traceDeliberateRequest (proc, testCase) { assert.deepStrictEqual(selfTraces, [], testCase.label) } -/** - * @param {import('node:child_process').ChildProcess} proc - * @param {ConfigurationCase} testCase - */ -async function evaluateUntilSettled (proc, testCase) { - const attempts = testCase.expected === 'disabled' ? 1 : 10 - let details - for (let attempt = 0; attempt < attempts; attempt++) { - ({ details } = await sendCommand(proc, { command: 'evaluate' })) - if (details.value === EXPECTED_VALUE) break - } - return details -} - /** * @param {ConfigurationCase} testCase * @param {object} details @@ -410,6 +400,7 @@ function setBooleanEnvironment (env, name, setting) { * @param {'access'|'evaluate'|'trace'} command.command * @param {string} [command.spanName] * @param {string} [command.url] + * @param {boolean} [command.waitForReady] */ async function sendCommand (proc, command) { const controller = new AbortController() diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 74f3dff192..bb8fbb2d4c 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -870,7 +870,7 @@ { "implementation": "A", "type": "int", - "default": "2", + "default": "5", "description": "Experimental: Set the agentless Feature Flagging UFC request timeout in seconds.", "allowed": "[1-9]\\d*", "namespace": "featureFlags" diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index ae638371a7..1c3bdcb307 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -5093,7 +5093,7 @@ rules: sinon.assert.notCalled(log.error) }) - it('defaults agentless delivery to cross-SDK timings', () => { + it('defaults agentless delivery timings', () => { const config = getConfig() assertObjectContains(config, { @@ -5101,7 +5101,7 @@ rules: 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, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 5, }, }) }) @@ -5143,7 +5143,7 @@ rules: ) assert.strictEqual( config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, - 2 + 5 ) sinon.assert.calledTwice(log.warn) }) @@ -5169,7 +5169,7 @@ rules: ) assert.strictEqual( config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, - 2 + 5 ) for (const name of [ 'configurationSource', 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 32a2482fe3..d8816abb8f 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -90,6 +90,7 @@ describe('AgentlessConfigurationSource', () => { if (response && !response.pending) { queueMicrotask(() => { + options.signal?.removeEventListener('abort', abort) callback( response.error ?? null, response.body, diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index b28ac7825f..8ac81dba43 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -21,7 +21,7 @@ describe('OpenFeature configuration source', () => { 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, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 5, }, site: 'datadoghq.com', env: 'my env', @@ -53,7 +53,7 @@ describe('OpenFeature configuration source', () => { ) assert.strictEqual(resolved.apiKey, 'test-api-key') assert.strictEqual(resolved.pollIntervalMs, 30_000) - assert.strictEqual(resolved.requestTimeoutMs, 2000) + assert.strictEqual(resolved.requestTimeoutMs, 5000) }) it('derives the staging UFC CDN endpoint from DD_SITE', () => { @@ -123,7 +123,7 @@ describe('OpenFeature configuration source', () => { endpoint: resolved.endpoint, apiKey: 'test-api-key', pollIntervalMs: 30_000, - requestTimeoutMs: 2000, + requestTimeoutMs: 5000, }), applyConfiguration )