Skip to content

Commit 6939c03

Browse files
authored
feat: undici and fetch() instrumentation (#2858)
Instrument the undici HTTP client library and Node's core `fetch()`. Closes: #2383
1 parent e38507c commit 6939c03

File tree

16 files changed

+823
-76
lines changed

16 files changed

+823
-76
lines changed

.ci/.jenkins_tav.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ TAV:
3131
- redis
3232
- restify
3333
- tedious
34+
- undici
3435
- ws

.tav.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,11 @@ aws-sdk:
551551
- node test/instrumentation/modules/aws-sdk/sns.test.js
552552
- node test/instrumentation/modules/aws-sdk/sqs.test.js
553553
- node test/instrumentation/modules/aws-sdk/dynamodb.test.js
554+
555+
# - [email protected] added its diagnostics_channel support.
556+
# - In [email protected] the `request.origin` property was added, which we need
557+
# in the 'undici:request:create' diagnostic message.
558+
undici:
559+
versions: '>=4.7.1 <6'
560+
commands: node test/instrumentation/modules/undici/undici.test.js
561+
node: '>=12.18'

CHANGELOG.asciidoc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ Notes:
3939
[float]
4040
===== Features
4141
42+
- Add instrumentation for the https://undici.nodejs.org[undici] HTTP client
43+
library. This also adds instrumentation of Node.js v18's
44+
https://nodejs.org/api/all.html#all_globals_fetch[`fetch()`], which uses
45+
undici under the hood. For the instrumentation to work one must be using
46+
node v14.17.0 or later, or have installed the
47+
https://www.npmjs.com/package/diagnostics_channel['diagnostics_channel' polyfill].
48+
({issues}2383[#2383])
49+
4250
- Added `exitSpanMinDuration` configuration field, allowing end users to
4351
set a time threshold for dropping exit spans. ({pull}2843[#2843])
4452

docs/supported-technologies.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ so those should be supported as well.
145145
|https://www.npmjs.com/package/pg[pg] |>=4.0.0 <9.0.0 |Will instrument all queries
146146
|https://www.npmjs.com/package/redis[redis] |>=2.0.0 <4.0.0 |Will instrument all queries
147147
|https://www.npmjs.com/package/tedious[tedious] |>=1.9 <15.0.0 | (Excluding v4.0.0.) Will instrument all queries
148+
|https://www.npmjs.com/package/undici[undici] | >=4.7.1 <6 | Will instrument undici HTTP requests, except HTTP CONNECT. Requires node v14.17.0 or later, or the user to have installed the https://www.npmjs.com/package/diagnostics_channel['diagnostics_channel' polyfill].
148149
|https://www.npmjs.com/package/ws[ws] |>=1.0.0 <8.0.0 |Will instrument outgoing WebSocket messages
149150
|=======================================================================
150151

examples/trace-elasticsearch8.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ const apm = require('../').start({ // elastic-apm-node
1818
logUncaughtExceptions: true
1919
})
2020

21-
// Currently, pre-releases of v8 are published as the "...-canary" package name.
2221
// eslint-disable-next-line no-unused-vars
23-
const { Client, HttpConnection } = require('@elastic/elasticsearch-canary')
22+
const { Client, HttpConnection } = require('@elastic/elasticsearch')
2423

2524
const client = new Client({
26-
// With version 8 of the client, you can use `HttpConnection` to use the old
27-
// HTTP client:
25+
// By default version 8 uses the new undici HTTP client lib. You can specify
26+
// `HttpConnection` to use the older HTTP client.
2827
// Connection: HttpConnection,
29-
node: `http://${process.env.ES_HOST || 'localhost'}:9200`
28+
node: `http://${process.env.ES_HOST || 'localhost'}:9200`,
29+
maxRetries: 1
3030
})
3131

3232
async function run () {

examples/trace-fetch.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env node --no-warnings
2+
3+
/*
4+
* Copyright Elasticsearch B.V. and other contributors where applicable.
5+
* Licensed under the BSD 2-Clause License; you may not use this file except in
6+
* compliance with the BSD 2-Clause License.
7+
*/
8+
9+
// This example shows use of Node v18's core `fetch()`. The Node.js APM agent
10+
// will automatically instrument it.
11+
12+
/* global fetch */ // for eslint
13+
14+
const apm = require('../').start({
15+
serviceName: 'example-trace-fetch'
16+
})
17+
18+
const url = process.argv[2] || 'https://httpstat.us/200'
19+
20+
async function main () {
21+
// For tracing spans to be created, there must be an active transaction.
22+
// Typically, a transaction is automatically started for incoming HTTP
23+
// requests to a Node.js server. However, because this script is not running
24+
// an HTTP server, we manually start a transaction. More details at:
25+
// https://www.elastic.co/guide/en/apm/agent/nodejs/current/custom-transactions.html
26+
const trans = apm.startTransaction('trans')
27+
try {
28+
const res = await fetch(url)
29+
for (const [k, v] of res.headers) {
30+
console.log(`${k}: ${v}`)
31+
}
32+
const body = await res.text()
33+
console.log('\n' + body)
34+
} catch (err) {
35+
console.error('fetch error:', err)
36+
} finally {
37+
if (trans) trans.end()
38+
}
39+
}
40+
41+
main()

examples/trace-undici.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env node --no-warnings
2+
3+
/*
4+
* Copyright Elasticsearch B.V. and other contributors where applicable.
5+
* Licensed under the BSD 2-Clause License; you may not use this file except in
6+
* compliance with the BSD 2-Clause License.
7+
*/
8+
9+
// This example shows the Node.js APM agent's instrumentation of the 'undici'
10+
// HTTP client library.
11+
//
12+
// Set `ELASTIC_APM_SERVER_URL` and `ELASTIC_APM_SECRET_TOKEN` environment
13+
// variables to configure the APM agent, then run this script.
14+
15+
const apm = require('../').start({
16+
serviceName: 'example-trace-undici',
17+
usePathAsTransactionName: true // for our simple HTTP server
18+
})
19+
20+
const http = require('http')
21+
const undici = require('undici')
22+
23+
// Start a simple HTTP server that we will call with undici.
24+
const server = http.createServer((req, res) => {
25+
console.log('incoming request: %s %s %s', req.method, req.url, req.headers)
26+
req.resume()
27+
req.on('end', function () {
28+
const resBody = JSON.stringify({ ping: 'pong' })
29+
setTimeout(() => {
30+
res.writeHead(200, {
31+
'content-type': 'application/json',
32+
'content-length': Buffer.byteLength(resBody)
33+
})
34+
res.end(resBody)
35+
}, 100) // Take ~100ms to respond.
36+
})
37+
})
38+
server.listen(3000, async () => {
39+
// For tracing spans to be created, there must be an active transaction.
40+
// Typically, a transaction is automatically started for incoming HTTP
41+
// requests to a Node.js server. However, because the undici calls are not
42+
// in the context of an incoming HTTP request we manually start a transaction.
43+
// More details at:
44+
// https://www.elastic.co/guide/en/apm/agent/nodejs/current/custom-transactions.html
45+
const trans = apm.startTransaction('trans', 'manual')
46+
47+
// Make a handful of requests and use Undici's pipelining
48+
// (https://undici.nodejs.org/#/?id=pipelining) to show concurrent requests.
49+
const client = new undici.Client('http://localhost:3000', {
50+
pipelining: 2
51+
})
52+
let lastReq
53+
for (let i = 0; i < 4; i++) {
54+
lastReq = client.request({ method: 'GET', path: '/ping-' + i })
55+
}
56+
const { statusCode, headers, body } = await lastReq
57+
console.log('last ping statusCode:', statusCode)
58+
console.log('last ping headers:', headers)
59+
for await (const data of body) {
60+
console.log('last ping data:', data.toString())
61+
}
62+
63+
trans.end()
64+
client.close()
65+
server.close()
66+
})

lib/instrumentation/index.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ const {
2121
AsyncHooksRunContextManager,
2222
AsyncLocalStorageRunContextManager
2323
} = require('./run-context')
24-
const {
25-
getLambdaHandlerInfo
26-
} = require('../lambda')
24+
const { getLambdaHandlerInfo } = require('../lambda')
25+
const undiciInstr = require('./modules/undici')
2726

2827
const nodeSupportsAsyncLocalStorage = semver.satisfies(process.versions.node, '>=14.5 || ^12.19.0')
28+
// Node v16.5.0 added fetch support (behind `--experimental-fetch` until
29+
// v18.0.0) based on [email protected]. We can instrument undici >=v4.7.1.
30+
const nodeHasInstrumentableFetch = typeof (global.fetch) === 'function'
2931

3032
var MODULES = [
3133
['@elastic/elasticsearch', '@elastic/elasticsearch-canary'],
@@ -62,13 +64,15 @@ var MODULES = [
6264
'redis',
6365
'restify',
6466
'tedious',
67+
'undici',
6568
'ws'
6669
]
6770

6871
module.exports = Instrumentation
6972

7073
function Instrumentation (agent) {
7174
this._agent = agent
75+
this._disableInstrumentationsSet = null
7276
this._hook = null // this._hook is only exposed for testing purposes
7377
this._started = false
7478
this._runCtxMgr = null
@@ -209,6 +213,11 @@ Instrumentation.prototype.start = function (runContextClass) {
209213

210214
this._runCtxMgr.enable()
211215
this._startHook()
216+
217+
if (nodeHasInstrumentableFetch && this._isModuleEnabled('undici')) {
218+
this._log.debug('instrumenting fetch')
219+
undiciInstr.instrumentUndici(this._agent)
220+
}
212221
}
213222

214223
// Stop active instrumentation and reset global state *as much as possible*.
@@ -230,6 +239,10 @@ Instrumentation.prototype.stop = function () {
230239
this._hook.unhook()
231240
this._hook = null
232241
}
242+
243+
if (nodeHasInstrumentableFetch) {
244+
undiciInstr.uninstrumentUndici()
245+
}
233246
}
234247

235248
// Reset internal state for (relatively) clean re-use of this Instrumentation.
@@ -243,6 +256,13 @@ Instrumentation.prototype.testReset = function () {
243256
}
244257
}
245258

259+
Instrumentation.prototype._isModuleEnabled = function (modName) {
260+
if (!this._disableInstrumentationsSet) {
261+
this._disableInstrumentationsSet = new Set(this._agent._conf.disableInstrumentations)
262+
}
263+
return this._agent._conf.instrument && !this._disableInstrumentationsSet.has(modName)
264+
}
265+
246266
Instrumentation.prototype._startHook = function () {
247267
if (!this._started) return
248268
if (this._hook) {
@@ -251,12 +271,11 @@ Instrumentation.prototype._startHook = function () {
251271
}
252272

253273
var self = this
254-
var disabled = new Set(this._agent._conf.disableInstrumentations)
255274

256275
this._agent.logger.debug('adding hook to Node.js module loader')
257276

258277
this._hook = hook(this._patches.keys, function (exports, name, basedir) {
259-
var enabled = self._agent._conf.instrument && !disabled.has(name)
278+
const enabled = self._isModuleEnabled(name)
260279
var pkg, version
261280

262281
const isHandlingLambda = self._lambdaHandlerInfo && self._lambdaHandlerInfo.module === name

0 commit comments

Comments
 (0)