Skip to content

Commit b84df7a

Browse files
committed
support bundling via esbuild (#2763)
1 parent f262f08 commit b84df7a

File tree

17 files changed

+552
-5
lines changed

17 files changed

+552
-5
lines changed

LICENSE-3rdparty.csv

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dev,chalk,MIT,Copyright Sindre Sorhus
3636
dev,checksum,MIT,Copyright Daniel D. Shaw
3737
dev,cli-table3,MIT,Copyright 2014 James Talmage
3838
dev,dotenv,BSD-2-Clause,Copyright 2015 Scott Motte
39+
dev,esbuild,MIT,Copyright (c) 2020 Evan Wallace
3940
dev,eslint,MIT,Copyright JS Foundation and other contributors https://js.foundation
4041
dev,eslint-config-standard,MIT,Copyright Feross Aboukhadijeh
4142
dev,eslint-plugin-import,MIT,Copyright 2015 Ben Mosher

README.md

+36
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,42 @@ That said, even if your application runs on Lambda, any core instrumentation iss
188188
Regardless of where you open the issue, someone at Datadog will try to help.
189189

190190

191+
## Bundling
192+
193+
Generally, `dd-trace` works by intercepting `require()` calls that a Node.js application makes when loading modules. This includes modules that are built-in to Node.js, like the `fs` module for accessing the filesystem, as well as modules installed from the npm registry, like the `pg` database module.
194+
195+
Also generally, bundlers work by crawling all of the `require()` calls that an application makes to files on disk, replacing the `require()` calls with custom code, and then concatenating all of the resulting JavaScript into one "bundled" file. When a built-in module is loaded, like `require('fs')`, that call can then remain the same in the resulting bundle.
196+
197+
Fundamentally APM tools like `dd-trace` stop working at this point. Perhaps they continue to intercept the calls for built-in modules but don't intercept calls to third party libraries. This means that by default when you bundle a `dd-trace` app with a bundler it is likely to capture information about disk access (via `fs`) and outbound HTTP requests (via `http`), but will otherwise omit calls to third party libraries (like extracting incoming request route information for the `express` framework or showing which query is run for the `mysql` database client).
198+
199+
To get around this, one can treat all third party modules, or at least third party modules that the APM needs to instrument, as being "external" to the bundler. With this setting the instrumented modules remain on disk and continue to be loaded via `require()` while the non-instrumented modules are bundled. Sadly this results in a build with many extraneous files and starts to defeat the purpose of bundling.
200+
201+
For these reasons it's necessary to have custom-built bundler plugins. Such plugins are able to instruct the bundler on how to behave, injecting intermediary code and otherwise intercepting the "translated" `require()` calls. The result is that many more packages are then included in the bundled JavaScript file. Some applications can have 100% of modules bundled, however native modules still need to remain external to the bundle.
202+
203+
### Esbuild Support
204+
205+
This library provides experimental esbuild support in the form of an esbuild plugin, and requires at least Node.js v14.17. To use the plugin, make sure you have `dd-trace@3+` installed, and then require the `dd-trace/esbuild` module when building your bundle.
206+
207+
Here's an example of how one might use `dd-trace` with esbuild:
208+
209+
```javascript
210+
const ddPlugin = require('dd-trace/esbuild')
211+
const esbuild = require('esbuild')
212+
213+
esbuild.build({
214+
entryPoints: ['app.js'],
215+
bundle: true,
216+
outfile: 'out.js',
217+
plugins: [ddPlugin],
218+
platform: 'node', // allows built-in modules to be required
219+
target: ['node16']
220+
}).catch((err) => {
221+
console.error(err)
222+
process.exit(1)
223+
})
224+
```
225+
226+
191227
## Security Vulnerabilities
192228

193229
If you have found a security issue, please contact the security team directly at [security@datadoghq.com](mailto:security@datadoghq.com).

esbuild.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
'use strict'
2+
3+
module.exports = require('./packages/datadog-esbuild/index.js')

integration-tests/esbuild.spec.js

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env node
2+
3+
'use strict'
4+
5+
const chproc = require('child_process')
6+
const path = require('path')
7+
8+
const CWD = process.cwd()
9+
const TEST_DIR = path.join(__dirname, 'esbuild')
10+
11+
// eslint-disable-next-line no-console
12+
console.log(`cd ${TEST_DIR}`)
13+
process.chdir(TEST_DIR)
14+
15+
// eslint-disable-next-line no-console
16+
console.log('npm run build')
17+
chproc.execSync('npm run build')
18+
19+
// eslint-disable-next-line no-console
20+
console.log('npm run built')
21+
try {
22+
chproc.execSync('npm run built', {
23+
timeout: 1000 * 30
24+
})
25+
} catch (err) {
26+
// eslint-disable-next-line no-console
27+
console.error(err)
28+
process.exit(1)
29+
} finally {
30+
process.chdir(CWD)
31+
}

integration-tests/esbuild/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
out.js
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env node
2+
3+
const tracer = require('../../').init() // dd-trace
4+
5+
const assert = require('assert')
6+
const express = require('express')
7+
const http = require('http')
8+
9+
const app = express()
10+
const PORT = 31415
11+
12+
assert.equal(express.static.mime.types.ogg, 'audio/ogg')
13+
14+
const server = app.listen(PORT, () => {
15+
setImmediate(() => {
16+
http.request(`http://localhost:${PORT}`).end() // query to self
17+
})
18+
})
19+
20+
app.get('/', async (_req, res) => {
21+
assert.equal(
22+
tracer.scope().active().context()._tags.component,
23+
'express',
24+
'the sample app bundled by esbuild is not properly instrumented'
25+
) // bad exit
26+
27+
res.json({ narwhal: 'bacons' })
28+
29+
setImmediate(() => {
30+
server.close() // clean exit
31+
setImmediate(() => {
32+
process.exit(0)
33+
})
34+
})
35+
})

integration-tests/esbuild/build.js

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env node
2+
3+
const ddPlugin = require('../../esbuild') // dd-trace/esbuild
4+
const esbuild = require('esbuild')
5+
6+
esbuild.build({
7+
entryPoints: ['basic-test.js'],
8+
bundle: true,
9+
outfile: 'out.js',
10+
plugins: [ddPlugin],
11+
platform: 'node',
12+
target: ['node16']
13+
}).catch((err) => {
14+
console.error(err) // eslint-disable-line no-console
15+
process.exit(1)
16+
})
+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env node
2+
3+
require('../../').init() // dd-trace
4+
const assert = require('assert')
5+
const express = require('express')
6+
const redis = require('redis')
7+
const app = express()
8+
const PORT = 3000
9+
const pg = require('pg')
10+
const pgp = require('pg-promise')() // transient dep of 'pg'
11+
12+
assert.equal(redis.Graph.name, 'Graph')
13+
assert.equal(pg.types.builtins.BOOL, 16)
14+
assert.equal(express.static.mime.types.ogg, 'audio/ogg')
15+
16+
const conn = {
17+
user: 'postgres',
18+
host: 'localhost',
19+
database: 'postgres',
20+
password: 'hunter2',
21+
port: 5433
22+
}
23+
24+
console.log('pg connect') // eslint-disable-line no-console
25+
const client = new pg.Client(conn)
26+
client.connect()
27+
28+
console.log('pg-promise connect') // eslint-disable-line no-console
29+
const client2 = pgp(conn)
30+
31+
app.get('/', async (_req, res) => {
32+
const query = await client.query('SELECT NOW() AS now')
33+
const query2 = await client2.query('SELECT NOW() AS now')
34+
res.json({
35+
connection_pg: query.rows[0].now,
36+
connection_pg_promise: query2[0].now
37+
})
38+
})
39+
40+
app.listen(PORT, () => {
41+
console.log(`Example app listening on port ${PORT}`) // eslint-disable-line no-console
42+
})
+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env node
2+
3+
import 'dd-trace/init.js'
4+
import assert from 'assert'
5+
import express from 'express'
6+
import redis from 'redis'
7+
const app = express()
8+
const PORT = 3000
9+
import pg from 'pg'
10+
import PGP from 'pg-promise' // transient dep of 'pg'
11+
const pgp = PGP()
12+
13+
assert.equal(redis.Graph.name, 'Graph')
14+
assert.equal(pg.types.builtins.BOOL, 16)
15+
assert.equal(express.static.mime.types.ogg, 'audio/ogg')
16+
17+
const conn = {
18+
user: 'postgres',
19+
host: 'localhost',
20+
database: 'postgres',
21+
password: 'hunter2',
22+
port: 5433
23+
}
24+
25+
console.log('pg connect') // eslint-disable-line no-console
26+
const client = new pg.Client(conn)
27+
client.connect()
28+
29+
console.log('pg-promise connect') // eslint-disable-line no-console
30+
const client2 = pgp(conn)
31+
32+
app.get('/', async (_req, res) => {
33+
const query = await client.query('SELECT NOW() AS now')
34+
const query2 = await client2.query('SELECT NOW() AS now')
35+
res.json({
36+
connection_pg: query.rows[0].now,
37+
connection_pg_promise: query2[0].now
38+
})
39+
})
40+
41+
app.listen(PORT, () => {
42+
console.log(`Example app listening on port ${PORT}`) // eslint-disable-line no-console
43+
})
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "esbuild-dd-trace-demo",
3+
"private": true,
4+
"version": "1.0.0",
5+
"description": "basic example app bundling dd-trace via esbuild",
6+
"main": "app.js",
7+
"scripts": {
8+
"build": "DD_TRACE_DEBUG=true node ./build.js",
9+
"built": "DD_TRACE_DEBUG=true node ./out.js",
10+
"raw": "DD_TRACE_DEBUG=true node ./app.js",
11+
"link": "pushd ../.. && yarn link && popd && yarn link dd-trace",
12+
"request": "curl http://localhost:3000 | jq"
13+
},
14+
"keywords": [
15+
"esbuild",
16+
"apm"
17+
],
18+
"author": "Thomas Hunter II <tlhunter@datadog.com>",
19+
"license": "ISC",
20+
"dependencies": {
21+
"esbuild": "0.16.12",
22+
"express": "^4.16.2"
23+
}
24+
}

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"checksum": "^0.1.1",
105105
"cli-table3": "^0.5.1",
106106
"dotenv": "8.2.0",
107+
"esbuild": "0.16.12",
107108
"eslint": "^8.23.0",
108109
"eslint-config-standard": "^11.0.0-beta.0",
109110
"eslint-plugin-import": "^2.8.0",

packages/datadog-esbuild/index.js

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use strict'
2+
3+
/* eslint-disable no-console */
4+
5+
const NAMESPACE = 'datadog'
6+
7+
const instrumented = Object.keys(require('../datadog-instrumentations/src/helpers/hooks.js'))
8+
const rawBuiltins = require('module').builtinModules
9+
10+
warnIfUnsupported()
11+
12+
const builtins = new Set()
13+
14+
for (const builtin of rawBuiltins) {
15+
builtins.add(builtin)
16+
builtins.add(`node:${builtin}`)
17+
}
18+
19+
const packagesOfInterest = new Set()
20+
21+
const DEBUG = !!process.env.DD_TRACE_DEBUG
22+
23+
// We don't want to handle any built-in packages via DCITM
24+
// Those packages will still be handled via RITM
25+
// Attempting to instrument them would fail as they have no package.json file
26+
for (const pkg of instrumented) {
27+
if (builtins.has(pkg)) continue
28+
if (pkg.startsWith('node:')) continue
29+
packagesOfInterest.add(pkg)
30+
}
31+
32+
const DC_CHANNEL = 'dd-trace:bundledModuleLoadStart'
33+
34+
module.exports.name = 'datadog-esbuild'
35+
36+
module.exports.setup = function (build) {
37+
build.onResolve({ filter: /.*/ }, args => {
38+
const packageName = args.path
39+
40+
if (args.namespace === 'file' && packagesOfInterest.has(packageName)) {
41+
// The file namespace is used when requiring files from disk in userland
42+
const pathToPackageJson = require.resolve(`${packageName}/package.json`, { paths: [ args.resolveDir ] })
43+
const pkg = require(pathToPackageJson)
44+
45+
if (DEBUG) {
46+
console.log(`resolve ${packageName}@${pkg.version}`)
47+
}
48+
49+
// https://esbuild.github.io/plugins/#on-resolve-arguments
50+
return {
51+
path: packageName,
52+
namespace: NAMESPACE,
53+
pluginData: {
54+
version: pkg.version
55+
}
56+
}
57+
} else if (args.namespace === 'datadog') {
58+
// The datadog namespace is used when requiring files that are injected during the onLoad stage
59+
// see note in onLoad
60+
61+
if (builtins.has(packageName)) return
62+
63+
return {
64+
path: require.resolve(packageName, { paths: [ args.resolveDir ] }),
65+
namespace: 'file'
66+
}
67+
}
68+
})
69+
70+
build.onLoad({ filter: /.*/, namespace: NAMESPACE }, args => {
71+
if (DEBUG) {
72+
console.log(`load ${args.path}@${args.pluginData.version}`)
73+
}
74+
75+
// JSON.stringify adds double quotes. For perf gain could simply add in quotes when we know it's safe.
76+
const contents = `
77+
const dc = require('diagnostics_channel');
78+
const ch = dc.channel(${JSON.stringify(DC_CHANNEL + ':' + args.path)});
79+
const mod = require(${JSON.stringify(args.path)});
80+
const payload = {
81+
module: mod,
82+
path: ${JSON.stringify(args.path)},
83+
version: ${JSON.stringify(args.pluginData.version)}
84+
};
85+
ch.publish(payload);
86+
module.exports = payload.module;
87+
`
88+
// https://esbuild.github.io/plugins/#on-load-results
89+
return {
90+
contents,
91+
loader: 'js'
92+
}
93+
})
94+
}
95+
96+
function warnIfUnsupported () {
97+
const [major, minor] = process.versions.node.split('.').map(Number)
98+
if (major < 14 || (major === 14 && minor < 17)) {
99+
console.error('WARNING: Esbuild support isn\'t available for older versions of Node.js.')
100+
console.error(`Expected: Node.js >= v14.17. Actual: Node.js = ${process.version}.`)
101+
console.error('This application may build properly with this version of Node.js, but unless a')
102+
console.error('more recent version is used at runtime, third party packages won\'t be instrumented.')
103+
}
104+
}

0 commit comments

Comments
 (0)