Skip to content
Open
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
87 changes: 70 additions & 17 deletions dev-packages/bundler-tests/tests/runtime-entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,35 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.join(__dirname, '../../..');
const FIXTURE_DIR = path.join(__dirname, '..', 'dist', 'runtime-entries');

/** Third-party build tools. No Sentry code is reachable through them. */
const BUILD_TOOL_PATTERNS: RegExp[] = [
/(^|\/)node_modules\/(vite|webpack|rollup|esbuild|oxc-parser)\//,
/(^|\/)node_modules\/@(babel|rollup|oxc-parser)\//,
/\.wasm$/,
];

const BUILD_TIME_PATTERNS: RegExp[] = [
/(^|\/)packages\/bundler-plugins\//,
/(^|\/)node_modules\/@sentry\/bundler-plugins(\/|$)/,
/(^|\/)packages\/server-utils\/build\/[^/]+\/orchestrion\/bundler\//,
/(^|\/)node_modules\/(vite|webpack|rollup|esbuild|oxc-parser)\//,
/(^|\/)node_modules\/@(babel|rollup|oxc-parser)\//,
/\.wasm$/,
...BUILD_TOOL_PATTERNS,
];

const RUNTIME_INJECTION_PATTERNS: RegExp[] = [
/(^|\/)packages\/server-runtime-injection\//,
/(^|\/)node_modules\/@sentry\/server-runtime-injection(\/|$)/,
];

// These config entries import the `@sentry/node` root, which installs
// runtime injection when a client is made.
const KNOWN_RUNTIME_INJECTION_VIOLATIONS = new Set([
// `getSentryRelease`
'@sentry/nextjs/config',
'@sentry/sveltekit/vite',
// `setupSentryNitroModule`, through the `@sentry/nitro` root
'@sentry/solidstart/vite',
]);

const KNOWN_VIOLATIONS = new Set([
// These root entries also re-export config-time code (`sentryAstro`,
// `withSentry`). They must keep it until v12 for backwards compatibility.
Expand Down Expand Up @@ -138,8 +158,8 @@ function writeFixture(specifier: string, conditions: string[], format: Format):
return file;
}

function isBuildTime(file: string): boolean {
return BUILD_TIME_PATTERNS.some(pattern => pattern.test(file));
function matches(file: string, patterns: RegExp[]): boolean {
return patterns.some(pattern => pattern.test(file));
}

type Reasons = Awaited<ReturnType<typeof nodeFileTrace>>['reasons'];
Expand Down Expand Up @@ -173,10 +193,10 @@ function importChain(reasons: Reasons, entry: string, file: string): string[] {
}

/**
* Each place where a runtime module imports a build-time module, as the
* import chain from the entry.
* Each place where a module imports a module that matches `targets`, as
* the import chain from the entry.
*/
async function findBuildTimeImports(fixture: string, conditions: string[]): Promise<string[]> {
async function findImports(fixture: string, conditions: string[], targets: RegExp[]): Promise<string[]> {
const imports: [importer: string, file: string][] = [];

const { fileList, reasons } = await nodeFileTrace([fixture], {
Expand All @@ -186,14 +206,14 @@ async function findBuildTimeImports(fixture: string, conditions: string[]): Prom
// A dynamic `fs` path in config code makes nft glob the whole repo.
// That is slow and finds user files, not package code.
analysis: { emitGlobs: false },
// Stop at the first build-time module, instead of tracing all of Vite
// or Babel. `.wasm` files are assets, so they stay in the trace with
// their importer.
ignore: file => isBuildTime(file) && !file.endsWith('.wasm'),
// Stop at the first target, and do not trace all of Vite or Babel.
// `.wasm` files are assets, so they stay in the trace with their
// importer.
ignore: file => matches(file, [...targets, ...BUILD_TOOL_PATTERNS]) && !file.endsWith('.wasm'),
async resolve(id, parent, job, cjsResolve) {
const resolved = await resolve(id, parent, job, cjsResolve);
for (const file of [resolved].flat()) {
if (isBuildTime(file)) {
if (matches(file, targets)) {
imports.push([path.relative(ROOT_DIR, parent), path.relative(ROOT_DIR, file)]);
}
}
Expand All @@ -202,7 +222,7 @@ async function findBuildTimeImports(fixture: string, conditions: string[]): Prom
});

for (const file of fileList) {
if (file.endsWith('.wasm')) {
if (file.endsWith('.wasm') && matches(file, targets)) {
for (const parent of reasons.get(file)?.parents ?? []) {
imports.push([parent, file]);
}
Expand All @@ -223,13 +243,21 @@ describe('server runtime entries do not reach build-time tooling', () => {

if (KNOWN_VIOLATIONS.has(specifier)) {
test(`${name} is a known violation`, async () => {
const violations = await findBuildTimeImports(writeFixture(specifier, conditions, format), conditions);
const violations = await findImports(
writeFixture(specifier, conditions, format),
conditions,
BUILD_TIME_PATTERNS,
);
// Remove the entry from KNOWN_VIOLATIONS when this fails.
expect(violations).not.toEqual([]);
});
} else {
test(name, async () => {
const violations = await findBuildTimeImports(writeFixture(specifier, conditions, format), conditions);
const violations = await findImports(
writeFixture(specifier, conditions, format),
conditions,
BUILD_TIME_PATTERNS,
);
expect(violations).toEqual([]);
});
}
Expand All @@ -241,13 +269,38 @@ describe('build-time entries reach build-time tooling', () => {
for (const { specifier, conditions, formats } of BUILD_TIME_CASES) {
for (const format of formats) {
test(`${specifier} (${format}, conditions: ${conditions.join(', ')})`, async () => {
const violations = await findBuildTimeImports(writeFixture(specifier, conditions, format), conditions);
const violations = await findImports(
writeFixture(specifier, conditions, format),
conditions,
BUILD_TIME_PATTERNS,
);
expect(violations).not.toEqual([]);
});
}
}
});

describe('build-time entries do not reach runtime injection', () => {
for (const { specifier, conditions, formats } of BUILD_TIME_CASES) {
for (const format of formats) {
const name = `${specifier} (${format}, conditions: ${conditions.join(', ')})`;
const find = (): Promise<string[]> =>
findImports(writeFixture(specifier, conditions, format), conditions, RUNTIME_INJECTION_PATTERNS);

if (KNOWN_RUNTIME_INJECTION_VIOLATIONS.has(specifier)) {
test(`${name} is a known violation`, async () => {
// Remove the entry from KNOWN_RUNTIME_INJECTION_VIOLATIONS when this fails.
expect(await find()).not.toEqual([]);
});
} else {
test(name, async () => {
expect(await find()).toEqual([]);
});
}
}
}
});

test('every export of a tested package is classified', () => {
const classified = new Set([...CASES, ...BUILD_TIME_CASES].map(({ specifier }) => specifier));
for (const specifier of CLIENT_ENTRIES) {
Expand Down
69 changes: 69 additions & 0 deletions dev-packages/bundler-tests/tests/startup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, test } from 'vitest';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* Node resolves, reads and evaluates each published file on its own, so
* the number of files that `@sentry/node` loads sets most of its startup
* cost. Our CJS builds publish flat entry files with a few shared chunks.
* The ESM builds keep one file per module, because bundlers use them and
* drop unused files. A count is stable in CI, where a timing is not.
*/

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_DIR = path.join(__dirname, '..', 'dist', 'startup');

// One entry file and its shared chunks.
const MAX_FILES_PER_PACKAGE = 10;

const COUNTER = `import { writeSync } from 'node:fs';
import { registerHooks } from 'node:module';
const files = new Set();
registerHooks({
load(url, context, nextLoad) {
if (url.startsWith('file:')) files.add(url);
return nextLoad(url, context);
},
});
// A pipe write from an exit handler must be sync, or Node cuts it off.
process.on('exit', () => writeSync(1, JSON.stringify([...files])));
`;

function loadedFiles(specifier: string): string[] {
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
const counter = path.join(FIXTURE_DIR, 'counter.mjs');
fs.writeFileSync(counter, COUNTER);
const fixture = path.join(FIXTURE_DIR, `${specifier.replace(/[@/]/g, '_')}.cjs`);
fs.writeFileSync(fixture, `require('${specifier}');\n`);

const output = execFileSync(process.execPath, ['--import', counter, fixture], { encoding: 'utf8' });
return (JSON.parse(output) as string[]).map(url => fileURLToPath(url)).filter(file => file !== fixture);
}

/** The package that `file` belongs to, for workspace and installed packages. */
function packageOf(file: string): string {
const installed = file.split('/node_modules/').at(-1) as string;
if (installed !== file) {
const [scope, name] = installed.split('/');
return scope?.startsWith('@') ? `${scope}/${name}` : (scope as string);
}
const workspace = file.match(/\/packages\/([^/]+)\//);
return workspace ? `@sentry/${workspace[1]}` : file;
}

describe('@sentry/node startup', () => {
test(`each Sentry package loads at most ${MAX_FILES_PER_PACKAGE} files`, () => {
const counts: Record<string, number> = {};
for (const file of loadedFiles('@sentry/node')) {
const pkg = packageOf(file);
if (pkg.startsWith('@sentry/')) {
counts[pkg] = (counts[pkg] ?? 0) + 1;
}
}

const tooMany = Object.fromEntries(Object.entries(counts).filter(([, count]) => count > MAX_FILES_PER_PACKAGE));
expect(tooMany).toEqual({});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test('worker bundle does not contain the orchestrion bundler plugins', () => {
);
expect(serverFiles.length).toBeGreaterThan(0);

const markers = ['code-transformer-bundler-plugins', '__codeTransformerWebpackDiagnostics'];
const markers = ['__codeTransformerWebpackDiagnostics'];

// The markers must still exist in the installed plugin build.
// If upstream renames them, this fails instead of letting the leak check below pass.
Expand Down
23 changes: 23 additions & 0 deletions dev-packages/rollup-utils/npmHelpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,29 @@ export function makeBaseNPMConfig(options = {}) {
});
}

/**
* Make the CJS variants flat: one file per entry, plus chunks for the code that entries share.
* Node resolves, reads and evaluates each file on its own, so `require()` starts much faster than
* with `preserveModules`. The ESM variants keep one file per module, because bundlers use them and
* drop unused files by the package's `sideEffects` setting. Name the entries with an object `input`
* to keep their paths. Chunk names have no hash, so file tracers see the same paths in each build.
*/
export function makeCjsVariantsFlat(configs) {
return configs.map(config =>
config.output.format === 'cjs'
? {
...config,
output: {
...config.output,
preserveModules: false,
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name].js',
},
}
: config,
);
}

export function makeNPMConfigVariants(baseConfig, options = {}) {
const { emitEsm = true, emitCjs = true, splitDevProd = false } = options;

Expand Down
27 changes: 18 additions & 9 deletions packages/aws-serverless/scripts/buildLambdaLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,9 @@ function buildPackageJson(): void {
function replaceSDKSource(): void {
console.log('Replacing SDK source.');

const envFiles = [
'./build/aws/dist-serverless/nodejs/node_modules/@sentry/core/build/cjs/utils/env.js',
'./build/aws/dist-serverless/nodejs/node_modules/@sentry/core/build/esm/utils/env.js',
const buildDirs = [
'./build/aws/dist-serverless/nodejs/node_modules/@sentry/core/build/cjs',
'./build/aws/dist-serverless/nodejs/node_modules/@sentry/core/build/esm',
];

// Tolerant of whitespace/quote variations: esbuild emits the marker and `return` on
Expand All @@ -234,19 +234,28 @@ function replaceSDKSource(): void {
const pattern = /\/\*! __SENTRY_SDK_SOURCE__ \*\/\s*return ["']npm["'];/;
const replacement = `/*! __SENTRY_SDK_SOURCE__ */ return 'aws-lambda-layer';`;

for (const envFile of envFiles) {
let content: string;
for (const buildDir of buildDirs) {
// The CJS build is flat, so the file that holds `getSDKSource` has no fixed name.
let jsFiles: string[];
try {
content = fs.readFileSync(envFile, 'utf-8');
jsFiles = fs
.readdirSync(buildDir, { recursive: true, encoding: 'utf-8' })
.filter(file => file.endsWith('.js'))
.map(file => path.join(buildDir, file));
} catch {
throw new Error(`Could not read ${envFile} while updating the SDK source for the Lambda layer.`);
throw new Error(`Could not read ${buildDir} while updating the SDK source for the Lambda layer.`);
}

if (!pattern.test(content)) {
const matches = jsFiles
.map(file => ({ file, content: fs.readFileSync(file, 'utf-8') }))
.filter(({ content }) => pattern.test(content));

if (matches.length !== 1) {
throw new Error(
`Could not find the __SENTRY_SDK_SOURCE__ marker in ${envFile}. The Lambda layer would silently report its SDK source as 'npm'. Has the marker or the transpile output changed?`,
`Expected the __SENTRY_SDK_SOURCE__ marker in exactly one file in ${buildDir}, found ${matches.length}. The Lambda layer would silently report its SDK source as 'npm'. Has the marker or the transpile output changed?`,
);
}
const { file: envFile, content } = matches[0] as { file: string; content: string };

// pnpm hard-links the files of `file:` dependencies to the monorepo packages. Removing the file first makes
// sure that the write does not also change `packages/core/build`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ const { getTraceDataMock } = vi.hoisted(() => ({
vi.mock('../../../core/build/esm/utils/traceData.js', () => ({
getTraceData: getTraceDataMock,
}));
vi.mock('../../../core/build/cjs/utils/traceData.js', () => ({
getTraceData: getTraceDataMock,
}));

describe('instrumentDurableObjectNamespace', () => {
beforeEach(() => {
Expand Down
3 changes: 0 additions & 3 deletions packages/cloudflare/test/utils/instrumentFetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ const { getTraceDataMock } = vi.hoisted(() => ({
vi.mock('../../../core/build/esm/utils/traceData.js', () => ({
getTraceData: getTraceDataMock,
}));
vi.mock('../../../core/build/cjs/utils/traceData.js', () => ({
getTraceData: getTraceDataMock,
}));

/** Vitest's `Request` is not typed identically to Workers `RequestInfo`. */
function workerRequest(r: Request): RequestInfo {
Expand Down
14 changes: 8 additions & 6 deletions packages/core/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import replace from '@rollup/plugin-replace';
import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
import { makeBaseNPMConfig, makeCjsVariantsFlat, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';

const packageJson = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf-8'));

Expand Down Expand Up @@ -33,9 +33,11 @@ const settings = {
},
};

export default makeNPMConfigVariants(
makeBaseNPMConfig({
...settings,
entrypoints: ['src/index.ts', 'src/server.ts', 'src/browser.ts'],
}),
export default makeCjsVariantsFlat(
makeNPMConfigVariants(
makeBaseNPMConfig({
...settings,
entrypoints: ['src/index.ts', 'src/server.ts', 'src/browser.ts'],
}),
),
);
Loading
Loading