diff --git a/index.d.ts b/index.d.ts index eae9b35db3..d8b90bf7c1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -800,7 +800,6 @@ declare namespace tracer { flaggingProvider?: { /** * Whether to enable the feature flagging provider. - * Requires Remote Config to be properly configured. * Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED environment variable. * * @default false diff --git a/index.d.v5.ts b/index.d.v5.ts index 6bbc664899..36bf1a7de9 100644 --- a/index.d.v5.ts +++ b/index.d.v5.ts @@ -870,7 +870,6 @@ declare namespace tracer { flaggingProvider?: { /** * Whether to enable the feature flagging provider. - * Requires Remote Config to be properly configured. * Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED environment variable. * * @default false diff --git a/integration-tests/openfeature/app/agentless-evaluation.js b/integration-tests/openfeature/app/agentless-evaluation.js new file mode 100644 index 0000000000..f5ea41a9d1 --- /dev/null +++ b/integration-tests/openfeature/app/agentless-evaluation.js @@ -0,0 +1,34 @@ +'use strict' + +const tracer = require('dd-trace') +const http = require('node:http') +const { OpenFeature } = require('@openfeature/server-sdk') + +tracer.init({ + env: 'integration', + service: 'ffe-agentless-integration', +}) + +OpenFeature.setProvider(tracer.openfeature) +const client = OpenFeature.getClient() + +const server = http.createServer((request, response) => { + if (request.url !== '/evaluate') { + response.writeHead(404).end() + return + } + + client.getStringDetails('agentless-integration-flag', 'default', { + targetingKey: 'integration-user', + }).then(details => { + response.setHeader('Content-Type', 'application/json') + response.end(JSON.stringify(details)) + }, error => { + response.writeHead(500, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ error: error.message })) + }) +}) + +server.listen(0, function () { + process.send?.({ port: this.address().port }) +}) diff --git a/integration-tests/openfeature/openfeature-agentless.spec.js b/integration-tests/openfeature/openfeature-agentless.spec.js new file mode 100644 index 0000000000..3869fc01d5 --- /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_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-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 3535817f4e..073e68a8ab 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,11 @@ 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_FEATURE_FLAGS_ENABLED: boolean; DD_GIT_BRANCH: string | undefined; DD_GIT_COMMIT_AUTHOR_DATE: string | undefined; DD_GIT_COMMIT_AUTHOR_EMAIL: string | undefined; @@ -688,6 +693,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: 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_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/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 9e18849ea4..e1a8dd37ea 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -817,6 +817,13 @@ "default": "false" } ], + "DD_FEATURE_FLAGS_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "true" + } + ], "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [ { "implementation": "A", @@ -827,6 +834,38 @@ "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." + } + ], + "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/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 new file mode 100644 index 0000000000..e467250a88 --- /dev/null +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -0,0 +1,378 @@ +'use strict' + +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') + +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. + * @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 && 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 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) { + 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 = { + ...getClientLibraryHeaders(), + 'Accept-Encoding': 'gzip', + } + 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. + this._fetch(this._config.endpoint, { + method: 'GET', + headers, + redirect: 'manual', + signal: controller.signal, + }).then(response => { + const result = { + statusCode: response.status, + etag: response.headers.get('etag') ?? undefined, + body: '', + } + if (response.status !== 200) { + response.body?.cancel?.().catch(() => {}) + finish(null, 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 })) + }) + }, error => finish(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) { + this._warnFailure(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 } + } + + /** + * 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) + } + } +} + +/** + * 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 +} + +/** + * 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/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js new file mode 100644 index 0000000000..80ba3c8fce --- /dev/null +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -0,0 +1,222 @@ +'use strict' + +const log = require('../log') + +const CONFIGURATION_SOURCE_AGENTLESS = 'agentless' +const CONFIGURATION_SOURCE_REMOTE_CONFIG = 'remote_config' + +const DISABLED_RESOLUTION = Object.freeze({ enabled: false }) +const AGENTLESS_CONFIGURATION = Object.freeze({ enabled: true, source: CONFIGURATION_SOURCE_AGENTLESS }) +const REMOTE_CONFIG_CONFIGURATION = Object.freeze({ enabled: true, source: CONFIGURATION_SOURCE_REMOTE_CONFIG }) + +const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' +const DEFAULT_POLL_INTERVAL_SECONDS = 30 +const DEFAULT_REQUEST_TIMEOUT_SECONDS = 2 +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 configuration = resolveConfiguration(config) + + if (!configuration.enabled || configuration.source !== CONFIGURATION_SOURCE_AGENTLESS) { + return configuration + } + + return { + ...configuration, + endpoint: endpoint(config, config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL), + pollIntervalMs: positiveMilliseconds( + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, + DEFAULT_POLL_INTERVAL_SECONDS, + 'poll interval', + MAX_POLL_INTERVAL_SECONDS + ), + requestTimeoutMs: positiveMilliseconds( + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + 'request timeout' + ), + apiKey: config.DD_API_KEY, + } +} + +/** + * Starts the selected first-party configuration source. + * + * Remote Config is installed separately because its lifecycle is owned by the + * tracer Remote Config client. + * + * @param {import('../config/config-base')} config - Tracer configuration. + * @param {Function} getOpenfeatureProxy - Returns the active provider. + * @returns {void} + */ +function enable (config, getOpenfeatureProxy) { + let sourceConfig + try { + sourceConfig = resolve(config) + } catch (error) { + log.error('Unable to configure Feature Flagging configuration source', error) + return + } + + if (sourceConfig.source === CONFIGURATION_SOURCE_AGENTLESS) { + const AgentlessConfigurationSource = require('./agentless_configuration_source') + const source = new AgentlessConfigurationSource(sourceConfig, ufc => { + getOpenfeatureProxy()._setConfiguration(ufc) + }) + getOpenfeatureProxy()._setConfigurationSource(source) + } +} + +/** + * Reports whether the explicit Remote Config source is selected. + * + * 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 resolveConfiguration(config).source === CONFIGURATION_SOURCE_REMOTE_CONFIG + } catch (error) { + log.error('Unable to configure Feature Flagging configuration source', error) + return false + } +} + +/** + * Reports whether the provider should be exposed for the resolved source. + * + * Invalid source values fail closed. + * + * @param {import('../config/config-base')} config - Tracer configuration. + * @returns {boolean} Whether Feature Flagging is enabled. + */ +function isEnabled (config) { + try { + return resolveConfiguration(config).enabled + } 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 {{enabled: boolean, source?: string}} Resolved enablement and source selection. + */ +function resolveConfiguration (config) { + if (config.DD_FEATURE_FLAGS_ENABLED === false) return DISABLED_RESOLUTION + + const value = config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE + const configuredSource = String(value ?? '').trim() + const origin = config.getOrigin?.('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE') + const hasExplicitSource = configuredSource !== '' && + (origin ? origin !== 'default' : value !== undefined && value !== null) + + if (!hasExplicitSource) { + const legacyEnabled = config.experimental?.flaggingProvider?.enabled + const legacyOrigin = config.getOrigin?.('experimental.flaggingProvider.enabled') + const hasExplicitLegacySetting = legacyOrigin + ? legacyOrigin !== 'default' + : legacyEnabled !== undefined && legacyEnabled !== null + + if (hasExplicitLegacySetting) { + if (legacyEnabled === true) return REMOTE_CONFIG_CONFIGURATION + if (legacyEnabled === false) return DISABLED_RESOLUTION + } + } + + const source = configuredSource.toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS + if (source === CONFIGURATION_SOURCE_AGENTLESS) return AGENTLESS_CONFIGURATION + if (source === CONFIGURATION_SOURCE_REMOTE_CONFIG) return REMOTE_CONFIG_CONFIGURATION + throw new Error(`Unsupported Feature Flagging configuration source: ${source}`) +} + +/** + * 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 = { + enable, + isEnabled, + isRemoteConfig, + resolve, +} diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index b793c29e1c..a8b37444a1 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -17,6 +17,9 @@ class FlaggingProvider extends DatadogNodeServerProvider { /** @type {SpanEnrichmentHook?} */ #spanEnrichmentHook + /** @type {{ start: Function, stop: Function }?} */ + #configurationSource + /** * @param {import('../tracer')} tracer - Datadog tracer instance * @param {import('../config')} config - Tracer configuration object @@ -50,9 +53,27 @@ class FlaggingProvider extends DatadogNodeServerProvider { * Cleans up resources including channel subscriptions. */ onClose () { + this.#configurationSource?.stop() + this.#configurationSource = null this.#spanEnrichmentHook?.destroy() } + /** + * Attaches and starts the provider's first-party configuration source. + * Repeated calls preserve the original source and dispose of the duplicate. + * + * @internal + * @param {{ start: Function, stop: Function }} source - Configuration source lifecycle. + */ + _setConfigurationSource (source) { + if (this.#configurationSource) { + source.stop() + return + } + this.#configurationSource = source + source.start() + } + /** * Internal method to update flag configuration from Remote Config. * This method is called automatically when Remote Config delivers UFC updates. diff --git a/packages/dd-trace/src/openfeature/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/register.js b/packages/dd-trace/src/openfeature/register.js index 3c7afd4886..4dd2a2ef97 100644 --- a/packages/dd-trace/src/openfeature/register.js +++ b/packages/dd-trace/src/openfeature/register.js @@ -11,9 +11,50 @@ 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 } +/** + * Exposes the provider without constructing it until application code reads it. + * The generic tracer lazy proxy is eager outside serverless environments, while + * agentless delivery must remain silent until the application uses OpenFeature. + * + * @param {import('../proxy')} proxy + * @param {import('../tracer')} tracer + * @param {import('../config/config-base')} config + * @param {object} configurationSource + */ +function defineFlaggingProvider (proxy, tracer, config, configurationSource) { + Reflect.defineProperty(proxy, 'openfeature', { + get () { + 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, + }) + configurationSource.enable(config, () => provider) + return provider + }, + configurable: true, + enumerable: true, + }) +} + registerFeature({ name: 'openfeature', noop, @@ -25,22 +66,29 @@ registerFeature({ * @param {import('../proxy')} proxy */ remoteConfig (rc, config, proxy) { + const configurationSource = require('./configuration_source') const openfeatureRemoteConfig = require('./remote_config') - openfeatureRemoteConfig.enable(rc, config, () => proxy.openfeature) + const subscribe = configurationSource.isRemoteConfig(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) { + const configurationSource = require('./configuration_source') + if (!configurationSource.isEnabled(config)) return + + if (!hasFlaggingProvider(proxy)) { + defineFlaggingProvider(proxy, tracer, config, configurationSource) + } 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..01615b67a3 100644 --- a/packages/dd-trace/src/openfeature/remote_config.js +++ b/packages/dd-trace/src/openfeature/remote_config.js @@ -6,16 +6,15 @@ const RemoteConfigCapabilities = require('../remote_config/capabilities') * Configures remote config handlers for openfeature feature flagging * * @param {object} rc - RemoteConfig instance - * @param {object} config - Tracer config * @param {Function} getOpenfeatureProxy - Function that returns the OpenFeature proxy from tracer + * @param {boolean} [subscribe] - Whether Agent Remote Config owns UFC delivery */ -function enable (rc, config, getOpenfeatureProxy) { - // Always enable capability for feature flag configuration - // This indicates the library supports this capability via remote config - rc.updateCapabilities(RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, true) +function enable (rc, getOpenfeatureProxy, subscribe = true) { + // Capability advertisement and product subscription both opt into the billed + // Agent Remote Config delivery path. + if (!subscribe) return - // Only register 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) => { 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/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 6f0d132e09..c83a48aeb9 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -4938,6 +4938,100 @@ rules: }) }) + context('Feature Flagging configuration source', () => { + it('defaults to an enabled provider with agentless delivery and cross-SDK timings', () => { + const config = getConfig() + + assertObjectContains(config, { + DD_FEATURE_FLAGS_ENABLED: true, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: undefined, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: 30, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 2, + }) + assert.strictEqual(config.experimental.flaggingProvider.enabled, false) + assert.strictEqual(config.getOrigin('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'default') + assert.strictEqual(config.getOrigin('experimental.flaggingProvider.enabled'), 'default') + }) + + it('reads the stable provider kill switch', () => { + process.env.DD_FEATURE_FLAGS_ENABLED = 'false' + + const config = getConfig() + + assert.strictEqual(config.DD_FEATURE_FLAGS_ENABLED, false) + assert.strictEqual(config.getOrigin('DD_FEATURE_FLAGS_ENABLED'), 'env_var') + }) + + for (const value of ['true', 'false']) { + it(`retains the deprecated experimental provider setting when set to ${value}`, () => { + process.env.DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = value + + const config = getConfig() + + assert.strictEqual(config.DD_FEATURE_FLAGS_ENABLED, true) + assert.strictEqual(config.experimental.flaggingProvider.enabled, value === 'true') + assert.strictEqual(config.getOrigin('experimental.flaggingProvider.enabled'), 'env_var') + }) + } + + 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') + assert.strictEqual(config.getOrigin('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'env_var') + }) + + 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/agentless_configuration_source.spec.js b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js new file mode 100644 index 0000000000..3b45f032be --- /dev/null +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -0,0 +1,524 @@ +'use strict' + +const assert = require('node:assert/strict') +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({ + 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 clock + let config + let fetch + let log + let requests + let responses + 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'), + pollIntervalMs: 30_000, + requestTimeoutMs: 2000, + apiKey: 'test-api-key', + } + log = { + debug: sinon.spy(), + error: 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, + }) + }) + + 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 })) + }) + } + + 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['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('suppresses tracing around agentless requests', () => { + responses.push({ statusCode: 200, body: VALID_RESPONSE }) + + source().pollOnce(() => {}) + + 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 + 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('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) } + 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' } }), + }, + { + 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.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() + + 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) + }) + + 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('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() + + configurationSource.start() + configurationSource.start() + + assert.strictEqual(requests.length, 1) + }) +}) 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..17e0e9af5b --- /dev/null +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -0,0 +1,277 @@ +'use strict' + +const assert = require('node:assert/strict') +const { beforeEach, describe, it } = require('mocha') +const proxyquire = require('proxyquire') +const sinon = require('sinon') + +require('../setup/core') + +describe('OpenFeature configuration source', () => { + let config + let configurationSource + let log + let AgentlessConfigurationSource + + beforeEach(() => { + config = { + DD_API_KEY: 'test-api-key', + DD_FEATURE_FLAGS_ENABLED: true, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: undefined, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: 30, + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: 2, + experimental: { + flaggingProvider: { + enabled: false, + }, + }, + getOrigin: sinon.stub().returns('default'), + site: 'datadoghq.com', + env: 'my env', + } + log = { + debug: sinon.spy(), + error: sinon.spy(), + warn: sinon.spy(), + } + AgentlessConfigurationSource = sinon.stub() + configurationSource = proxyquire('../../src/openfeature/configuration_source', { + '../log': log, + './agentless_configuration_source': AgentlessConfigurationSource, + }) + }) + + for (const value of [undefined, null, '', ' ', ' AgEnTlEsS ']) { + it(`normalizes ${JSON.stringify(value)} to the default agentless source`, () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + + assert.strictEqual(configurationSource.resolve(config).source, 'agentless') + }) + } + + for (const value of ['', ' ']) { + it(`treats explicit ${JSON.stringify(value)} as absent for legacy Remote Config grandfathering`, () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + config.experimental.flaggingProvider.enabled = true + config.getOrigin.withArgs('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE').returns('env_var') + config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: true, source: 'remote_config' }) + }) + + it(`treats explicit ${JSON.stringify(value)} as absent for legacy disablement`, () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + config.experimental.flaggingProvider.enabled = false + config.getOrigin.withArgs('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE').returns('env_var') + config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: false }) + }) + + it(`treats explicit ${JSON.stringify(value)} as absent for the default agentless source`, () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = value + config.getOrigin.withArgs('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE').returns('env_var') + + assert.strictEqual(configurationSource.resolve(config).source, 'agentless') + }) + } + + it('grandfathers the legacy enabled setting onto Remote Config when the source is defaulted', () => { + config.experimental.flaggingProvider.enabled = true + config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: true, source: 'remote_config' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), true) + assert.strictEqual(configurationSource.isEnabled(config), true) + }) + + it('keeps the provider disabled for the legacy false setting when the source is defaulted', () => { + config.experimental.flaggingProvider.enabled = false + config.getOrigin.withArgs('experimental.flaggingProvider.enabled').returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: false }) + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + assert.strictEqual(configurationSource.isEnabled(config), false) + }) + + it('lets an explicit agentless source override legacy enablement', () => { + config.experimental.flaggingProvider.enabled = true + config.getOrigin.returns('env_var') + + assert.strictEqual(configurationSource.resolve(config).source, 'agentless') + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + assert.strictEqual(configurationSource.isEnabled(config), true) + }) + + it('lets an explicit Remote Config source override legacy disablement', () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + config.experimental.flaggingProvider.enabled = false + config.getOrigin.returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: true, source: 'remote_config' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), true) + assert.strictEqual(configurationSource.isEnabled(config), true) + }) + + it('lets the stable kill switch override every configuration source', () => { + config.DD_FEATURE_FLAGS_ENABLED = false + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + config.experimental.flaggingProvider.enabled = true + config.getOrigin.returns('env_var') + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: false }) + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + assert.strictEqual(configurationSource.isEnabled(config), false) + }) + + it('defaults to the Datadog UFC CDN endpoint and includes the environment', () => { + config.DD_SITE = 'raw-env-key.invalid' + const resolved = configurationSource.resolve(config) + + 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.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = '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.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/custom/ufc?tenant=one' + + assert.strictEqual( + configurationSource.resolve(config).endpoint.toString(), + 'https://example.com/custom/ufc?tenant=one' + ) + }) + + it('derives and starts 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) + }) + + it('allows an operator-owned agentless endpoint on GovCloud', () => { + config.site = 'ddog-gov.com' + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + '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.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'file:///tmp/ufc.json' + + assert.throws( + () => configurationSource.resolve(config), + /must use HTTP or HTTPS/ + ) + }) + + 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() } + + 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.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = ' REMOTE_CONFIG ' + delete config.site + + assert.deepStrictEqual(configurationSource.resolve(config), { enabled: true, source: 'remote_config' }) + assert.strictEqual(configurationSource.isRemoteConfig(config), true) + }) + + it('fails closed for an unsupported source', () => { + config.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'other' + + assert.throws(() => configurationSource.resolve(config), /Unsupported Feature Flagging configuration source/) + assert.strictEqual(configurationSource.isRemoteConfig(config), false) + sinon.assert.calledOnce(log.error) + }) + + 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 + + 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.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = 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 + ) + }) +}) diff --git a/packages/dd-trace/test/openfeature/flagging_provider.spec.js b/packages/dd-trace/test/openfeature/flagging_provider.spec.js index 4ab2e7f827..2e793de0a9 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider.spec.js @@ -200,6 +200,31 @@ describe('FlaggingProvider', () => { sinon.assert.notCalled(mockSpanEnrichmentHook.destroy) }) + + it('stops the attached configuration source', () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + const source = { start: sinon.spy(), stop: sinon.spy() } + provider._setConfigurationSource(source) + + provider.onClose() + + sinon.assert.calledOnce(source.start) + sinon.assert.calledOnce(source.stop) + }) + + it('keeps the first configuration source on repeated attachment', () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + const first = { start: sinon.spy(), stop: sinon.spy() } + const duplicate = { start: sinon.spy(), stop: sinon.spy() } + + provider._setConfigurationSource(first) + provider._setConfigurationSource(duplicate) + + sinon.assert.calledOnce(first.start) + sinon.assert.notCalled(first.stop) + sinon.assert.notCalled(duplicate.start) + sinon.assert.calledOnce(duplicate.stop) + }) }) describe('inheritance', () => { diff --git a/packages/dd-trace/test/openfeature/register.spec.js b/packages/dd-trace/test/openfeature/register.spec.js index 80a6170a11..d6180560f1 100644 --- a/packages/dd-trace/test/openfeature/register.spec.js +++ b/packages/dd-trace/test/openfeature/register.spec.js @@ -10,9 +10,11 @@ require('../setup/core') describe('OpenFeature register', () => { let config + let configurationSource let feature let lazyProxy let openfeatureModule + let openfeatureRemoteConfig let proxy let registerFeature let tracer @@ -31,11 +33,21 @@ describe('OpenFeature register', () => { enable: sinon.spy(), disable: sinon.spy(), } + openfeatureRemoteConfig = { + enable: sinon.spy(), + } + configurationSource = { + enable: sinon.spy(), + isEnabled: sinon.stub().returns(true), + isRemoteConfig: sinon.stub().returns(false), + } delete require.cache[require.resolve('../../src/openfeature/register')] proxyquire('../../src/openfeature/register', { '../feature-registry': { registerFeature }, './flagging_provider': FlaggingProvider, + './configuration_source': configurationSource, + './remote_config': openfeatureRemoteConfig, './index': openfeatureModule, './noop': NoopFlaggingProvider, }) @@ -70,33 +82,77 @@ describe('OpenFeature register', () => { assert.strictEqual(feature.factory(), openfeatureModule) }) - it('defines the flagging provider when enabled', () => { + it('does not initialize the module or construct the flagging provider until application code accesses it', () => { feature.enable(config, tracer, proxy, lazyProxy) + sinon.assert.notCalled(proxy._modules.openfeature.enable) + sinon.assert.notCalled(lazyProxy) + sinon.assert.notCalled(configurationSource.enable) + assert.strictEqual(typeof Reflect.getOwnPropertyDescriptor(proxy, 'openfeature').get, 'function') + + const provider = proxy.openfeature + + 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]) + sinon.assert.calledOnceWithExactly(configurationSource.enable, config, sinon.match.func) }) it('keeps an existing flagging provider on repeated enable calls', () => { feature.enable(config, tracer, proxy, lazyProxy) - const provider = proxy.openfeature + feature.enable(config, tracer, proxy, lazyProxy) + sinon.assert.notCalled(proxy._modules.openfeature.enable) + sinon.assert.notCalled(configurationSource.enable) + const provider = proxy.openfeature feature.enable(config, tracer, proxy, lazyProxy) sinon.assert.calledTwice(proxy._modules.openfeature.enable) - sinon.assert.calledOnce(lazyProxy) + sinon.assert.notCalled(lazyProxy) + sinon.assert.calledOnce(configurationSource.enable) assert.strictEqual(proxy.openfeature, provider) }) it('does not define the flagging provider when disabled', () => { config.experimental.flaggingProvider.enabled = false + configurationSource.isEnabled.returns(false) feature.enable(config, tracer, proxy, lazyProxy) + sinon.assert.calledOnceWithExactly(configurationSource.isEnabled, config) sinon.assert.notCalled(proxy._modules.openfeature.enable) sinon.assert.notCalled(lazyProxy) assert.strictEqual(proxy.openfeature, feature.noop) + sinon.assert.notCalled(configurationSource.enable) + }) + + it('installs Remote Config delivery when the provider is enabled and Remote Config is selected', () => { + const rc = {} + configurationSource.isRemoteConfig.returns(true) + + feature.remoteConfig(rc, config, proxy) + + sinon.assert.calledOnceWithExactly(configurationSource.isRemoteConfig, config) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, true) + }) + + it('does not install Remote Config delivery when the provider is disabled', () => { + const rc = {} + config.experimental.flaggingProvider.enabled = false + configurationSource.isRemoteConfig.returns(false) + + feature.remoteConfig(rc, config, proxy) + + sinon.assert.calledOnceWithExactly(configurationSource.isRemoteConfig, config) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, false) + }) + + it('does not install Remote Config delivery unless explicitly selected', () => { + const rc = {} + + feature.remoteConfig(rc, config, proxy) + + sinon.assert.calledOnceWithExactly(configurationSource.isRemoteConfig, config) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, 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..d9284beefb 100644 --- a/packages/dd-trace/test/openfeature/remote_config.spec.js +++ b/packages/dd-trace/test/openfeature/remote_config.spec.js @@ -10,7 +10,6 @@ require('../setup/mocha') describe('OpenFeature Remote Config', () => { let rc - let config let openfeatureProxy let getOpenfeatureProxy let handlers @@ -25,14 +24,6 @@ describe('OpenFeature Remote Config', () => { }), } - config = { - experimental: { - flaggingProvider: { - enabled: true, - }, - }, - } - openfeatureProxy = { _setConfiguration: sinon.spy(), } @@ -42,7 +33,7 @@ describe('OpenFeature Remote Config', () => { describe('enable', () => { it('should enable FFE_FLAG_CONFIGURATION_RULES capability', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) sinon.assert.calledOnceWithExactly( rc.updateCapabilities, @@ -52,13 +43,13 @@ describe('OpenFeature Remote Config', () => { }) it('should register FFE_FLAGS product handler', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) sinon.assert.calledOnceWithExactly(rc.setProductHandler, 'FFE_FLAGS', sinon.match.func) }) it('should call _setConfiguration on apply action when feature is enabled', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') @@ -69,7 +60,7 @@ describe('OpenFeature Remote Config', () => { }) it('should call _setConfiguration on modify action when feature is enabled', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) const flagConfig = { flags: { 'modified-flag': {} } } const handler = handlers.get('FFE_FLAGS') @@ -80,7 +71,7 @@ describe('OpenFeature Remote Config', () => { }) it('should call _setConfiguration(null) on unapply action to clear config', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') @@ -91,7 +82,7 @@ describe('OpenFeature Remote Config', () => { }) it('should not call _setConfiguration on unknown action', () => { - enable(rc, config, getOpenfeatureProxy) + enable(rc, getOpenfeatureProxy) const flagConfig = { flags: { 'test-flag': {} } } const handler = handlers.get('FFE_FLAGS') @@ -101,22 +92,11 @@ describe('OpenFeature Remote Config', () => { sinon.assert.notCalled(openfeatureProxy._setConfiguration) }) - it('should not register product handler when experimental feature is disabled', () => { - config.experimental.flaggingProvider.enabled = false - enable(rc, config, getOpenfeatureProxy) + it('should not advertise capability or register a handler without explicit Remote Config opt-in', () => { + 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..c8a09ff4c0 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -7,6 +7,7 @@ const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') const proxyquire = require('proxyquire') const featureRegistry = require('../src/feature-registry') +const RemoteConfigCapabilities = require('../src/remote_config/capabilities') require('./setup/core') @@ -283,7 +284,7 @@ describe('TracerProxy', () => { noop: noopOpenfeature, factory: () => openfeature, remoteConfig (rc, config, proxy) { - openfeatureRcEnable(rc, config, () => proxy.openfeature) + openfeatureRcEnable(rc, () => proxy.openfeature, config.experimental.flaggingProvider.enabled === true) }, enable (config, tracer, proxy, lazyProxy) { if (config.experimental.flaggingProvider.enabled) { @@ -420,6 +421,17 @@ describe('TracerProxy', () => { sinon.assert.calledWith(openfeatureProvider._setConfiguration, flagConfig) }) + it('should not setup FFE_FLAGS Remote Config when openfeature provider is disabled', () => { + proxy.init() + + assert.strictEqual(handlers.has('FFE_FLAGS'), false) + sinon.assert.neverCalledWith( + rc.updateCapabilities, + RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, + true + ) + }) + it('should handle FFE_FLAGS modify action', () => { config.experimental.flaggingProvider.enabled = true 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',