Skip to content

Commit 7ffa2a3

Browse files
committed
feat(openfeature): resolve configuration delivery
Define source selection, managed and custom endpoint derivation, staging and GovCloud routing, and bounded timing semantics. Keep this policy layer independent from transport and provider activation, with focused resolver tests.
1 parent 76d4d45 commit 7ffa2a3

2 files changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
'use strict'
2+
3+
const log = require('../log')
4+
5+
const CONFIGURATION_SOURCE_AGENTLESS = 'agentless'
6+
const CONFIGURATION_SOURCE_REMOTE_CONFIG = 'remote_config'
7+
8+
const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server'
9+
const DEFAULT_POLL_INTERVAL_SECONDS = 30
10+
const DEFAULT_REQUEST_TIMEOUT_SECONDS = 2
11+
const MAX_POLL_INTERVAL_SECONDS = 60 * 60
12+
13+
/**
14+
* Resolves Feature Flagging configuration-source settings.
15+
*
16+
* @param {import('../config/config-base')} config - Tracer configuration.
17+
* @returns {object} Resolved source settings.
18+
*/
19+
function resolve (config) {
20+
const flaggingProvider = config.experimental.flaggingProvider
21+
const mode = resolveMode(config)
22+
23+
if (mode === CONFIGURATION_SOURCE_REMOTE_CONFIG) {
24+
return { mode }
25+
}
26+
27+
return {
28+
mode,
29+
endpoint: endpoint(config, flaggingProvider.agentlessBaseUrl),
30+
pollIntervalMs: positiveMilliseconds(
31+
flaggingProvider.agentlessPollIntervalSeconds,
32+
DEFAULT_POLL_INTERVAL_SECONDS,
33+
'poll interval',
34+
MAX_POLL_INTERVAL_SECONDS
35+
),
36+
requestTimeoutMs: positiveMilliseconds(
37+
flaggingProvider.agentlessRequestTimeoutSeconds,
38+
DEFAULT_REQUEST_TIMEOUT_SECONDS,
39+
'request timeout'
40+
),
41+
apiKey: config.DD_API_KEY,
42+
}
43+
}
44+
45+
/**
46+
* Reports whether the explicit Remote Config source is selected.
47+
*
48+
* Invalid source values fail closed and do not enable Remote Config delivery.
49+
*
50+
* @param {import('../config/config-base')} config - Tracer configuration.
51+
* @returns {boolean} Whether Remote Config should own UFC delivery.
52+
*/
53+
function isRemoteConfig (config) {
54+
try {
55+
return resolveMode(config) === CONFIGURATION_SOURCE_REMOTE_CONFIG
56+
} catch (error) {
57+
log.error('Unable to configure Feature Flagging configuration source', error)
58+
return false
59+
}
60+
}
61+
62+
/**
63+
* Normalizes and validates source selection without resolving agentless
64+
* endpoint or timing configuration.
65+
*
66+
* @param {import('../config/config-base')} config - Tracer configuration.
67+
* @returns {string} Selected configuration-source mode.
68+
*/
69+
function resolveMode (config) {
70+
const value = config.experimental.flaggingProvider.configurationSource
71+
const mode = String(value ?? '').trim().toLowerCase() || CONFIGURATION_SOURCE_AGENTLESS
72+
if (mode !== CONFIGURATION_SOURCE_AGENTLESS && mode !== CONFIGURATION_SOURCE_REMOTE_CONFIG) {
73+
throw new Error(`Unsupported Feature Flagging configuration source: ${mode}`)
74+
}
75+
return mode
76+
}
77+
78+
/**
79+
* Builds the agentless rules-based server endpoint.
80+
*
81+
* A configured URL with a non-root path is treated as the exact endpoint. A
82+
* configured origin (or root URL) receives the standard rules-based server
83+
* path.
84+
*
85+
* @param {import('../config/config-base')} config - Tracer configuration.
86+
* @param {string | undefined} configuredBaseUrl - Optional endpoint or origin override.
87+
* @returns {URL} Agentless endpoint.
88+
*/
89+
function endpoint (config, configuredBaseUrl) {
90+
const configured = configuredBaseUrl?.trim()
91+
92+
if (!configured) {
93+
const url = new URL(`https://ufc-server.ff-cdn.${String(config.site).toLowerCase()}${DEFAULT_AGENTLESS_PATH}`)
94+
if (config.env) url.searchParams.set('dd_env', config.env)
95+
return url
96+
}
97+
98+
let url
99+
try {
100+
url = new URL(configured)
101+
} catch (error) {
102+
throw new Error(`Invalid Feature Flagging agentless URL: ${configured}`, { cause: error })
103+
}
104+
105+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
106+
throw new Error('Feature Flagging agentless URL must use HTTP or HTTPS')
107+
}
108+
109+
if (url.pathname === '' || url.pathname === '/') {
110+
url.pathname = DEFAULT_AGENTLESS_PATH
111+
}
112+
113+
return url
114+
}
115+
116+
/**
117+
* Converts a positive number of seconds to milliseconds, falling back for
118+
* invalid or non-positive values.
119+
*
120+
* @param {unknown} value - Configured seconds.
121+
* @param {number} fallbackSeconds - Default seconds.
122+
* @param {string} setting - Human-readable setting name.
123+
* @param {number} [maximumSeconds] - Optional inclusive maximum.
124+
* @returns {number} Positive milliseconds.
125+
*/
126+
function positiveMilliseconds (value, fallbackSeconds, setting, maximumSeconds) {
127+
const seconds = Number(value)
128+
if (!Number.isFinite(seconds) || seconds <= 0) {
129+
log.warn(
130+
'Invalid Feature Flagging agentless %s: %s. The value must be positive; using %ss',
131+
setting,
132+
value,
133+
fallbackSeconds
134+
)
135+
return fallbackSeconds * 1000
136+
}
137+
if (maximumSeconds !== undefined && seconds > maximumSeconds) {
138+
log.warn(
139+
'Feature Flagging agentless %s %s exceeds the maximum of %ss; using %ss',
140+
setting,
141+
value,
142+
maximumSeconds,
143+
maximumSeconds
144+
)
145+
return maximumSeconds * 1000
146+
}
147+
return Math.max(1, Math.round(seconds * 1000))
148+
}
149+
150+
module.exports = {
151+
isRemoteConfig,
152+
resolve,
153+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
'use strict'
2+
3+
const assert = require('node:assert/strict')
4+
const { beforeEach, describe, it } = require('mocha')
5+
const proxyquire = require('proxyquire')
6+
const sinon = require('sinon')
7+
8+
require('../setup/core')
9+
10+
describe('OpenFeature configuration source', () => {
11+
let config
12+
let configurationSource
13+
let log
14+
15+
beforeEach(() => {
16+
config = {
17+
DD_API_KEY: 'test-api-key',
18+
site: 'datadoghq.com',
19+
env: 'my env',
20+
experimental: {
21+
flaggingProvider: {
22+
configurationSource: 'agentless',
23+
agentlessBaseUrl: undefined,
24+
agentlessPollIntervalSeconds: 30,
25+
agentlessRequestTimeoutSeconds: 2,
26+
},
27+
},
28+
}
29+
log = {
30+
debug: sinon.spy(),
31+
error: sinon.spy(),
32+
warn: sinon.spy(),
33+
}
34+
configurationSource = proxyquire('../../src/openfeature/configuration_source', {
35+
'../log': log,
36+
})
37+
})
38+
39+
for (const value of [undefined, null, '', ' ', ' AgEnTlEsS ']) {
40+
it(`normalizes ${JSON.stringify(value)} to the default agentless source`, () => {
41+
config.experimental.flaggingProvider.configurationSource = value
42+
43+
assert.strictEqual(configurationSource.resolve(config).mode, 'agentless')
44+
})
45+
}
46+
47+
it('defaults to the Datadog UFC CDN endpoint and includes the environment', () => {
48+
config.DD_SITE = 'raw-env-key.invalid'
49+
const resolved = configurationSource.resolve(config)
50+
51+
assert.strictEqual(
52+
resolved.endpoint.toString(),
53+
'https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=my+env'
54+
)
55+
assert.strictEqual(resolved.apiKey, 'test-api-key')
56+
assert.strictEqual(resolved.pollIntervalMs, 30_000)
57+
assert.strictEqual(resolved.requestTimeoutMs, 2000)
58+
})
59+
60+
it('derives the staging UFC CDN endpoint from DD_SITE', () => {
61+
config.site = 'datad0g.com'
62+
config.env = 'staging'
63+
64+
assert.strictEqual(
65+
configurationSource.resolve(config).endpoint.toString(),
66+
'https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging'
67+
)
68+
})
69+
70+
it('appends the standard path to a configured origin', () => {
71+
config.experimental.flaggingProvider.agentlessBaseUrl = 'http://127.0.0.1:8080/'
72+
73+
const resolved = configurationSource.resolve(config)
74+
assert.strictEqual(
75+
resolved.endpoint.toString(),
76+
'http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server'
77+
)
78+
})
79+
80+
it('preserves an exact configured path and query', () => {
81+
config.experimental.flaggingProvider.agentlessBaseUrl = 'https://example.com/custom/ufc?tenant=one'
82+
83+
assert.strictEqual(
84+
configurationSource.resolve(config).endpoint.toString(),
85+
'https://example.com/custom/ufc?tenant=one'
86+
)
87+
})
88+
89+
it('derives the managed GovCloud endpoint without hard-coding availability', () => {
90+
config.site = 'DDOG-GOV.COM'
91+
config.env = 'prod'
92+
93+
const resolved = configurationSource.resolve(config)
94+
95+
assert.strictEqual(
96+
resolved.endpoint.toString(),
97+
'https://ufc-server.ff-cdn.ddog-gov.com/api/v2/feature-flagging/config/rules-based/server?dd_env=prod'
98+
)
99+
sinon.assert.notCalled(log.warn)
100+
})
101+
102+
it('allows an operator-owned agentless endpoint on GovCloud', () => {
103+
config.site = 'ddog-gov.com'
104+
config.experimental.flaggingProvider.agentlessBaseUrl = 'https://flags.example.test/custom/ufc?tenant=test'
105+
106+
const resolved = configurationSource.resolve(config)
107+
108+
assert.strictEqual(resolved.endpoint.toString(), 'https://flags.example.test/custom/ufc?tenant=test')
109+
sinon.assert.notCalled(log.warn)
110+
})
111+
112+
it('rejects non-HTTP endpoints', () => {
113+
config.experimental.flaggingProvider.agentlessBaseUrl = 'file:///tmp/ufc.json'
114+
115+
assert.throws(
116+
() => configurationSource.resolve(config),
117+
/must use HTTP or HTTPS/
118+
)
119+
})
120+
121+
it('recognizes explicit Remote Config without resolving agentless settings', () => {
122+
config.experimental.flaggingProvider.configurationSource = ' REMOTE_CONFIG '
123+
delete config.site
124+
125+
assert.deepStrictEqual(configurationSource.resolve(config), { mode: 'remote_config' })
126+
assert.strictEqual(configurationSource.isRemoteConfig(config), true)
127+
})
128+
129+
for (const value of ['offline', 'other']) {
130+
it(`fails closed for the unsupported ${value} source`, () => {
131+
config.experimental.flaggingProvider.configurationSource = value
132+
133+
assert.throws(() => configurationSource.resolve(config), /Unsupported Feature Flagging configuration source/)
134+
assert.strictEqual(configurationSource.isRemoteConfig(config), false)
135+
sinon.assert.calledOnce(log.error)
136+
})
137+
}
138+
139+
it('falls back to positive timing defaults with warnings', () => {
140+
config.experimental.flaggingProvider.agentlessPollIntervalSeconds = 0
141+
config.experimental.flaggingProvider.agentlessRequestTimeoutSeconds = -1
142+
143+
assert.strictEqual(configurationSource.isRemoteConfig(config), false)
144+
sinon.assert.notCalled(log.warn)
145+
146+
const resolved = configurationSource.resolve(config)
147+
148+
assert.strictEqual(resolved.pollIntervalMs, 30_000)
149+
assert.strictEqual(resolved.requestTimeoutMs, 2000)
150+
sinon.assert.calledTwice(log.warn)
151+
})
152+
153+
it('caps the polling interval at one hour', () => {
154+
config.experimental.flaggingProvider.agentlessPollIntervalSeconds = 4 * 60 * 60
155+
156+
const resolved = configurationSource.resolve(config)
157+
158+
assert.strictEqual(resolved.pollIntervalMs, 60 * 60 * 1000)
159+
sinon.assert.calledOnceWithExactly(
160+
log.warn,
161+
'Feature Flagging agentless %s %s exceeds the maximum of %ss; using %ss',
162+
'poll interval',
163+
4 * 60 * 60,
164+
60 * 60,
165+
60 * 60
166+
)
167+
})
168+
})

0 commit comments

Comments
 (0)