diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 4680296deae..ea062e0b61a 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -16,6 +16,8 @@ All notable changes to experimental packages in this project will be documented ### :rocket: (Enhancement) +* feat(instrumentation): Add support for patching modules via diagnostic channels, to support auto instrumentation with bundlers [#5334](https://github.com/open-telemetry/opentelemetry-js/pull/5334) + ### :bug: (Bug Fix) ### :books: (Refine Doc) diff --git a/experimental/packages/opentelemetry-instrumentation/src/platform/node/instrumentation.ts b/experimental/packages/opentelemetry-instrumentation/src/platform/node/instrumentation.ts index c99d52ea2c7..d21a5af892c 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/platform/node/instrumentation.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/platform/node/instrumentation.ts @@ -16,6 +16,7 @@ import * as types from '../../types'; import * as path from 'path'; +import * as diagch from 'diagnostics_channel'; import { types as utilTypes } from 'util'; import { satisfies } from 'semver'; import { wrap, unwrap, massWrap, massUnwrap } from 'shimmer'; @@ -30,6 +31,7 @@ import { InstrumentationConfig, InstrumentationModuleDefinition, } from '../../types'; +import { DiagChSubscribe, OTelBundleLoadMessage } from '../../types_internal'; import { diag } from '@opentelemetry/api'; import type { OnRequireFn } from 'require-in-the-middle'; import { Hook as HookRequire } from 'require-in-the-middle'; @@ -178,9 +180,10 @@ export abstract class InstrumentationBase< module: InstrumentationModuleDefinition, exports: T, name: string, - baseDir?: string | void + baseDir?: string | void, + version?: string ): T { - if (!baseDir) { + if (!version && !baseDir) { if (typeof module.patch === 'function') { module.moduleExports = exports; if (this._enabled) { @@ -196,7 +199,9 @@ export abstract class InstrumentationBase< return exports; } - const version = this._extractPackageVersion(baseDir); + if (!version && baseDir) { + version = this._extractPackageVersion(baseDir); + } module.moduleVersion = version; if (module.name === name) { // main module @@ -285,6 +290,11 @@ export abstract class InstrumentationBase< } this._warnOnPreloadedModules(); + + const imdsFromHookPath = new Map< + string, + InstrumentationModuleDefinition[] + >(); for (const module of this._modules) { const hookFn: HookFn = (exports, name, baseDir) => { if (!baseDir && path.isAbsolute(name)) { @@ -312,6 +322,56 @@ export abstract class InstrumentationBase< hookFn ); this._hooks.push(esmHook); + + const imdsByModuleName = imdsFromHookPath.get(module.name) ?? []; + imdsFromHookPath.set(module.name, imdsByModuleName); + imdsByModuleName.push(module); + for (const file of module.files) { + const imdsByFileName = imdsFromHookPath.get(file.name) ?? []; + imdsFromHookPath.set(file.name, imdsByFileName); + imdsByFileName.push(module); + } + } + + // `diagch.subscribe` was added in Node.js v18.7.0, v16.17.0. + const subscribe: DiagChSubscribe = (diagch as any).subscribe; + if (typeof subscribe === 'function') { + // A bundler plugin, e.g. `@opentelemetry/esbuild-plugin`, can pass + // a loaded module to this instrumentation via the well-known + // `otel:bundle:load` diagnostics channel message. The message includes + // the module exports, that can be patched in-place. + subscribe('otel:bundle:load', rawMessage => { + const message = rawMessage as OTelBundleLoadMessage; + if ( + (typeof message.name !== 'string' && + typeof message.file !== 'string') || + typeof message.version !== 'string' + ) { + this._diag.debug( + 'skipping invalid "otel:bundle:load" diagch message', + rawMessage + ); + return; + } + const names = [message.name, message.file].filter(Boolean) as string[]; + for (const name of names) { + const imds = imdsFromHookPath.get(name); + if (!imds) { + // This loaded module is not relevant for this instrumentation. + return; + } + for (const imd of imds) { + const patchedExports = this._onRequire( + imd, + message.exports, + name, + undefined, + message.version // Package version was determined at bundle-time. + ); + message.exports = patchedExports; + } + } + }); } } diff --git a/experimental/packages/opentelemetry-instrumentation/src/types_internal.ts b/experimental/packages/opentelemetry-instrumentation/src/types_internal.ts index 6d678fea907..a20735148b8 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/types_internal.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/types_internal.ts @@ -28,3 +28,25 @@ export interface AutoLoaderOptions { meterProvider?: MeterProvider; loggerProvider?: LoggerProvider; } + +/** + * A subset of types for Node.js `diagnostics_channel`. + * `diagnostics_channel.subscribe` was added in Node.js v18.7.0, v16.17.0. + * The current `@types/node` dependency is for an earlier version (v14) of + * Node.js + */ +type DiagChChannelListener = (message: unknown, name: string | symbol) => void; +export type DiagChSubscribe = ( + name: string | symbol, + onMessage: DiagChChannelListener +) => void; + +/** + * The shape of a `otel:bundle:load` diagnostics_channel message. + */ +export type OTelBundleLoadMessage = { + name?: string; + file?: string; + version: string; + exports: any; +};