diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 76f97ada1d4..1ba8232482e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -417,6 +417,7 @@ /integration-tests/import-variants.spec.js @DataDog/lang-platform-js /integration-tests/init/ @DataDog/lang-platform-js /integration-tests/init.spec.js @DataDog/lang-platform-js +/integration-tests/helpers/fake-agent.js @DataDog/lang-platform-js @DataDog/feature-flagging-and-experimentation-sdk /integration-tests/memory-leak/ @DataDog/lang-platform-js /integration-tests/mocha-parallel-files-fixtures/ @DataDog/lang-platform-js /integration-tests/mocha-parallel-files.spec.js @DataDog/lang-platform-js diff --git a/integration-tests/helpers/fake-agent.js b/integration-tests/helpers/fake-agent.js index 3f98984e784..2de85ddd09a 100644 --- a/integration-tests/helpers/fake-agent.js +++ b/integration-tests/helpers/fake-agent.js @@ -31,6 +31,7 @@ module.exports = class FakeAgent extends EventEmitter { port = 0 advertiseDebuggerV2IntakeSupport = true debuggerV2IntakeStatusCode = 202 + evpProxyVersions = [2] /** @type {Set} */ #sockets = new Set() /** @type {Record} */ @@ -50,6 +51,9 @@ module.exports = class FakeAgent extends EventEmitter { if (options.debuggerV2IntakeStatusCode !== undefined) { this.debuggerV2IntakeStatusCode = options.debuggerV2IntakeStatusCode } + if (options.evpProxyVersions !== undefined) { + this.evpProxyVersions = [...options.evpProxyVersions] + } } /** @@ -376,7 +380,10 @@ function buildExpressServer (agent) { app.use(bodyParser.json({ limit: Infinity, type: 'application/json' })) app.get('/info', (req, res) => { - const endpoints = ['/evp_proxy/v2', '/debugger/v1/input'] + const endpoints = [ + ...agent.evpProxyVersions.map(version => `/evp_proxy/v${version}`), + '/debugger/v1/input', + ] if (agent.advertiseDebuggerV2IntakeSupport) { endpoints.push('/debugger/v2/input') } @@ -565,10 +572,14 @@ function buildExpressServer (agent) { }) }) - app.post('/evp_proxy/v2/api/v2/exposures', (req, res) => { + app.post([ + '/evp_proxy/v2/api/v2/exposures', + '/evp_proxy/v4/api/v2/exposures', + ], (req, res) => { res.status(200).send() agent.emit('exposures', { headers: req.headers, + path: req.path, payload: req.body, }) }) diff --git a/integration-tests/openfeature/openfeature-exposure-events.spec.js b/integration-tests/openfeature/openfeature-exposure-events.spec.js index ed1e047dbb9..3ee2059902f 100644 --- a/integration-tests/openfeature/openfeature-exposure-events.spec.js +++ b/integration-tests/openfeature/openfeature-exposure-events.spec.js @@ -54,7 +54,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { let agent, proc beforeEach(async () => { - agent = await new FakeAgent().start() + agent = await new FakeAgent(0, { evpProxyVersions: [2, 4] }).start() proc = await spawnProc(appFile, { cwd, env: { @@ -78,7 +78,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { let receivedAckUpdate = false // Listen for exposure events - agent.on('exposures', ({ payload, headers }) => { + agent.on('exposures', ({ payload, headers, path }) => { assert.ok(Object.hasOwn(payload, 'exposures'), `Available keys: ${inspect(Object.keys(payload))}`) assertObjectContains(payload, { context: { @@ -94,6 +94,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { try { assert.strictEqual(headers['content-type'], 'application/json') assert.strictEqual(headers['x-datadog-evp-subdomain'], 'event-platform-intake') + assert.strictEqual(path, '/evp_proxy/v2/api/v2/exposures') // Verify we got exposure events from flag evaluations assert.strictEqual(exposureEvents.length, 2) diff --git a/packages/dd-trace/src/openfeature/index.js b/packages/dd-trace/src/openfeature/index.js index 7cafbaec016..e3e030ac39a 100644 --- a/packages/dd-trace/src/openfeature/index.js +++ b/packages/dd-trace/src/openfeature/index.js @@ -3,7 +3,7 @@ const { channel } = require('dc-polyfill') const log = require('../log') const ExposuresWriter = require('./writers/exposures') -const { setAgentStrategy } = require('./writers/util') +const { setExposureDeliveryStrategy } = require('./writers/util') const exposureSubmitCh = channel('ffe:exposure:submit') const flushCh = channel('ffe:writers:flush') @@ -45,10 +45,10 @@ function enable (config) { exposureSubmitCh.subscribe(_handleExposureSubmit) flushCh.subscribe(_handleFlush) - setAgentStrategy(config, (hasAgent, route) => { + setExposureDeliveryStrategy(config, (enabled, route) => { if (exposuresWriter !== writer) return - writer.setEnabled(hasAgent, route) + writer.setEnabled(enabled, route) }) } diff --git a/packages/dd-trace/src/openfeature/writers/util.js b/packages/dd-trace/src/openfeature/writers/util.js index 0adc525ed14..a2026ef6930 100644 --- a/packages/dd-trace/src/openfeature/writers/util.js +++ b/packages/dd-trace/src/openfeature/writers/util.js @@ -1,20 +1,44 @@ 'use strict' -const logger = require('../../log') -const { EVP_PROXY_PATH_V2 } = require('../../evp_proxy/constants') +const { + EVP_EVENT_PLATFORM_SUBDOMAIN, + EVP_PROXY_PATH_V2, + EVP_PROXY_PATH_V4, + EVP_SUBDOMAIN_HEADER_NAME, +} = require('../../evp_proxy/constants') +const { createDirectEVPRoute } = require('../../evp_proxy/direct') const { discoverEVPProxy } = require('../../evp_proxy/discovery') +const logger = require('../../log') + +let missingRouteWarningLogged = false + +/** + * Logs the unavailable exposure-delivery warning once. + * + * @returns {void} + */ +function warnExposureDeliveryUnavailable () { + if (missingRouteWarningLogged) return + missingRouteWarningLogged = true + logger.warn( + 'Feature Flags exposure delivery is disabled because no compatible local EVP route or direct intake ' + + 'credentials are available.' + ) +} /** - * Determines if the agent supports EVP proxy and sets the writer enabled state accordingly + * Preserves Agent exposure delivery for the Remote Configuration source. + * * @param {import('../../config')} config - Tracer configuration object * @param {Function} setWriterEnabledValue - Callback to set the writer enabled state + * @returns {void} */ function setAgentStrategy (config, setWriterEnabledValue) { discoverEVPProxy(config.url, { supportedPaths: [EVP_PROXY_PATH_V2], - }, (err, route) => { - if (err) { - logger.debug('FFE Writer disabled - error getting agent info: %s', err.message) + }, (error, route) => { + if (error) { + logger.debug('FFE Writer disabled - error getting agent info: %s', error.message) setWriterEnabledValue(false) return } @@ -29,6 +53,67 @@ function setAgentStrategy (config, setWriterEnabledValue) { }) } -module.exports = { - setAgentStrategy, +/** + * Selects a local serverless receiver or authenticated direct intake. + * + * Local discovery is optional for delivery. A missing listener, discovery + * error, or incompatible receiver selects direct intake when credentials exist. + * + * @param {import('../../config')} config - Tracer configuration object + * @param {Function} setWriterEnabledValue - Callback to set the writer enabled state + * @returns {void} + */ +function setAgentlessStrategy (config, setWriterEnabledValue) { + const directRoute = createDirectEVPRoute(config, EVP_EVENT_PLATFORM_SUBDOMAIN) + + discoverEVPProxy(config.url, { + supportedPaths: [EVP_PROXY_PATH_V4, EVP_PROXY_PATH_V2], + }, (error, localRoute) => { + if (localRoute) { + const route = { + ...localRoute, + headers: { + [EVP_SUBDOMAIN_HEADER_NAME]: EVP_EVENT_PLATFORM_SUBDOMAIN, + }, + ...(directRoute && { fallback: directRoute }), + } + logger.debug('FFE Writer enabled with local EVP route %s', route.basePath) + setWriterEnabledValue(true, route) + return + } + + if (directRoute) { + if (error) { + logger.debug('FFE Writer using direct EVP intake after local discovery failed: %s', error.message) + } else { + logger.debug('FFE Writer using direct EVP intake because no compatible local route was advertised') + } + setWriterEnabledValue(true, directRoute) + return + } + + if (error) { + logger.debug('FFE Writer disabled - error getting local receiver info: %s', error.message) + } + warnExposureDeliveryUnavailable() + setWriterEnabledValue(false) + }) +} + +/** + * Applies the exposure-delivery strategy for the configured Feature Flags source. + * + * @param {import('../../config')} config - Tracer configuration object + * @param {Function} setWriterEnabledValue - Callback to set the writer enabled state + * @returns {void} + */ +function setExposureDeliveryStrategy (config, setWriterEnabledValue) { + if (config.featureFlags?.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE === 'agentless') { + setAgentlessStrategy(config, setWriterEnabledValue) + return + } + + setAgentStrategy(config, setWriterEnabledValue) } + +module.exports = { setExposureDeliveryStrategy } diff --git a/packages/dd-trace/test/openfeature/index.spec.js b/packages/dd-trace/test/openfeature/index.spec.js index a3d950ff5e1..bf0237365b7 100644 --- a/packages/dd-trace/test/openfeature/index.spec.js +++ b/packages/dd-trace/test/openfeature/index.spec.js @@ -14,7 +14,7 @@ describe('OpenFeature Module', () => { let openfeatureModule let mockWriter let ExposuresWriterStub - let setAgentStrategyStub + let setExposureDeliveryStrategyStub beforeEach(() => { config = { @@ -30,11 +30,11 @@ describe('OpenFeature Module', () => { } ExposuresWriterStub = sinon.stub().returns(mockWriter) - setAgentStrategyStub = sinon.stub() + setExposureDeliveryStrategyStub = sinon.stub() openfeatureModule = proxyquire('../../src/openfeature', { './writers/exposures': ExposuresWriterStub, - './writers/util': { setAgentStrategy: setAgentStrategyStub }, + './writers/util': { setExposureDeliveryStrategy: setExposureDeliveryStrategyStub }, }) }) @@ -52,17 +52,17 @@ describe('OpenFeature Module', () => { openfeatureModule.enable(config) sinon.assert.calledOnceWithExactly(ExposuresWriterStub, config) - sinon.assert.calledOnce(setAgentStrategyStub) + sinon.assert.calledOnce(setExposureDeliveryStrategyStub) }) - it('passes the discovered route to the writer', () => { + it('configures the writer with the selected exposure route', () => { + openfeatureModule.enable(config) + const setWriterEnabled = setExposureDeliveryStrategyStub.firstCall.args[1] const route = { - url: new URL('http://localhost:8126'), - basePath: '/evp_proxy/v2', + url: new URL('http://serverless-init:8126'), + basePath: '/evp_proxy/v4', } - setAgentStrategyStub.callsArgWith(1, true, route) - - openfeatureModule.enable(config) + setWriterEnabled(true, route) sinon.assert.calledOnceWithExactly(mockWriter.setEnabled, true, route) }) @@ -85,10 +85,10 @@ describe('OpenFeature Module', () => { ExposuresWriterStub.onSecondCall().returns(replacementWriter) openfeatureModule.enable(config) - const staleCallback = setAgentStrategyStub.firstCall.args[1] + const staleCallback = setExposureDeliveryStrategyStub.firstCall.args[1] openfeatureModule.disable() openfeatureModule.enable(config) - const currentCallback = setAgentStrategyStub.secondCall.args[1] + const currentCallback = setExposureDeliveryStrategyStub.secondCall.args[1] staleCallback(true, staleRoute) sinon.assert.notCalled(replacementWriter.setEnabled) diff --git a/packages/dd-trace/test/openfeature/writers/exposures-transport.spec.js b/packages/dd-trace/test/openfeature/writers/exposures-transport.spec.js new file mode 100644 index 00000000000..a17aa151d25 --- /dev/null +++ b/packages/dd-trace/test/openfeature/writers/exposures-transport.spec.js @@ -0,0 +1,196 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it, afterEach } = require('mocha') +const nock = require('nock') + +require('../../setup/core') +const { clearCache } = require('../../../src/agent/info') +const ExposuresWriter = require('../../../src/openfeature/writers/exposures') +const { setExposureDeliveryStrategy } = require('../../../src/openfeature/writers/util') + +describe('OpenFeature Exposures Writer transport', () => { + let writer + + afterEach(() => { + writer?.destroy() + clearCache() + nock.cleanAll() + }) + + it('should use local EVP when allowed headers omit the Agent-consumed routing header', async () => { + const config = { + url: new URL('http://localhost:8126'), + site: 'datadoghq.com', + DD_API_KEY: 'test-api-key', + service: 'test-service', + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + } + const infoRequest = nock('http://localhost:8126') + .get('/info') + .reply(200, { + endpoints: ['/evp_proxy/v4/', '/evp_proxy/v2/'], + evp_proxy_allowed_headers: ['Content-Type', 'Accept-Encoding'], + }) + + const requestReceived = new Promise((resolve, reject) => { + nock('http://localhost:8126', { + reqheaders: { + 'content-type': 'application/json', + 'x-datadog-evp-subdomain': 'event-platform-intake', + }, + }) + .post('/evp_proxy/v4/api/v2/exposures') + .reply(202, (uri, body) => { + try { + assert.strictEqual(uri, '/evp_proxy/v4/api/v2/exposures') + assert.strictEqual(body.context.service, 'test-service') + assert.strictEqual(body.exposures.length, 1) + assert.strictEqual(body.exposures[0].flag.key, 'checkout') + resolve() + } catch (error) { + reject(error) + } + return '' + }) + }) + + writer = new ExposuresWriter(config) + await new Promise((resolve, reject) => { + setExposureDeliveryStrategy(config, (enabled, route) => { + try { + assert.strictEqual(enabled, true) + assert.strictEqual(route.basePath, '/evp_proxy/v4') + writer.setEnabled(enabled, route) + resolve() + } catch (error) { + reject(error) + } + }) + }) + infoRequest.done() + + writer.append({ + timestamp: 1672531200000, + allocation: { key: 'allocation' }, + flag: { key: 'checkout' }, + variant: { key: 'enabled' }, + subject: { id: 'customer-1' }, + }) + writer.flush() + + await requestReceived + }) + + it('should retry direct after a local EVP 405 response', async () => { + const config = { + url: new URL('http://localhost:8126'), + site: 'datadoghq.com', + DD_API_KEY: 'test-api-key', + service: 'test-service', + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + } + const infoRequest = nock('http://localhost:8126') + .get('/info') + .reply(200, { endpoints: ['/evp_proxy/v4'] }) + const localRequest = nock('http://localhost:8126') + .post('/evp_proxy/v4/api/v2/exposures') + .reply(405) + + const directRequestReceived = new Promise(resolve => { + nock('https://event-platform-intake.datadoghq.com', { + reqheaders: { + 'content-type': 'application/json', + 'dd-api-key': 'test-api-key', + }, + }) + .post('/api/v2/exposures') + .reply(202, () => { + resolve() + return '' + }) + }) + + writer = new ExposuresWriter(config) + await new Promise((resolve, reject) => { + setExposureDeliveryStrategy(config, (enabled, route) => { + try { + assert.strictEqual(enabled, true) + assert.strictEqual(route.basePath, '/evp_proxy/v4') + assert.strictEqual(route.fallback.basePath, '') + writer.setEnabled(enabled, route) + resolve() + } catch (error) { + reject(error) + } + }) + }) + infoRequest.done() + + writer.append({ + timestamp: 1672531200000, + allocation: { key: 'allocation' }, + flag: { key: 'checkout' }, + variant: { key: 'enabled' }, + subject: { id: 'customer-1' }, + }) + writer.flush() + + await directRequestReceived + localRequest.done() + }) + + it('should send direct when no local receiver is listening', async () => { + const config = { + url: new URL('http://127.0.0.1:9'), + site: 'datadoghq.com', + DD_API_KEY: 'test-api-key', + service: 'test-service', + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + } + const infoRequest = nock('http://127.0.0.1:9') + .get('/info') + .replyWithError(Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' })) + + const directRequestReceived = new Promise(resolve => { + nock('https://event-platform-intake.datadoghq.com', { + reqheaders: { + 'content-type': 'application/json', + 'dd-api-key': 'test-api-key', + }, + }) + .post('/api/v2/exposures') + .reply(202, () => { + resolve() + return '' + }) + }) + + writer = new ExposuresWriter(config) + await new Promise((resolve, reject) => { + setExposureDeliveryStrategy(config, (enabled, route) => { + try { + assert.strictEqual(enabled, true) + assert.strictEqual(route.basePath, '') + writer.setEnabled(enabled, route) + resolve() + } catch (error) { + reject(error) + } + }) + }) + infoRequest.done() + + writer.append({ + timestamp: 1672531200000, + allocation: { key: 'allocation' }, + flag: { key: 'checkout' }, + variant: { key: 'enabled' }, + subject: { id: 'customer-1' }, + }) + writer.flush() + + await directRequestReceived + }) +}) diff --git a/packages/dd-trace/test/openfeature/writers/util.spec.js b/packages/dd-trace/test/openfeature/writers/util.spec.js index ebf5bcb0e84..a4d4de7edaf 100644 --- a/packages/dd-trace/test/openfeature/writers/util.spec.js +++ b/packages/dd-trace/test/openfeature/writers/util.spec.js @@ -1,61 +1,183 @@ 'use strict' const assert = require('node:assert/strict') +const { format } = require('node:util') const { describe, it, beforeEach } = require('mocha') -const proxyquire = require('proxyquire') +const proxyquire = require('proxyquire').noPreserveCache() const sinon = require('sinon') -describe('OpenFeature writer strategy', () => { - let config +require('../../setup/core') + +describe('OpenFeature exposure delivery strategy', () => { + let createDirectEVPRoute let discoverEVPProxy let log - let setAgentStrategy + let setExposureDeliveryStrategy + let setWriterEnabledValue beforeEach(() => { - config = { url: new URL('http://localhost:8126') } + createDirectEVPRoute = sinon.stub() discoverEVPProxy = sinon.stub() - log = { debug: sinon.stub() } + log = { + debug: sinon.spy(), + warn: sinon.spy(), + } + setWriterEnabledValue = sinon.spy() - ;({ setAgentStrategy } = proxyquire('../../../src/openfeature/writers/util', { + ;({ setExposureDeliveryStrategy } = proxyquire('../../../src/openfeature/writers/util', { + '../../evp_proxy/direct': { createDirectEVPRoute }, '../../evp_proxy/discovery': { discoverEVPProxy }, '../../log': log, })) }) - it('discovers Agent EVP v2 and returns a plain route', () => { + it('preserves Agent EVP v2 discovery for Remote Configuration', () => { + const config = { + url: new URL('http://localhost:8126'), + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config' }, + } const route = { url: config.url, basePath: '/evp_proxy/v2', } discoverEVPProxy.yields(null, route) - const callback = sinon.stub() - setAgentStrategy(config, callback) + setExposureDeliveryStrategy(config, setWriterEnabledValue) sinon.assert.calledOnceWithExactly(discoverEVPProxy, config.url, { supportedPaths: ['/evp_proxy/v2'], }, sinon.match.func) - sinon.assert.calledOnceWithExactly(callback, true, route) + sinon.assert.calledOnceWithExactly(setWriterEnabledValue, true, route) + sinon.assert.notCalled(createDirectEVPRoute) + }) + + it('disables Remote Configuration exposure delivery when discovery fails', () => { + discoverEVPProxy.yields(new Error('Agent unavailable')) + + setExposureDeliveryStrategy({ + url: new URL('http://localhost:8126'), + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'remote_config' }, + }, setWriterEnabledValue) + + sinon.assert.calledOnceWithExactly(setWriterEnabledValue, false) + assert.match(log.debug.firstCall.args[0], /error getting agent info/) + }) + + it('prefers an advertised agentless EVP v4 route and keeps direct fallback', () => { + const config = { + url: new URL('http://serverless-init:8126'), + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + } + const localRoute = { + url: config.url, + basePath: '/evp_proxy/v4', + } + const directRoute = { + url: new URL('https://event-platform-intake.datadoghq.com'), + basePath: '', + headers: { 'DD-API-KEY': 'test-api-key' }, + } + createDirectEVPRoute.returns(directRoute) + discoverEVPProxy.yields(null, localRoute) + + setExposureDeliveryStrategy(config, setWriterEnabledValue) + + sinon.assert.calledOnceWithExactly(discoverEVPProxy, config.url, { + supportedPaths: ['/evp_proxy/v4', '/evp_proxy/v2'], + }, sinon.match.func) + sinon.assert.calledOnceWithExactly(setWriterEnabledValue, true, { + ...localRoute, + headers: { + 'X-Datadog-EVP-Subdomain': 'event-platform-intake', + }, + fallback: directRoute, + }) + }) + + it('accepts an advertised agentless route without requiring forwarded routing headers', () => { + const config = { + url: new URL('http://serverless-init:8126'), + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + } + discoverEVPProxy.yields(null, { + url: config.url, + basePath: '/evp_proxy/v2', + }) + + setExposureDeliveryStrategy(config, setWriterEnabledValue) + + sinon.assert.calledOnceWithExactly(discoverEVPProxy, config.url, { + supportedPaths: ['/evp_proxy/v4', '/evp_proxy/v2'], + }, sinon.match.func) + sinon.assert.calledOnceWithMatch(setWriterEnabledValue, true, { + basePath: '/evp_proxy/v2', + }) }) - it('disables exposure delivery when no compatible route exists', () => { + it('uses direct intake when no compatible local route is advertised', () => { + const config = { + url: new URL('http://localhost:8126'), + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + } + const directRoute = { + url: new URL('https://event-platform-intake.datadoghq.com'), + basePath: '', + headers: { 'DD-API-KEY': 'test-api-key' }, + } + createDirectEVPRoute.returns(directRoute) discoverEVPProxy.yields(null) - const callback = sinon.stub() - setAgentStrategy(config, callback) + setExposureDeliveryStrategy(config, setWriterEnabledValue) - sinon.assert.calledOnceWithExactly(callback, false) + sinon.assert.calledOnceWithExactly(setWriterEnabledValue, true, directRoute) + sinon.assert.notCalled(log.warn) }) - it('disables exposure delivery when discovery fails', () => { - const expectedError = new Error('Agent unavailable') - discoverEVPProxy.yields(expectedError) - const callback = sinon.stub() + it('uses direct intake when no local receiver is listening', () => { + const config = { + url: new URL('http://127.0.0.1:9'), + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + } + const directRoute = { + url: new URL('https://event-platform-intake.datadoghq.com'), + basePath: '', + headers: { 'DD-API-KEY': 'test-api-key' }, + } + createDirectEVPRoute.returns(directRoute) + discoverEVPProxy.yields(new Error('connect ECONNREFUSED')) - setAgentStrategy(config, callback) + setExposureDeliveryStrategy(config, setWriterEnabledValue) - sinon.assert.calledOnceWithExactly(callback, false) - assert.match(log.debug.firstCall.args[0], /error getting agent info/) + sinon.assert.calledOnceWithExactly(setWriterEnabledValue, true, directRoute) + sinon.assert.notCalled(log.warn) + assert.match(format(...log.debug.firstCall.args), /local discovery failed/) + }) + + it('disables delivery and warns when no local route or direct credentials exist', () => { + discoverEVPProxy.yields(new Error('connect ECONNREFUSED')) + + setExposureDeliveryStrategy({ + url: new URL('http://127.0.0.1:9'), + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + }, setWriterEnabledValue) + + sinon.assert.calledOnceWithExactly(setWriterEnabledValue, false) + sinon.assert.calledOnce(log.warn) + assert.match(format(...log.warn.firstCall.args), /direct intake credentials/) + }) + + it('logs the unavailable exposure-delivery warning once', () => { + discoverEVPProxy.yields(new Error('connect ECONNREFUSED')) + const config = { + url: new URL('http://127.0.0.1:9'), + featureFlags: { DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: 'agentless' }, + } + + setExposureDeliveryStrategy(config, setWriterEnabledValue) + setExposureDeliveryStrategy(config, setWriterEnabledValue) + + sinon.assert.calledTwice(setWriterEnabledValue) + sinon.assert.calledOnce(log.warn) }) })