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/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/integration-tests/openfeature/app/configuration-source-evaluation.js b/integration-tests/openfeature/app/configuration-source-evaluation.js new file mode 100644 index 0000000000..a8c61dc055 --- /dev/null +++ b/integration-tests/openfeature/app/configuration-source-evaluation.js @@ -0,0 +1,79 @@ +'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] + * @param {boolean} [message.waitForReady] + */ +async function handleMessage (message) { + try { + if (message.command === 'access') { + const provider = tracer.openfeature + if (message.waitForReady) { + await OpenFeature.setProviderAndWait(provider) + } else { + OpenFeature.setProvider(provider) + } + 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-configuration-sources.spec.js b/integration-tests/openfeature/openfeature-configuration-sources.spec.js new file mode 100644 index 0000000000..566c36639b --- /dev/null +++ b/integration-tests/openfeature/openfeature-configuration-sources.spec.js @@ -0,0 +1,683 @@ +'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 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' +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 = 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 */ +/** @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) + const accessCommand = { + command: 'access', + waitForReady: testCase.expected !== 'disabled', + } + + if (testCase.expected === 'agentless') { + await Promise.all([ + waitForObservation(agentlessRequests, testCase.identifier, 'agentless', hasObservation), + sendCommand(proc, accessCommand), + ]) + } else { + await sendCommand(proc, accessCommand) + } + + const { details } = await sendCommand(proc, { command: 'evaluate' }) + 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 {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['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 + } + + 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] + * @param {boolean} [command.waitForReady] + */ +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-Encoding': 'gzip', + '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 806db8de9b..9dec6c4d28 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -431,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[]; @@ -690,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/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/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 f89e3e53d8..bb8fbb2d4c 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -835,6 +835,55 @@ "default": "false" } ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": [ + { + "implementation": "A", + "type": "string", + "default": "agentless", + "allowed": "agentless|remote_config", + "description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config.", + "namespace": "featureFlags", + "transform": "toLowerCase" + } + ], + "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.", + "namespace": "featureFlags", + "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.", + "allowed": "[1-9]\\d*", + "namespace": "featureFlags" + } + ], + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": [ + { + "implementation": "A", + "type": "int", + "default": "5", + "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": [ { "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/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index e5baca2e5e..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('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,21 +26,11 @@ const maxActiveBufferSize = 1024 * 1024 * 64 let activeBufferSize = 0 -/** - * @param {string} hostname Host as resolved by {@link parseUrl}; IPv6 is unbracketed (`::1`). - */ -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 - * @param {(error: Error|null, result: string, statusCode: number) => void} callback + * @param {(error: Error|null, result?: string|null, statusCode?: number, + * headers?: import('node:http').IncomingHttpHeaders) => void} callback */ function request (data, options, callback) { if (!options.headers) { @@ -106,34 +95,50 @@ function request (data, options, callback) { options.agent = isSecure ? httpsAgent : httpAgent - const onResponse = (res, finalize) => { + /** + * @param {import('node:http').IncomingMessage} res + * @param {(error: Error|null, result?: string|null, statusCode?: number, + * headers?: import('node:http').IncomingHttpHeaders) => void} complete + * @param {(error: Error) => void} handleError + */ + 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 = '' @@ -154,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) } }) } @@ -162,6 +167,7 @@ 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 */ const attempt = attemptIndex => { if (!request.writable) { log.debug('Maximum number of active requests reached: payload is discarded.') @@ -172,28 +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)) - - req.once('close', finalize) - req.once('timeout', finalize) - - req.once('error', error => { + /** + * @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() - if (attemptIndex < getMaxAttempts(options) && isRetriableNetworkError(error)) { + callback(error, result, statusCode, headers) + } + + /** + * @param {Error} error + */ + const handleError = (error) => { + if (settled) return + + 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 2b7b5521ce..5be999ae05 100644 --- a/packages/dd-trace/src/feature-registry.js +++ b/packages/dd-trace/src/feature-registry.js @@ -1,12 +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 {Function} [remoteConfig] - * @property {Function} [enable] + * @property {() => FeatureModule} factory + * @property {(config: import('./config/config-base')) => boolean} isEnabled + * @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 new file mode 100644 index 0000000000..fc50bf1711 --- /dev/null +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -0,0 +1,322 @@ +'use strict' + +/* eslint-disable no-await-in-loop -- Polls and retries must remain sequential. */ + +const { setTimeout: sleep } = require('node:timers/promises') + +const request = require('../exporters/common/request') +const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers') +const log = require('../log') + +const MAX_ATTEMPTS = 3 +const FIRST_RETRY_MIN_MS = 2000 +const FIRST_RETRY_MAX_MS = 10_000 +const SECOND_RETRY_MIN_MS = 5000 +const SECOND_RETRY_MAX_MS = 30_000 +const RETRY_JITTER = 0.2 + +/** + * @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 {Error | null | undefined} error + * @property {string | undefined} body + * @property {number | undefined} statusCode + * @property {import('node:http').IncomingHttpHeaders | undefined} headers + */ + +class AgentlessConfigurationSource { + /** @type {AbortController | undefined} */ + #abortController + + /** @type {(configuration: UniversalFlagConfiguration) => void} */ + #applyConfiguration + + #applicationFailureLogged = false + + /** @type {AgentlessSourceConfig} */ + #config + + /** @type {string | undefined} */ + #etag + + /** @type {Set} */ + #failureWarnings = new Set() + + #malformedPayloadLogged = false + + /** + * @param {AgentlessSourceConfig} config + * @param {(configuration: UniversalFlagConfiguration) => void} applyConfiguration + */ + constructor (config, applyConfiguration) { + this.#config = config + this.#applyConfiguration = applyConfiguration + } + + /** + * @returns {void} + */ + start () { + if (this.#abortController) return + + const abortController = new AbortController() + this.#abortController = abortController + this.#poll(abortController) + } + + /** + * @returns {void} + */ + stop () { + this.#abortController?.abort() + this.#abortController = undefined + } + + /** + * @param {AbortController} abortController + * @returns {Promise} + */ + 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 + } + } + } + + /** + * @param {AbortController} abortController + * @returns {Promise} + */ + 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(response) + return + } + + if (attempt === MAX_ATTEMPTS) { + this.#warnFailure(response.statusCode, response.error, attempt) + return + } + + const delay = retryDelay(this.#config.pollIntervalMs, attempt, Math.random()) + if (!await wait(delay, abortController.signal)) return + } + } + + /** + * @param {AbortSignal} signal + * @returns {Promise} + */ + #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 + */ + const execute = (resolve) => { + /** + * @param {Error | null} error + * @param {string | import('node:http').IncomingMessage | null | undefined} body + * @param {number | undefined} statusCode + * @param {import('node:http').IncomingHttpHeaders | undefined} responseHeaders + */ + const onResponse = (error, body, statusCode, responseHeaders) => { + resolve({ + error, + body: typeof body === 'string' ? body : undefined, + statusCode, + headers: responseHeaders, + }) + } + + request('', { + url: this.#config.endpoint, + method: 'GET', + headers, + retry: false, + signal, + timeout: this.#config.requestTimeoutMs, + }, onResponse) + } + + return new Promise(execute) + } + + /** + * @param {PollResponse} response + * @returns {void} + */ + #apply (response) { + const statusCode = response.statusCode + if (statusCode === 304) return + + if (statusCode === 401 || statusCode === 403) { + this.#warnFailure(statusCode, undefined, 1) + return + } + + if (statusCode !== 200) return + + let configuration + try { + configuration = parseConfiguration(response.body) + } catch { + if (!this.#malformedPayloadLogged) { + this.#malformedPayloadLogged = true + log.error('Feature Flagging agentless endpoint returned malformed UFC payload') + } + return + } + + try { + this.#applyConfiguration(configuration) + } catch (error) { + if (!this.#applicationFailureLogged) { + this.#applicationFailureLogged = true + log.warn('Feature Flagging agentless UFC payload could not be applied: %s', errorMessage(error)) + } + return + } + + const etag = response.headers?.etag + const value = Array.isArray(etag) ? etag[0] : etag + this.#etag = value?.trim() || undefined + } + + /** + * @param {number | undefined} statusCode + * @param {unknown} error + * @param {number} attempts + * @returns {void} + */ + #warnFailure (statusCode, error, attempts) { + 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( + '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, attempts) + } else if (attempts > 1) { + log.warn('Feature Flagging agentless request failed after %d attempts: %s', attempts, errorMessage(error)) + } else { + log.warn('Feature Flagging agentless request failed: %s', errorMessage(error)) + } + } +} + +/** + * @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 parseConfiguration (body) { + const { data } = JSON.parse(body) + if (data?.type !== 'universal-flag-configuration') { + throw new Error('Expected a JSON:API Universal Flag Configuration resource') + } + + const { attributes } = data + if (typeof attributes?.format !== 'string' || + typeof attributes.createdAt !== 'string' || + 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') + } + + return attributes +} + +/** + * @param {unknown} error + * @returns {string} + */ +function errorMessage (error) { + return error instanceof Error ? error.message : String(error ?? 'request was not sent') +} + +/** + * @param {number | undefined} status + * @returns {boolean} + */ +function isRetryableStatus (status) { + return status === 408 || status === 429 || (status >= 500 && status <= 599) +} + +/** + * @param {number} pollIntervalMs + * @param {number} attempt + * @param {number} random + * @returns {number} + */ +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))) +} + +/** + * @param {number} value + * @param {number} minimum + * @param {number} maximum + * @returns {number} + */ +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..0bd7327953 --- /dev/null +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -0,0 +1,90 @@ +'use strict' + +const { isLoopbackHost } = require('../exporters/common/url') +const log = require('../log') + +const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server' +const MAX_POLL_INTERVAL_SECONDS = 60 * 60 + +/** + * @typedef {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1} UniversalFlagConfiguration + */ + +/** + * @param {import('../config/config-base')} config + * @param {(configuration: UniversalFlagConfiguration) => void} applyConfiguration + */ +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 { + 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) + } +} + +/** + * 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.${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 { + 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 + } + + return url +} + +module.exports = { + create, +} diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index 174155f28e..8d3d434c9e 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -2,11 +2,13 @@ const { channel } = require('dc-polyfill') 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 } = require('./require-provider') + /** * OpenFeature provider that integrates with Datadog's feature flagging system. * Extends DatadogNodeServerProvider to add tracer integration and configuration management. @@ -15,21 +17,19 @@ 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 */ 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) { @@ -43,6 +43,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() } /** @@ -50,22 +53,10 @@ class FlaggingProvider extends DatadogNodeServerProvider { * Cleans up resources including channel subscriptions. */ onClose () { + this.#configurationSource?.stop() + this.#configurationSource = undefined this.#spanEnrichmentHook?.destroy() - } - - /** - * 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..81153babae 100644 --- a/packages/dd-trace/src/openfeature/register.js +++ b/packages/dd-trace/src/openfeature/register.js @@ -4,43 +4,32 @@ const { registerFeature } = require('../feature-registry') const noop = new (require('./noop'))() -/** - * @param {import('../proxy')} proxy - * @returns {boolean} - */ -function hasFlaggingProvider (proxy) { - const descriptor = Reflect.getOwnPropertyDescriptor(proxy, 'openfeature') - - return descriptor?.value !== undefined && descriptor.value !== noop -} +/** @typedef {import('../proxy') & { openfeature: object }} OpenFeatureProxy */ registerFeature({ name: 'openfeature', noop, factory: () => require('./index'), + provider: () => require('./flagging_provider'), - /** - * @param {object} rc - RemoteConfig instance - * @param {import('../config/config-base')} config - * @param {import('../proxy')} proxy - */ - remoteConfig (rc, config, proxy) { - const openfeatureRemoteConfig = require('./remote_config') - openfeatureRemoteConfig.enable(rc, config, () => proxy.openfeature) + /** @param {import('../config/config-base')} config */ + isEnabled (config) { + return config.featureFlags.DD_FEATURE_FLAGS_ENABLED }, /** + * @param {import('../remote_config')} rc - RemoteConfig instance * @param {import('../config/config-base')} config - * @param {import('../tracer')} tracer - * @param {import('../proxy')} proxy - * @param {Function} lazyProxy + * @param {OpenFeatureProxy} proxy */ - enable (config, tracer, proxy, lazyProxy) { - if (config.experimental.flaggingProvider.enabled) { - proxy._modules.openfeature.enable(config) - if (!hasFlaggingProvider(proxy)) { - lazyProxy(proxy, 'openfeature', () => require('./flagging_provider'), tracer, config) - } - } + remoteConfig (rc, config, proxy) { + const openfeatureRemoteConfig = require('./remote_config') + const subscribe = config.featureFlags.DD_FEATURE_FLAGS_ENABLED && + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE === 'remote_config' + openfeatureRemoteConfig.enable( + rc, + () => proxy.openfeature, + subscribe + ) }, }) 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/src/proxy.js b/packages/dd-trace/src/proxy.js index d9a8ab1dba..8b4c8899f4 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,36 @@ 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 || state === FEATURE_STATE_LAZY) return + states[feature.name] = FEATURE_STATE_LAZY + + Reflect.defineProperty(this, feature.name, { + get: () => { + 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, + enumerable: true, + }) + states[feature.name] = FEATURE_STATE_ACTIVE + return provider + }, + configurable: true, + enumerable: true, + }) + } + /** * @param {import('./config/config-base')} config - Tracer configuration */ @@ -322,9 +358,6 @@ class Tracer extends NoopProxy { } this._tracingInitialized = true } - for (const feature of Object.values(features)) { - feature.enable?.(config, this._tracer, this, lazyProxy) - } if (config.experimental?.aiguard?.enabled) { this._modules.aiguard.enable(this._tracer, config) } @@ -337,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 28a2d019f8..08d2e26fdc 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -77,11 +77,11 @@ let agentTelemetry = true */ function getHeaders (config, application, reqType) { const headers = { + '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, - '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 2bb84343e3..1c3bdcb307 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', @@ -4952,6 +4954,239 @@ rules: }) }) + context('Feature Flagging configuration source', () => { + 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 timings', () => { + const config = getConfig() + + assertObjectContains(config, { + 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: 5, + }, + }) + }) + + it('reads the configuration source environment variable', () => { + process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + + const config = getConfig() + + assert.strictEqual(config.featureFlags.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, { + 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, + 5 + ) + sinon.assert.calledTwice(log.warn) + }) + + 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.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, + 5 + ) + 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/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index d04bc71c69..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') @@ -44,6 +45,7 @@ describe('request', function () { let docker let maxAttempts let retryStubs + let runInNoopContext beforeEach(() => { log = { @@ -64,7 +66,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 +111,165 @@ describe('request', 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 request with an AbortSignal', async () => { + nock('http://localhost:80') + .get('/path') + .delayConnection(1000) + .reply(200, 'OK') + + 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() + + 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') @@ -534,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 new file mode 100644 index 0000000000..d8816abb8f --- /dev/null +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -0,0 +1,584 @@ +'use strict' + +const assert = require('node:assert/strict') +const zlib = require('node:zlib') + +const { afterEach, beforeEach, describe, it } = require('mocha') +const nock = require('nock') +const proxyquire = require('proxyquire').noCallThru() +const sinon = require('sinon') + +const { VERSION } = require('../../../../version') +require('../setup/core') + +const VALID_UFC = { + createdAt: '2026-01-01T00:00:00.000Z', + format: 'SERVER', + environment: { name: 'test' }, + flags: {}, +} + +/** + * @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 log + let random + let request + let requests + let responses + let sources + + 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 = { + error: sinon.spy(), + warn: sinon.spy(), + } + random = sinon.stub(Math, 'random').returns(0.5) + requests = [] + responses = [] + sources = [] + + /** + * @param {string} data + * @param {{ signal?: AbortSignal }} options + * @param {Function} callback + */ + const sendRequest = (data, options, callback) => { + const response = responses.shift() + const requestRecord = { + aborted: false, + 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(() => { + options.signal?.removeEventListener('abort', abort) + callback( + response.error ?? null, + response.body, + response.statusCode, + response.headers + ) + }) + } + } + request = sinon.spy(sendRequest) + + AgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { + '../exporters/common/request': request, + '../log': log, + }) + }) + + afterEach(() => { + for (const configurationSource of sources) configurationSource.stop() + random.restore() + clock?.restore() + nock.cleanAll() + }) + + function source () { + const configurationSource = new AgentlessConfigurationSource(config, applyConfiguration) + sources.push(configurationSource) + return configurationSource + } + + 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 an accepted ETag', async () => { + responses.push( + { statusCode: 200, headers: { etag: [' W/"ufc-v1" '] }, body: responseBody() }, + { statusCode: 304 } + ) + + 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.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) + + await tick(30_000) + + assert.strictEqual(requests[1].options.headers['If-None-Match'], 'W/"ufc-v1"') + sinon.assert.calledOnce(applyConfiguration) + }) + + 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 } + ) + + source().start() + await flush() + 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.calledTwice(applyConfiguration) + }) + + 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', + 'dd-api-key': 'test-api-key', + }, + }) + .get('/api/v2/feature-flagging/config/rules-based/server') + .reply(200, body, { + 'content-encoding': 'gzip', + etag: '"real-path"', + }) + + let resolveConfiguration + const applied = new Promise(resolve => { + resolveConfiguration = resolve + }) + const RealAgentlessConfigurationSource = proxyquire('../../src/openfeature/agentless_configuration_source', { + '../log': log, + }) + const configurationSource = new RealAgentlessConfigurationSource(config, resolveConfiguration) + sources.push(configurationSource) + + configurationSource.start() + + assert.deepStrictEqual(await applied, VALID_UFC) + assert.ok(nock.isDone()) + }) + + 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 }) + + 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 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: responseBody({ ...VALID_UFC, format: undefined }), + }, + { statusCode: 304 } + ) + + source().start() + await flush() + await tick(30_000) + await tick(30_000) + await tick(30_000) + + sinon.assert.calledOnce(applyConfiguration) + sinon.assert.calledOnce(log.error) + assert.strictEqual(requests[3].options.headers['If-None-Match'], '"good"') + }) + + it('rejects unrelated and invalid UFC resources', async () => { + responses.push( + { + statusCode: 200, + body: JSON.stringify({ data: { type: 'other', attributes: VALID_UFC } }), + }, + { + statusCode: 200, + body: responseBody({ ...VALID_UFC, environment: [] }), + }, + { + statusCode: 200, + body: JSON.stringify({ + data: { + type: 'universal-flag-configuration', + attributes: 'invalid', + }, + }), + } + ) + + source().start() + await flush() + await tick(30_000) + await tick(30_000) + + sinon.assert.notCalled(applyConfiguration) + sinon.assert.calledOnce(log.error) + }) + + 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) + + 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 () => { + 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 } + ) + + source().start() + await flush() + await tick(30_000) + await tick(30_000) + await tick(30_000) + + 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 () => { + responses.push( + { statusCode: 408 }, + { statusCode: 429 }, + { statusCode: 200, body: responseBody() } + ) + + source().start() + await flush() + await tick(4999) + assert.strictEqual(requests.length, 1) + await tick(1) + assert.strictEqual(requests.length, 2) + await tick(9999) + assert.strictEqual(requests.length, 2) + await tick(1) + + assert.strictEqual(requests.length, 3) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) + }) + + it('retries network and request-timeout errors without transport retries', async () => { + responses.push( + { error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) }, + { error: Object.assign(new Error('reset'), { code: 'ECONNRESET' }) }, + { statusCode: 200, body: responseBody() } + ) + + source().start() + await flush() + await tick(5000) + await tick(10_000) + + assert.strictEqual(requests.length, 3) + for (const requestRecord of requests) assert.strictEqual(requestRecord.options.retry, false) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) + }) + + it('retries when the shared transport cannot send the request', async () => { + responses.push( + {}, + { statusCode: 200, body: responseBody() } + ) + + source().start() + await flush() + await tick(5000) + + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) + }) + + 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') }, + { statusCode: 401 } + ) + + source().start() + await flush() + await tick(5000) + await tick(10_000) + 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) + + assert.deepStrictEqual(log.warn.firstCall.args, [ + 'Feature Flagging agentless endpoint returned HTTP %d after %d attempts', + 503, + 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 () => { + responses.push( + { error: new Error('first') }, + { error: new Error('second') }, + {} + ) + + source().start() + await flush() + await tick(5000) + await tick(10_000) + + sinon.assert.calledOnceWithExactly( + log.warn, + 'Feature Flagging agentless request failed after %d attempts: %s', + 3, + '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' + ) + }) + + it('does not retry authentication or other non-retryable statuses', async () => { + responses.push( + { statusCode: 401 }, + { statusCode: 404 } + ) + + source().start() + await flush() + + 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(30_000) + + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnce(log.warn) + sinon.assert.notCalled(applyConfiguration) + }) + + it('uses fixed-delay polling after a request completes', async () => { + responses.push( + { pending: true }, + { statusCode: 200, body: responseBody() } + ) + + source().start() + await tick(30_000) + assert.strictEqual(requests.length, 1) + + requests[0].callback(null, responseBody(), 200, {}) + await flush() + await tick(29_999) + assert.strictEqual(requests.length, 1) + await tick(1) + + assert.strictEqual(requests.length, 2) + }) + + 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, responseBody(), 200, {}) + await flush() + configurationSource.start() + + assert.strictEqual(requests.length, 1) + }) + + it('stops an active request and ignores its response', async () => { + responses.push({ pending: true, ignoreAbort: true }) + const configurationSource = source() + + configurationSource.start() + configurationSource.stop() + requests[0].callback(null, responseBody(), 200, {}) + await flush() + + assert.strictEqual(requests[0].aborted, true) + sinon.assert.notCalled(applyConfiguration) + }) + + 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() + configurationSource.start() + requests[0].callback(null, responseBody(oldConfiguration), 200, {}) + await flush() + + 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 and can restart', async () => { + responses.push( + { error: Object.assign(new Error('reset'), { code: 'ECONNRESET' }) }, + { statusCode: 200, body: responseBody() } + ) + const configurationSource = source() + + configurationSource.start() + await flush() + configurationSource.stop() + configurationSource.start() + await flush() + + assert.strictEqual(requests.length, 2) + sinon.assert.calledOnceWithExactly(applyConfiguration, VALID_UFC) + }) +}) 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..8ac81dba43 --- /dev/null +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -0,0 +1,223 @@ +'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', + 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: 5, + }, + 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, + }) + }) + + 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 = createSourceConfig() + + 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, 5000) + }) + + it('derives the staging UFC CDN endpoint from DD_SITE', () => { + config.site = 'datad0g.com' + config.env = 'staging' + + assert.strictEqual( + 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.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://127.0.0.1:8080/' + + const resolved = createSourceConfig() + assert.strictEqual( + resolved.endpoint.toString(), + 'http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server' + ) + }) + + 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' + + assert.strictEqual( + createSourceConfig().endpoint.toString(), + 'https://example.com/custom/ufc?tenant=one' + ) + }) + + it('derives and creates the managed GovCloud endpoint without hard-coding availability', () => { + config.site = 'DDOG-GOV.COM' + config.env = 'prod' + const applyConfiguration = sinon.spy() + + const source = configurationSource.create(config, applyConfiguration) + const resolved = AgentlessConfigurationSource.firstCall.args[0] + + 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( + AgentlessConfigurationSource, + sinon.match({ + endpoint: resolved.endpoint, + apiKey: 'test-api-key', + pollIntervalMs: 30_000, + requestTimeoutMs: 5000, + }), + 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.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + 'https://flags.example.test/custom/ufc?tenant=test' + + 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.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'file:///tmp/ufc.json' + + 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 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()) + + 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.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', () => { + delete config.DD_API_KEY + + const source = configurationSource.create(config, sinon.spy()) + + sinon.assert.calledOnceWithMatch( + log.error, + 'Unable to configure Feature Flagging configuration source', + sinon.match.instanceOf(Error) + ) + assert.strictEqual(source, undefined) + sinon.assert.notCalled(AgentlessConfigurationSource) + }) + + it('does not create an agentless source for Remote Config delivery', () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'remote_config' + delete config.site + + const source = configurationSource.create(config, sinon.spy()) + + assert.strictEqual(source, undefined) + sinon.assert.notCalled(AgentlessConfigurationSource) + }) + + it('does not create an agentless source when Feature Flags are disabled', () => { + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false + delete config.site + + const source = configurationSource.create(config, sinon.spy()) + + 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 6d437fdaf7..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 @@ -201,6 +169,41 @@ describe('FlaggingProvider', () => { sinon.assert.notCalled(mockSpanEnrichmentHook.destroy) }) + + it('stops the attached configuration source', () => { + const source = { start: sinon.spy(), stop: sinon.spy() } + configurationSource.create.returns(source) + const provider = new FlaggingProvider(mockTracer, mockConfig) + + provider.onClose() + + sinon.assert.calledOnce(source.start) + sinon.assert.calledOnce(source.stop) + }) + + 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 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) + }) }) describe('inheritance', () => { 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..13d67209ed 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,92 +13,129 @@ require('../setup/core') describe('OpenFeature register', () => { let config let feature - let lazyProxy + let FlaggingProvider let openfeatureModule + let openfeatureRemoteConfig let proxy let registerFeature - let tracer function NoopFlaggingProvider () {} - function FlaggingProvider (...args) { - 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(), } + openfeatureRemoteConfig = { + enable: sinon.spy(), + } + FlaggingProvider = function () {} 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, - }, - }, - } - tracer = {} - proxy = { - openfeature: feature.noop, - _modules: { - openfeature: { - enable: sinon.spy(), - }, + featureFlags: { + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless', + DD_FEATURE_FLAGS_ENABLED: true, }, } - 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('defines the flagging provider when enabled', () => { - feature.enable(config, tracer, proxy, lazyProxy) + 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/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'))}), + 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']) { + 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)) + } + } + } + }) + + it('selects the provider from the calculated Feature Flags state', () => { + assert.strictEqual(feature.isEnabled(config), true) + + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false + + assert.strictEqual(feature.isEnabled(config), false) + }) + + 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(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(openfeatureRemoteConfig.enable, rc, sinon.match.func, true) + assert.strictEqual(openfeatureRemoteConfig.enable.firstCall.args[1](), proxy.openfeature) }) - it('keeps an existing flagging provider on repeated enable calls', () => { - feature.enable(config, tracer, proxy, lazyProxy) - const provider = proxy.openfeature + it('does not install Remote Config delivery when disabled', () => { + const rc = {} + config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false - feature.enable(config, tracer, proxy, lazyProxy) + feature.remoteConfig(rc, config, proxy) - sinon.assert.calledTwice(proxy._modules.openfeature.enable) - sinon.assert.calledOnce(lazyProxy) - assert.strictEqual(proxy.openfeature, provider) + sinon.assert.calledOnceWithExactly(openfeatureRemoteConfig.enable, rc, sinon.match.func, false) }) - it('does not define the flagging provider when disabled', () => { - config.experimental.flaggingProvider.enabled = false + it('does not install Remote Config delivery for the default agentless source', () => { + const rc = {} - feature.enable(config, tracer, proxy, lazyProxy) + feature.remoteConfig(rc, config, proxy) - sinon.assert.notCalled(proxy._modules.openfeature.enable) - sinon.assert.notCalled(lazyProxy) - assert.strictEqual(proxy.openfeature, feature.noop) + 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..2e35f114a8 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 }) @@ -268,30 +273,19 @@ 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, - remoteConfig (rc, config, proxy) { - openfeatureRcEnable(rc, config, () => proxy.openfeature) + provider: () => OpenFeatureProvider, + /** @param {object} config */ + isEnabled (config) { + return config.featureFlags.DD_FEATURE_FLAGS_ENABLED }, - enable (config, tracer, proxy, lazyProxy) { - if (config.experimental.flaggingProvider.enabled) { - proxy._modules.openfeature.enable(config) - if (!hasOpenfeatureProvider(proxy)) { - lazyProxy(proxy, 'openfeature', () => OpenFeatureProvider, tracer, config) - } - } + 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) }, }) @@ -408,8 +402,30 @@ 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('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.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 +433,38 @@ 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('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() + + 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 +472,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 +489,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 + 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 */ config.setRemoteConfig = remoteConfig => { config.DD_TRACE_ENABLED = remoteConfig.DD_TRACE_ENABLED @@ -463,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', () => { 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',