Skip to content

Commit 0fe045f

Browse files
authored
fix(span-stats,exporters): derive socketPath from the agent URL (#9042)
* fix(span-stats): derive socketPath from the agent URL The span-stats writer hand-copied protocol/hostname/port into the request options and never passed the agent URL, so request() could not reach its unix-socket branch and left protocol set to 'unix:'. With a named-pipe or UDS agent URL, http.request then threw ERR_INVALID_PROTOCOL and every span stats payload was dropped. Passing `url` lets request() map a unix: URL onto options.socketPath, the same way the trace agent exporter already does. * fix(exporters): recompose named-pipe socket paths in one shared helper The CI-visibility and profiling exporters built request options from `url.pathname` directly, so a Windows named-pipe agent URL (`unix://./pipe/...`) lost its `//.` authority and the request hit the wrong socket path. parseUrl now lives in exporters/common/url.js and folds that authority back into the path, so all three request paths derive the socket path the same way and can no longer drift. Drive-by fix: * The common request spec restores its sinon sandbox instead of resetting it, so the `http.request` spy no longer leaks into sibling suites. * refactor(telemetry): send the agentless backend URL as a URL object The agent-telemetry fallback passed the agentless intake endpoint to `request` as a string while the primary agentless path already constructed a `URL`. Build it with `new URL` up front and guard it the same way, so an invalid endpoint is logged and the request skipped rather than throwing from inside the request helper.
1 parent b236c48 commit 0fe045f

13 files changed

Lines changed: 254 additions & 61 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@
346346
/packages/datadog-core @DataDog/lang-platform-js
347347
/packages/datadog-shimmer @DataDog/lang-platform-js
348348
/packages/dd-trace/*/crashtracking @DataDog/lang-platform-js
349-
/packages/dd-trace/src/exporters/common/retry.js @DataDog/lang-platform-js
349+
/packages/dd-trace/src/exporters/common/ @DataDog/lang-platform-js
350350
/packages/dd-trace/test/agent/ @DataDog/lang-platform-js
351351
/packages/dd-trace/test/dd-trace.spec.js @DataDog/lang-platform-js
352352
/packages/dd-trace/test/dogstatsd.spec.js @DataDog/lang-platform-js

packages/dd-trace/src/ci-visibility/requests/request.js

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,10 @@ const {
1212
isRetriableNetworkError,
1313
singleJitteredDelay,
1414
} = require('../../exporters/common/retry')
15-
const { urlToHttpOptions } = require('../../exporters/common/url-to-http-options-polyfill')
15+
const { parseUrl } = require('../../exporters/common/url')
1616

1717
const legacyStorage = storage('legacy')
1818

19-
function parseUrl (urlObjOrString) {
20-
if (urlObjOrString !== null && typeof urlObjOrString === 'object') {
21-
return urlToHttpOptions(urlObjOrString)
22-
}
23-
24-
const url = urlToHttpOptions(new URL(urlObjOrString))
25-
26-
if (url.protocol === 'unix:' && url.hostname === '.') {
27-
const udsPath = urlObjOrString.slice(5)
28-
url.path = udsPath
29-
url.pathname = udsPath
30-
}
31-
32-
return url
33-
}
34-
3519
/**
3620
* Simplified HTTP request for test optimization (library config). Uses common HTTP agents.
3721
* Retries: 429 (with X-ratelimit-reset, max 30s wait),

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

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const zlib = require('zlib')
1111

1212
const { storage } = require('../../../../datadog-core')
1313
const log = require('../../log')
14-
const { urlToHttpOptions } = require('./url-to-http-options-polyfill')
14+
const { parseUrl } = require('./url')
1515
const docker = require('./docker')
1616
const { httpAgent, httpsAgent } = require('./agents')
1717
const {
@@ -27,25 +27,6 @@ const maxActiveBufferSize = 1024 * 1024 * 64
2727

2828
let activeBufferSize = 0
2929

30-
/**
31-
* @param {string|URL|object} urlObjOrString
32-
* @returns {object}
33-
*/
34-
function parseUrl (urlObjOrString) {
35-
if (urlObjOrString !== null && typeof urlObjOrString === 'object') return urlToHttpOptions(urlObjOrString)
36-
37-
const url = urlToHttpOptions(new URL(urlObjOrString))
38-
39-
// Special handling if we're using named pipes on Windows
40-
if (url.protocol === 'unix:' && url.hostname === '.') {
41-
const udsPath = urlObjOrString.slice(5)
42-
url.path = udsPath
43-
url.pathname = udsPath
44-
}
45-
46-
return url
47-
}
48-
4930
/**
5031
* @param {string} hostname Host as resolved by {@link parseUrl}; IPv6 is unbracketed (`::1`).
5132
*/
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use strict'
2+
3+
const { urlToHttpOptions } = require('./url-to-http-options-polyfill')
4+
5+
/**
6+
* Convert an agent/intake URL into Node http(s) request options.
7+
*
8+
* A Windows named pipe (`unix://./pipe/<name>`) parses the `.` as the URL
9+
* authority, dropping it from the path. Fold it back so the socket path stays
10+
* `//./pipe/<name>`; otherwise it collapses to `/pipe/<name>` and misses the
11+
* pipe. Exporters receive `config.url` as a URL object, so this must run for
12+
* both string and object input.
13+
*
14+
* @param {string|URL|object} urlObjOrString
15+
* @returns {object}
16+
*/
17+
function parseUrl (urlObjOrString) {
18+
// `urlToHttpOptions` returns `pathname` at runtime, but @types/node narrows it
19+
// to `ClientRequestArgs`, which omits it; cast so the named-pipe fold below can
20+
// read and rewrite it.
21+
const url = /** @type {import('node:http').ClientRequestArgs & { pathname: string }} */ (
22+
urlObjOrString !== null && typeof urlObjOrString === 'object'
23+
? urlToHttpOptions(urlObjOrString)
24+
: urlToHttpOptions(new URL(urlObjOrString))
25+
)
26+
27+
if (url.protocol === 'unix:' && url.hostname === '.') {
28+
url.path = url.pathname = `//.${url.pathname}`
29+
}
30+
31+
return url
32+
}
33+
34+
module.exports = { parseUrl }

packages/dd-trace/src/exporters/span-stats/writer.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ function makeRequest (data, url, cb) {
3737
'Datadog-Meta-Tracer-Version': pkg.version,
3838
'Content-Type': 'application/msgpack',
3939
},
40-
protocol: url.protocol,
41-
hostname: url.hostname,
42-
port: url.port,
40+
url,
4341
}
4442

4543
log.debug('Request to the intake: %j', options)

packages/dd-trace/src/profiling/exporters/agent.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
const { request: httpRequest } = require('http')
44
const { request: httpsRequest } = require('https')
55
const perf = require('perf_hooks').performance
6-
const { urlToHttpOptions } = require('url')
76

87
const retry = require('../../../../../vendor/dist/retry')
98
// TODO: avoid using dd-trace internals. Make this a separate module?
109
const docker = require('../../exporters/common/docker')
1110
const FormData = require('../../exporters/common/form-data')
11+
const { parseUrl } = require('../../exporters/common/url')
1212
const log = require('../../log')
1313
const { storage } = require('../../../../datadog-core')
1414
const version = require('../../../../../package.json').version
@@ -156,13 +156,13 @@ class AgentExporter extends EventSerializer {
156156

157157
docker.inject(options.headers)
158158

159-
if (this._url.protocol === 'unix:') {
160-
options.socketPath = this._url.pathname
159+
const url = parseUrl(this._url)
160+
if (url.protocol === 'unix:') {
161+
options.socketPath = url.pathname
161162
} else {
162-
const httpOptions = urlToHttpOptions(this._url)
163-
options.protocol = httpOptions.protocol
164-
options.hostname = httpOptions.hostname
165-
options.port = httpOptions.port
163+
options.protocol = url.protocol
164+
options.hostname = url.hostname
165+
options.port = url.port
166166
}
167167

168168
// eslint-disable-next-line eslint-rules/eslint-log-printf-style

packages/dd-trace/src/telemetry/send-data.js

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -182,23 +182,25 @@ function sendData (config, application, host, reqType, payload = {}, cb = () =>
182182
agentTelemetry = false
183183
}
184184
// figure out which data center to send to
185-
const backendUrl = getAgentlessTelemetryEndpoint(config.site)
185+
let backendUrl
186+
try {
187+
backendUrl = new URL(getAgentlessTelemetryEndpoint(config.site))
188+
} catch {
189+
log.error('Invalid Telemetry URL')
190+
return
191+
}
186192
const backendHeader = { ...options.headers, 'DD-API-KEY': config.apiKey }
187193
const backendOptions = {
188194
...options,
189195
url: backendUrl,
190196
headers: backendHeader,
191197
path: '/api/v2/apmtelemetry',
192198
}
193-
if (backendUrl) {
194-
request(data, backendOptions, (error) => {
195-
if (error) {
196-
log.error('Error sending telemetry data', error)
197-
}
198-
})
199-
} else {
200-
log.error('Invalid Telemetry URL')
201-
}
199+
request(data, backendOptions, (error) => {
200+
if (error) {
201+
log.error('Error sending telemetry data', error)
202+
}
203+
})
202204
}
203205

204206
if (!error && !agentTelemetry) {

packages/dd-trace/test/ci-visibility/requests/request.spec.js

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

33
const assert = require('node:assert/strict')
4+
const http = require('node:http')
45

56
const { describe, it, beforeEach, afterEach } = require('mocha')
67
const nock = require('nock')
@@ -78,4 +79,22 @@ describe('ci-visibility/requests/request', () => {
7879
done()
7980
})
8081
})
82+
83+
// A Windows named pipe URL object must reach http.request as a single
84+
// `//./pipe/<name>` socket path; the connection itself fails on the test host,
85+
// which is fine — we only pin the options the request was built with.
86+
it('derives the socket path for a Windows named pipe URL object', (done) => {
87+
const requestSpy = sinon.spy(http, 'request')
88+
89+
request('{}', { url: new URL('unix://./pipe/datadog'), path: '/path' }, () => {
90+
requestSpy.restore()
91+
try {
92+
assert.ok(requestSpy.called)
93+
assert.strictEqual(requestSpy.getCall(0).args[0].socketPath, '//./pipe/datadog')
94+
} catch (error) {
95+
return done(error)
96+
}
97+
done()
98+
})
99+
})
81100
})

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,30 @@ describe('request', function () {
431431
})
432432
})
433433

434+
// Config always hands exporters a URL object (`new URL(...)`), not a string,
435+
// so the object branch of parseUrl must apply the same named-pipe handling.
436+
// The URL parser splits `unix://./pipe/foo` into authority `.` + path
437+
// `/pipe/foo`; without folding `.` back the socket path collapses to
438+
// `/pipe/foo` and misses the pipe.
439+
it('should parse windows named pipes given as a URL object properly', (done) => {
440+
const sandbox = sinon.createSandbox()
441+
sandbox.spy(http, 'request')
442+
443+
maxAttempts = 1
444+
445+
request(
446+
Buffer.from(''), {
447+
url: new URL('unix://./pipe/datadogtrace'),
448+
method: 'PUT',
449+
},
450+
() => {
451+
const callOptions = http.request.getCall(0).args[0]
452+
sandbox.restore()
453+
assert.strictEqual(callOptions.socketPath, '//./pipe/datadogtrace')
454+
done()
455+
})
456+
})
457+
434458
it('should calculate correct Content-Length header for multi-byte characters', (done) => {
435459
const sandbox = sinon.createSandbox()
436460
sandbox.spy(http, 'request')
@@ -470,7 +494,7 @@ describe('request', function () {
470494
})
471495

472496
afterEach(() => {
473-
sandbox.reset()
497+
sandbox.restore()
474498
})
475499

476500
it('should properly set request host with IPv6', (done) => {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use strict'
2+
3+
const assert = require('node:assert/strict')
4+
5+
const { describe, it } = require('mocha')
6+
7+
require('../../setup/core')
8+
9+
const { parseUrl } = require('../../../src/exporters/common/url')
10+
11+
describe('exporters/common/url parseUrl', () => {
12+
describe('unix domain sockets', () => {
13+
it('keeps the socket path for a string URL', () => {
14+
const url = parseUrl('unix:///var/run/datadog/apm.socket')
15+
16+
assert.strictEqual(url.protocol, 'unix:')
17+
assert.strictEqual(url.pathname, '/var/run/datadog/apm.socket')
18+
})
19+
20+
it('keeps the socket path for a URL object', () => {
21+
const url = parseUrl(new URL('unix:///var/run/datadog/apm.socket'))
22+
23+
assert.strictEqual(url.protocol, 'unix:')
24+
assert.strictEqual(url.pathname, '/var/run/datadog/apm.socket')
25+
})
26+
})
27+
28+
// The `.` authority of a `unix://./pipe/<name>` URL is parsed out of the path,
29+
// so it must be folded back into `//./pipe/<name>`. Both branches matter: the
30+
// string form is what tests/CLIs pass, the object form is what config hands
31+
// every exporter.
32+
describe('windows named pipes', () => {
33+
it('folds the authority back for a string URL', () => {
34+
const url = parseUrl('unix://./pipe/datadog')
35+
36+
assert.strictEqual(url.protocol, 'unix:')
37+
assert.strictEqual(url.pathname, '//./pipe/datadog')
38+
})
39+
40+
it('folds the authority back for a URL object', () => {
41+
const url = parseUrl(new URL('unix://./pipe/datadog'))
42+
43+
assert.strictEqual(url.protocol, 'unix:')
44+
assert.strictEqual(url.pathname, '//./pipe/datadog')
45+
})
46+
47+
it('keeps the backslash form untouched', () => {
48+
const url = parseUrl(new URL('unix:\\\\.\\pipe\\datadog'))
49+
50+
assert.strictEqual(url.protocol, 'unix:')
51+
assert.strictEqual(url.pathname, '\\\\.\\pipe\\datadog')
52+
})
53+
})
54+
55+
describe('http(s) urls', () => {
56+
it('maps protocol, hostname and port for a string URL', () => {
57+
const url = parseUrl('https://127.0.0.1:8126/path')
58+
59+
assert.strictEqual(url.protocol, 'https:')
60+
assert.strictEqual(url.hostname, '127.0.0.1')
61+
assert.strictEqual(String(url.port), '8126')
62+
})
63+
64+
it('maps protocol, hostname and port for a URL object', () => {
65+
const url = parseUrl(new URL('http://localhost:8126'))
66+
67+
assert.strictEqual(url.protocol, 'http:')
68+
assert.strictEqual(url.hostname, 'localhost')
69+
assert.strictEqual(String(url.port), '8126')
70+
})
71+
})
72+
})

0 commit comments

Comments
 (0)