Skip to content

Commit b99ca54

Browse files
committed
feat(openfeature): activate agentless delivery
Expose the configuration surface, attach the selected source to the provider lifecycle, and keep Agent Remote Configuration behind explicit opt-in. Pair the activation path with config, provider, shutdown, and packaged-application tests.
1 parent b83caab commit b99ca54

16 files changed

Lines changed: 451 additions & 7 deletions

index.d.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -800,7 +800,6 @@ declare namespace tracer {
800800
flaggingProvider?: {
801801
/**
802802
* Whether to enable the feature flagging provider.
803-
* Requires Remote Config to be properly configured.
804803
* Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED environment variable.
805804
*
806805
* @default false
@@ -818,6 +817,39 @@ declare namespace tracer {
818817
* Programmatic configuration takes precedence over the environment variables listed above.
819818
*/
820819
initializationTimeoutMs?: number
820+
/**
821+
* Where Universal Flag Configuration is loaded from. Agentless delivery
822+
* fetches from the Datadog UFC CDN endpoint and evaluates locally.
823+
*
824+
* @default 'agentless'
825+
* @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE
826+
* Programmatic configuration takes precedence over the environment variables listed above.
827+
*/
828+
configurationSource?: 'agentless' | 'remote_config'
829+
/**
830+
* Optional agentless configuration endpoint or base URL. A base URL
831+
* receives the standard rules-based server path.
832+
*
833+
* @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL
834+
* Programmatic configuration takes precedence over the environment variables listed above.
835+
*/
836+
agentlessBaseUrl?: string
837+
/**
838+
* Agentless configuration polling interval in seconds, capped at one hour.
839+
*
840+
* @default 30
841+
* @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS
842+
* Programmatic configuration takes precedence over the environment variables listed above.
843+
*/
844+
agentlessPollIntervalSeconds?: number
845+
/**
846+
* Agentless configuration request timeout in seconds.
847+
*
848+
* @default 2
849+
* @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS
850+
* Programmatic configuration takes precedence over the environment variables listed above.
851+
*/
852+
agentlessRequestTimeoutSeconds?: number
821853
/**
822854
* Configuration for span enrichment with feature flag evaluation data.
823855
*/

index.d.v5.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,6 @@ declare namespace tracer {
870870
flaggingProvider?: {
871871
/**
872872
* Whether to enable the feature flagging provider.
873-
* Requires Remote Config to be properly configured.
874873
* Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED environment variable.
875874
*
876875
* @default false
@@ -888,6 +887,39 @@ declare namespace tracer {
888887
* Programmatic configuration takes precedence over the environment variables listed above.
889888
*/
890889
initializationTimeoutMs?: number
890+
/**
891+
* Where Universal Flag Configuration is loaded from. Agentless delivery
892+
* fetches from the Datadog UFC CDN endpoint and evaluates locally.
893+
*
894+
* @default 'agentless'
895+
* @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE
896+
* Programmatic configuration takes precedence over the environment variables listed above.
897+
*/
898+
configurationSource?: 'agentless' | 'remote_config'
899+
/**
900+
* Optional agentless configuration endpoint or base URL. A base URL
901+
* receives the standard rules-based server path.
902+
*
903+
* @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL
904+
* Programmatic configuration takes precedence over the environment variables listed above.
905+
*/
906+
agentlessBaseUrl?: string
907+
/**
908+
* Agentless configuration polling interval in seconds, capped at one hour.
909+
*
910+
* @default 30
911+
* @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS
912+
* Programmatic configuration takes precedence over the environment variables listed above.
913+
*/
914+
agentlessPollIntervalSeconds?: number
915+
/**
916+
* Agentless configuration request timeout in seconds.
917+
*
918+
* @default 2
919+
* @env DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS
920+
* Programmatic configuration takes precedence over the environment variables listed above.
921+
*/
922+
agentlessRequestTimeoutSeconds?: number
891923
/**
892924
* Configuration for span enrichment with feature flag evaluation data.
893925
*/
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use strict'
2+
3+
const tracer = require('dd-trace')
4+
const http = require('node:http')
5+
const { OpenFeature } = require('@openfeature/server-sdk')
6+
7+
tracer.init({
8+
env: 'integration',
9+
service: 'ffe-agentless-integration',
10+
})
11+
12+
OpenFeature.setProvider(tracer.openfeature)
13+
const client = OpenFeature.getClient()
14+
15+
const server = http.createServer((request, response) => {
16+
if (request.url !== '/evaluate') {
17+
response.writeHead(404).end()
18+
return
19+
}
20+
21+
client.getStringDetails('agentless-integration-flag', 'default', {
22+
targetingKey: 'integration-user',
23+
}).then(details => {
24+
response.setHeader('Content-Type', 'application/json')
25+
response.end(JSON.stringify(details))
26+
}, error => {
27+
response.writeHead(500, { 'Content-Type': 'application/json' })
28+
response.end(JSON.stringify({ error: error.message }))
29+
})
30+
})
31+
32+
server.listen(0, function () {
33+
process.send?.({ port: this.address().port })
34+
})
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use strict'
2+
3+
const assert = require('node:assert/strict')
4+
const http = require('node:http')
5+
const path = require('node:path')
6+
const zlib = require('node:zlib')
7+
const { afterEach, before, beforeEach, describe, it } = require('mocha')
8+
const { sandboxCwd, useSandbox, spawnProc, stopProc } = require('../helpers')
9+
10+
const UFC = {
11+
createdAt: '2026-01-01T00:00:00.000Z',
12+
environment: { name: 'integration' },
13+
flags: {
14+
'agentless-integration-flag': {
15+
key: 'agentless-integration-flag',
16+
enabled: true,
17+
variationType: 'STRING',
18+
variations: {
19+
local: { key: 'local', value: 'loaded-from-agentless' },
20+
},
21+
allocations: [
22+
{
23+
key: 'agentless-integration-allocation',
24+
splits: [{ variationKey: 'local', shards: [] }],
25+
doLog: false,
26+
},
27+
],
28+
},
29+
},
30+
}
31+
32+
describe('OpenFeature agentless configuration integration', () => {
33+
let appFile
34+
let backend
35+
let backendUrl
36+
let cwd
37+
let observedRequests
38+
let proc
39+
40+
useSandbox(
41+
['@openfeature/server-sdk', '@openfeature/core'],
42+
false,
43+
[path.join(__dirname, 'app')]
44+
)
45+
46+
before(() => {
47+
cwd = sandboxCwd()
48+
appFile = path.join(cwd, 'app', 'agentless-evaluation.js')
49+
})
50+
51+
beforeEach(async () => {
52+
observedRequests = []
53+
backend = http.createServer((request, response) => {
54+
observedRequests.push({
55+
url: request.url,
56+
headers: request.headers,
57+
})
58+
59+
if (request.headers['if-none-match'] === '"agentless-integration"') {
60+
response.writeHead(304).end()
61+
return
62+
}
63+
64+
response.writeHead(200, {
65+
'Content-Type': 'application/json',
66+
'Content-Encoding': 'gzip',
67+
ETag: '"agentless-integration"',
68+
})
69+
const body = JSON.stringify({
70+
data: {
71+
id: '1',
72+
type: 'universal-flag-configuration',
73+
attributes: UFC,
74+
},
75+
})
76+
response.end(zlib.gzipSync(body))
77+
})
78+
await new Promise((resolve, reject) => {
79+
backend.once('error', reject)
80+
backend.listen(0, '127.0.0.1', resolve)
81+
})
82+
backendUrl = `http://127.0.0.1:${backend.address().port}`
83+
84+
proc = await spawnProc(appFile, {
85+
cwd,
86+
env: {
87+
DD_API_KEY: 'integration-api-key',
88+
DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true',
89+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: backendUrl,
90+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: '5',
91+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: '1',
92+
DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'false',
93+
DD_REMOTE_CONFIGURATION_ENABLED: 'false',
94+
},
95+
})
96+
})
97+
98+
afterEach(async () => {
99+
await stopProc(proc)
100+
await new Promise(resolve => backend.close(resolve))
101+
})
102+
103+
it('loads UFC from the default agentless source and evaluates locally', async () => {
104+
let details
105+
for (let attempt = 0; attempt < 50; attempt++) {
106+
const response = await fetch(`${proc.url}/evaluate`)
107+
details = await response.json()
108+
if (details.value === 'loaded-from-agentless') break
109+
await new Promise(resolve => setTimeout(resolve, 20))
110+
}
111+
112+
assert.strictEqual(details.value, 'loaded-from-agentless')
113+
assert.notStrictEqual(details.reason, 'ERROR')
114+
115+
for (let attempt = 0; observedRequests.length < 2 && attempt < 350; attempt++) {
116+
await new Promise(resolve => setTimeout(resolve, 20))
117+
}
118+
119+
assert.ok(observedRequests.length >= 2)
120+
assert.strictEqual(
121+
observedRequests[0].url,
122+
'/api/v2/feature-flagging/config/rules-based/server'
123+
)
124+
assert.strictEqual(observedRequests[0].headers['dd-api-key'], 'integration-api-key')
125+
assert.strictEqual(observedRequests[0].headers['accept-encoding'], 'gzip')
126+
assert.strictEqual(observedRequests[0].headers['dd-flagging-source-mode'], undefined)
127+
assert.strictEqual(observedRequests[1].headers['if-none-match'], '"agentless-integration"')
128+
})
129+
})

integration-tests/openfeature/openfeature-exposure-events.spec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => {
6161
DD_TRACE_AGENT_PORT: agent.port,
6262
DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1',
6363
DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true',
64+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config',
6465
},
6566
})
6667
})
@@ -160,6 +161,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => {
160161
DD_TRACE_AGENT_PORT: agent.port,
161162
DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1',
162163
DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true',
164+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config',
163165
},
164166
})
165167
})
@@ -243,6 +245,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => {
243245
DD_TRACE_AGENT_PORT: agent.port,
244246
DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1',
245247
DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true',
248+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config',
246249
},
247250
})
248251
})

packages/dd-trace/src/config/generated-config-types.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,10 @@ export interface GeneratedConfig {
424424
enableGetRumData: boolean;
425425
exporter: string;
426426
flaggingProvider: {
427+
agentlessBaseUrl: string | undefined;
428+
agentlessPollIntervalSeconds: number;
429+
agentlessRequestTimeoutSeconds: number;
430+
configurationSource: string;
427431
enabled: boolean;
428432
initializationTimeoutMs: number;
429433
spanEnrichment: {
@@ -688,6 +692,10 @@ export interface GeneratedEnvVarConfig {
688692
DD_EXPERIMENTAL_TEST_OPT_VITEST_NO_WORKER_INIT: boolean | undefined;
689693
DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE: boolean;
690694
DD_EXTERNAL_ENV: string | undefined;
695+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: string;
696+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: string | undefined;
697+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: number;
698+
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: number;
691699
DD_GIT_BRANCH: string | undefined;
692700
DD_GIT_COMMIT_AUTHOR_DATE: string | undefined;
693701
DD_GIT_COMMIT_AUTHOR_EMAIL: string | undefined;

packages/dd-trace/src/config/supported-configurations.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,46 @@
827827
"default": "false"
828828
}
829829
],
830+
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": [
831+
{
832+
"implementation": "A",
833+
"type": "string",
834+
"configurationNames": [
835+
"experimental.flaggingProvider.configurationSource"
836+
],
837+
"default": "agentless"
838+
}
839+
],
840+
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": [
841+
{
842+
"implementation": "A",
843+
"type": "string",
844+
"configurationNames": [
845+
"experimental.flaggingProvider.agentlessBaseUrl"
846+
],
847+
"default": null
848+
}
849+
],
850+
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": [
851+
{
852+
"implementation": "A",
853+
"type": "int",
854+
"configurationNames": [
855+
"experimental.flaggingProvider.agentlessPollIntervalSeconds"
856+
],
857+
"default": "30"
858+
}
859+
],
860+
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": [
861+
{
862+
"implementation": "A",
863+
"type": "int",
864+
"configurationNames": [
865+
"experimental.flaggingProvider.agentlessRequestTimeoutSeconds"
866+
],
867+
"default": "2"
868+
}
869+
],
830870
"DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [
831871
{
832872
"implementation": "B",

packages/dd-trace/src/openfeature/configuration_source.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,34 @@ function resolve (config) {
4242
}
4343
}
4444

45+
/**
46+
* Starts the selected first-party configuration source.
47+
*
48+
* Remote Config is installed separately because its lifecycle is owned by the
49+
* tracer Remote Config client.
50+
*
51+
* @param {import('../config/config-base')} config - Tracer configuration.
52+
* @param {Function} getOpenfeatureProxy - Returns the active provider.
53+
* @returns {void}
54+
*/
55+
function enable (config, getOpenfeatureProxy) {
56+
let sourceConfig
57+
try {
58+
sourceConfig = resolve(config)
59+
} catch (error) {
60+
log.error('Unable to configure Feature Flagging configuration source', error)
61+
return
62+
}
63+
64+
if (sourceConfig.mode === CONFIGURATION_SOURCE_AGENTLESS) {
65+
const AgentlessConfigurationSource = require('./agentless_configuration_source')
66+
const source = new AgentlessConfigurationSource(sourceConfig, ufc => {
67+
getOpenfeatureProxy()._setConfiguration(ufc)
68+
})
69+
getOpenfeatureProxy()._setConfigurationSource(source)
70+
}
71+
}
72+
4573
/**
4674
* Reports whether the explicit Remote Config source is selected.
4775
*
@@ -148,6 +176,7 @@ function positiveMilliseconds (value, fallbackSeconds, setting, maximumSeconds)
148176
}
149177

150178
module.exports = {
179+
enable,
151180
isRemoteConfig,
152181
resolve,
153182
}

0 commit comments

Comments
 (0)