Skip to content

Commit 59ba684

Browse files
committed
refactor(exporters): share EVP proxy utilities
Add shared EVP protocol constants, route discovery, and path construction.\n\nRefactor the existing Feature Flags Agent path to use the shared utilities. Add migration pointers for CI Visibility and LLM Observability. Document the Agent-compatible receiver contract.
1 parent d820eae commit 59ba684

19 files changed

Lines changed: 513 additions & 42 deletions

File tree

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,12 +383,14 @@
383383
/packages/dd-trace/src/feature-registry.js @DataDog/lang-platform-js
384384
/packages/dd-trace/src/exporters/common/ @DataDog/lang-platform-js
385385
/packages/dd-trace/src/exporters/common/client-library-headers.js @DataDog/lang-platform-js @DataDog/feature-flagging-and-experimentation-sdk
386+
/packages/dd-trace/src/evp_proxy/ @DataDog/lang-platform-js
386387
/packages/dd-trace/src/guardrails/ @DataDog/lang-platform-js
387388
/packages/dd-trace/src/proxy.js @DataDog/lang-platform-js
388389
/packages/dd-trace/test/agent/ @DataDog/lang-platform-js
389390
/packages/dd-trace/test/dd-trace.spec.js @DataDog/lang-platform-js
390391
/packages/dd-trace/test/dogstatsd.spec.js @DataDog/lang-platform-js
391392
/packages/dd-trace/test/encode/ @DataDog/lang-platform-js
393+
/packages/dd-trace/test/evp_proxy/ @DataDog/lang-platform-js
392394
/packages/dd-trace/test/esm-named-exports.spec.js @DataDog/lang-platform-js
393395
/packages/dd-trace/test/exporter.spec.js @DataDog/lang-platform-js
394396
/packages/dd-trace/test/exporters/ @DataDog/lang-platform-js

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@
5757
"test:core:ci": "node scripts/c8-ci.js test:core",
5858
"test:code-origin": "mocha \"packages/datadog-code-origin/test/**/*.spec.js\"",
5959
"test:code-origin:ci": "node scripts/c8-ci.js test:code-origin",
60+
"test:evp_proxy": "mocha \"packages/dd-trace/test/evp_proxy/**/*.spec.js\"",
6061
"test:lambda": "mocha \"packages/dd-trace/test/lambda/**/*.spec.js\"",
6162
"test:lambda:ci": "node scripts/c8-ci.js test:lambda",
6263
"test:llmobs:sdk": "mocha --exclude \"packages/dd-trace/test/llmobs/plugins/**/*.spec.js\" \"packages/dd-trace/test/llmobs/**/*.spec.js\"",
6364
"test:llmobs:sdk:ci": "node scripts/c8-ci.js test:llmobs:sdk",
6465
"test:llmobs:plugins": "mocha \"packages/dd-trace/test/llmobs/plugins/@(${PLUGINS})/*.spec.js\"",
6566
"test:llmobs:plugins:ci": "yarn services && node scripts/c8-ci.js test:llmobs:plugins",
66-
"test:openfeature": "mocha \"packages/dd-trace/test/openfeature/**/*.spec.js\"",
67+
"test:openfeature": "mocha \"packages/dd-trace/test/evp_proxy/**/*.spec.js\" \"packages/dd-trace/test/openfeature/**/*.spec.js\"",
6768
"test:openfeature:ci": "node scripts/c8-ci.js test:openfeature",
6869
"test:plugins": "node --expose-gc ./node_modules/mocha/bin/mocha.js \"packages/datadog-plugin-@(${PLUGINS})/test/**/${SPEC:-*}*.spec.js\"",
6970
"test:plugins:ci": "yarn services && node scripts/c8-ci.js test:plugins",

packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const CiVisibilityExporter = require('../ci-visibility-exporter')
77
const { fetchAgentInfo } = require('../../../agent/info')
88
const { DEBUGGER_INPUT_V1 } = require('../../../debugger/constants')
99

10+
// TODO: Use the shared utilities in src/evp_proxy when this product migrates its EVP version policy.
1011
const AGENT_EVP_PROXY_PATH_PREFIX = '/evp_proxy/v'
1112
const AGENT_EVP_PROXY_PATH_REGEX = /\/evp_proxy\/v(\d+)\/?/
1213

packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Writer extends BaseWriter {
4040
if (this._evpProxyPrefix) {
4141
options.path = `${this._evpProxyPrefix}/api/v2/citestcycle`
4242
delete options.headers['dd-api-key']
43+
// TODO: Migrate CI Visibility EVP headers and subdomains to the shared utilities in src/evp_proxy.
4344
options.headers['X-Datadog-EVP-Subdomain'] = 'citestcycle-intake'
4445
}
4546

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use strict'
2+
3+
module.exports = {
4+
EVP_PROXY_PATH_V2: '/evp_proxy/v2',
5+
EVP_PROXY_PATH_V4: '/evp_proxy/v4',
6+
EVP_SUBDOMAIN_HEADER_NAME: 'X-Datadog-EVP-Subdomain',
7+
EVP_EVENT_PLATFORM_SUBDOMAIN: 'event-platform-intake',
8+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
'use strict'
2+
3+
const { fetchAgentInfo } = require('../agent/info')
4+
const log = require('../log')
5+
6+
const TRAILING_SLASHES = /\/+$/
7+
8+
/**
9+
* Receiver discovery contract
10+
*
11+
* The tracer sends `GET /info` to its configured local Agent URL. An
12+
* Agent-compatible trace receiver produces the response. The tracer does not.
13+
*
14+
* The full Agent and serverless-init embed the same trace receiver. A future
15+
* in-process receiver can expose the same contract. Callers can therefore
16+
* select capabilities without detecting the receiver implementation.
17+
*
18+
* For EVP discovery, `endpoints` advertises registered proxy paths.
19+
* `evp_proxy_allowed_headers` advertises headers that the proxy forwards to
20+
* intake. It does not include routing headers that the proxy consumes, such as
21+
* `X-Datadog-EVP-Subdomain`.
22+
*
23+
* An advertised route is not a health check. The receiver can register an EVP
24+
* route while configuration disables its handler. The request then returns
25+
* `405`. The `/info` version also identifies the embedded Agent code, not a
26+
* serverless-init image or deployment type.
27+
*
28+
* This module only discovers a candidate route. A missing or unresponsive
29+
* `/info` endpoint returns an error through the shared request timeout and
30+
* retry policy. A valid response without a compatible path returns no route.
31+
* Discovery sends no events, so the caller can safely select direct intake
32+
* after either result. The caller also owns later delivery failures. It can
33+
* switch future batches after an ambiguous timeout or reset, but it must not
34+
* replay the current batch because the first receiver might have accepted it.
35+
*
36+
* Reference implementations:
37+
*
38+
* Agent `/info` and EVP proxy:
39+
* https://github.com/DataDog/datadog-agent/tree/main/pkg/trace/api
40+
*
41+
* serverless-init entry point and embedded trace receiver:
42+
* https://github.com/DataDog/datadog-agent/blob/main/cmd/serverless-init/main.go
43+
* https://github.com/DataDog/datadog-agent/blob/main/pkg/serverless/trace/trace.go
44+
*/
45+
46+
/**
47+
* Selects the first advertised EVP proxy path that the caller supports.
48+
*
49+
* @param {object} agentInfo - Agent `/info` response
50+
* @param {object} options - Selection options
51+
* @param {string[]} options.supportedPaths - Supported paths in preference order
52+
* @param {string[]} [options.requiredHeaders] - Headers that the proxy must forward to intake. Do not include
53+
* routing headers that the Agent consumes, such as X-Datadog-EVP-Subdomain.
54+
* @returns {string|undefined} Selected normalized path
55+
*/
56+
function selectEVPProxyPath (agentInfo, { supportedPaths, requiredHeaders = [] } = {}) {
57+
if (!Array.isArray(agentInfo?.endpoints) ||
58+
!Array.isArray(supportedPaths) ||
59+
!Array.isArray(requiredHeaders) ||
60+
requiredHeaders.some(header => typeof header !== 'string')) {
61+
return
62+
}
63+
64+
const allowedHeaders = agentInfo.evp_proxy_allowed_headers
65+
if (allowedHeaders !== undefined) {
66+
if (!Array.isArray(allowedHeaders)) return
67+
68+
const normalizedHeaders = new Set()
69+
for (const header of allowedHeaders) {
70+
if (typeof header === 'string') {
71+
normalizedHeaders.add(header.toLowerCase())
72+
}
73+
}
74+
75+
if (requiredHeaders.some(header => !normalizedHeaders.has(header.toLowerCase()))) {
76+
return
77+
}
78+
}
79+
80+
const advertisedPaths = new Set()
81+
for (const endpoint of agentInfo.endpoints) {
82+
if (typeof endpoint === 'string') {
83+
advertisedPaths.add(endpoint.replace(TRAILING_SLASHES, ''))
84+
}
85+
}
86+
87+
for (const supportedPath of supportedPaths) {
88+
if (typeof supportedPath !== 'string') continue
89+
90+
const normalizedPath = supportedPath.replace(TRAILING_SLASHES, '')
91+
if (advertisedPaths.has(normalizedPath)) {
92+
return normalizedPath
93+
}
94+
}
95+
}
96+
97+
/**
98+
* Discovers an EVP proxy route through the configured Agent URL.
99+
*
100+
* This function performs discovery only when the caller invokes it. It stores
101+
* no state. The Agent information client owns its existing response cache.
102+
*
103+
* @param {URL} url - Configured Agent URL
104+
* @param {object} options - Selection options
105+
* @param {string[]} options.supportedPaths - Supported paths in preference order
106+
* @param {string[]} [options.requiredHeaders] - Headers that the proxy must forward to intake. Do not include
107+
* routing headers that the Agent consumes, such as X-Datadog-EVP-Subdomain.
108+
* @param {(error: Error|null, route?: {url: URL, basePath: string}) => void} callback - Result callback
109+
* @returns {void}
110+
*/
111+
function discoverEVPProxy (url, options, callback) {
112+
fetchAgentInfo(url, (error, agentInfo) => {
113+
if (error) {
114+
callback(error)
115+
return
116+
}
117+
118+
const basePath = selectEVPProxyPath(agentInfo, options)
119+
if (basePath === undefined) {
120+
callback(null)
121+
return
122+
}
123+
124+
log.debug('EVP proxy route %s discovered through the configured local receiver', basePath)
125+
callback(null, { url, basePath })
126+
})
127+
}
128+
129+
module.exports = {
130+
discoverEVPProxy,
131+
selectEVPProxyPath,
132+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use strict'
2+
3+
const LEADING_SLASHES = /^\/+/
4+
const TRAILING_SLASHES = /\/+$/
5+
6+
/**
7+
* Joins a caller-supplied EVP proxy path and product endpoint.
8+
*
9+
* This utility does not perform EVP proxy discovery.
10+
*
11+
* @param {string} basePath - EVP proxy base path
12+
* @param {string} endpoint - Product intake endpoint
13+
* @returns {string} Joined request path
14+
*/
15+
function joinEVPProxyPath (basePath, endpoint) {
16+
const normalizedBasePath = basePath.replace(TRAILING_SLASHES, '')
17+
const normalizedEndpoint = endpoint.replace(LEADING_SLASHES, '')
18+
19+
return `${normalizedBasePath}/${normalizedEndpoint}`
20+
}
21+
22+
module.exports = { joinEVPProxyPath }

packages/dd-trace/src/llmobs/writers/base.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ class BaseLLMObsWriter {
234234
if (this._agentless) {
235235
options.headers['DD-API-KEY'] = this._config.DD_API_KEY || ''
236236
} else {
237+
// TODO: Migrate LLMObs EVP headers and subdomains to the shared utilities in src/evp_proxy.
237238
options.headers[EVP_SUBDOMAIN_HEADER_NAME] = this._intake
238239
}
239240

packages/dd-trace/src/llmobs/writers/util.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const { EVP_PROXY_AGENT_BASE_PATH } = require('../constants/writers')
55
const telemetry = require('../telemetry')
66
const { fetchAgentInfo } = require('../../agent/info')
77

8+
// TODO: Use the shared utilities in src/evp_proxy when LLMObs migrates this product-specific fallback policy.
89
/**
910
* @param {import('../../config/config-base')} config
1011
* @param {(agentless: boolean) => void} setWritersAgentlessValue

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

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,6 @@
11
'use strict'
22

33
module.exports = {
4-
/**
5-
* @constant
6-
* @type {string} Base path for EVP proxy agent endpoint
7-
*/
8-
EVP_PROXY_AGENT_BASE_PATH: '/evp_proxy/v2/',
9-
10-
/**
11-
* @constant
12-
* @type {string} HTTP header name for EVP subdomain routing
13-
*/
14-
EVP_SUBDOMAIN_HEADER_NAME: 'X-Datadog-EVP-Subdomain',
15-
16-
/**
17-
* @constant
18-
* @type {string} EVP subdomain value for event platform intake
19-
*/
20-
EVP_SUBDOMAIN_VALUE: 'event-platform-intake',
21-
224
/**
235
* @constant
246
* @type {string} API endpoint for exposure events EVP track

0 commit comments

Comments
 (0)