diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index dd6f7f4613cb..14dedc28363d 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -29,6 +29,8 @@ const NODE_EXPORTS_IGNORE = [ // factories for that same Node-runtime-only opt-in, so it isn't surfaced // through the framework / serverless SDKs either. 'diagnosticsChannelInjectionIntegrations', + // Companion to the above two, same reasoning (Next.js re-exports it via `export * from '@sentry/node'`) + 'isDiagnosticsChannelInjectionEnabled', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', ]; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 988c71e16f9a..a7910bd4e2d7 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -85,6 +85,7 @@ "@sentry/node": "10.65.0", "@sentry/opentelemetry": "10.65.0", "@sentry/react": "10.65.0", + "@sentry/server-utils": "10.65.0", "@sentry/vercel-edge": "10.65.0", "@sentry/webpack-plugin": "^5.3.0", "rollup": "^4.60.3", diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts new file mode 100644 index 000000000000..cf8b26f31771 --- /dev/null +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -0,0 +1,22 @@ +/** + * Instrumented packages verified (via e2e) to bundle correctly, removed from Sentry's own + * `serverExternalPackages` defaults so the build-time loader can transform them. Everything else + * stays external and is instrumented by the runtime module hook instead. Deliberately an + * allowlist — bundling can break packages outright (e.g. `mysql` 2.x under Turbopack). + */ +export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis']; + +/** + * The orchestrion runtime machinery must stay external — its parser breaks when bundled, which + * silently disables the runtime module hook. + */ +export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [ + '@apm-js-collab/tracing-hooks', + '@apm-js-collab/code-transformer', +]; + +/** Remove the given packages from a `serverExternalPackages` list. */ +export function filterInstrumentedExternals(externals: string[], packagesToBundle: string[]): string[] { + const set = new Set(packagesToBundle); + return externals.filter(name => !set.has(name)); +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 9b25e9fe2061..31298b6067a6 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,8 +1,15 @@ import { debug } from '@sentry/core'; import * as path from 'path'; +import { getOrchestrionLoaderPath, getSentryInstrumentations } from '@sentry/server-utils/orchestrion/webpack'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; -import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; +import type { + JSONValue, + NextConfigObject, + SentryBuildOptions, + TurbopackMatcherWithRule, + TurbopackOptions, +} from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; import { generateValueInjectionRules } from './generateValueInjectionRules'; @@ -106,9 +113,42 @@ export function constructTurbopackConfig({ }); } + newConfig.rules = maybeAddOrchestrionRule(newConfig.rules, userSentryOptions, nextJsVersion); + return newConfig; } +/** + * Adds the orchestrion code-transform loader rule when diagnostics-channel injection is enabled. + */ +function maybeAddOrchestrionRule( + rules: TurbopackOptions['rules'], + userSentryOptions: SentryBuildOptions | undefined, + nextJsVersion: string | undefined, +): TurbopackOptions['rules'] { + if ( + !userSentryOptions?._experimental?.useDiagnosticsChannelInjection || + !nextJsVersion || + !supportsTurbopackRuleCondition(nextJsVersion) + ) { + return rules; + } + + return safelyAddTurbopackRule(rules, { + matcher: '*.{js,mjs,cjs}', + rule: { + condition: 'node', + loaders: [ + { + loader: getOrchestrionLoaderPath(), + // `instrumentations` is JSON-serializable + options: { instrumentations: getSentryInstrumentations() as unknown as JSONValue[] }, + }, + ], + }, + }); +} + /** * Safely add a Turbopack rule to the existing rules. * diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 7d500e65420e..1ca30de85804 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -10,7 +10,7 @@ type NextRewrite = { destination: string; }; -interface WebpackPluginInstance { +export interface WebpackPluginInstance { [index: string]: unknown; apply: (compiler: unknown) => void; } @@ -765,6 +765,19 @@ export type SentryBuildOptions = { enabled?: boolean; ignoredComponents?: string[]; }; + /** + * EXPERIMENTAL: Wire up orchestrion diagnostics-channel instrumentation at build time. + * + * When enabled, `withSentryConfig` injects the orchestrion code-transform loader for bundled + * server packages and keeps the remaining instrumented packages external so the runtime module + * hook picks them up. You must still call `Sentry.experimentalUseDiagnosticsChannelInjection()` + * in your server config to record spans. + * + * Turbopack support requires Next.js 16+; the webpack path works on earlier versions. + * + * @experimental May change or be removed in any release. + */ + useDiagnosticsChannelInjection?: boolean; }>; /** diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 73e5184abc01..94db1cca621d 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -20,7 +20,9 @@ import type { WebpackConfigObject, WebpackConfigObjectWithModuleRules, WebpackEntryProperty, + WebpackPluginInstance, } from './types'; +import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; @@ -429,6 +431,11 @@ export function constructWebpackConfigFunction({ }), ); + // Orchestrion code-transform loader — Node server runtime only, never the edge compilation + if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { + newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as WebpackPluginInstance); + } + return newConfig; }; } diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index c468b4a1f18e..93ec6e42e243 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -1,6 +1,7 @@ import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { getTracingHooksDirectory } from '@sentry/server-utils/orchestrion/webpack'; import type { NextConfigObject, SentryBuildOptions } from '../types'; /** @@ -50,6 +51,14 @@ export function setUpBuildTimeVariables( buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; } + // Marker read by the server SDK to warn if the runtime opt-in call is missing. + if (userSentryOptions._experimental?.useDiagnosticsChannelInjection) { + buildTimeVariables._sentryUseDiagnosticsChannelInjection = 'true'; + // Resolved here (where the SDK is a real on-disk package) and inlined, because the runtime + // module hook can't resolve the bare specifier from a bundled server chunk under pnpm. + buildTimeVariables._sentryOrchestrionTracingHooksDir = getTracingHooksDirectory(); + } + if (basePath) { buildTimeVariables._sentryBasePath = basePath; } diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts index 6d71e53595a7..e96ecbf1719f 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts @@ -85,9 +85,11 @@ export function getFinalConfigObject( maybeEnableTurbopackSourcemaps(incomingUserNextConfigObject, userSentryOptions, bundlerInfo); + const useDiagnosticsChannelInjection = userSentryOptions._experimental?.useDiagnosticsChannelInjection ?? false; + return { ...incomingUserNextConfigObject, - ...getServerExternalPackagesPatch(incomingUserNextConfigObject, nextMajor), + ...getServerExternalPackagesPatch(incomingUserNextConfigObject, nextMajor, useDiagnosticsChannelInjection), ...getWebpackPatch({ incomingUserNextConfigObject, userSentryOptions, diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts index edd62b8ba8c3..18460dda5ec2 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -1,3 +1,8 @@ +import { + BUNDLE_SAFE_INSTRUMENTED_PACKAGES, + filterInstrumentedExternals, + ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES, +} from '../diagnosticsChannelInjection'; import { handleRunAfterProductionCompile } from '../handleRunAfterProductionCompile'; import type { RouteManifest } from '../manifest/types'; import { constructTurbopackConfig } from '../turbopack'; @@ -227,23 +232,32 @@ export function maybeEnableTurbopackSourcemaps( export function getServerExternalPackagesPatch( incomingUserNextConfigObject: NextConfigObject, nextMajor: number | undefined, + useDiagnosticsChannelInjection = false, ): Partial { + // Diagnostics-channel injection: only bundle-safe packages leave OUR defaults (→ build-time + // loader); everything else stays external (→ runtime module hook), including the orchestrion + // machinery itself, which breaks when bundled. + const mergeExternals = (userProvided: string[] | undefined): string[] => { + const defaults = useDiagnosticsChannelInjection + ? filterInstrumentedExternals(DEFAULT_SERVER_EXTERNAL_PACKAGES, BUNDLE_SAFE_INSTRUMENTED_PACKAGES) + : DEFAULT_SERVER_EXTERNAL_PACKAGES; + return [ + ...(userProvided || []), + ...defaults, + ...(useDiagnosticsChannelInjection ? ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES : []), + ]; + }; + if (nextMajor && nextMajor >= 15) { - return { - serverExternalPackages: [ - ...(incomingUserNextConfigObject.serverExternalPackages || []), - ...DEFAULT_SERVER_EXTERNAL_PACKAGES, - ], - }; + return { serverExternalPackages: mergeExternals(incomingUserNextConfigObject.serverExternalPackages) }; } return { experimental: { ...incomingUserNextConfigObject.experimental, - serverComponentsExternalPackages: [ - ...(incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages || []), - ...DEFAULT_SERVER_EXTERNAL_PACKAGES, - ], + serverComponentsExternalPackages: mergeExternals( + incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages, + ), }, }; } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 246c00c28fd8..ee0d2346c4f2 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -12,7 +12,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; +import { + experimentalUseDiagnosticsChannelInjection as nodeExperimentalUseDiagnosticsChannelInjection, + getDefaultIntegrations, + httpIntegration, + init as nodeInit, + isDiagnosticsChannelInjectionEnabled, +} from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; @@ -41,8 +47,26 @@ export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/n const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; + _sentryUseDiagnosticsChannelInjection?: string; + _sentryOrchestrionTracingHooksDir?: string; }; +/** + * EXPERIMENTAL: Next.js-aware variant of `Sentry.experimentalUseDiagnosticsChannelInjection()` + * from `@sentry/node` (see its docs for behavior and caveats). + * + * Next.js bundles the SDK into the server build, from where the runtime module hook can't resolve + * the `@apm-js-collab/tracing-hooks` bare specifier under isolated installs (pnpm). This variant + * points the hook at the package location that `withSentryConfig` resolved at build time. + * + * @experimental May change or be removed in any release. + */ +export function experimentalUseDiagnosticsChannelInjection(): void { + const tracingHooksDir = + process.env._sentryOrchestrionTracingHooksDir || globalWithInjectedValues._sentryOrchestrionTracingHooksDir; + nodeExperimentalUseDiagnosticsChannelInjection(tracingHooksDir ? { tracingHooksDir } : undefined); +} + // Call at module level so `next build` prerender workers still register the runner without `init` prepareSafeIdGeneratorContext(); @@ -138,6 +162,17 @@ export function init(options: NodeOptions): NodeClient | undefined { customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName })); } + // The build wired the orchestrion loader but the runtime opt-in is missing → no DB spans. + const useDiagnosticsChannelInjection = + process.env._sentryUseDiagnosticsChannelInjection || globalWithInjectedValues._sentryUseDiagnosticsChannelInjection; + if (DEBUG_BUILD && useDiagnosticsChannelInjection && !isDiagnosticsChannelInjectionEnabled()) { + debug.warn( + '[@sentry/nextjs] `useDiagnosticsChannelInjection` is enabled in `withSentryConfig`, but ' + + '`Sentry.experimentalUseDiagnosticsChannelInjection()` was not called before `Sentry.init()`. ' + + 'Server DB spans will not be recorded.', + ); + } + // Detect if running on OpenNext/Cloudflare and get runtime config const cloudflareConfig = getCloudflareRuntimeConfig(); diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts new file mode 100644 index 000000000000..881da6a3caf6 --- /dev/null +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + BUNDLE_SAFE_INSTRUMENTED_PACKAGES, + filterInstrumentedExternals, +} from '../../src/config/diagnosticsChannelInjection'; +import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime'; +import { getServerExternalPackagesPatch } from '../../src/config/withSentryConfig/getFinalConfigObjectBundlerUtils'; +import type { NextConfigObject } from '../../src/config/types'; + +describe('filterInstrumentedExternals', () => { + it('removes the given packages, keeps the rest', () => { + expect( + filterInstrumentedExternals(['express', 'pg', 'pg-pool', 'ioredis', 'mongodb'], ['pg', 'pg-pool', 'ioredis']), + ).toEqual(['express', 'mongodb']); + }); + + it('is a no-op with an empty bundle list', () => { + expect(filterInstrumentedExternals(['express', 'pg'], [])).toEqual(['express', 'pg']); + }); +}); + +describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => { + it('keeps everything external except the bundle-safe allowlist, and adds the runtime machinery', () => { + const patch = getServerExternalPackagesPatch({}, 16, true); + const externals = patch.serverExternalPackages ?? []; + + // Only the verified bundle-safe packages leave the external list (→ build-time loader). + for (const name of BUNDLE_SAFE_INSTRUMENTED_PACKAGES) { + expect(externals).not.toContain(name); + } + // Other instrumented packages stay external (→ runtime module hook). + expect(externals).toContain('mysql'); + expect(externals).toContain('pg'); + expect(externals).toContain('pg-pool'); + // The orchestrion machinery must be external for the runtime hook to work. + expect(externals).toContain('@apm-js-collab/tracing-hooks'); + expect(externals).toContain('@apm-js-collab/code-transformer'); + }); + + it('respects user-provided externals even for bundle-safe packages', () => { + const patch = getServerExternalPackagesPatch({ serverExternalPackages: ['ioredis'] }, 16, true); + expect(patch.serverExternalPackages).toContain('ioredis'); + }); + + it('is unchanged with the flag off', () => { + const patch = getServerExternalPackagesPatch({}, 16, false); + const externals = patch.serverExternalPackages ?? []; + expect(externals).toContain('ioredis'); + expect(externals).toContain('mysql'); + expect(externals).not.toContain('@apm-js-collab/tracing-hooks'); + }); +}); + +describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { + it('injects the flag marker and the tracing-hooks location', () => { + const nextConfig: NextConfigObject = {}; + setUpBuildTimeVariables(nextConfig, { _experimental: { useDiagnosticsChannelInjection: true } }, undefined); + + expect(nextConfig.env).toMatchObject({ + _sentryUseDiagnosticsChannelInjection: 'true', + // The runtime module hook joins subpaths onto this, so it must be an absolute directory. + _sentryOrchestrionTracingHooksDir: expect.stringMatching(/@apm-js-collab[/+]tracing-hooks/), + }); + }); + + it('injects neither with the flag off', () => { + const nextConfig: NextConfigObject = {}; + setUpBuildTimeVariables(nextConfig, {}, undefined); + + expect(nextConfig.env).not.toHaveProperty('_sentryUseDiagnosticsChannelInjection'); + expect(nextConfig.env).not.toHaveProperty('_sentryOrchestrionTracingHooksDir'); + }); +}); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index debc0ed77ae4..b822495d1a67 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -16,6 +16,10 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; +vi.mock('@sentry/server-utils/orchestrion/webpack', () => ({ + sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }), +})); + describe('constructWebpackConfigFunction()', () => { it('includes expected properties', async () => { vi.spyOn(core, 'loadModule').mockImplementation(() => ({ @@ -789,4 +793,53 @@ describe('constructWebpackConfigFunction()', () => { expect(treeshakePlugin.definitions).not.toHaveProperty('__RRWEB_EXCLUDE_SHADOW_DOM__'); }); }); + + describe('orchestrion webpack plugin', () => { + const findOrchestrionPlugin = (config: { plugins?: unknown[] }): unknown => + config.plugins?.find(plugin => (plugin as { _name?: string })._name === 'sentry-orchestrion-webpack-plugin'); + + it('adds the plugin to the node server build when diagnostics-channel injection is enabled', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + expect(findOrchestrionPlugin(finalWebpackConfig)).toBeDefined(); + }); + + it('does not add the plugin to the edge build', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined(); + }); + + it('does not add the plugin to the client build', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: clientWebpackConfig, + incomingWebpackBuildContext: clientBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined(); + }); + + it('does not add the plugin when diagnostics-channel injection is not enabled', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: {}, + }); + + expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined(); + }); + }); }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index a03f9e239525..907adfa6b012 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -51,6 +51,7 @@ export { experimentalUseDiagnosticsChannelInjection, diagnosticsChannelInjectionIntegrations, } from './sdk/experimentalUseDiagnosticsChannelInjection'; +export { isDiagnosticsChannelInjectionEnabled } from './sdk/diagnosticsChannelInjection'; export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 08d24479480f..e7c9cbcd0fd8 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -4,6 +4,7 @@ import { redisChannelIntegration, detectOrchestrionSetup, } from '@sentry/server-utils/orchestrion'; +import type { RegisterDiagnosticsChannelInjectionOptions } from '@sentry/server-utils/orchestrion/register'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; import { cacheResponseHook } from '../integrations/tracing/redis/cache'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; @@ -45,7 +46,12 @@ export function diagnosticsChannelInjectionIntegrations(): typeof channelIntegra * * @experimental May change or be removed in any release. */ -export function experimentalUseDiagnosticsChannelInjection(): void { +export function experimentalUseDiagnosticsChannelInjection( + // Forwarded to `registerDiagnosticsChannelInjection()`; framework SDKs whose bundlers compile + // the SDK into the app (e.g. `@sentry/nextjs`) use it to point the runtime module hook at the + // tracing-hooks package location resolved at build time. Plain Node apps don't need it. + options?: RegisterDiagnosticsChannelInjectionOptions, +): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { // The registry integrations 1:1 replace the OTel integration of the same name. const integrations = Object.values(channelIntegrations).map(createIntegration => createIntegration()); @@ -61,7 +67,7 @@ export function experimentalUseDiagnosticsChannelInjection(): void { redisChannelIntegration({ responseHook: cacheResponseHook }), ], replacedOtelIntegrationNames, - register: registerDiagnosticsChannelInjection, + register: () => registerDiagnosticsChannelInjection(options), detect: detectOrchestrionSetup, }; }); diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index c904ae93d95e..749dd600cb80 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -41,6 +41,11 @@ "types": "./build/types/orchestrion/bundler/vite.d.ts", "import": "./build/esm/orchestrion/bundler/vite.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/import-hook": { "import": "./build/orchestrion/import-hook.mjs" } @@ -61,6 +66,9 @@ ], "orchestrion/vite": [ "build/types-ts3.8/orchestrion/bundler/vite.d.ts" + ], + "orchestrion/webpack": [ + "build/types-ts3.8/orchestrion/bundler/webpack.d.ts" ] }, "*": { @@ -75,6 +83,9 @@ ], "orchestrion/vite": [ "build/types/orchestrion/bundler/vite.d.ts" + ], + "orchestrion/webpack": [ + "build/types/orchestrion/bundler/webpack.d.ts" ] } }, diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index ca2c6cf19486..5f02614f716d 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -34,6 +34,7 @@ export default [ // `Sentry.init()` to install the channel-injection hooks. 'src/orchestrion/runtime/register.ts', 'src/orchestrion/bundler/vite.ts', + 'src/orchestrion/bundler/webpack.ts', ], packageSpecificConfig: { output: { diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts new file mode 100644 index 000000000000..04eb56a2c6b9 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -0,0 +1,55 @@ +// EXPERIMENTAL — orchestrion code-transform loader + webpack plugin. The loader is exposed +// separately because Turbopack can only take webpack loaders (via `turbopack.rules`), not plugins. + +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; + +// Both branches use `createRequire` (never alias the CJS `require`) so bundlers consuming this +// module don't emit a "Critical dependency" warning. +function getOrchestrionRequire(): ReturnType { + let nodeRequire: ReturnType; + /*! rollup-include-cjs-only */ + nodeRequire = createRequire(__filename); + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + nodeRequire = createRequire(import.meta.url); + /*! rollup-include-esm-only-end */ + return nodeRequire; +} + +/** Absolute path to the code-transform loader (a webpack loader; also usable as a Turbopack loader). */ +export function getOrchestrionLoaderPath(): string { + return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'); +} + +/** + * Absolute path to the `@apm-js-collab/tracing-hooks` package directory, resolved from this + * package's own dependency graph. SDKs inject it at build time so the runtime module hook can + * load the package even where the bare specifier doesn't resolve (bundled SDK code under + * isolated installs, e.g. pnpm). + */ +export function getTracingHooksDirectory(): string { + const packageJsonPath = getOrchestrionRequire().resolve('@apm-js-collab/tracing-hooks/package.json'); + // This avoids any backslash-escaping concerns on Windows + return dirname(packageJsonPath).replace(/\\/g, '/'); +} + +/** The central instrumentation config, to pass as the loader's `instrumentations` option. */ +export function getSentryInstrumentations(): InstrumentationConfig[] { + return SENTRY_INSTRUMENTATIONS; +} + +/** + * The code-transform webpack plugin, pre-fed the instrumentation config. Unlike the Vite plugin it + * does NOT inject the `__SENTRY_ORCHESTRION__.bundler` marker — that would disable the runtime + * module hook, which externalized packages still need (hybrid setup). + */ +export function sentryOrchestrionWebpackPlugin(): unknown { + const mod = getOrchestrionRequire()('@apm-js-collab/code-transformer-bundler-plugins/webpack') as { + default?: (options: { instrumentations: InstrumentationConfig[] }) => unknown; + }; + const codeTransformerWebpack = mod.default ?? (mod as unknown as NonNullable); + return codeTransformerWebpack({ instrumentations: SENTRY_INSTRUMENTATIONS }); +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 195464f7375f..157672c8c3cd 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -5,11 +5,37 @@ import { pathToFileURL } from 'node:url'; import { DEBUG_BUILD } from '../../debug-build'; import { SENTRY_INSTRUMENTATIONS } from '../config'; +export interface RegisterDiagnosticsChannelInjectionOptions { + /** + * Absolute directory of the `@apm-js-collab/tracing-hooks` package (forward slashes). + * + * Needed when SDK code is bundled into an app's server build: the default bare-specifier + * require then resolves from the emitted chunk, which fails under isolated installs (pnpm). + * Framework SDKs (e.g. `@sentry/nextjs`) resolve the package at build time and pass its + * location here; it is loaded through an opaque `createRequire` that bundlers can't trace. + */ + tracingHooksDir?: string; +} + declare global { // eslint-disable-next-line no-var var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; } +/** `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. */ +function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolean { + const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); + const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); + const denoVersion = parseVersion(denoVersionString ?? '0.0.0'); + return ( + (nodeVersion[0] ?? 0) > 25 || + (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || + (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || + (denoVersion[0] ?? 0) > 2 || + (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8) + ); +} + /** * Synchronously register the diagnostics-channel injection module hooks. * @@ -25,7 +51,7 @@ declare global { * Idempotent via `globalThis.__SENTRY_ORCHESTRION__` — a no-op if the runtime * `--import` hook or a bundler plugin already injected the channels. */ -export function registerDiagnosticsChannelInjection(): void { +export function registerDiagnosticsChannelInjection(options?: RegisterDiagnosticsChannelInjectionOptions): void { const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); // Already injected (runtime --import hook or bundler plugin) — nothing to do. @@ -34,17 +60,22 @@ export function registerDiagnosticsChannelInjection(): void { } const globalAny = globalThis as { Bun?: unknown; Deno?: { version?: { deno?: string } } }; - const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); - const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); - const denoVersion = parseVersion(globalAny.Deno?.version?.deno ?? '0.0.0'); - // `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. - const stableSyncHooks = - (nodeVersion[0] ?? 0) > 25 || - (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || - (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || - (denoVersion[0] ?? 0) > 2 || - (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8); + const stableSyncHooks = hasStableSyncModuleHooks(globalAny.Deno?.version?.deno); + let thisModuleUrl: string; + /*! rollup-include-cjs-only */ + thisModuleUrl = pathToFileURL(__filename).href; + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + thisModuleUrl = import.meta.url; + /*! rollup-include-esm-only-end */ + + // Default: bare specifiers via a plain (aliased) `require`, so bundlers see and resolve them + // like any other dependency. Override: with `tracingHooksDir`, absolute paths are loaded through + // `createRequire`, which bundlers leave as a true runtime require — they must not statically + // resolve these (Turbopack fails the build on an absolute request, and the machinery breaks when + // bundled anyway). `createRequire` rather than ignore-comments because webpack only honors + // `webpackIgnore` on `import()`, not `require()` (it compiles the call to a broken module stub). let nodeRequire: (specifier: string) => unknown; /*! rollup-include-cjs-only */ nodeRequire = require; @@ -53,6 +84,9 @@ export function registerDiagnosticsChannelInjection(): void { nodeRequire = createRequire(import.meta.url); /*! rollup-include-esm-only-end */ + const tracingHooksDir = options?.tracingHooksDir; + const requireFromHooksDir = tracingHooksDir ? createRequire(thisModuleUrl) : undefined; + // `Module.registerHooks` / `Module.register` are newer than the @types/node // we build against, hence the cast. const mod = Module as unknown as { @@ -71,7 +105,11 @@ export function registerDiagnosticsChannelInjection(): void { // We require() the module here so that we can synchronously load it, // including from a CommonJS Sentry build, without bundlers pulling in. // All versions in stableSyncHooks support this. - const { initialize, resolve, load } = nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') as { + const { initialize, resolve, load } = ( + requireFromHooksDir + ? requireFromHooksDir(`${tracingHooksDir}/hook-sync.mjs`) + : nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') + ) as { initialize: (opts: { instrumentations: unknown }) => void; resolve: unknown; load: unknown; @@ -83,16 +121,14 @@ export function registerDiagnosticsChannelInjection(): void { // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 // path. Bun/Deno are excluded: they don't support this combination and // must use the stable `registerHooks` path above (or none at all). - let parentURL: string; - /*! rollup-include-cjs-only */ - parentURL = pathToFileURL(__filename).href; - /*! rollup-include-cjs-only-end */ - /*! rollup-include-esm-only */ - parentURL = import.meta.url; - /*! rollup-include-esm-only-end */ - - mod.register('@apm-js-collab/tracing-hooks/hook.mjs', { - parentURL, + // `Module.register` resolves ESM-style: a bare package specifier is resolved against + // `parentURL`, but a filesystem path (the `tracingHooksDir` override) is not a valid ESM + // specifier and must be passed as a file:// URL. + const hookSpecifier = tracingHooksDir + ? pathToFileURL(`${tracingHooksDir}/hook.mjs`).href + : '@apm-js-collab/tracing-hooks/hook.mjs'; + mod.register(hookSpecifier, { + parentURL: thisModuleUrl, data: { instrumentations: SENTRY_INSTRUMENTATIONS }, }); @@ -101,7 +137,11 @@ export function registerDiagnosticsChannelInjection(): void { // are resolved through the CJS machinery and never reach the ESM // register hook, so without this patch the file we want to instrument // loads untransformed. - const ModulePatch = nodeRequire('@apm-js-collab/tracing-hooks') as new (opts: { instrumentations: unknown }) => { + const ModulePatch = ( + requireFromHooksDir && tracingHooksDir + ? requireFromHooksDir(tracingHooksDir) + : nodeRequire('@apm-js-collab/tracing-hooks') + ) as new (opts: { instrumentations: unknown }) => { patch: () => void; }; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts new file mode 100644 index 000000000000..b4c309b9152c --- /dev/null +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { getTracingHooksDirectory } from '../../src/orchestrion/bundler/webpack'; + +describe('getTracingHooksDirectory', () => { + it('returns the tracing-hooks package directory with the runtime hook entry points', () => { + const dir = getTracingHooksDirectory(); + + expect(dir).not.toContain('\\'); + // The runtime module hook loads these files by joining them onto the directory. + expect(existsSync(join(dir, 'hook-sync.mjs'))).toBe(true); + expect(existsSync(join(dir, 'hook.mjs'))).toBe(true); + expect(existsSync(join(dir, 'package.json'))).toBe(true); + }); +});