Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ddTrace esbuild plugin /* @dd-bundle: */ comment expressions PoC #2899

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dev,chalk,MIT,Copyright Sindre Sorhus
dev,checksum,MIT,Copyright Daniel D. Shaw
dev,cli-table3,MIT,Copyright 2014 James Talmage
dev,dotenv,BSD-2-Clause,Copyright 2015 Scott Motte
dev,esbuild,MIT,Copyright (c) 2020 Evan Wallace
dev,eslint,MIT,Copyright JS Foundation and other contributors https://js.foundation
dev,eslint-config-standard,MIT,Copyright Feross Aboukhadijeh
dev,eslint-plugin-import,MIT,Copyright 2015 Ben Mosher
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,50 @@ That said, even if your application runs on Lambda, any core instrumentation iss
Regardless of where you open the issue, someone at Datadog will try to help.


## Bundling

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.

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.

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).

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.

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.

### Esbuild Support

This library provides experimental esbuild support in the v3 release line and requires at least Node.js v14.17. To use the bundler, make sure you have `dd-trace` installed, and then require the `dd-trace/esbuild` module when building your bundle.

Here's an example of how one might use `dd-trace` with esbuild:

```javascript
const ddPlugin = require('dd-trace/esbuild')
const esbuild = require('esbuild')

esbuild.build({
entryPoints: ['app.js'],
bundle: true,
outfile: 'out.js',
plugins: [ddPlugin],
external: [ // this depends on the native modules used by your app
'pg-native',
'graphql/language/visitor',
'graphql/language/printer',
'graphql/utilities',
],
platform: 'node', // allows built-in modules to be required
target: ['node16']
}).catch((err) => {
console.error(err)
process.exit(1)
})
```

When you run your build, if you get errors when encountering native modules, you'll need to add them to the `external` list. In the above example the application is using the native Postgres library as well as some native GraphQL libraries.


## Security Vulnerabilities

If you have found a security issue, please contact the security team directly at [security@datadoghq.com](mailto:security@datadoghq.com).
3 changes: 3 additions & 0 deletions esbuild.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'use strict'

module.exports = require('./packages/datadog-esbuild/index.js')
31 changes: 31 additions & 0 deletions integration-tests/esbuild.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env node

'use strict'

const chproc = require('child_process')
const path = require('path')

const CWD = process.cwd()
const TEST_DIR = path.join(__dirname, 'esbuild')

// eslint-disable-next-line no-console
console.log(`cd ${TEST_DIR}`)
process.chdir(TEST_DIR)

// eslint-disable-next-line no-console
console.log('npm run build')
chproc.execSync('npm run build')

// eslint-disable-next-line no-console
console.log('npm run built')
try {
chproc.execSync('npm run built', {
timeout: 1000 * 30
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
process.exit(1)
} finally {
process.chdir(CWD)
}
1 change: 1 addition & 0 deletions integration-tests/esbuild/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
out.js
Empty file.
Empty file.
35 changes: 35 additions & 0 deletions integration-tests/esbuild/basic-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env node

const tracer = require('../../').init() // dd-trace

const assert = require('assert')
const express = require('express')
const http = require('http')

const app = express()
const PORT = 31415

assert.equal(express.static.mime.types.ogg, 'audio/ogg')

const server = app.listen(PORT, () => {
setImmediate(() => {
http.request(`http://localhost:${PORT}`).end() // query to self
})
})

app.get('/', async (_req, res) => {
assert.equal(
tracer.scope().active().context()._tags.component,
'express',
'the sample app bundled by esbuild is not properly instrumented'
) // bad exit

res.json({ narwhal: 'bacons' })

setImmediate(() => {
server.close() // clean exit
setImmediate(() => {
process.exit(0)
})
})
})
16 changes: 16 additions & 0 deletions integration-tests/esbuild/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env node

const ddPlugin = require('../../esbuild') // dd-trace/esbuild
const esbuild = require('esbuild')

esbuild.build({
entryPoints: ['basic-test.js'],
bundle: true,
outfile: 'out.js',
plugins: [ddPlugin],
platform: 'node',
target: ['node16']
}).catch((err) => {
console.error(err) // eslint-disable-line no-console
process.exit(1)
})
42 changes: 42 additions & 0 deletions integration-tests/esbuild/complex-app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env node

require('../../').init() // dd-trace
const assert = require('assert')
const express = require('express')
const redis = require('redis')
const app = express()
const PORT = 3000
const pg = require('pg')
const pgp = require('pg-promise')() // transient dep of 'pg'

assert.equal(redis.Graph.name, 'Graph')
assert.equal(pg.types.builtins.BOOL, 16)
assert.equal(express.static.mime.types.ogg, 'audio/ogg')

const conn = {
user: 'postgres',
host: 'localhost',
database: 'postgres',
password: 'hunter2',
port: 5433
}

console.log('pg connect') // eslint-disable-line no-console
const client = new pg.Client(conn)
client.connect()

console.log('pg-promise connect') // eslint-disable-line no-console
const client2 = pgp(conn)

app.get('/', async (_req, res) => {
const query = await client.query('SELECT NOW() AS now')
const query2 = await client2.query('SELECT NOW() AS now')
res.json({
connection_pg: query.rows[0].now,
connection_pg_promise: query2[0].now
})
})

app.listen(PORT, () => {
console.log(`Example app listening on port ${PORT}`) // eslint-disable-line no-console
})
43 changes: 43 additions & 0 deletions integration-tests/esbuild/complex-app.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env node

import 'dd-trace/init.js'
import assert from 'assert'
import express from 'express'
import redis from 'redis'
const app = express()
const PORT = 3000
import pg from 'pg'
import PGP from 'pg-promise' // transient dep of 'pg'
const pgp = PGP()

assert.equal(redis.Graph.name, 'Graph')
assert.equal(pg.types.builtins.BOOL, 16)
assert.equal(express.static.mime.types.ogg, 'audio/ogg')

const conn = {
user: 'postgres',
host: 'localhost',
database: 'postgres',
password: 'hunter2',
port: 5433
}

console.log('pg connect') // eslint-disable-line no-console
const client = new pg.Client(conn)
client.connect()

console.log('pg-promise connect') // eslint-disable-line no-console
const client2 = pgp(conn)

app.get('/', async (_req, res) => {
const query = await client.query('SELECT NOW() AS now')
const query2 = await client2.query('SELECT NOW() AS now')
res.json({
connection_pg: query.rows[0].now,
connection_pg_promise: query2[0].now
})
})

app.listen(PORT, () => {
console.log(`Example app listening on port ${PORT}`) // eslint-disable-line no-console
})
24 changes: 24 additions & 0 deletions integration-tests/esbuild/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "esbuild-dd-trace-demo",
"private": true,
"version": "1.0.0",
"description": "basic example app bundling dd-trace via esbuild",
"main": "app.js",
"scripts": {
"build": "DD_TRACE_DEBUG=true node ./build.js",
"built": "DD_TRACE_DEBUG=true node ./out.js",
"raw": "DD_TRACE_DEBUG=true node ./app.js",
"link": "pushd ../.. && yarn link && popd && yarn link dd-trace",
"request": "curl http://localhost:3000 | jq"
},
"keywords": [
"esbuild",
"apm"
],
"author": "Thomas Hunter II <tlhunter@datadog.com>",
"license": "ISC",
"dependencies": {
"esbuild": "0.16.12",
"express": "^4.16.2"
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"checksum": "^0.1.1",
"cli-table3": "^0.5.1",
"dotenv": "8.2.0",
"esbuild": "0.16.12",
"eslint": "^8.23.0",
"eslint-config-standard": "^11.0.0-beta.0",
"eslint-plugin-import": "^2.8.0",
Expand Down
87 changes: 87 additions & 0 deletions packages/datadog-esbuild/dd-bundle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use strict'

const path = require('path')
const fs = require('fs')

const DD_BUNDLE_COMMENT = /\/\*\s*@dd-bundle:(.*)\s*\*\//

function isPackageIncluded (packagePath, packageName, resolveDir, builtins) {
return !resolveDir.includes('node_modules') &&
!builtins.has(packageName) &&
!packageName.endsWith('package.json') &&
packagePath.includes('appsec')
}

function readTemplate (resolveDir, tmplPath) {
const fileContent = fs.readFileSync(path.join(resolveDir, tmplPath), 'utf-8')

// TODO: should we escape fileContent?
return `\`${fileContent}\``
}

function resolve (packageName, resolveDir) {
const packageToResolve = packageName === '..' ? `../index.js` : packageName
return require.resolve(packageToResolve, { paths: [ resolveDir ] })
}

async function getDDBundleData (packageName, resolveDir, builtins) {
const packagePath = resolve(packageName, resolveDir)

if (isPackageIncluded(packagePath, packageName, resolveDir, builtins)) {
let contents
if (fs.existsSync(packagePath)) {
contents = await fs.promises.readFile(packagePath, 'utf-8')
if (contents.match(DD_BUNDLE_COMMENT)) {
return {
resolveDir,
packagePath,
contents
}
}
}
}
}

function replaceDDBundle ({ contents, packagePath }) {
const resolveDir = path.dirname(packagePath)
const lines = contents.split('\n')
let modified = false
try {
lines.forEach((line, index) => {
const match = line.match(DD_BUNDLE_COMMENT)
if (!match) return

const expr = match[1]

// TODO: support one expression language like spEL?
const exprEvalMatch = expr.match(/\${(.*)}/)
if (exprEvalMatch) {
// eslint-disable-next-line no-unused-vars
const template = ((base) => (path) => readTemplate(base, path))(resolveDir)

// TODO: should we get the AST and modify the tree instead a quick line replacement?
// eslint-disable-next-line no-eval
const resolved = eval(exprEvalMatch[1])
lines[index + 1] = expr.replace(exprEvalMatch[0], resolved)
} else {
lines[index + 1] = expr
}
modified = true
})
} catch (e) {
modified = false
}

if (modified) {
contents = lines.join('\n')
}
return {
contents,
resolveDir
}
}

module.exports = {
getDDBundleData,
replaceDDBundle
}
Loading