diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 749dd600cb80..91664028fd25 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -41,11 +41,19 @@ "types": "./build/types/orchestrion/bundler/vite.d.ts", "import": "./build/esm/orchestrion/bundler/vite.js" }, + "./orchestrion/rollup": { + "types": "./build/types/orchestrion/bundler/rollup.d.ts", + "import": "./build/esm/orchestrion/bundler/rollup.js" + }, "./orchestrion/webpack": { "types": "./build/types/orchestrion/bundler/webpack.d.ts", "import": "./build/esm/orchestrion/bundler/webpack.js", "require": "./build/cjs/orchestrion/bundler/webpack.js" }, + "./orchestrion/esbuild": { + "types": "./build/types/orchestrion/bundler/esbuild.d.ts", + "import": "./build/esm/orchestrion/bundler/esbuild.js" + }, "./orchestrion/import-hook": { "import": "./build/orchestrion/import-hook.mjs" } @@ -67,8 +75,14 @@ "orchestrion/vite": [ "build/types-ts3.8/orchestrion/bundler/vite.d.ts" ], + "orchestrion/rollup": [ + "build/types-ts3.8/orchestrion/bundler/rollup.d.ts" + ], "orchestrion/webpack": [ "build/types-ts3.8/orchestrion/bundler/webpack.d.ts" + ], + "orchestrion/esbuild": [ + "build/types-ts3.8/orchestrion/bundler/esbuild.d.ts" ] }, "*": { @@ -84,8 +98,14 @@ "orchestrion/vite": [ "build/types/orchestrion/bundler/vite.d.ts" ], + "orchestrion/rollup": [ + "build/types/orchestrion/bundler/rollup.d.ts" + ], "orchestrion/webpack": [ "build/types/orchestrion/bundler/webpack.d.ts" + ], + "orchestrion/esbuild": [ + "build/types/orchestrion/bundler/esbuild.d.ts" ] } }, @@ -94,13 +114,13 @@ }, "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", - "@apm-js-collab/code-transformer": "^0.15.0", "@apm-js-collab/tracing-hooks": "^0.10.1", "@sentry/conventions": "^0.15.1", "@sentry/core": "10.65.0", "magic-string": "~0.30.0" }, "devDependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", "@types/node": "^18.19.1", "vite": "^6.4.3" }, diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 5f02614f716d..0b6412cee679 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -19,10 +19,10 @@ export default [ ...orchestrionRuntimeHooks, ...makeNPMConfigVariants( makeBaseNPMConfig({ - // `src/orchestrion/config/index.ts` and `src/orchestrion/bundler/vite.ts` are - // loaded via dedicated subpath exports (`.../orchestrion/config`, - // `.../orchestrion/vite`) — neither is reachable from `src/index.ts`, so we - // list them as separate entrypoints to guarantee they end up in build/esm + // `src/orchestrion/config/index.ts` and the `src/orchestrion/bundler/*.ts` + // plugins are loaded via dedicated subpath exports (`.../orchestrion/config`, + // `.../orchestrion/vite`, etc.) — none are reachable from `src/index.ts`, so + // we list them as separate entrypoints to guarantee they end up in build/esm // and build/cjs. `src/orchestrion/index.ts` backs the `./orchestrion` // subpath export. entrypoints: [ @@ -34,7 +34,9 @@ export default [ // `Sentry.init()` to install the channel-injection hooks. 'src/orchestrion/runtime/register.ts', 'src/orchestrion/bundler/vite.ts', + 'src/orchestrion/bundler/rollup.ts', 'src/orchestrion/bundler/webpack.ts', + 'src/orchestrion/bundler/esbuild.ts', ], packageSpecificConfig: { output: { diff --git a/packages/server-utils/src/orchestrion/bundler/esbuild.ts b/packages/server-utils/src/orchestrion/bundler/esbuild.ts new file mode 100644 index 000000000000..391cdd0229dc --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/esbuild.ts @@ -0,0 +1,42 @@ +import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/esbuild'; +import { instrumentedModuleNames, withoutInstrumentedExternals } from '../config'; +import type { PluginOptions } from './options'; +import { orchestrionTransformOptions } from './options'; + +/** + * esbuild plugin that runs the orchestrion code transform on the bundled output. + * + * Use when bundling a Node app with esbuild. For unbundled Node processes use the + * runtime hook instead (`node --import @sentry/node/orchestrion app.js`). + * + * esbuild does not flatten nested `plugins` arrays, so this returns a single + * plugin that strips instrumented packages from an `external` denylist before + * delegating to the upstream transform. + * + * @example + * ```ts + * // build.mjs + * import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/esbuild'; + * await esbuild.build({ plugins: [sentryOrchestrionPlugin()] }); + * ``` + */ +export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { + const transformer = codeTransformer(orchestrionTransformOptions(options)); + + return { + name: 'sentry-orchestrion', + async setup(build): Promise { + // Strip instrumented packages from an `external` denylist so esbuild + // bundles them and the transform's `onLoad` actually sees their source; + // an externalized dependency is resolved from `node_modules` at runtime + // and never gets the diagnostics_channel calls injected. Mutating + // `initialOptions` inside `setup` is respected by esbuild — the upstream + // plugin sets `initialOptions.metafile` the same way. + build.initialOptions.external = withoutInstrumentedExternals( + build.initialOptions.external, + instrumentedModuleNames(options.instrumentations), + ); + await transformer.setup(build); + }, + }; +} diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts new file mode 100644 index 000000000000..b285b3074236 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -0,0 +1,28 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; +import type codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; + +export type PluginOptions = { + /** + * Additional instrumentations to include with the default instrumentation. + */ + instrumentations?: InstrumentationConfig[]; +}; + +/** + * The `@apm-js-collab/code-transformer-bundler-plugins` options shared by every + * orchestrion bundler plugin. + * + * `injectDiagnostics` sets `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at + * app boot so the `_experimentalSetupOrchestrion()` detector can confirm the + * bundler path ran (rather than relying on a build-time flag that wouldn't be + * visible to the runtime). + */ +export function orchestrionTransformOptions(options: PluginOptions): Parameters[0] { + return { + instrumentations: [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])], + injectDiagnostics: () => { + return '(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{}).bundler=true;'; + }, + }; +} diff --git a/packages/server-utils/src/orchestrion/bundler/rollup.ts b/packages/server-utils/src/orchestrion/bundler/rollup.ts new file mode 100644 index 000000000000..3f2f482bc913 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/rollup.ts @@ -0,0 +1,36 @@ +import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; +import { instrumentedModuleNames, withoutInstrumentedExternals } from '../config'; +import type { PluginOptions } from './options'; +import { orchestrionTransformOptions } from './options'; + +/** + * Rollup plugin that runs the orchestrion code transform on the bundled output. + * + * Use when bundling a Node app with Rollup. For unbundled Node processes use the + * runtime hook instead (`node --import @sentry/node/orchestrion app.js`). + * + * @example + * ```ts + * // rollup.config.js + * import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; + * export default { plugins: [sentryOrchestrionPlugin()] }; + * ``` + */ +export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { + return { + ...codeTransformer(orchestrionTransformOptions(options)), + // The `options` hook runs before the build starts and can rewrite the + // resolved input options. Rollup's `external` may be a string, RegExp, + // function, or array of `string | RegExp`; `withoutInstrumentedExternals` + // only understands a string denylist, so we touch only that form and leave + // every other shape untouched. + options(inputOptions) { + const { external } = inputOptions; + if (Array.isArray(external) && external.every(entry => typeof entry === 'string')) { + const moduleNames = instrumentedModuleNames(options.instrumentations); + return { ...inputOptions, external: withoutInstrumentedExternals(external, moduleNames) }; + } + return null; + }, + }; +} diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 4cadc7b40925..75ad1ab49855 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -1,26 +1,7 @@ -// EXPERIMENTAL — Vite plugin that runs the orchestrion code transform at build -// time, injecting `diagnostics_channel.tracingChannel` calls into the libraries -// listed in `SENTRY_INSTRUMENTATIONS`. -// -// This file is published ESM-only via the `@sentry/server-utils/orchestrion/vite` -// subpath export. `@apm-js-collab/code-transformer-bundler-plugins` is -// `"type": "module"`, so consuming it from a CJS build is intentionally -// unsupported — vite.config.ts is almost always ESM in practice. The CJS -// rollup variant still emits this file, but `package.json` only exposes the -// ESM entry, so attempts to `require('@sentry/server-utils/orchestrion/vite')` will -// fail at resolution time rather than producing a half-broken plugin. - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type UnknownPlugin = any; - import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; -import MagicString from 'magic-string'; -import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; - -// `vite` types live in the package's ESM-only subpath; under Node16 module -// resolution with TS treating @sentry/server-utils as CJS, importing them produces a -// false positive. We don't need the runtime value for typing — `UnknownPlugin` -// is sufficient — so we omit the import entirely. +import { instrumentedModuleNames } from '../config'; +import type { PluginOptions } from './options'; +import { orchestrionTransformOptions } from './options'; /** * Vite plugin that runs the orchestrion code transform on the bundled output. @@ -29,44 +10,16 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * pipeline, SvelteKit). For unbundled Node processes use the runtime hook * instead (`node --import @sentry/node/orchestrion app.js`). * - * Returns two plugins: - * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a - * single-line banner to entry chunks. The banner sets - * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the - * `_experimentalSetupOrchestrion()` detector can confirm the bundler path - * ran (rather than relying on a build-time flag that wouldn't be visible - * to the runtime). - * Also injects every instrumented package name into `ssr.noExternal` via - * the `config` hook, since externalized deps are `require()`d at runtime - * from `node_modules` and never pass through the transform. - * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` - * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. - * * @example * ```ts * // vite.config.ts - * import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; + * import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(): UnknownPlugin[] { - const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); - const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) - ? codeTransformerPlugins - : [codeTransformerPlugins]; - return [bundlerMarkerPlugin(), ...codeTransformerArray]; -} - -function bundlerMarkerPlugin(): UnknownPlugin { - const banner = [ - 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', - 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', - '', - ].join('\n'); - +export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { return { - name: 'sentry-orchestrion-marker', - enforce: 'pre' as const, + ...codeTransformer(orchestrionTransformOptions(options)), config(): { ssr: { noExternal: string[] } } { // Force-bundle every instrumented package so the code transform actually // sees its source. Vite externalizes dependencies in SSR builds by @@ -75,16 +28,7 @@ function bundlerMarkerPlugin(): UnknownPlugin { // diagnostics_channel calls never get injected. Vite merges array // `noExternal` entries with the user's config, so we don't overwrite // their additions. - return { ssr: { noExternal: INSTRUMENTED_MODULE_NAMES } }; - }, - renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: unknown } | null { - if (!chunk.isEntry) return null; - // Prepend via magic-string so the entry chunk's sourcemap stays aligned — - // returning `map: null` here would shift every mapping by the banner's - // line count and misattribute server stack traces. - const ms = new MagicString(code); - ms.prepend(banner); - return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + return { ssr: { noExternal: instrumentedModuleNames(options.instrumentations) } }; }, }; } diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index b0e4d3e8b6d1..bb686f2ecb17 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -36,14 +36,21 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ /** * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` - * (e.g. `['mysql']`). + * merged with any caller-provided `instrumentations` (e.g. `['mysql']`). * * Bundler plugins MUST ensure these are actually bundled rather than * externalized: an externalized dependency is resolved from `node_modules` at * runtime and never passes through the code transform's `onLoad`, so its - * diagnostics_channel calls are silently never injected. + * diagnostics_channel calls are silently never injected. This includes a + * plugin's custom `instrumentations`, otherwise those extra packages can stay + * externalized and their transform never runs. */ -export const INSTRUMENTED_MODULE_NAMES: string[] = uniq(SENTRY_INSTRUMENTATIONS.map(i => i.module.name)); +export function instrumentedModuleNames(instrumentations: InstrumentationConfig[] = []): string[] { + return uniq([...SENTRY_INSTRUMENTATIONS, ...instrumentations].map(i => i.module.name)); +} + +/** The instrumented module names from the default Sentry config, with no custom additions. */ +export const INSTRUMENTED_MODULE_NAMES: string[] = instrumentedModuleNames(); /** * Returns `external` with any instrumented packages removed, so a bundler that @@ -52,14 +59,17 @@ export const INSTRUMENTED_MODULE_NAMES: string[] = uniq(SENTRY_INSTRUMENTATIONS. * (`'mysql/lib/...'`); wildcard/other patterns are left untouched. `undefined` * is returned unchanged. * - * (Vite uses an `ssr.noExternal` allowlist instead, so it consumes - * `INSTRUMENTED_MODULE_NAMES` directly rather than this helper.) + * Pass `moduleNames` from `instrumentedModuleNames(options.instrumentations)` so + * custom instrumentations are stripped too; it defaults to the Sentry config + * list. (Vite uses an `ssr.noExternal` allowlist instead, so it consumes + * `instrumentedModuleNames` directly rather than this helper.) */ -export function withoutInstrumentedExternals(external: readonly string[] | undefined): string[] | undefined { +export function withoutInstrumentedExternals( + external: readonly string[] | undefined, + moduleNames: string[] = INSTRUMENTED_MODULE_NAMES, +): string[] | undefined { if (!external) { return undefined; } - return external.filter( - entry => !INSTRUMENTED_MODULE_NAMES.some(name => entry === name || entry.startsWith(`${name}/`)), - ); + return external.filter(entry => !moduleNames.some(name => entry === name || entry.startsWith(`${name}/`))); } diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index adf18e807cd4..86e56ddd77a7 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -1,5 +1,10 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { describe, expect, it } from 'vitest'; -import { INSTRUMENTED_MODULE_NAMES, withoutInstrumentedExternals } from '../../src/orchestrion/config'; +import { + INSTRUMENTED_MODULE_NAMES, + instrumentedModuleNames, + withoutInstrumentedExternals, +} from '../../src/orchestrion/config'; describe('orchestrion config — scoped @hapi/hapi module', () => { it('includes the scoped @hapi/hapi name in INSTRUMENTED_MODULE_NAMES', () => { @@ -13,3 +18,24 @@ describe('orchestrion config — scoped @hapi/hapi module', () => { expect(withoutInstrumentedExternals(external)).toEqual(['react']); }); }); + +describe('orchestrion config — custom instrumentations', () => { + const customInstrumentation = { module: { name: 'my-lib' } } as InstrumentationConfig; + + it('includes custom instrumentation module names alongside the defaults', () => { + const names = instrumentedModuleNames([customInstrumentation]); + expect(names).toContain('my-lib'); + expect(names).toContain('@hapi/hapi'); + }); + + it('strips custom instrumentation packages from an externals list', () => { + const external = ['react', 'my-lib', 'my-lib/sub']; + const names = instrumentedModuleNames([customInstrumentation]); + expect(withoutInstrumentedExternals(external, names)).toEqual(['react']); + }); + + it('leaves custom instrumentation packages externalized when only the defaults are used', () => { + const external = ['react', 'my-lib']; + expect(withoutInstrumentedExternals(external)).toEqual(['react', 'my-lib']); + }); +});