Skip to content

Commit 6f638b1

Browse files
feat(evp_proxy): add direct intake route (#9739)
* feat(evp_proxy): add direct intake route * fix(evp_proxy): validate direct intake URL
1 parent f05d882 commit 6f638b1

7 files changed

Lines changed: 242 additions & 4 deletions

File tree

LICENSE-3rdparty.csv

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,21 @@
4444
"@oxc-project/types","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']"
4545
"@tybys/wasm-util","https://github.com/toyobayashi/wasm-util","['MIT']","['toyobayashi']"
4646
"@types/estree","https://github.com/DefinitelyTyped/DefinitelyTyped","['MIT']","['DefinitelyTyped']"
47+
"agent-base","https://github.com/TooTallNate/proxy-agents","['MIT']","['Nathan Rajlich']"
4748
"argparse","https://github.com/nodeca/argparse","['Python-2.0']","['nodeca']"
4849
"astring","https://github.com/davidbonnet/astring","['MIT']","['David Bonnet']"
4950
"cjs-module-lexer","https://github.com/nodejs/cjs-module-lexer","['MIT']","['Guy Bedford']"
5051
"crypto-randomuuid","npm:crypto-randomuuid","['MIT']","['Stephen Belanger']"
5152
"dc-polyfill","https://github.com/DataDog/dc-polyfill","['MIT']","['Thomas Hunter II']"
5253
"dd-trace","https://github.com/DataDog/dd-trace-js","['(Apache-2.0 OR BSD-3-Clause)']","['Datadog Inc. <info@datadoghq.com>']"
54+
"debug","https://github.com/debug-js/debug","['MIT']","['Josh Junon']"
5355
"detect-newline","https://github.com/sindresorhus/detect-newline","['MIT']","['Sindre Sorhus']"
5456
"es-module-lexer","https://github.com/guybedford/es-module-lexer","['MIT']","['Guy Bedford']"
5557
"escape-string-regexp","https://github.com/sindresorhus/escape-string-regexp","['MIT']","['Sindre Sorhus']"
5658
"esquery","https://github.com/estools/esquery","['BSD-3-Clause']","['Joel Feenstra']"
5759
"estraverse","https://github.com/estools/estraverse","['BSD-2-Clause']","['estools']"
5860
"fast-fifo","https://github.com/mafintosh/fast-fifo","['MIT']","['Mathias Buus']"
61+
"https-proxy-agent","https://github.com/TooTallNate/proxy-agents","['MIT']","['Nathan Rajlich']"
5962
"import-in-the-middle","https://github.com/nodejs/import-in-the-middle","['Apache-2.0']","['Bryan English']"
6063
"istanbul-lib-coverage","https://github.com/istanbuljs/istanbuljs","['BSD-3-Clause']","['Krishnan Anantheswaran']"
6164
"jest-docblock","https://github.com/jestjs/jest","['MIT']","['jestjs']"
@@ -68,13 +71,15 @@
6871
"lru-cache","https://github.com/isaacs/node-lru-cache","['ISC']","['Isaac Z. Schlueter']"
6972
"meriyah","https://github.com/meriyah/meriyah","['ISC']","['Kenny F.']"
7073
"module-details-from-path","https://github.com/watson/module-details-from-path","['MIT']","['Thomas Watson']"
74+
"ms","https://github.com/vercel/ms","['MIT']","['vercel']"
7175
"mutexify","https://github.com/mafintosh/mutexify","['MIT']","['Mathias Buus']"
7276
"node-addon-api","https://github.com/nodejs/node-addon-api","['MIT']","['nodejs']"
7377
"node-gyp-build","https://github.com/prebuild/node-gyp-build","['MIT']","['Mathias Buus']"
7478
"opentracing","https://github.com/opentracing/opentracing-javascript","['Apache-2.0']","['opentracing']"
7579
"oxc-parser","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']"
7680
"pprof-format","https://github.com/DataDog/pprof-format","['MIT']","['Datadog Inc.']"
7781
"protobufjs","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']"
82+
"proxy-from-env","https://github.com/Rob--W/proxy-from-env","['MIT']","['Rob Wu']"
7883
"queue-tick","https://github.com/mafintosh/queue-tick","['MIT']","['Mathias Buus']"
7984
"retry","https://github.com/tim-kos/node-retry","['MIT']","['Tim Koschützki']"
8085
"rfdc","https://github.com/davidmarkclements/rfdc","['MIT']","['David Mark Clements']"

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,10 @@
179179
],
180180
"dependencies": {
181181
"dc-polyfill": "^0.1.11",
182+
"https-proxy-agent": "^7.0.6",
182183
"import-in-the-middle": "^3.3.2",
183-
"opentracing": ">=0.14.7"
184+
"opentracing": ">=0.14.7",
185+
"proxy-from-env": "^2.1.0"
184186
},
185187
"optionalDependencies": {
186188
"@datadog/libdatadog": "0.12.1",
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict'
2+
3+
const { format } = require('node:url')
4+
5+
const { HttpsProxyAgent } = require('https-proxy-agent')
6+
const { getProxyForUrl } = require('proxy-from-env')
7+
const log = require('../log')
8+
9+
/**
10+
* @typedef {object} DirectEVPRoute
11+
* @property {URL} url - Direct intake URL
12+
* @property {string} basePath - Direct intake base path
13+
* @property {object} headers - Direct intake authentication headers
14+
* @property {import('node:https').Agent} [agent] - Optional HTTPS proxy agent
15+
*/
16+
17+
/**
18+
* Creates an authenticated direct EVP intake route.
19+
*
20+
* This helper does not perform local receiver discovery.
21+
*
22+
* @param {import('../config/config-base')} config - Tracer configuration
23+
* @param {string} intake - EVP intake subdomain
24+
* @returns {DirectEVPRoute|undefined} Direct route when credentials and site are available
25+
*/
26+
function createDirectEVPRoute (config, intake) {
27+
const apiKey = config.DD_API_KEY
28+
if (!apiKey || !config.site) return
29+
30+
try {
31+
const hostname = `${intake}.${config.site}`.toLowerCase()
32+
const url = new URL(format({
33+
protocol: 'https:',
34+
hostname,
35+
}))
36+
if (
37+
url.hostname !== hostname ||
38+
url.username ||
39+
url.password ||
40+
url.port ||
41+
url.pathname !== '/' ||
42+
url.search ||
43+
url.hash
44+
) {
45+
throw new Error('Invalid direct EVP intake URL')
46+
}
47+
48+
const proxyUrl = getProxyForUrl(url.href)
49+
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined
50+
51+
return {
52+
url,
53+
basePath: '',
54+
headers: {
55+
'DD-API-KEY': apiKey,
56+
},
57+
...(agent && { agent }),
58+
}
59+
} catch (error) {
60+
log.debug('Unable to configure direct EVP intake: %s', error.message)
61+
}
62+
}
63+
64+
module.exports = { createDirectEVPRoute }

packages/dd-trace/src/exporters/common/request.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ function request (data, options, callback) {
9393

9494
docker.inject(options.headers)
9595

96-
options.agent = isSecure ? httpsAgent : httpAgent
96+
const connectionOptions = {
97+
...options,
98+
agent: options.agent ?? (isSecure ? httpsAgent : httpAgent),
99+
}
97100

98101
/**
99102
* @param {import('node:http').IncomingMessage} res
@@ -218,7 +221,7 @@ function request (data, options, callback) {
218221
}
219222
}
220223

221-
const req = client.request(options, (res) => onResponse(res, complete, handleError))
224+
const req = client.request(connectionOptions, (res) => onResponse(res, complete, handleError))
222225

223226
req.once('close', finalize)
224227
req.once('timeout', finalize)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use strict'
2+
3+
const assert = require('node:assert/strict')
4+
5+
const { describe, it, beforeEach } = require('mocha')
6+
const proxyquire = require('proxyquire').noPreserveCache()
7+
const sinon = require('sinon')
8+
9+
describe('direct EVP route', () => {
10+
let createDirectEVPRoute
11+
let getProxyForUrl
12+
let HttpsProxyAgent
13+
let log
14+
15+
beforeEach(() => {
16+
getProxyForUrl = sinon.stub().returns('')
17+
HttpsProxyAgent = sinon.stub().callsFake(proxyUrl => ({ proxyUrl }))
18+
log = { debug: sinon.spy() }
19+
20+
;({ createDirectEVPRoute } = proxyquire('../../src/evp_proxy/direct', {
21+
'https-proxy-agent': { HttpsProxyAgent },
22+
'proxy-from-env': { getProxyForUrl },
23+
'../log': log,
24+
}))
25+
})
26+
27+
it('creates an authenticated route from API key and site', () => {
28+
const route = createDirectEVPRoute({
29+
DD_API_KEY: 'test-api-key',
30+
site: 'datadoghq.com',
31+
}, 'event-platform-intake')
32+
33+
assert.deepStrictEqual(route, {
34+
url: new URL('https://event-platform-intake.datadoghq.com'),
35+
basePath: '',
36+
headers: {
37+
'DD-API-KEY': 'test-api-key',
38+
},
39+
})
40+
})
41+
42+
it('normalizes site casing', () => {
43+
const route = createDirectEVPRoute({
44+
DD_API_KEY: 'test-api-key',
45+
site: 'DATADOGHQ.EU',
46+
}, 'event-platform-intake')
47+
48+
assert.deepStrictEqual(route, {
49+
url: new URL('https://event-platform-intake.datadoghq.eu'),
50+
basePath: '',
51+
headers: {
52+
'DD-API-KEY': 'test-api-key',
53+
},
54+
})
55+
})
56+
57+
it('uses the standard HTTPS proxy for direct intake', () => {
58+
const proxyUrl = 'http://proxy:8202'
59+
getProxyForUrl.returns(proxyUrl)
60+
61+
const route = createDirectEVPRoute({
62+
DD_API_KEY: 'test-api-key',
63+
site: 'datadoghq.com',
64+
}, 'event-platform-intake')
65+
66+
assert.deepStrictEqual(route.agent, { proxyUrl })
67+
sinon.assert.calledOnceWithExactly(
68+
getProxyForUrl,
69+
'https://event-platform-intake.datadoghq.com/'
70+
)
71+
sinon.assert.calledOnceWithExactly(HttpsProxyAgent, proxyUrl)
72+
})
73+
74+
it('does not create a route without an API key', () => {
75+
assert.strictEqual(createDirectEVPRoute({
76+
site: 'datadoghq.com',
77+
}, 'event-platform-intake'), undefined)
78+
})
79+
80+
it('does not create a route without a site', () => {
81+
assert.strictEqual(createDirectEVPRoute({
82+
DD_API_KEY: 'test-api-key',
83+
}, 'event-platform-intake'), undefined)
84+
})
85+
86+
it('does not create a route for an invalid site', () => {
87+
assert.strictEqual(createDirectEVPRoute({
88+
DD_API_KEY: 'test-api-key',
89+
site: 'not a host',
90+
}, 'event-platform-intake'), undefined)
91+
92+
sinon.assert.calledOnceWithExactly(
93+
log.debug,
94+
'Unable to configure direct EVP intake: %s',
95+
sinon.match.string
96+
)
97+
})
98+
99+
for (const site of [
100+
'datadoghq.com@evil.example',
101+
'datadoghq.com:password@evil.example',
102+
'datadoghq.com:443',
103+
'datadoghq.com/path',
104+
'datadoghq.com?query',
105+
'datadoghq.com#fragment',
106+
]) {
107+
it(`does not create a route for a site with URL components: ${site}`, () => {
108+
assert.strictEqual(createDirectEVPRoute({
109+
DD_API_KEY: 'test-api-key',
110+
site,
111+
}, 'event-platform-intake'), undefined)
112+
113+
sinon.assert.calledOnceWithExactly(
114+
log.debug,
115+
'Unable to configure direct EVP intake: %s',
116+
sinon.match.string
117+
)
118+
})
119+
}
120+
})

packages/dd-trace/test/exporters/common/request.spec.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,50 @@ describe('request', function () {
111111
})
112112
})
113113

114+
it('preserves a caller-supplied connection agent', (done) => {
115+
const customAgent = new http.Agent()
116+
const sandbox = sinon.createSandbox()
117+
sandbox.spy(http, 'request')
118+
nock('http://test:123').get('/path').reply(200, 'OK')
119+
120+
request(Buffer.from(''), {
121+
agent: customAgent,
122+
protocol: 'http:',
123+
hostname: 'test',
124+
port: 123,
125+
path: '/path',
126+
method: 'GET',
127+
}, (error) => {
128+
const callOptions = http.request.getCall(0).args[0]
129+
sandbox.restore()
130+
customAgent.destroy()
131+
assert.strictEqual(callOptions.agent, customAgent)
132+
done(error)
133+
})
134+
})
135+
136+
it('selects a new default agent when callers reuse options with another protocol', (done) => {
137+
const options = {
138+
url: new URL('http://test:123'),
139+
path: '/path',
140+
method: 'GET',
141+
}
142+
nock('http://test:123').get('/path').reply(200, 'OK')
143+
144+
request(Buffer.from(''), options, (httpError) => {
145+
if (httpError) return done(httpError)
146+
147+
assert.strictEqual(options.agent, undefined)
148+
options.url = new URL('https://test:443')
149+
nock('https://test:443').get('/path').reply(200, 'OK')
150+
151+
request(Buffer.from(''), options, (httpsError) => {
152+
assert.strictEqual(options.agent, undefined)
153+
done(httpsError)
154+
})
155+
})
156+
})
157+
114158
it('does not retry when retries are disabled', (done) => {
115159
maxAttempts = 5
116160
const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' })

yarn.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2588,7 +2588,7 @@ https-proxy-agent@^5.0.1:
25882588
agent-base "6"
25892589
debug "4"
25902590

2591-
https-proxy-agent@^7.0.5:
2591+
https-proxy-agent@^7.0.5, https-proxy-agent@^7.0.6:
25922592
version "7.0.6"
25932593
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9"
25942594
integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==

0 commit comments

Comments
 (0)