diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/package.json b/dev-packages/e2e-tests/test-applications/tsx-express/package.json index 6659dbcfc603..df56af29737e 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/package.json +++ b/dev-packages/e2e-tests/test-applications/tsx-express/package.json @@ -27,7 +27,6 @@ "tsx": "^4.20.3" }, "volta": { - "node": "20.19.5", "extends": "../../package.json" } } diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario-reload.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario-reload.mjs new file mode 100644 index 000000000000..07e8afb65e04 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario-reload.mjs @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { tracingChannel } from 'node:diagnostics_channel'; +import { createRequire } from 'node:module'; + +Sentry.init({ tracesSampleRate: 0 }); + +let loads = 0; +let injections = 0; +Sentry.getClient().on('orchestrion.module-injected', moduleName => { + if (moduleName === 'dataloader') { + injections++; + } +}); +tracingChannel('orchestrion:dataloader:load').start.subscribe(() => { + loads++; +}); + +const url = import.meta.resolve('dataloader'); +await import(`${url}?first`); +await import(`${url}?second`); + +const require = createRequire(import.meta.url); +delete require.cache[require.resolve('dataloader')]; +injections = 0; + +const DataLoader = require('dataloader'); +const value = await new DataLoader(async keys => keys).load('real-key'); +process.stdout.write(JSON.stringify([{ value, loads, injections }])); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario.cjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario.cjs new file mode 100644 index 000000000000..7f567101fd87 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario.cjs @@ -0,0 +1,29 @@ +const Sentry = require('@sentry/node'); +const { tracingChannel } = require('node:diagnostics_channel'); + +Sentry.init({ tracesSampleRate: 0 }); + +let loads = 0; +let injections = 0; +Sentry.getClient().on('orchestrion.module-injected', moduleName => { + if (moduleName === 'dataloader') { + injections++; + } +}); +tracingChannel('orchestrion:dataloader:load').start.subscribe(() => { + loads++; +}); + +async function run() { + const observations = []; + for (let attempt = 0; attempt < 2; attempt++) { + const DataLoader = require('dataloader'); + const value = await new DataLoader(async keys => keys).load('real-key'); + observations.push({ value, loads, injections }); + delete require.cache[require.resolve('dataloader')]; + } + await Sentry.close(1000); + process.stdout.write(JSON.stringify(observations)); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario.mjs new file mode 100644 index 000000000000..58772fbed931 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/scenario.mjs @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node'; +import { tracingChannel } from 'node:diagnostics_channel'; + +Sentry.init({ tracesSampleRate: 0 }); + +let loads = 0; +let injections = 0; +Sentry.getClient().on('orchestrion.module-injected', moduleName => { + if (moduleName === 'dataloader') { + injections++; + } +}); +tracingChannel('orchestrion:dataloader:load').start.subscribe(() => { + loads++; +}); + +const { default: DataLoader } = await import( + `${import.meta.resolve('dataloader')}${process.argv[2] ?? '?instrumentation-test'}` +); +const value = await new DataLoader(async keys => keys).load('real-key'); +await Sentry.close(1000); +process.stdout.write(JSON.stringify([{ value, loads, injections }])); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/test.ts b/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/test.ts new file mode 100644 index 000000000000..09bbe2b284b1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-tsx/test.ts @@ -0,0 +1,49 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { expect, test } from 'vitest'; + +test.each([ + { name: 'plain Node CommonJS', flags: [], scenario: 'scenario.cjs', operations: 2 }, + { name: 'tsx CommonJS', flags: ['--require', 'tsx/cjs'], scenario: 'scenario.cjs', operations: 2 }, + { name: 'plain Node ESM', flags: [], scenario: 'scenario.mjs', operations: 1 }, + // tsx can replace the hook's source, requiring another transform before compilation. + { + name: 'tsx ESM', + flags: ['--import', 'tsx'], + scenario: 'scenario.mjs', + operations: 1, + injections: expect.any(Number), + }, + { + name: 'plain Node ESM fragment', + flags: [], + scenario: 'scenario.mjs', + operations: 1, + suffix: '#instrumentation-test', + }, + { + name: 'tsx ESM fragment', + flags: ['--import', 'tsx'], + scenario: 'scenario.mjs', + operations: 1, + suffix: '#instrumentation-test', + injections: expect.any(Number), + }, + { name: 'plain Node after queried imports', flags: [], scenario: 'scenario-reload.mjs', operations: 1 }, + { name: 'tsx after queried imports', flags: ['--import', 'tsx'], scenario: 'scenario-reload.mjs', operations: 1 }, +])('instruments each operation exactly once with $name', ({ flags, scenario, operations, suffix, injections }) => { + const result = spawnSync(process.execPath, [...flags, join(__dirname, scenario), suffix ?? '?instrumentation-test'], { + encoding: 'utf8', + timeout: 30_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual( + Array.from({ length: operations }, (_, index) => ({ + value: 'real-key', + loads: index + 1, + injections: injections ?? index + 1, + })), + ); +}); diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index fd9289aeb26d..4b51eea9a053 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -1,4 +1,5 @@ import { consoleSandbox, debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; +import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import * as Module from 'node:module'; import { dirname, join } from 'node:path'; @@ -10,6 +11,11 @@ import { initialize, load, resolve, createDiagnosticsPort } from '@apm-js-collab import { setDiagnosticsHook } from '@apm-js-collab/tracing-hooks/lib/diagnostics.js'; type NodeModule = { + Module: { + prototype: { + _compile: (this: unknown, content: string, filename: string, ...args: unknown[]) => unknown; + }; + }; registerHooks?: (options: { load: Function; resolve: Function }) => { deregister: () => void }; register?: typeof register; }; @@ -192,7 +198,7 @@ export function registerDiagnosticsChannelInjection(): void { // `Module.registerHooks` / `Module.register` are newer than the @types/node // we build against, hence the cast. - const mod = Module as NodeModule; + const mod = Module as unknown as NodeModule; setDiagnosticsHook(({ url, moduleName, error }): void => { if (error) { @@ -224,7 +230,42 @@ export function registerDiagnosticsChannelInjection(): void { try { if (typeof mod.registerHooks === 'function' && stableSyncHooks) { initialize({ instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS }); - mod.registerHooks({ resolve, load: globalAny.Deno ? withDenoFormats(load) : load }); + const filesLoadedByHook = new Map(); + const loadHook = globalAny.Deno ? withDenoFormats(load) : load; + mod.registerHooks({ + resolve, + load: (url: string, context: { format?: string }, nextLoad: Function) => { + const result = loadHook(url, context, nextLoad) as + | { format?: string; source?: string | Uint8Array; shortCircuit?: boolean } + | undefined; + const format = result?.format ?? context.format; + if ( + (format == null || format === 'commonjs') && + result?.shortCircuit === true && + result?.source != null && + url.startsWith('file:') + ) { + const sourceHash = createHash('sha256').update(result.source).digest('hex'); + filesLoadedByHook.set(pathToFileURL(fileURLToPath(url)).href, sourceHash); + } + return result; + }, + }); + + // Custom require hooks bypass load. Avoid transforming files twice when load already handled them. + const prototype = mod.Module.prototype; + const originalCompile = prototype._compile; + new ModulePatch({ instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS }).patch(); + const patchedCompile = prototype._compile; + prototype._compile = function (content, filename, ...args) { + const url = pathToFileURL(filename).href; + const loadedSourceHash = filesLoadedByHook.get(url); + filesLoadedByHook.delete(url); + const alreadyTransformed = + loadedSourceHash !== undefined && loadedSourceHash === createHash('sha256').update(content).digest('hex'); + const compile = alreadyTransformed ? originalCompile : patchedCompile; + return compile.call(this, content, filename, ...args); + }; debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 diff --git a/packages/server-runtime-injection/test/register.test.ts b/packages/server-runtime-injection/test/register.test.ts index f34408f98009..3dfa3bdb4b15 100644 --- a/packages/server-runtime-injection/test/register.test.ts +++ b/packages/server-runtime-injection/test/register.test.ts @@ -15,10 +15,12 @@ type LoadHook = (url: string, context: unknown, nextLoad: (url: string, context: // the real `Module.registerHooks({ resolve, load })` with the mocked (undefined-returning) callbacks, // leaving a broken resolve hook installed process-wide that crashes vitest's next dynamic `import()`. const registerHooksMock = vi.fn<(options: { load: LoadHook; resolve: unknown }) => void>(); +const modulePrototypeMock = vi.hoisted(() => ({ _compile: vi.fn() })); vi.mock('node:module', async importOriginal => { const actual = await importOriginal(); return { ...actual, + Module: { prototype: modulePrototypeMock }, registerHooks: (options: { load: LoadHook; resolve: unknown }) => registerHooksMock(options), register: vi.fn(), }; @@ -81,6 +83,7 @@ describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', } beforeEach(async () => { + modulePrototypeMock._compile = vi.fn(); delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; setDiagnosticsHookMock.mockClear(); warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -93,9 +96,19 @@ describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', afterEach(() => { delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + vi.unstubAllGlobals(); warnSpy.mockRestore(); }); + it('installs the CommonJS compiler fallback on Deno', () => { + vi.stubGlobal('Deno', { version: { deno: '2.8.3' } }); + const originalCompile = modulePrototypeMock._compile; + + registerDiagnosticsChannelInjection(); + + expect(modulePrototypeMock._compile).not.toBe(originalCompile); + }); + it('includes the underlying error when registering the hooks fails', () => { // Deno always takes the `Module.registerHooks` path, independent of the Node version running the tests. (globalThis as { Deno?: unknown }).Deno = { version: { deno: '2.8.3' } };