Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,9 @@ describe('OpenFeature configuration source contract', () => {

assert.strictEqual(configurationCases.length, 63)
assert.deepStrictEqual(countDeliveries(configurationCases), {
agentless: 16,
remote_config: 16,
disabled: 31,
agentless: 12,
remote_config: 12,
disabled: 39,
})

await runCases(configurationCases)
Expand Down Expand Up @@ -341,7 +341,7 @@ function assertDeliveryTraffic (testCase) {
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-api-key'], undefined, testCase.label)
assert.strictEqual(request.headers['dd-client-library-language'], 'nodejs', testCase.label)
assert.strictEqual(request.headers['dd-client-library-version'], VERSION, testCase.label)
}
Expand Down Expand Up @@ -677,6 +677,7 @@ 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 (source.name === 'offline' || source.name === 'invalid') return 'disabled'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

if (legacy === 'true') return 'remote_config'
if (legacy === 'false') return 'disabled'
return 'agentless'
Expand Down
7 changes: 4 additions & 3 deletions packages/dd-trace/src/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@ const parseErrors = new Map()
* @param {string} source - The source of the value.
* @param {string} baseMessage - The base message to use for the warning.
* @param {Error} [error] - An error that was thrown while parsing the value.
* @param {boolean} [pickedDefault] - Whether the invalid value was discarded in favor of a fallback.
*/
function warnInvalidValue (value, optionName, source, baseMessage, error) {
function warnInvalidValue (value, optionName, source, baseMessage, error, pickedDefault = true) {
const canonicalName = (optionsTable[optionName]?.canonicalName ?? optionName) + source
// Lazy load log module to avoid circular dependency
if (!parseErrors.has(canonicalName)) {
// TODO: Rephrase: It will fallback to former source (or default if not set)
let message = `${baseMessage}: ${util.inspect(value)} for ${optionName} (source: ${source}), picked default`
let message = `${baseMessage}: ${util.inspect(value)} for ${optionName} (source: ${source})`
if (pickedDefault) message += ', picked default'
if (error) {
error.stack = error.toString()
message += `\n\n${util.inspect(error)}`
Expand Down
4 changes: 2 additions & 2 deletions packages/dd-trace/src/config/generated-config-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export interface GeneratedConfig {
};
};
featureFlags: {
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config";
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: string;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: string | undefined;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: number;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: number;
Expand Down Expand Up @@ -697,7 +697,7 @@ 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: string;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: string | undefined;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: number;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: number;
Expand Down
7 changes: 7 additions & 0 deletions packages/dd-trace/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,13 @@ class Config extends ConfigBase {
}
}

const configurationSource = this.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE
if (this.featureFlags.DD_FEATURE_FLAGS_ENABLED &&
configurationSource !== 'agentless' &&
configurationSource !== 'remote_config') {
setAndTrack(this, 'featureFlags.DD_FEATURE_FLAGS_ENABLED', false)
}

if (this.url ||
os.type() !== 'Windows_NT' &&
!trackedConfigOrigins.has('hostname') &&
Expand Down
26 changes: 26 additions & 0 deletions packages/dd-trace/src/config/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ const transformers = {
toLowerCase (value) {
return toCase(value, 'toLowerCase')
},
/**
* Normalizes a Feature Flagging configuration source while preserving unsupported values for
* diagnostics. A blank value is treated as unset so legacy configuration can still apply.
*
* @param {string} value
* @param {string} optionName
* @param {string} source
* @returns {string | undefined}
*/
configurationSource (value, optionName, source) {
const normalized = value.trim().toLowerCase()
if (normalized === '') return

if (normalized !== 'agentless' && normalized !== 'remote_config') {
warnInvalidValue(
value,
optionName,
source,
'Unsupported Feature Flagging configuration source; provider disabled',
undefined,
false
)
}

return normalized
},
toUpperCase (value) {
return toCase(value, 'toUpperCase')
},
Expand Down
11 changes: 5 additions & 6 deletions packages/dd-trace/src/config/supported-configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -840,18 +840,17 @@
"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.",
"description": "Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config; offline is reserved and currently unsupported.",
"namespace": "featureFlags",
"transform": "toLowerCase"
"transform": "configurationSource"
}
],
"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.",
"description": "Override the agentless Feature Flagging UFC endpoint or base URL.",
"namespace": "featureFlags",
"sensitive": true
}
Expand All @@ -861,7 +860,7 @@
"implementation": "A",
"type": "int",
"default": "30",
"description": "Experimental: Set the agentless Feature Flagging UFC polling interval in seconds, capped at one hour.",
"description": "Set the agentless Feature Flagging UFC polling interval in seconds, capped at one hour.",
"allowed": "[1-9]\\d*",
"namespace": "featureFlags"
}
Expand All @@ -871,7 +870,7 @@
"implementation": "A",
"type": "int",
"default": "5",
"description": "Experimental: Set the agentless Feature Flagging UFC request timeout in seconds.",
"description": "Set the agentless Feature Flagging UFC request timeout in seconds.",
"allowed": "[1-9]\\d*",
"namespace": "featureFlags"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const RETRY_JITTER = 0.2
* @property {URL} endpoint
* @property {number} pollIntervalMs
* @property {number} requestTimeoutMs
* @property {string} apiKey
* @property {string | undefined} apiKey
*/

/**
Expand Down Expand Up @@ -138,7 +138,7 @@ class AgentlessConfigurationSource {
#request (signal) {
const headers = getClientLibraryHeaders()
headers['Accept-Encoding'] = 'gzip'
headers['DD-API-KEY'] = this.#config.apiKey
if (this.#config.apiKey) headers['DD-API-KEY'] = this.#config.apiKey
if (this.#etag) headers['If-None-Match'] = this.#etag

/**
Expand Down Expand Up @@ -228,10 +228,7 @@ class AgentlessConfigurationSource {
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
)
log.warn('Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', statusCode)
} else if (statusCode) {
log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, attempts)
} else if (attempts > 1) {
Expand Down
17 changes: 6 additions & 11 deletions packages/dd-trace/src/openfeature/configuration_source.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict'

const { isLoopbackHost } = require('../exporters/common/url')
const log = require('../log')

const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server'
Expand All @@ -23,21 +22,21 @@ function create (config, applyConfiguration) {
DD_FEATURE_FLAGS_ENABLED: enabled,
} = config.featureFlags

if (!enabled || source !== 'agentless') {
return
}
if (!enabled || source !== 'agentless') return

const hasCustomEndpoint = Boolean(baseUrl?.trim())

try {
if (!config.DD_API_KEY) {
throw new Error('DD_API_KEY is required for Feature Flagging agentless delivery')
if (!hasCustomEndpoint && !config.DD_API_KEY) {
throw new Error('DD_API_KEY is required for the default Datadog Feature Flagging endpoint')
}

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,
apiKey: hasCustomEndpoint ? undefined : config.DD_API_KEY,
}, applyConfiguration)
} catch (error) {
log.error('Unable to configure Feature Flagging configuration source', error)
Expand Down Expand Up @@ -74,10 +73,6 @@ function endpoint (config, configuredBaseUrl) {
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
}
Expand Down
41 changes: 30 additions & 11 deletions packages/dd-trace/test/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5034,16 +5034,26 @@ rules:
expected: { enabled: true, source: 'remote_config' },
},
{
name: 'falls back from an invalid source to legacy enablement',
name: 'disables the provider for an invalid source',
source: 'other',
expected: { enabled: false },
},
{
name: 'disables the provider for the reserved offline source',
source: 'offline',
expected: { enabled: false },
},
{
name: 'disables the provider for an invalid source despite legacy enablement',
source: 'other',
legacyEnabled: 'true',
expected: { enabled: true, source: 'remote_config' },
expected: { enabled: false },
},
{
name: 'falls back from the reserved offline source to legacy enablement',
name: 'disables the provider for the reserved offline source despite legacy enablement',
source: 'offline',
legacyEnabled: 'true',
expected: { enabled: true, source: 'remote_config' },
expected: { enabled: false },
},
]) {
it(name, () => {
Expand All @@ -5069,27 +5079,36 @@ rules:
})
}

it('falls back from an invalid source through calculated legacy precedence', () => {
it('disables an explicit unsupported source while preserving diagnostic context', () => {
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()
const warning = 'Unsupported Feature Flagging configuration source; provider disabled: ' +
"'offline' for DD_FEATURE_FLAGS_CONFIGURATION_SOURCE (source: env_var)"

assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_ENABLED, true)
assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'remote_config')
assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_ENABLED, false)
assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'offline')
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('featureFlags.DD_FEATURE_FLAGS_ENABLED'), 'calculated')
assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'env_var')
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_FEATURE_FLAGS_ENABLED', value: false, origin: 'calculated' },
{
name: 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE',
value: 'offline',
origin: 'env_var',
error: { message: warning },
},
{ name: 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', value: true, origin: 'env_var' },
])
sinon.assert.calledOnceWithExactly(log.warn, warning)

config.setRemoteConfig({})

sinon.assert.calledOnce(log.warn)
sinon.assert.notCalled(log.error)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,15 @@ describe('AgentlessConfigurationSource', () => {
clock.restore()
clock = undefined
const body = zlib.gzipSync(responseBody())
nock('http://127.0.0.1:8080', {
config.endpoint = new URL('http://flags.dev.internal:8080/custom/ufc')
delete config.apiKey
nock('http://flags.dev.internal:8080', {
badheaders: ['dd-api-key'],
reqheaders: {
'accept-encoding': 'gzip',
'dd-api-key': 'test-api-key',
},
})
.get('/api/v2/feature-flagging/config/rules-based/server')
.get('/custom/ufc')
.reply(200, body, {
'content-encoding': 'gzip',
etag: '"real-path"',
Expand Down Expand Up @@ -414,7 +416,7 @@ describe('AgentlessConfigurationSource', () => {
'third',
])
assert.deepStrictEqual(log.warn.thirdCall.args, [
'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid',
'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication',
401,
])
})
Expand Down Expand Up @@ -480,7 +482,7 @@ describe('AgentlessConfigurationSource', () => {
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',
'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication',
401
)

Expand All @@ -491,6 +493,22 @@ describe('AgentlessConfigurationSource', () => {
sinon.assert.notCalled(applyConfiguration)
})

it('omits a missing API key and reports the endpoint authentication failure', async () => {
delete config.apiKey
responses.push({ statusCode: 401 })

source().start()
await flush()

assert.strictEqual(Object.hasOwn(requests[0].options.headers, 'DD-API-KEY'), false)
sinon.assert.calledOnceWithExactly(
log.warn,
'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication',
401
)
sinon.assert.notCalled(applyConfiguration)
})

it('uses fixed-delay polling after a request completes', async () => {
responses.push(
{ pending: true },
Expand Down
Loading
Loading