From 649636fa07f997b2175ce2983d8270b233c9bdf4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 9 Aug 2026 13:43:38 -0400 Subject: [PATCH 1/2] feat(evp_proxy): add direct intake route --- LICENSE-3rdparty.csv | 5 ++ package.json | 4 +- packages/dd-trace/src/evp_proxy/direct.js | 51 ++++++++++++ .../dd-trace/src/exporters/common/request.js | 7 +- .../dd-trace/test/evp_proxy/direct.spec.js | 83 +++++++++++++++++++ .../test/exporters/common/request.spec.js | 44 ++++++++++ yarn.lock | 2 +- 7 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 packages/dd-trace/src/evp_proxy/direct.js create mode 100644 packages/dd-trace/test/evp_proxy/direct.spec.js diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 38964991e23..9fae5b5de66 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -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. ']" +"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']" @@ -68,6 +71,7 @@ "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']" @@ -75,6 +79,7 @@ "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']" diff --git a/package.json b/package.json index 9932011bd6b..d485e95fc1c 100644 --- a/package.json +++ b/package.json @@ -179,8 +179,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.12.1", diff --git a/packages/dd-trace/src/evp_proxy/direct.js b/packages/dd-trace/src/evp_proxy/direct.js new file mode 100644 index 00000000000..e81f3a67af3 --- /dev/null +++ b/packages/dd-trace/src/evp_proxy/direct.js @@ -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 } diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index dc050a45228..77c93deed4f 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -93,7 +93,10 @@ function request (data, options, callback) { docker.inject(options.headers) - options.agent = isSecure ? httpsAgent : httpAgent + const connectionOptions = { + ...options, + agent: options.agent ?? (isSecure ? httpsAgent : httpAgent), + } /** * @param {import('node:http').IncomingMessage} res @@ -218,7 +221,7 @@ function request (data, options, callback) { } } - const req = client.request(options, (res) => onResponse(res, complete, handleError)) + const req = client.request(connectionOptions, (res) => onResponse(res, complete, handleError)) req.once('close', finalize) req.once('timeout', finalize) diff --git a/packages/dd-trace/test/evp_proxy/direct.spec.js b/packages/dd-trace/test/evp_proxy/direct.spec.js new file mode 100644 index 00000000000..193bfb042f8 --- /dev/null +++ b/packages/dd-trace/test/evp_proxy/direct.spec.js @@ -0,0 +1,83 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it, beforeEach } = require('mocha') +const proxyquire = require('proxyquire').noPreserveCache() +const sinon = require('sinon') + +describe('direct EVP route', () => { + let createDirectEVPRoute + let getProxyForUrl + let HttpsProxyAgent + let log + + beforeEach(() => { + getProxyForUrl = sinon.stub().returns('') + HttpsProxyAgent = sinon.stub().callsFake(proxyUrl => ({ proxyUrl })) + log = { debug: sinon.spy() } + + ;({ createDirectEVPRoute } = proxyquire('../../src/evp_proxy/direct', { + 'https-proxy-agent': { HttpsProxyAgent }, + 'proxy-from-env': { getProxyForUrl }, + '../log': log, + })) + }) + + it('creates an authenticated route from API key and site', () => { + const route = createDirectEVPRoute({ + DD_API_KEY: 'test-api-key', + site: 'datadoghq.com', + }, 'event-platform-intake') + + assert.deepStrictEqual(route, { + url: new URL('https://event-platform-intake.datadoghq.com'), + basePath: '', + headers: { + 'DD-API-KEY': 'test-api-key', + }, + }) + }) + + it('uses the standard HTTPS proxy for direct intake', () => { + const proxyUrl = 'http://proxy:8202' + getProxyForUrl.returns(proxyUrl) + + const route = createDirectEVPRoute({ + DD_API_KEY: 'test-api-key', + site: 'datadoghq.com', + }, 'event-platform-intake') + + assert.deepStrictEqual(route.agent, { proxyUrl }) + sinon.assert.calledOnceWithExactly( + getProxyForUrl, + 'https://event-platform-intake.datadoghq.com/' + ) + sinon.assert.calledOnceWithExactly(HttpsProxyAgent, proxyUrl) + }) + + it('does not create a route without an API key', () => { + assert.strictEqual(createDirectEVPRoute({ + site: 'datadoghq.com', + }, 'event-platform-intake'), undefined) + }) + + it('does not create a route without a site', () => { + assert.strictEqual(createDirectEVPRoute({ + DD_API_KEY: 'test-api-key', + }, 'event-platform-intake'), undefined) + }) + + it('does not create a route for an invalid site', () => { + assert.strictEqual(createDirectEVPRoute({ + DD_API_KEY: 'test-api-key', + site: 'not a host', + }, 'event-platform-intake'), undefined) + + sinon.assert.calledOnceWithExactly( + log.debug, + 'Unable to configure direct EVP intake: %s', + sinon.match.string + ) + }) +}) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index eceaf687c6e..0c6d5d3b90f 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -111,6 +111,50 @@ describe('request', function () { }) }) + it('preserves a caller-supplied connection agent', (done) => { + const customAgent = new http.Agent() + const sandbox = sinon.createSandbox() + sandbox.spy(http, 'request') + nock('http://test:123').get('/path').reply(200, 'OK') + + request(Buffer.from(''), { + agent: customAgent, + protocol: 'http:', + hostname: 'test', + port: 123, + path: '/path', + method: 'GET', + }, (error) => { + const callOptions = http.request.getCall(0).args[0] + sandbox.restore() + customAgent.destroy() + assert.strictEqual(callOptions.agent, customAgent) + done(error) + }) + }) + + it('selects a new default agent when callers reuse options with another protocol', (done) => { + const options = { + url: new URL('http://test:123'), + path: '/path', + method: 'GET', + } + nock('http://test:123').get('/path').reply(200, 'OK') + + request(Buffer.from(''), options, (httpError) => { + if (httpError) return done(httpError) + + assert.strictEqual(options.agent, undefined) + options.url = new URL('https://test:443') + nock('https://test:443').get('/path').reply(200, 'OK') + + request(Buffer.from(''), options, (httpsError) => { + assert.strictEqual(options.agent, undefined) + done(httpsError) + }) + }) + }) + it('does not retry when retries are disabled', (done) => { maxAttempts = 5 const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }) diff --git a/yarn.lock b/yarn.lock index 4a919cb26ee..9d4c6487018 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2588,7 +2588,7 @@ https-proxy-agent@^5.0.1: agent-base "6" debug "4" -https-proxy-agent@^7.0.5: +https-proxy-agent@^7.0.5, https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== From d6595d084e83fa526857da9f42f988ca05a21b1c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 18 Aug 2026 09:45:06 -0700 Subject: [PATCH 2/2] fix(evp_proxy): validate direct intake URL --- packages/dd-trace/src/evp_proxy/direct.js | 15 +++++++- .../dd-trace/test/evp_proxy/direct.spec.js | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/dd-trace/src/evp_proxy/direct.js b/packages/dd-trace/src/evp_proxy/direct.js index e81f3a67af3..77186ade847 100644 --- a/packages/dd-trace/src/evp_proxy/direct.js +++ b/packages/dd-trace/src/evp_proxy/direct.js @@ -28,10 +28,23 @@ function createDirectEVPRoute (config, intake) { if (!apiKey || !config.site) return try { + const hostname = `${intake}.${config.site}`.toLowerCase() const url = new URL(format({ protocol: 'https:', - hostname: `${intake}.${config.site}`, + hostname, })) + if ( + url.hostname !== hostname || + url.username || + url.password || + url.port || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error('Invalid direct EVP intake URL') + } + const proxyUrl = getProxyForUrl(url.href) const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined diff --git a/packages/dd-trace/test/evp_proxy/direct.spec.js b/packages/dd-trace/test/evp_proxy/direct.spec.js index 193bfb042f8..3dde3900357 100644 --- a/packages/dd-trace/test/evp_proxy/direct.spec.js +++ b/packages/dd-trace/test/evp_proxy/direct.spec.js @@ -39,6 +39,21 @@ describe('direct EVP route', () => { }) }) + it('normalizes site casing', () => { + const route = createDirectEVPRoute({ + DD_API_KEY: 'test-api-key', + site: 'DATADOGHQ.EU', + }, 'event-platform-intake') + + assert.deepStrictEqual(route, { + url: new URL('https://event-platform-intake.datadoghq.eu'), + basePath: '', + headers: { + 'DD-API-KEY': 'test-api-key', + }, + }) + }) + it('uses the standard HTTPS proxy for direct intake', () => { const proxyUrl = 'http://proxy:8202' getProxyForUrl.returns(proxyUrl) @@ -80,4 +95,26 @@ describe('direct EVP route', () => { sinon.match.string ) }) + + for (const site of [ + 'datadoghq.com@evil.example', + 'datadoghq.com:password@evil.example', + 'datadoghq.com:443', + 'datadoghq.com/path', + 'datadoghq.com?query', + 'datadoghq.com#fragment', + ]) { + it(`does not create a route for a site with URL components: ${site}`, () => { + assert.strictEqual(createDirectEVPRoute({ + DD_API_KEY: 'test-api-key', + site, + }, 'event-platform-intake'), undefined) + + sinon.assert.calledOnceWithExactly( + log.debug, + 'Unable to configure direct EVP intake: %s', + sinon.match.string + ) + }) + } })