Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"tsx": "^4.20.3"
},
"volta": {
"node": "20.19.5",
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -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 }]));
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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 }]));
Original file line number Diff line number Diff line change
@@ -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,
})),
);
});
45 changes: 43 additions & 2 deletions packages/server-runtime-injection/src/register.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
};
Expand Down Expand Up @@ -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;
Comment thread
JPeer264 marked this conversation as resolved.

setDiagnosticsHook(({ url, moduleName, error }): void => {
if (error) {
Expand Down Expand Up @@ -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<string, string>();
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
Expand Down
13 changes: 13 additions & 0 deletions packages/server-runtime-injection/test/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof NodeModule>();
return {
...actual,
Module: { prototype: modulePrototypeMock },
registerHooks: (options: { load: LoadHook; resolve: unknown }) => registerHooksMock(options),
register: vi.fn(),
};
Expand Down Expand Up @@ -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);
Expand All @@ -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' } };
Expand Down
Loading