Skip to content

Commit 6928f88

Browse files
committed
feat(esbuild-plugin): esbuild plugin to prepare bundled modules for instrumentation
(More details to come.) Refs: #1856 Refs: open-telemetry/opentelemetry-js#4818
1 parent 2dc2f72 commit 6928f88

File tree

9 files changed

+423
-0
lines changed

9 files changed

+423
-0
lines changed

package-lock.json

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/esbuild-plugin/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# OTel esbuild-plugin
2+
3+
This is a proposal for a `diagnostics_channel`-based mechanism for bundlers
4+
to hand off a loaded module, at runtime, to possibly active OTel
5+
instrumentations. This is an alternative proposal to
6+
https://github.com/open-telemetry/opentelemetry-js-contrib/pull/1856
7+
8+
More details in the PR.
9+
10+
XXX obviously I need to fill this all in
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package-lock=false
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const http = require('http');
2+
const fastify = require('fastify');
3+
const {createClient} = require('redis');
4+
// const clientS3 = require('@aws-sdk/client-s3');
5+
// console.log('XXX client-s3: ', !!clientS3);
6+
7+
const redis = createClient();
8+
9+
const server = fastify();
10+
server.get('/ping', async (req, reply) => {
11+
const bar = await redis.get('bar');
12+
reply.send(`pong (redis key "bar" is: ${bar})`);
13+
});
14+
15+
async function main() {
16+
await redis.connect();
17+
await redis.set('bar', 'baz');
18+
19+
await server.listen({port: 3000});
20+
const port = server.server.address().port;
21+
await new Promise((resolve) => {
22+
http.get(`http://localhost:${port}/ping`, (res) => {
23+
const chunks = [];
24+
res.on('data', (chunk) => { chunks.push(chunk); });
25+
res.on('end', () => {
26+
console.log('client res: status=%s headers=%s body=%s',
27+
res.statusCode, res.headers, Buffer.concat(chunks).toString());
28+
resolve();
29+
});
30+
});
31+
});
32+
server.close();
33+
34+
await redis.quit();
35+
36+
setTimeout(function () {
37+
console.log('Done lame wait for batch span send.')
38+
// console.log('XXX ', process._getActiveHandles());
39+
}, 10000);
40+
}
41+
42+
main();
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
2+
import * as assert from 'assert';
3+
import * as fs from 'fs';
4+
import * as module from 'module';
5+
import * as path from 'path';
6+
import * as esbuild from 'esbuild';
7+
import { FastifyInstrumentation } from '@opentelemetry/instrumentation-fastify';
8+
import { RedisInstrumentation } from '@opentelemetry/instrumentation-redis-4';
9+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
10+
11+
// XXX esbuild plugin for OTel, heavily influenced by https://github.com/DataDog/dd-trace-js/tree/master/packages/datadog-esbuild/
12+
// TODO: add DD copyright to top of file? e.g. similar to https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation/hook.mjs
13+
14+
// XXX does this plugin need to be CommonJS so a CJS-using esbuild.js file can used it? Probably, yes.
15+
16+
const NAME = '@opentelemetry/esbuild-plugin'
17+
const DEBUG = ['all', 'verbose', 'debug'].includes(process.env.OTEL_LOG_LEVEL.toLowerCase())
18+
const debug = DEBUG
19+
? (msg, ...args) => { console.debug(`${NAME} debug: ${msg}`, ...args); }
20+
: () => {};
21+
22+
// XXX doc this
23+
function pkgInfoFromPath(abspath) {
24+
const normpath = path.sep !== '/'
25+
? abspath.replaceAll(path.sep, '/')
26+
: abspath;
27+
const NM = 'node_modules/';
28+
let idx = normpath.lastIndexOf(NM);
29+
if (idx < 0) {
30+
return;
31+
}
32+
idx += NM.length;
33+
let endIdx = normpath.indexOf('/', idx);
34+
if (endIdx < 0) {
35+
return;
36+
}
37+
if (normpath[idx] === '@') {
38+
endIdx = normpath.indexOf('/', endIdx + 1);
39+
if (endIdx < 0) {
40+
return;
41+
}
42+
}
43+
44+
assert.equal(path.sep.length, 1);
45+
return {
46+
name: normpath.slice(idx, endIdx),
47+
// XXX doc normalization
48+
fullModulePath: normpath.slice(idx),
49+
pjPath: path.join(abspath.slice(0, endIdx), 'package.json'),
50+
};
51+
}
52+
53+
/**
54+
* How this works. Take `require('fastify')`, for example.
55+
*
56+
* - esbuild calls:
57+
* onResolve({path: 'fastify', namespace: 'file' })`
58+
* which the plugin resolves to:
59+
* {path: 'fastify', namespace: 'otel', pluginData}`
60+
* where `pluginData` includes the absolute path to load and the package
61+
* version. Importantly the namespace is changed to 'otel'.
62+
*
63+
* - esbuild calls:
64+
* onLoad({path: 'fastify', namespace: 'otel', pluginData})
65+
* which the plugin resolves to a stub module that does:
66+
* - `require('${absolute path to module}')`,
67+
* - sends a diag chan message to a possibly waiting OTel SDK to optionally
68+
* patch the loaded module exports,
69+
* - re-exports the, possibly now patched, module
70+
*
71+
* - esbuild calls:
72+
* onResolve({path: '/.../node_modules/fastify/fastify.js', namespace: 'otel' })`
73+
* which the plugin resolves back to the 'file' namespace
74+
*
75+
* - esbuild's default file loading loads the "fastify.js" as usual
76+
*
77+
* Which module paths to stub depends on the patching data for each given OTel
78+
* Instrumentation. Node.js builtin modules, like `net`, need not be stubbed
79+
* because they will be marked external (i.e. not inlined) by esbuild with the
80+
* `platform: 'node'` config.
81+
*/
82+
const CHANNEL_NAME = 'otel:bundle:load';
83+
function otelPlugin(instrs) {
84+
// XXX move 'intsr' to keyed option
85+
// XXX add debug bool option so can choose in esbuild.mjs file
86+
87+
return {
88+
name: 'opentelemetry',
89+
setup(build) {
90+
// Skip out gracefully if Node.js is too old for this plugin.
91+
// - Want `module.isBuiltin`, added in node v18.6.0, v16.17.0.
92+
// - Want `diagch.subscribe` added in node v18.7.0, v16.17.0
93+
// (to avoid https://github.com/nodejs/node/issues/42170).
94+
// Note: these constraints *could* be avoided with added code and deps if
95+
// really necessary.
96+
const [major, minor] = process.versions.node.split('.').map(Number);
97+
if (major < 16 || major === 16 && minor < 17 || major === 18 && minor < 7) {
98+
console.warn(`@opentelemetry/esbuild-plugin warn: this plugin requires at least Node.js v16.17.0, v18.7.0 to work; current version is ${process.version}`)
99+
return;
100+
}
101+
102+
const externals = new Set(build.initialOptions.external || []);
103+
104+
// From the given OTel Instrumentation instances, determine which
105+
// load paths (e.g. 'fastify', 'mongodb/lib/sessions.js') will possibly
106+
// need to be patched at runtime.
107+
const pathsToStub = new Set();
108+
for (let instr of instrs) {
109+
const defns = instr.getModuleDefinitions();
110+
for (let defn of defns) {
111+
if (typeof defn.patch === 'function') {
112+
pathsToStub.add(defn.name);
113+
}
114+
for (let fileDefn of defn.files) {
115+
pathsToStub.add(fileDefn.name);
116+
}
117+
}
118+
}
119+
debug('module paths to stub:', pathsToStub);
120+
121+
build.onResolve({ filter: /.*/ }, async (args) => {
122+
if (externals.has(args.path)) {
123+
// If this esbuild is configured to leave a package external, then
124+
// no need to stub for it in the bundle.
125+
return;
126+
}
127+
if (module.isBuiltin(args.path)) {
128+
// Node.js builtin modules are left in the bundle as `require(...)`,
129+
// so no need for stubbing.
130+
return
131+
}
132+
133+
if (args.namespace === 'file') {
134+
// console.log('XXX onResolve file:', args);
135+
136+
// This resolves the absolute path of the module, which is used in the stub.
137+
// XXX Not sure if should prefer:
138+
// require.resolve(args.path, {paths: [args.resolveDir]})
139+
// Dev Note: Most of the bundle-time perf hit from this plugin is
140+
// from this `build.resolve()`.
141+
const resolved = await build.resolve(args.path, {
142+
kind: args.kind,
143+
resolveDir: args.resolveDir
144+
// Implicit `namespace: ''` here avoids recursion.
145+
});
146+
if (resolved.errors.length > 0) {
147+
return { errors: resolved.errors };
148+
}
149+
150+
// Get the package name and version.
151+
const pkgInfo = pkgInfoFromPath(resolved.path)
152+
if (!pkgInfo) {
153+
debug(`skip resolved path, could not determine pkgInfo: "${resolved.path}"`);
154+
return;
155+
}
156+
157+
let matchPath;
158+
if (pathsToStub.has(args.path)) {
159+
// E.g. `require('fastify')` matches
160+
// `InstrumentationNodeModuleDefinition { name: 'fastify' }`
161+
// from `@opentelemetry/instrumentation-fastify`.
162+
matchPath = args.path;
163+
} else if (pkgInfo.fullModulePath !== args.path && pathsToStub.has(pkgInfo.fullModulePath)) {
164+
// E.g. `require('./multi-commander')` from `@redis/client/...` matches
165+
// `InstrumentationNodeModuleFile { name: '@redis/client/dist/lib/client/multi-command.js' }
166+
// from `@opentelemetry/instrumentation-fastify`.
167+
matchPath = pkgInfo.fullModulePath;
168+
} else {
169+
// This module is not one that given instrumentations care about.
170+
return;
171+
}
172+
173+
// Get the package version from its package.json.
174+
let pkgVersion;
175+
try {
176+
const pjContent = await fs.promises.readFile(pkgInfo.pjPath);
177+
pkgVersion = JSON.parse(pjContent).version;
178+
} catch (err) {
179+
debug(`skip "${matchPath}": could not determine package version: ${err.message}`);
180+
return;
181+
}
182+
183+
return {
184+
path: matchPath,
185+
namespace: 'otel',
186+
pluginData: {
187+
fullPath: resolved.path,
188+
pkgName: pkgInfo.name,
189+
pkgVersion,
190+
}
191+
};
192+
193+
} else if (args.namespace === 'otel') {
194+
return {
195+
path: args.path,
196+
namespace: 'file',
197+
// We expect `args.path` to always be an absolute path (from
198+
// resolved.path above), so `resolveDir` isn't necessary.
199+
};
200+
}
201+
})
202+
203+
build.onLoad({ filter: /.*/, namespace: 'otel' }, async (args) => {
204+
debug(`stub module "${args.path}"`);
205+
return {
206+
contents: `
207+
const diagch = require('diagnostics_channel');
208+
const ch = diagch.channel('${CHANNEL_NAME}');
209+
const mod = require('${args.pluginData.fullPath}');
210+
const message = {
211+
name: '${args.path}',
212+
version: '${args.pluginData.pkgVersion}',
213+
exports: mod,
214+
};
215+
ch.publish(message);
216+
module.exports = message.exports;
217+
`,
218+
loader: 'js',
219+
}
220+
})
221+
},
222+
}
223+
}
224+
225+
await esbuild.build({
226+
entryPoints: ['app.js'],
227+
bundle: true,
228+
platform: 'node',
229+
target: ['node14'],
230+
outdir: 'build',
231+
plugins: [otelPlugin(
232+
// [ new FastifyInstrumentation(), new RedisInstrumentation(), ]
233+
getNodeAutoInstrumentations(),
234+
)],
235+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
var diagch = require("diagnostics_channel");
2+
diagch.subscribe("otel:bundle:load", (message, name) => {
3+
console.log('minisdk received message:', name, message);
4+
});
5+
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "opentelemetry-esbuild-plugin-example",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"build": "OTEL_LOG_LEVEL=debug node esbuild.mjs"
7+
},
8+
"XXX dependencies": {
9+
"@opentelemetry/auto-instrumentations-node": "^0.47.1",
10+
"@opentelemetry/instrumentation": "file:../../../../opentelemetry-js10/experimental/packages/opentelemetry-instrumentation",
11+
"@opentelemetry/instrumentation-fastify": "^0.37.0",
12+
"tabula": "^1.10.0"
13+
},
14+
"dependencies": {
15+
"@aws-sdk/client-s3": "^3.600.0",
16+
"@opentelemetry/auto-instrumentations-node": "^0.47.1",
17+
"fastify": "^4.28.0",
18+
"redis": "^4.6.14"
19+
}
20+
}

packages/esbuild-plugin/lib/index.js

Whitespace-only changes.

0 commit comments

Comments
 (0)