Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,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
Expand All @@ -383,12 +384,14 @@
/packages/dd-trace/src/feature-registry.js @DataDog/lang-platform-js
/packages/dd-trace/src/exporters/common/ @DataDog/lang-platform-js
/packages/dd-trace/src/exporters/common/client-library-headers.js @DataDog/lang-platform-js @DataDog/feature-flagging-and-experimentation-sdk
/packages/dd-trace/src/evp_proxy/ @DataDog/lang-platform-js
/packages/dd-trace/src/guardrails/ @DataDog/lang-platform-js
/packages/dd-trace/src/proxy.js @DataDog/lang-platform-js
/packages/dd-trace/test/agent/ @DataDog/lang-platform-js
/packages/dd-trace/test/dd-trace.spec.js @DataDog/lang-platform-js
/packages/dd-trace/test/dogstatsd.spec.js @DataDog/lang-platform-js
/packages/dd-trace/test/encode/ @DataDog/lang-platform-js
/packages/dd-trace/test/evp_proxy/ @DataDog/lang-platform-js
/packages/dd-trace/test/esm-named-exports.spec.js @DataDog/lang-platform-js
/packages/dd-trace/test/exporter.spec.js @DataDog/lang-platform-js
/packages/dd-trace/test/exporters/ @DataDog/lang-platform-js
Expand Down
5 changes: 5 additions & 0 deletions LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,21 @@
"@oxc-project/types","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']"
"@tybys/wasm-util","https://github.com/toyobayashi/wasm-util","['MIT']","['toyobayashi']"
"@types/estree","https://github.com/DefinitelyTyped/DefinitelyTyped","['MIT']","['DefinitelyTyped']"
"agent-base","https://github.com/TooTallNate/proxy-agents","['MIT']","['Nathan Rajlich']"
"argparse","https://github.com/nodeca/argparse","['Python-2.0']","['nodeca']"
"astring","https://github.com/davidbonnet/astring","['MIT']","['David Bonnet']"
"cjs-module-lexer","https://github.com/nodejs/cjs-module-lexer","['MIT']","['Guy Bedford']"
"crypto-randomuuid","npm:crypto-randomuuid","['MIT']","['Stephen Belanger']"
"dc-polyfill","https://github.com/DataDog/dc-polyfill","['MIT']","['Thomas Hunter II']"
"dd-trace","https://github.com/DataDog/dd-trace-js","['(Apache-2.0 OR BSD-3-Clause)']","['Datadog Inc. <info@datadoghq.com>']"
"debug","https://github.com/debug-js/debug","['MIT']","['Josh Junon']"
"detect-newline","https://github.com/sindresorhus/detect-newline","['MIT']","['Sindre Sorhus']"
"es-module-lexer","https://github.com/guybedford/es-module-lexer","['MIT']","['Guy Bedford']"
"escape-string-regexp","https://github.com/sindresorhus/escape-string-regexp","['MIT']","['Sindre Sorhus']"
"esquery","https://github.com/estools/esquery","['BSD-3-Clause']","['Joel Feenstra']"
"estraverse","https://github.com/estools/estraverse","['BSD-2-Clause']","['estools']"
"fast-fifo","https://github.com/mafintosh/fast-fifo","['MIT']","['Mathias Buus']"
"https-proxy-agent","https://github.com/TooTallNate/proxy-agents","['MIT']","['Nathan Rajlich']"
"import-in-the-middle","https://github.com/nodejs/import-in-the-middle","['Apache-2.0']","['Bryan English']"
"istanbul-lib-coverage","https://github.com/istanbuljs/istanbuljs","['BSD-3-Clause']","['Krishnan Anantheswaran']"
"jest-docblock","https://github.com/jestjs/jest","['MIT']","['jestjs']"
Expand All @@ -68,13 +71,15 @@
"lru-cache","https://github.com/isaacs/node-lru-cache","['ISC']","['Isaac Z. Schlueter']"
"meriyah","https://github.com/meriyah/meriyah","['ISC']","['Kenny F.']"
"module-details-from-path","https://github.com/watson/module-details-from-path","['MIT']","['Thomas Watson']"
"ms","https://github.com/vercel/ms","['MIT']","['vercel']"
"mutexify","https://github.com/mafintosh/mutexify","['MIT']","['Mathias Buus']"
"node-addon-api","https://github.com/nodejs/node-addon-api","['MIT']","['nodejs']"
"node-gyp-build","https://github.com/prebuild/node-gyp-build","['MIT']","['Mathias Buus']"
"opentracing","https://github.com/opentracing/opentracing-javascript","['Apache-2.0']","['opentracing']"
"oxc-parser","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']"
"pprof-format","https://github.com/DataDog/pprof-format","['MIT']","['Datadog Inc.']"
"protobufjs","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']"
"proxy-from-env","https://github.com/Rob--W/proxy-from-env","['MIT']","['Rob Wu']"
"queue-tick","https://github.com/mafintosh/queue-tick","['MIT']","['Mathias Buus']"
"retry","https://github.com/tim-kos/node-retry","['MIT']","['Tim Koschützki']"
"rfdc","https://github.com/davidmarkclements/rfdc","['MIT']","['David Mark Clements']"
Expand Down
15 changes: 13 additions & 2 deletions integration-tests/helpers/fake-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ module.exports = class FakeAgent extends EventEmitter {
port = 0
advertiseDebuggerV2IntakeSupport = true
debuggerV2IntakeStatusCode = 202
evpProxyVersions = [2]
/** @type {Set<import('net').Socket>} */
#sockets = new Set()
/** @type {Record<string, RemoteConfigFile>} */
Expand All @@ -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]
}
}

/**
Expand Down Expand Up @@ -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')
}
Expand Down Expand Up @@ -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,
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@
"test:core:ci": "node scripts/c8-ci.js test:core",
"test:code-origin": "mocha \"packages/datadog-code-origin/test/**/*.spec.js\"",
"test:code-origin:ci": "node scripts/c8-ci.js test:code-origin",
"test:evp_proxy": "mocha \"packages/dd-trace/test/evp_proxy/**/*.spec.js\"",
"test:lambda": "mocha \"packages/dd-trace/test/lambda/**/*.spec.js\"",
"test:lambda:ci": "node scripts/c8-ci.js test:lambda",
"test:llmobs:sdk": "mocha --exclude \"packages/dd-trace/test/llmobs/plugins/**/*.spec.js\" \"packages/dd-trace/test/llmobs/**/*.spec.js\"",
"test:llmobs:sdk:ci": "node scripts/c8-ci.js test:llmobs:sdk",
"test:llmobs:plugins": "mocha \"packages/dd-trace/test/llmobs/plugins/@(${PLUGINS})/*.spec.js\"",
"test:llmobs:plugins:ci": "yarn services && node scripts/c8-ci.js test:llmobs:plugins",
"test:openfeature": "mocha \"packages/dd-trace/test/openfeature/**/*.spec.js\"",
"test:openfeature": "mocha \"packages/dd-trace/test/evp_proxy/**/*.spec.js\" \"packages/dd-trace/test/openfeature/**/*.spec.js\"",
"test:openfeature:ci": "node scripts/c8-ci.js test:openfeature",
"test:plugins": "node --expose-gc ./node_modules/mocha/bin/mocha.js \"packages/datadog-plugin-@(${PLUGINS})/test/**/${SPEC:-*}*.spec.js\"",
"test:plugins:ci": "yarn services && node scripts/c8-ci.js test:plugins",
Expand Down Expand Up @@ -176,8 +177,10 @@
],
"dependencies": {
"dc-polyfill": "^0.1.11",
"https-proxy-agent": "^7.0.6",
"import-in-the-middle": "^3.3.2",
"opentracing": ">=0.14.7"
"opentracing": ">=0.14.7",
"proxy-from-env": "^2.1.0"
},
"optionalDependencies": {
"@datadog/libdatadog": "0.9.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const CiVisibilityExporter = require('../ci-visibility-exporter')
const { fetchAgentInfo } = require('../../../agent/info')
const { DEBUGGER_INPUT_V1 } = require('../../../debugger/constants')

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class Writer extends BaseWriter {
if (this._evpProxyPrefix) {
options.path = `${this._evpProxyPrefix}/api/v2/citestcycle`
delete options.headers['dd-api-key']
// TODO: Migrate CI Visibility EVP headers and subdomains to the shared utilities in src/evp_proxy.
options.headers['X-Datadog-EVP-Subdomain'] = 'citestcycle-intake'
}

Expand Down
8 changes: 8 additions & 0 deletions packages/dd-trace/src/evp_proxy/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use strict'

module.exports = {
EVP_PROXY_PATH_V2: '/evp_proxy/v2',
EVP_PROXY_PATH_V4: '/evp_proxy/v4',
EVP_SUBDOMAIN_HEADER_NAME: 'X-Datadog-EVP-Subdomain',
EVP_EVENT_PLATFORM_SUBDOMAIN: 'event-platform-intake',
}
51 changes: 51 additions & 0 deletions packages/dd-trace/src/evp_proxy/direct.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict'

const { format } = require('node:url')

const { HttpsProxyAgent } = require('https-proxy-agent')
const { getProxyForUrl } = require('proxy-from-env')
const log = require('../log')

/**
* @typedef {object} DirectEVPRoute
* @property {URL} url - Direct intake URL
* @property {string} basePath - Direct intake base path
* @property {object} headers - Direct intake authentication headers
* @property {import('node:https').Agent} [agent] - Optional HTTPS proxy agent
*/

/**
* Creates an authenticated direct EVP intake route.
*
* This helper does not perform local receiver discovery.
*
* @param {import('../config/config-base')} config - Tracer configuration
* @param {string} intake - EVP intake subdomain
* @returns {DirectEVPRoute|undefined} Direct route when credentials and site are available
*/
function createDirectEVPRoute (config, intake) {
const apiKey = config.DD_API_KEY
if (!apiKey || !config.site) return

try {
const url = new URL(format({
protocol: 'https:',
hostname: `${intake}.${config.site}`,
}))
const proxyUrl = getProxyForUrl(url.href)
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined

return {
url,
basePath: '',
headers: {
'DD-API-KEY': apiKey,
},
...(agent && { agent }),
}
} catch (error) {
log.debug('Unable to configure direct EVP intake: %s', error.message)
}
}

module.exports = { createDirectEVPRoute }
132 changes: 132 additions & 0 deletions packages/dd-trace/src/evp_proxy/discovery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'use strict'

const { fetchAgentInfo } = require('../agent/info')
const log = require('../log')

const TRAILING_SLASHES = /\/+$/

/**
* Receiver discovery contract
*
* The tracer sends `GET /info` to its configured local Agent URL. An
* Agent-compatible trace receiver produces the response. The tracer does not.
*
* The full Agent and serverless-init embed the same trace receiver. A future
* in-process receiver can expose the same contract. Callers can therefore
* select capabilities without detecting the receiver implementation.
*
* For EVP discovery, `endpoints` advertises registered proxy paths.
* `evp_proxy_allowed_headers` advertises headers that the proxy forwards to
* intake. It does not include routing headers that the proxy consumes, such as
* `X-Datadog-EVP-Subdomain`.
*
* An advertised route is not a health check. The receiver can register an EVP
* route while configuration disables its handler. The request then returns
* `405`. The `/info` version also identifies the embedded Agent code, not a
* serverless-init image or deployment type.
*
* This module only discovers a candidate route. A missing or unresponsive
* `/info` endpoint returns an error through the shared request timeout and
* retry policy. A valid response without a compatible path returns no route.
* Discovery sends no events, so the caller can safely select direct intake
* after either result. The caller also owns later delivery failures. It can
* switch future batches after an ambiguous timeout or reset, but it must not
* replay the current batch because the first receiver might have accepted it.
*
* Reference implementations:
*
* Agent `/info` and EVP proxy:
* https://github.com/DataDog/datadog-agent/tree/main/pkg/trace/api
*
* serverless-init entry point and embedded trace receiver:
* https://github.com/DataDog/datadog-agent/blob/main/cmd/serverless-init/main.go
* https://github.com/DataDog/datadog-agent/blob/main/pkg/serverless/trace/trace.go
*/

/**
* Selects the first advertised EVP proxy path that the caller supports.
*
* @param {object} agentInfo - Agent `/info` response
* @param {object} options - Selection options
* @param {string[]} options.supportedPaths - Supported paths in preference order
* @param {string[]} [options.requiredHeaders] - Headers that the proxy must forward to intake. Do not include
* routing headers that the Agent consumes, such as X-Datadog-EVP-Subdomain.
* @returns {string|undefined} Selected normalized path
*/
function selectEVPProxyPath (agentInfo, { supportedPaths, requiredHeaders = [] } = {}) {
if (!Array.isArray(agentInfo?.endpoints) ||
!Array.isArray(supportedPaths) ||
!Array.isArray(requiredHeaders) ||
requiredHeaders.some(header => typeof header !== 'string')) {
return
}

const allowedHeaders = agentInfo.evp_proxy_allowed_headers
if (allowedHeaders !== undefined) {
if (!Array.isArray(allowedHeaders)) return

const normalizedHeaders = new Set()
for (const header of allowedHeaders) {
if (typeof header === 'string') {
normalizedHeaders.add(header.toLowerCase())
}
}

if (requiredHeaders.some(header => !normalizedHeaders.has(header.toLowerCase()))) {
return
}
}

const advertisedPaths = new Set()
for (const endpoint of agentInfo.endpoints) {
if (typeof endpoint === 'string') {
advertisedPaths.add(endpoint.replace(TRAILING_SLASHES, ''))
}
}

for (const supportedPath of supportedPaths) {
if (typeof supportedPath !== 'string') continue

const normalizedPath = supportedPath.replace(TRAILING_SLASHES, '')
if (advertisedPaths.has(normalizedPath)) {
return normalizedPath
}
}
}

/**
* Discovers an EVP proxy route through the configured Agent URL.
*
* This function performs discovery only when the caller invokes it. It stores
* no state. The Agent information client owns its existing response cache.
*
* @param {URL} url - Configured Agent URL
* @param {object} options - Selection options
* @param {string[]} options.supportedPaths - Supported paths in preference order
* @param {string[]} [options.requiredHeaders] - Headers that the proxy must forward to intake. Do not include
* routing headers that the Agent consumes, such as X-Datadog-EVP-Subdomain.
* @param {(error: Error|null, route?: {url: URL, basePath: string}) => void} callback - Result callback
* @returns {void}
*/
function discoverEVPProxy (url, options, callback) {
fetchAgentInfo(url, (error, agentInfo) => {
if (error) {
callback(error)
return
}

const basePath = selectEVPProxyPath(agentInfo, options)
if (basePath === undefined) {
callback(null)
return
}

log.debug('EVP proxy route %s discovered through the configured local receiver', basePath)
callback(null, { url, basePath })
})
}

module.exports = {
discoverEVPProxy,
selectEVPProxyPath,
}
22 changes: 22 additions & 0 deletions packages/dd-trace/src/evp_proxy/path.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use strict'

const LEADING_SLASHES = /^\/+/
const TRAILING_SLASHES = /\/+$/

/**
* Joins a caller-supplied EVP proxy path and product endpoint.
*
* This utility does not perform EVP proxy discovery.
*
* @param {string} basePath - EVP proxy base path
* @param {string} endpoint - Product intake endpoint
* @returns {string} Joined request path
*/
function joinEVPProxyPath (basePath, endpoint) {
const normalizedBasePath = basePath.replace(TRAILING_SLASHES, '')
const normalizedEndpoint = endpoint.replace(LEADING_SLASHES, '')

return `${normalizedBasePath}/${normalizedEndpoint}`
}

module.exports = { joinEVPProxyPath }
Loading
Loading